package handlers import ( "errors" "io/fs" "net/http" "os" "path/filepath" "strconv" "strings" "github.com/gin-gonic/gin" "booklib/internal/bookfile" "booklib/internal/store" ) const defaultCover = `` func (h *H) bookRoot(c *gin.Context, b store.Book) (string, bool) { lib, ok := h.getLib(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") } // checkPath 纵深防御:path 越界 → 403,与原契约一致。 func checkPath(c *gin.Context, root string, b store.Book) (string, bool) { abs, perr := absBookPath(root, b) if perr != nil { err(c, http.StatusForbidden, "forbidden", "unsafe path") return "", false } return abs, true } // mapContentErr 把 media/bookfile 的文件级失败映射回原契约状态码: // 文件不在盘上 → 404,其余(坏包等)由调用方决定 422/500。 func mapContentErr(c *gin.Context, e error) bool { if errors.Is(e, fs.ErrNotExist) { err(c, http.StatusNotFound, "not_found", "file missing on disk") return true } return false } 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 _, ok := checkPath(c, root, b); ok { e := h.med.EnsureCover(c, b.ID, b.Format, b.FileSize, b.ModTS, root, b.Path) switch { case e == nil: if entries, re := os.ReadDir(dir); re == nil { for _, en := range entries { if !strings.Contains(en.Name(), ".tmp") { http.ServeFile(c.Writer, c.Request, filepath.Join(dir, en.Name())) return } } } case mapContentErr(c, e): return // 404 已回复 } // 抽取失败(坏包)→ 落到占位 SVG,与原契约一致 } else { return // 403 已回复 } } else { return // 404/503 已回复 } } if c.Writer.Written() { 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, ok := checkPath(c, root, b) if !ok { return } if _, e := os.Stat(abs); e != nil { err(c, http.StatusNotFound, "not_found", "file missing on disk") return } c.Header("ETag", `"`+h.med.CacheBuster(b.FileSize, b.ModTS)+`"`) c.Header("Cache-Control", "private, must-revalidate") http.ServeFile(c.Writer, c.Request, abs) } // pageIndex 走 media(redis 缓存 + 索引提取);路径校验仍在 handler,保住 403 契约。 func (h *H) pageIndex(c *gin.Context, b store.Book, root string) ([]string, bool) { if _, ok := checkPath(c, root, b); !ok { return nil, false } idx, e := h.med.PageIndex(c, b.ID, b.FileSize, b.ModTS, root, b.Path) if e != nil { if !mapContentErr(c, e) { err(c, http.StatusUnprocessableEntity, "broken", e.Error()) } return nil, false } return idx, true } 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, ok := h.pageIndex(c, b, root) if !ok { return } c.JSON(http.StatusOK, gin.H{"count": len(idx), "chapters": h.med.ChaptersOf(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, ok := h.pageIndex(c, b, root) if !ok { return } if n >= len(idx) { err(c, http.StatusNotFound, "not_found", "no such page") return } // miss → 解压落盘(media 内部唯一 tmp 名 + rename 原子,并发重做同页幂等) dst, e := h.med.EnsurePage(c, b.ID, b.FileSize, b.ModTS, root, b.Path, n, idx) if e != nil { if mapContentErr(c, e) { return } err(c, http.StatusInternalServerError, "internal", "extract page") return } h.immutable(c) http.ServeFile(c.Writer, c.Request, dst) }