package handlers import ( "fmt" "log" "net/http" "os" "path" "path/filepath" "strconv" "strings" "time" "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.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()) // B11: check all write errors; clean tmp only on failure. if we := os.WriteFile(tmp, img, 0o644); we != nil { log.Printf("serve: write cover tmp: %v", we) os.Remove(tmp) } else if re := os.Rename(tmp, dst); re != nil { log.Printf("serve: rename cover: %v", re) os.Remove(tmp) } else { http.ServeFile(c.Writer, c.Request, dst) } } } } } } 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("pagesidx2:%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), "chapters": chaptersOf(idx)}) } type cbzChapter struct { Title string `json:"title"` Start int `json:"start"` } // chaptersOf 页索引已自然序排列,按父目录分组:每个新目录开一章,标题取目录名;扁平包或只有一组返回 nil func chaptersOf(idx []string) []cbzChapter { type grp struct { dir string start int } var grps []grp last := "\x00" for i, n := range idx { d := path.Dir(n) if d == last { continue } last = d if d == "." { // 根目录散页不开章 continue } grps = append(grps, grp{d, i}) } if len(grps) < 2 { return nil } titles := make(map[string]int) out := make([]cbzChapter, len(grps)) for i, g := range grps { out[i] = cbzChapter{Title: path.Base(g.dir), Start: g.start} titles[out[i].Title]++ } for i, g := range grps { // 同名目录(不同父级)撞车 → 用全路径消歧 if titles[out[i].Title] > 1 { out[i].Title = g.dir } } return out } 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) }