refactor(repo): conform structure to AGENTS.md (web->frontend, internal/api->cmd/webui/{api,handlers}, docs/README+CHANGELOGs, module .gitignores, drop stray library/ and committed debug bins)

This commit is contained in:
2026-09-07 21:37:30 +08:00
parent 961de429f9
commit ca64bc0d73
33 changed files with 324 additions and 205 deletions
+226
View File
@@ -0,0 +1,226 @@
package handlers
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"booklib/internal/bookfile"
"booklib/internal/store"
)
const defaultCover = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="170"><rect width="120" height="170" rx="6" fill="#2a2a33"/><path d="M30 25h60v120H30z" fill="#3a3a45"/><path d="M30 25h60M60 25v120" stroke="#555" stroke-width="2"/></svg>`
func (h *H) bookRoot(c *gin.Context, b store.Book) (string, bool) {
lib, ok := h.getLibRow(c, b.LibraryID)
if !ok {
return "", false
}
return h.libRoot(c, lib)
}
func (h *H) immutable(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
}
func (h *H) ServeCover(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
h.immutable(c)
dir := bookfile.CoverDir(h.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
if entries, e := os.ReadDir(dir); e == nil {
for _, en := range entries { // 跳过写一半的 .tmp 落盘中间态
if !strings.Contains(en.Name(), ".tmp") {
http.ServeFile(c.Writer, c.Request, filepath.Join(dir, en.Name()))
return
}
}
}
if b.Format == "cbz" || b.Format == "epub" { // 自愈:缓存丢了就地抽封面(重启/卷漂移/扫描器还没跑到)
if root, ok := h.bookRoot(c, b); ok {
if f, size, ok := h.openBook(c, b, root); ok {
defer f.Close()
var img []byte
var ext string
var e error
if b.Format == "cbz" {
img, ext, e = bookfile.CBZCover(f, size)
} else {
img, ext, e = bookfile.EPUBCover(f, size)
}
if e == nil {
dst := filepath.Join(dir, "cover"+ext)
if e := os.MkdirAll(dir, 0o755); e == nil {
tmp := fmt.Sprintf("%s.tmp-%d", dst, time.Now().UnixNano()) // 并发幂等:唯一 tmp + rename 原子
if e := os.WriteFile(tmp, img, 0o644); e == nil {
if e := os.Rename(tmp, dst); e == nil {
http.ServeFile(c.Writer, c.Request, dst)
}
}
os.Remove(tmp)
}
}
}
}
}
if c.Writer.Written() { // openBook/bookRoot 已写 403/404/500,不再叠加占位图
return
}
c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover))
}
func (h *H) ServeFile(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
root, ok := h.bookRoot(c, b)
if !ok {
return
}
abs, perr := absBookPath(root, b)
if perr != nil {
err(c, http.StatusForbidden, "forbidden", "unsafe path")
return
}
c.Header("ETag", `"`+bookfile.Hash(b.FileSize, b.ModTS)+`"`)
c.Header("Cache-Control", "private, must-revalidate")
http.ServeFile(c.Writer, c.Request, abs)
}
func (h *H) openBook(c *gin.Context, b store.Book, root string) (*os.File, int64, bool) {
abs, perr := absBookPath(root, b)
if perr != nil {
err(c, http.StatusForbidden, "forbidden", "unsafe path")
return nil, 0, false
}
f, perr := os.Open(abs)
if perr != nil {
err(c, http.StatusNotFound, "not_found", "file missing on disk")
return nil, 0, false
}
st, perr := f.Stat()
if perr != nil {
f.Close()
err(c, http.StatusInternalServerError, "internal", "stat")
return nil, 0, false
}
return f, st.Size(), true
}
func (h *H) pageIndex(c *gin.Context, b store.Book, root string) ([]string, error) {
hash := bookfile.Hash(b.FileSize, b.ModTS)
key := fmt.Sprintf("pagesidx:%d:%s", b.ID, hash)
if v, ok := h.rdb.Get(c, key); ok && v != "" {
return strings.Split(v, "\n"), nil
}
f, size, ok := h.openBook(c, b, root)
if !ok {
return nil, os.ErrNotExist
}
defer f.Close()
idx, e := bookfile.PageIndex(f, size)
if e != nil {
return nil, e
}
if len(idx) > 0 { // 空索引不缓存,否则 warm 命中 "" 会 Split 出幽灵页
h.rdb.Set(c, key, strings.Join(idx, "\n"), 7*24*time.Hour)
}
return idx, nil
}
func (h *H) PagesCount(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
if b.Format != "cbz" {
err(c, http.StatusBadRequest, "bad_request", "pages only for cbz")
return
}
root, ok := h.bookRoot(c, b)
if !ok {
return
}
idx, e := h.pageIndex(c, b, root)
if e != nil {
if c.Writer.Written() {
return // openBook 已写 403/404,不再叠加 422
}
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
return
}
c.JSON(http.StatusOK, gin.H{"count": len(idx)})
}
func (h *H) Page(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
if b.Format != "cbz" {
err(c, http.StatusBadRequest, "bad_request", "pages only for cbz")
return
}
n, e := strconv.Atoi(c.Param("n"))
if e != nil || n < 0 {
err(c, http.StatusBadRequest, "bad_request", "bad page number")
return
}
root, ok := h.bookRoot(c, b)
if !ok {
return
}
idx, e := h.pageIndex(c, b, root)
if e != nil {
if c.Writer.Written() {
return // openBook 已写 403/404,不再叠加 422
}
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
return
}
if n >= len(idx) {
err(c, http.StatusNotFound, "not_found", "no such page")
return
}
ext := strings.ToLower(filepath.Ext(idx[n]))
dir := bookfile.PagesDir(h.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
dst := filepath.Join(dir, strconv.Itoa(n)+ext)
if _, e := os.Stat(dst); e != nil { // miss → 解压落盘(并发重做同页幂等,唯一 tmp 名 + rename 原子)
f, size, ok := h.openBook(c, b, root)
if !ok {
return
}
defer f.Close()
data, e := bookfile.ReadEntry(f, size, idx[n])
if e != nil {
err(c, http.StatusInternalServerError, "internal", "extract page")
return
}
if e := os.MkdirAll(dir, 0o755); e != nil {
err(c, http.StatusInternalServerError, "internal", "cache dir")
return
}
tmp := fmt.Sprintf("%s.tmp-%d", dst, time.Now().UnixNano())
if e := os.WriteFile(tmp, data, 0o644); e != nil {
os.Remove(tmp)
err(c, http.StatusInternalServerError, "internal", "write cache")
return
}
if e := os.Rename(tmp, dst); e != nil {
os.Remove(tmp)
err(c, http.StatusInternalServerError, "internal", "rename cache")
return
}
}
h.immutable(c)
http.ServeFile(c.Writer, c.Request, dst)
}