diff --git a/backend/internal/api/books.go b/backend/internal/api/books.go index 93a0085..c921bca 100644 --- a/backend/internal/api/books.go +++ b/backend/internal/api/books.go @@ -1,6 +1,7 @@ package api import ( + "errors" "fmt" "net/http" "os" @@ -10,15 +11,20 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5" "booklib/internal/bookfile" "booklib/internal/store" ) func (a *api) getBookRow(c *gin.Context, id int64) (store.Book, bool) { - b, perr := a.st.GetBook(c, id) - if perr != nil { - err(c, http.StatusNotFound, "not_found", "no such book") + b, e := a.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 + } + err(c, http.StatusInternalServerError, "internal", "db error") return store.Book{}, false } return b, true diff --git a/backend/internal/api/books_test.go b/backend/internal/api/books_test.go index acf3ce5..042dbf0 100644 --- a/backend/internal/api/books_test.go +++ b/backend/internal/api/books_test.go @@ -144,6 +144,23 @@ func loginAs(t *testing.T, h http.Handler, user, pass string) string { return v.Token } +func TestDeleteUnsafePath403(t *testing.T) { + st, _, h, booksDir := setupAPI(t) + atok := adminToken(t, h) + lib, _ := newLibrary(t, st, h, atok, booksDir, "libs") + id, e := st.InsertBook(context.Background(), lib.ID, "../../x.cbz", "x", "cbz", 1, 1, 0) + if e != nil { + t.Fatal(e) + } + w := do(h, "DELETE", "/api/books/"+itoa(id), atok, nil) + if w.Code != 403 { + t.Fatalf("unsafe delete want 403 got %d %s", w.Code, w.Body) + } + if _, e := st.GetBook(context.Background(), id); e != nil { // 403 提前返回,行必须保留 + t.Fatalf("row must survive: %v", e) + } +} + func TestAbsBookPathTraversalRejected(t *testing.T) { root := "/data/books/lib" // 纯路径逻辑,不碰文件系统,无需 DB for _, bad := range []string{"../../etc/passwd", "a/../../../etc/x", "../sibling"} { diff --git a/backend/internal/api/content.go b/backend/internal/api/content.go new file mode 100644 index 0000000..332212f --- /dev/null +++ b/backend/internal/api/content.go @@ -0,0 +1,184 @@ +package api + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "booklib/internal/bookfile" + "booklib/internal/store" +) + +const defaultCover = `` + +func (a *api) bookRoot(c *gin.Context, b store.Book) (string, bool) { + lib, ok := a.getLibRow(c, b.LibraryID) + if !ok { + return "", false + } + return a.libRoot(c, lib) +} + +func (a *api) immutable(c *gin.Context) { + c.Header("Cache-Control", "public, max-age=31536000, immutable") +} + +func (a *api) serveCover(c *gin.Context) { + b, ok := a.bookFromParam(c) + if !ok { + return + } + a.immutable(c) + dir := bookfile.CoverDir(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))) + if entries, err := os.ReadDir(dir); err == nil && len(entries) > 0 { + http.ServeFile(c.Writer, c.Request, filepath.Join(dir, entries[0].Name())) + return + } + c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover)) +} + +func (a *api) serveFile(c *gin.Context) { + b, ok := a.bookFromParam(c) + if !ok { + return + } + root, ok := a.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 (a *api) 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 (a *api) 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 := a.rdb.Get(c, key); ok { + return strings.Split(v, "\n"), nil + } + f, size, ok := a.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 + } + a.rdb.Set(c, key, strings.Join(idx, "\n"), 7*24*time.Hour) + return idx, nil +} + +func (a *api) pagesCount(c *gin.Context) { + b, ok := a.bookFromParam(c) + if !ok { + return + } + if b.Format != "cbz" { + err(c, http.StatusBadRequest, "bad_request", "pages only for cbz") + return + } + root, ok := a.bookRoot(c, b) + if !ok { + return + } + idx, e := a.pageIndex(c, b, root) + if e != nil { + err(c, http.StatusUnprocessableEntity, "broken", e.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"count": len(idx)}) +} + +func (a *api) page(c *gin.Context) { + b, ok := a.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 := a.bookRoot(c, b) + if !ok { + return + } + idx, e := a.pageIndex(c, b, root) + if e != nil { + 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(a.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 := a.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 + } + } + a.immutable(c) + http.ServeFile(c.Writer, c.Request, dst) +} diff --git a/backend/internal/api/content_test.go b/backend/internal/api/content_test.go new file mode 100644 index 0000000..ea4b323 --- /dev/null +++ b/backend/internal/api/content_test.go @@ -0,0 +1,126 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func serveFixture(t *testing.T) (http.Handler, string, string) { + st, sc, h, booksDir := setupAPI(t) + atok := adminToken(t, h) + lib, root := newLibrary(t, st, h, atok, booksDir, "comics") + writeCBZ(t, filepath.Join(root, "s", "one.cbz"), 3) + os.WriteFile(filepath.Join(root, "two.txt"), []byte("plain text body"), 0o644) + scanNow(t, sc, lib) + w := do(h, "GET", "/api/books?q=one", atok, nil) + var bs []map[string]any + json.Unmarshal(w.Body.Bytes(), &bs) + cbzID := itoa(bs[0]["id"]) + w = do(h, "GET", "/api/books?q=two", atok, nil) // 检索走 title(文件名去扩展名),不是 path + json.Unmarshal(w.Body.Bytes(), &bs) + txtID := itoa(bs[0]["id"]) + return h, cbzID, txtID +} + +func TestCoverCBZAndPlaceholder(t *testing.T) { + h, cbzID, txtID := serveFixture(t) + tok := adminToken(t, h) + w := do(h, "GET", "/api/books/"+cbzID+"/cover", tok, nil) + if w.Code != 200 || !strings.Contains(w.Header().Get("Content-Type"), "image/") { + t.Fatalf("cbz cover %d %s %q", w.Code, w.Body, w.Header().Get("Content-Type")) + } + if !strings.Contains(w.Header().Get("Cache-Control"), "immutable") { + t.Fatal("cover must be immutable") + } + w = do(h, "GET", "/api/books/"+txtID+"/cover", tok, nil) + if w.Code != 200 || w.Header().Get("Content-Type") != "image/svg+xml" { + t.Fatalf("placeholder cover %d %q", w.Code, w.Header().Get("Content-Type")) + } + w = do(h, "GET", "/api/books/999999/cover", tok, nil) + if w.Code != 404 { + t.Fatalf("missing book cover want 404 got %d", w.Code) + } +} + +func TestPages(t *testing.T) { + h, cbzID, txtID := serveFixture(t) + tok := adminToken(t, h) + w := do(h, "GET", "/api/books/"+cbzID+"/pages", tok, nil) + var v struct{ Count int } + json.Unmarshal(w.Body.Bytes(), &v) + if w.Code != 200 || v.Count != 3 { + t.Fatalf("pages %d %s", w.Code, w.Body) + } + w = do(h, "GET", "/api/books/"+cbzID+"/pages/1", tok, nil) + if w.Code != 200 || !strings.Contains(w.Body.String(), "IMG") { + t.Fatalf("page 1 %d", w.Code) + } + if !strings.Contains(w.Header().Get("Cache-Control"), "immutable") { + t.Fatal("page must be immutable") + } + for _, bad := range []string{"4", "-1", "abc"} { + if w = do(h, "GET", "/api/books/"+cbzID+"/pages/"+bad, tok, nil); w.Code != 404 && w.Code != 400 { + t.Fatalf("pages/%s want 404/400 got %d", bad, w.Code) + } + } + if w = do(h, "GET", "/api/books/"+txtID+"/pages", tok, nil); w.Code != 400 { + t.Fatalf("pages on txt want 400 got %d", w.Code) + } + // 二次命中磁盘缓存(服务仍 200,字节一致) + w2 := do(h, "GET", "/api/books/"+cbzID+"/pages/2", tok, nil) + w3 := do(h, "GET", "/api/books/"+cbzID+"/pages/2", tok, nil) + if w2.Code != 200 || w2.Body.String() != w3.Body.String() { + t.Fatal("page cache inconsistent") + } +} + +func TestBrokenCBZ(t *testing.T) { + st, sc, h, booksDir := setupAPI(t) + atok := adminToken(t, h) + lib, root := newLibrary(t, st, h, atok, booksDir, "comics") + os.MkdirAll(filepath.Join(root, "b"), 0o755) + os.WriteFile(filepath.Join(root, "b", "bad.cbz"), []byte("not a zip at all"), 0o644) + scanNow(t, sc, lib) + w := do(h, "GET", "/api/books?q=bad", atok, nil) + var bs []map[string]any + json.Unmarshal(w.Body.Bytes(), &bs) + id := itoa(bs[0]["id"]) + if w = do(h, "GET", "/api/books/"+id+"/pages", atok, nil); w.Code != 422 { + t.Fatalf("broken pages want 422 got %d", w.Code) + } + // 封面目录缺失(坏 cbz 抽不出封面)→ 占位 SVG 兜底 + if w = do(h, "GET", "/api/books/"+id+"/cover", atok, nil); w.Code != 200 || w.Header().Get("Content-Type") != "image/svg+xml" { + t.Fatalf("broken cover want placeholder svg got %d %q", w.Code, w.Header().Get("Content-Type")) + } +} + +func TestFileRangeETag(t *testing.T) { + h, _, txtID := serveFixture(t) + tok := adminToken(t, h) + w := do(h, "GET", "/api/books/"+txtID+"/file", tok, nil) + if w.Code != 200 || w.Body.String() != "plain text body" { + t.Fatalf("file %d %q", w.Code, w.Body) + } + if w.Header().Get("ETag") == "" { + t.Fatal("no etag") + } + if etag := w.Header().Get("ETag"); !strings.HasPrefix(etag, `"`) || !strings.HasSuffix(etag, `"`) { + t.Fatalf("etag must be quoted: %q", etag) + } + if w.Header().Get("Accept-Ranges") != "bytes" { + t.Fatal("no accept-ranges") + } + req := httptest.NewRequest("GET", "/api/books/"+txtID+"/file", nil) + req.Header.Set("Range", "bytes=0-4") + req.Header.Set("Authorization", "Bearer "+tok) + ww := httptest.NewRecorder() + h.ServeHTTP(ww, req) + if ww.Code != 206 || ww.Body.String() != "plain" { + t.Fatalf("range %d %q", ww.Code, ww.Body) + } +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index d856fce..83e8841 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -37,5 +37,9 @@ func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner p.GET("/books", a.listBooks) p.GET("/books/:id", a.getBook) p.DELETE("/books/:id", a.adminOnly(), a.deleteBook) + p.GET("/books/:id/cover", a.serveCover) + p.GET("/books/:id/file", a.serveFile) + p.GET("/books/:id/pages", a.pagesCount) + p.GET("/books/:id/pages/:n", a.page) return r }