package handlers import ( "errors" "fmt" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5" "booklib/internal/bookfile" "booklib/internal/store" ) func (h *H) getBookRow(c *gin.Context, id int64) (store.Book, bool) { b, e := h.st.GetBook(c, id) if e != nil { if errors.Is(e, pgx.ErrNoRows) { err(c, http.StatusNotFound, "not_found", "no such book") return store.Book{}, false } dbErr(c, e) return store.Book{}, false } return b, true } func (h *H) bookFromParam(c *gin.Context) (store.Book, bool) { id, e := strconv.ParseInt(c.Param("id"), 10, 64) if e != nil { err(c, http.StatusBadRequest, "bad_request", "bad id") return store.Book{}, false } return h.getBookRow(c, id) } func (h *H) getLibRow(c *gin.Context, id int64) (store.Library, bool) { l, e := h.st.GetLibrary(c, id) if e != nil { if errors.Is(e, pgx.ErrNoRows) { err(c, http.StatusNotFound, "not_found", "no such library") return store.Library{}, false } dbErr(c, e) return store.Library{}, false } return l, true } // absBookPath: books.path 永远相对且不含 ..;拼接后二次前缀校验(纵深防御) func absBookPath(root string, b store.Book) (string, error) { abs := filepath.Join(root, filepath.FromSlash(b.Path)) if filepath.Clean(abs) != abs || !hasPrefixDir(abs, root) { return "", os.ErrPermission } return abs, nil } func hasPrefixDir(p, dir string) bool { rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(p)) return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) } func bookJSON(b store.Book, percent float64, libraryName string) gin.H { h := bookfile.Hash(b.FileSize, b.ModTS) j := gin.H{ "id": b.ID, "library_id": b.LibraryID, "path": b.Path, "title": b.Title, "format": b.Format, "size": b.FileSize, "mtime": b.ModTS, "pages": b.PageCount, "state": b.State, "error": b.ErrMsg, "added_at": b.AddedAt.Format(time.RFC3339), "percent": percent, "cover_url": fmt.Sprintf("/api/books/%d/cover?v=%s", b.ID, h), } if b.Format == "cbz" { j["pages_url"] = fmt.Sprintf("/api/books/%d/pages", b.ID) j["page_url_fmt"] = fmt.Sprintf("/api/books/%d/pages/%%d?v=%s", b.ID, h) } else { j["file_url"] = fmt.Sprintf("/api/books/%d/file?v=%s", b.ID, h) } if libraryName != "" { j["library"] = libraryName } return j } func (h *H) ListBooks(c *gin.Context) { libID, _ := strconv.ParseInt(c.Query("library"), 10, 64) views, e := h.st.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c)) if e != nil { dbErr(c, e) return } out := make([]gin.H, 0, len(views)) for _, v := range views { out = append(out, bookJSON(v.Book, v.Percent, v.LibraryName)) } c.JSON(http.StatusOK, out) } func (h *H) GetBook(c *gin.Context) { b, ok := h.bookFromParam(c) if !ok { return } p, e := h.st.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent if e != nil && !errors.Is(e, pgx.ErrNoRows) { dbErr(c, e) return } lib, e := h.st.GetLibrary(c, b.LibraryID) if e != nil && !errors.Is(e, pgx.ErrNoRows) { // 库被并发删则留空 library 名,书仍可见 dbErr(c, e) return } c.JSON(http.StatusOK, bookJSON(b, p.Percent, lib.Name)) } func (h *H) DeleteBook(c *gin.Context) { b, ok := h.bookFromParam(c) if !ok { return } lib, ok := h.getLibRow(c, b.LibraryID) if !ok { return } root, ok := h.libRoot(c, lib) if !ok { return } abs, e := absBookPath(root, b) if e != nil { err(c, http.StatusForbidden, "forbidden", "unsafe path") return } if e := os.Remove(abs); e != nil && !os.IsNotExist(e) { err(c, http.StatusInternalServerError, "internal", "remove file") return } key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)) os.RemoveAll(bookfile.CoverDir(h.cfg.CacheDir, key)) os.RemoveAll(bookfile.PagesDir(h.cfg.CacheDir, key)) if e := h.st.DeleteBook(c, b.ID); e != nil { dbErr(c, e) return } c.Status(http.StatusNoContent) }