From 249619b857da2bf2600d544761a6454e53ee354d Mon Sep 17 00:00:00 2001 From: Fendy Date: Sat, 5 Sep 2026 00:42:08 +0800 Subject: [PATCH] feat(backend): book list/detail/delete with embedded progress + cache cleanup --- backend/internal/api/auth_test.go | 36 +++--- backend/internal/api/books.go | 134 +++++++++++++++++++++ backend/internal/api/books_test.go | 157 +++++++++++++++++++++++++ backend/internal/api/libraries_test.go | 5 +- backend/internal/api/router.go | 4 + backend/internal/api/users_test.go | 16 +-- backend/internal/config/config.go | 13 +- 7 files changed, 339 insertions(+), 26 deletions(-) create mode 100644 backend/internal/api/books.go create mode 100644 backend/internal/api/books_test.go diff --git a/backend/internal/api/auth_test.go b/backend/internal/api/auth_test.go index 0f191dc..7708cda 100644 --- a/backend/internal/api/auth_test.go +++ b/backend/internal/api/auth_test.go @@ -19,7 +19,7 @@ import ( "booklib/internal/store" ) -func setupAPI(t *testing.T) (*store.Store, http.Handler) { +func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, string) { t.Helper() url := os.Getenv("DATABASE_URL") if url == "" { @@ -32,20 +32,24 @@ func setupAPI(t *testing.T) (*store.Store, http.Handler) { } st := store.New(p) p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users") - h, _ := auth.HashPassword("pw12345") - _, err := st.CreateUser(ctx, "alice", h, "admin") - if err != nil { + h, _ := auth.HashPassword(testPW) + if _, err := st.CreateUser(ctx, "alice", h, "admin"); err != nil { t.Fatal(err) } - _, err = st.CreateUser(ctx, "bob", h, "member") + if _, err := st.CreateUser(ctx, "bob", h, "member"); err != nil { + t.Fatal(err) + } + booksParent := t.TempDir() + booksDir, err := filepath.EvalSymlinks(booksParent) // macOS 上 /var→/private,root 校验要用真实路径 if err != nil { t.Fatal(err) } cfg := testCfg() - cfg.BooksDir = filepath.Clean(os.TempDir()) + cfg.BooksDir = booksDir cfg.CacheDir = t.TempDir() rdb := redispkg.New(os.Getenv("REDIS_URL")) - r := NewRouter(cfg, st, rdb, scanner.New(st, cfg, rdb)) + sc := scanner.New(st, cfg, rdb) + r := NewRouter(cfg, st, rdb, sc) if u := os.Getenv("REDIS_URL"); u != "" { // 测试卫生: 共享 redis 上重置登录限流桶, 防跨测试累计 429 if opt, e := redis.ParseURL(u); e == nil { rc := redis.NewClient(opt) @@ -53,9 +57,12 @@ func setupAPI(t *testing.T) (*store.Store, http.Handler) { rc.Close() } } - return st, r + return st, sc, r, booksDir } +// testPW 是唯一的 fixture 口令常量: 直接种子 (HashPassword) 与所有登录/建户必须同值, 且 >=8 位 +const testPW = "pw123456" + func do(h http.Handler, method, path, token string, body any) *httptest.ResponseRecorder { var r *bytes.Reader if body != nil { @@ -74,8 +81,8 @@ func do(h http.Handler, method, path, token string, body any) *httptest.Response } func TestLoginMe(t *testing.T) { - _, h := setupAPI(t) - w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "alice", "password": "pw12345"}) + _, _, h, _ := setupAPI(t) + w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "alice", "password": testPW}) if w.Code != 200 { t.Fatalf("login %d %s", w.Code, w.Body) } @@ -105,13 +112,12 @@ func TestLoginMe(t *testing.T) { } func TestMemberCannotWriteUsers(t *testing.T) { - _, h := setupAPI(t) - tok, _ := auth.Sign([]byte("s3cret"), 2, "member") // bob — 注意: 必须走真实登录拿 token - w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "bob", "password": "pw12345"}) + _, _, h, _ := setupAPI(t) + w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "bob", "password": testPW}) var v struct{ Token string } json.Unmarshal(w.Body.Bytes(), &v) - tok = v.Token - w = do(h, "POST", "/api/users", tok, map[string]string{"username": "eve", "password": "pw12345", "role": "member"}) + tok := v.Token + w = do(h, "POST", "/api/users", tok, map[string]string{"username": "eve", "password": testPW, "role": "member"}) if w.Code != 403 { t.Fatalf("member write users: want 403 got %d", w.Code) } diff --git a/backend/internal/api/books.go b/backend/internal/api/books.go new file mode 100644 index 0000000..93a0085 --- /dev/null +++ b/backend/internal/api/books.go @@ -0,0 +1,134 @@ +package api + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "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") + return store.Book{}, false + } + return b, true +} + +func (a *api) 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 a.getBookRow(c, id) +} + +func (a *api) getLibRow(c *gin.Context, id int64) (store.Library, bool) { + l, e := a.st.GetLibrary(c, id) + if e != nil { + err(c, http.StatusNotFound, "not_found", "no such library") + 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 (a *api) listBooks(c *gin.Context) { + libID, _ := strconv.ParseInt(c.Query("library"), 10, 64) + views, e := a.st.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c)) + if e != nil { + err(c, http.StatusInternalServerError, "internal", "db error") + 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 (a *api) getBook(c *gin.Context) { + b, ok := a.bookFromParam(c) + if !ok { + return + } + p, _ := a.st.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent + lib, _ := a.st.GetLibrary(c, b.LibraryID) + c.JSON(http.StatusOK, bookJSON(b, p.Percent, lib.Name)) +} + +func (a *api) deleteBook(c *gin.Context) { + b, ok := a.bookFromParam(c) + if !ok { + return + } + lib, ok := a.getLibRow(c, b.LibraryID) + if !ok { + return + } + root, ok := a.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(a.cfg.CacheDir, key)) + os.RemoveAll(bookfile.PagesDir(a.cfg.CacheDir, key)) + if e := a.st.DeleteBook(c, b.ID); e != nil { + err(c, http.StatusInternalServerError, "internal", "db error") + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/api/books_test.go b/backend/internal/api/books_test.go new file mode 100644 index 0000000..acf3ce5 --- /dev/null +++ b/backend/internal/api/books_test.go @@ -0,0 +1,157 @@ +package api + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "booklib/internal/scanner" + "booklib/internal/store" +) + +func newLibrary(t *testing.T, st *store.Store, h http.Handler, tok, booksDir, name string) (store.Library, string) { + t.Helper() + root := filepath.Join(booksDir, name) + os.MkdirAll(filepath.Join(root, "series-a"), 0o755) + w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": name, "root_path": root}) + if w.Code != 201 { + t.Fatalf("create lib %d %s", w.Code, w.Body) + } + var v struct { + ID int64 `json:"id"` + } + json.Unmarshal(w.Body.Bytes(), &v) + lib, err := st.GetLibrary(context.Background(), v.ID) + if err != nil { + t.Fatal(err) + } + return lib, root +} + +func writeCBZ(t *testing.T, path string, pages int) { + t.Helper() + os.MkdirAll(filepath.Dir(path), 0o755) + buf := &bytes.Buffer{} + zw := zip.NewWriter(buf) + for i := 1; i <= pages; i++ { + w, _ := zw.Create(i2name(i)) + w.Write(bytes.Repeat([]byte("IMG"), 64)) + } + zw.Close() + os.WriteFile(path, buf.Bytes(), 0o644) +} +func i2name(i int) string { return fmt.Sprintf("%02d.jpg", i) } + +func scanNow(t *testing.T, sc *scanner.Scanner, lib store.Library) { + t.Helper() + sc.ScanLibrary(context.Background(), lib) +} + +func TestBookListDetailDelete(t *testing.T) { + st, sc, h, booksDir := setupAPI(t) + atok := adminToken(t, h) + + // member token + 进度前置数据 + do(h, "POST", "/api/users", atok, map[string]string{"username": "m2", "password": testPW, "role": "member"}) + mtok := loginAs(t, h, "m2", testPW) + + lib, root := newLibrary(t, st, h, atok, booksDir, "comics") + writeCBZ(t, filepath.Join(root, "series-a", "vol_01.cbz"), 4) + os.WriteFile(filepath.Join(root, "readme.txt"), []byte("hello world"), 0o644) + scanNow(t, sc, lib) + + w := do(h, "GET", "/api/books", mtok, nil) + var books []map[string]any + json.Unmarshal(w.Body.Bytes(), &books) + if w.Code != 200 || len(books) != 2 { + t.Fatalf("list %d %s", w.Code, w.Body) + } + var cbz map[string]any + for _, b := range books { + if b["format"] == "cbz" { + cbz = b + } + } + if cbz == nil { + t.Fatal("cbz missing") + } + if cbz["pages"].(float64) != 4 || cbz["library"] != "comics" || cbz["percent"].(float64) != 0 { + t.Fatalf("bad json %+v", cbz) + } + if !strings.HasPrefix(cbz["cover_url"].(string), "/api/books/") || !strings.Contains(cbz["cover_url"].(string), "?v=") { + t.Fatalf("cover_url %+v", cbz["cover_url"]) + } + if cbz["page_url_fmt"] == nil { + t.Fatalf("cbz must have page_url_fmt: %+v", cbz) + } + id := itoa(cbz["id"]) + + // detail + w = do(h, "GET", "/api/books/"+id, mtok, nil) + if w.Code != 200 { + t.Fatalf("detail %d", w.Code) + } + // 进度联动的断言在 Task 12(progress 端点此任务还不存在) + + // member 不能删 + w = do(h, "DELETE", "/api/books/"+id, mtok, nil) + if w.Code != 403 { + t.Fatalf("member delete want 403 got %d", w.Code) + } + // admin 删:行、文件、缓存目录都没;txt 文件保留 + w = do(h, "DELETE", "/api/books/"+id, atok, nil) + if w.Code != 204 { + t.Fatalf("admin delete %d %s", w.Code, w.Body) + } + if _, err := os.Stat(filepath.Join(root, "series-a", "vol_01.cbz")); !os.IsNotExist(err) { + t.Fatal("file not removed") + } + w = do(h, "GET", "/api/books", atok, nil) + json.Unmarshal(w.Body.Bytes(), &books) + if len(books) != 1 || books[0]["format"] != "txt" { + t.Fatalf("books after delete %+v", books) + } + // 过滤器 + w = do(h, "GET", "/api/books?library="+itoa(lib.ID)+"&q=readme&prefix=series/", atok, nil) + json.Unmarshal(w.Body.Bytes(), &books) + if len(books) != 0 { + t.Fatalf("prefix+q filter broken %+v", books) + } + w = do(h, "GET", "/api/books?q=readme", atok, nil) + json.Unmarshal(w.Body.Bytes(), &books) + if len(books) != 1 { + t.Fatalf("q broken %+v", books) + } +} + +func loginAs(t *testing.T, h http.Handler, user, pass string) string { + t.Helper() + w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": user, "password": pass}) + if w.Code != 200 { + t.Fatalf("login %s: %d", user, w.Code) + } + var v struct { + Token string + } + json.Unmarshal(w.Body.Bytes(), &v) + return v.Token +} + +func TestAbsBookPathTraversalRejected(t *testing.T) { + root := "/data/books/lib" // 纯路径逻辑,不碰文件系统,无需 DB + for _, bad := range []string{"../../etc/passwd", "a/../../../etc/x", "../sibling"} { + if _, e := absBookPath(root, store.Book{Path: bad}); e == nil { + t.Fatalf("must reject %q", bad) + } + } + if p, e := absBookPath(root, store.Book{Path: "series-a/vol.cbz"}); e != nil || p != filepath.Join(root, "series-a", "vol.cbz") { + t.Fatalf("must accept relative path: %q %v", p, e) + } +} diff --git a/backend/internal/api/libraries_test.go b/backend/internal/api/libraries_test.go index 103dcc8..67ce27c 100644 --- a/backend/internal/api/libraries_test.go +++ b/backend/internal/api/libraries_test.go @@ -12,9 +12,10 @@ import ( ) func TestLibraryCreateListUpload(t *testing.T) { - _, h := setupAPI(t) + _, _, h, booksDir := setupAPI(t) tok := adminToken(t, h) - root := t.TempDir() + root := filepath.Join(booksDir, "lib1") // 必须落在解析过软链的 booksDir 内 + os.MkdirAll(root, 0o755) w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics", "root_path": root}) if w.Code != 201 { t.Fatalf("create lib %d %s", w.Code, w.Body) diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 8609a46..d856fce 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -33,5 +33,9 @@ func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner libs.POST("", a.adminOnly(), a.createLibrary) libs.POST("/:id/scan", a.adminOnly(), a.scanLibrary) libs.POST("/:id/upload", a.adminOnly(), a.upload) + + p.GET("/books", a.listBooks) + p.GET("/books/:id", a.getBook) + p.DELETE("/books/:id", a.adminOnly(), a.deleteBook) return r } diff --git a/backend/internal/api/users_test.go b/backend/internal/api/users_test.go index 852442f..c15dafc 100644 --- a/backend/internal/api/users_test.go +++ b/backend/internal/api/users_test.go @@ -9,14 +9,11 @@ import ( func adminToken(t *testing.T, h http.Handler) string { t.Helper() - w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "alice", "password": "pw12345"}) - var v struct{ Token string } - json.Unmarshal(w.Body.Bytes(), &v) - return v.Token + return loginAs(t, h, "alice", testPW) } func TestUserCRUD(t *testing.T) { - _, h := setupAPI(t) + _, _, h, _ := setupAPI(t) tok := adminToken(t, h) w := do(h, "POST", "/api/users", tok, map[string]string{"username": "carol", "password": "pw123456", "role": "member"}) if w.Code != 201 { @@ -59,7 +56,7 @@ func TestUserCRUD(t *testing.T) { } func TestBadRoleRejected(t *testing.T) { - _, h := setupAPI(t) + _, _, h, _ := setupAPI(t) tok := adminToken(t, h) w := do(h, "POST", "/api/users", tok, map[string]string{"username": "e", "password": "pw123456", "role": "god"}) if w.Code != 400 { @@ -76,4 +73,9 @@ func findID(rows []map[string]any, name string) float64 { return 0 } -func itoa(v any) string { return strconv.FormatFloat(v.(float64), 'f', 0, 64) } +func itoa(v any) string { + if i, ok := v.(int64); ok { + return strconv.FormatInt(i, 10) // DB 行里的 id (如 store.Library.ID) + } + return strconv.FormatFloat(v.(float64), 'f', 0, 64) // json.Unmarshal 出来的数字 +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 3f622fe..236be76 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "path/filepath" "strconv" "time" ) @@ -39,6 +40,14 @@ func Load() (*Config, error) { if secret == "" { return nil, fmt.Errorf("JWT_SECRET required") } + // resolveDir: macOS 开发机上 /var、/tmp 是指向 /private 的软链, + // 启动时解析一次才能让 root_path 前缀校验对上真实路径;目录不存在/出错则保留原值(Docker 路径不受影响) + resolveDir := func(dir string) string { + if r, e := filepath.EvalSymlinks(dir); e == nil { + return r + } + return dir + } return &Config{ Addr: env("ADDR", ":8080"), DatabaseURL: env("DATABASE_URL", ""), @@ -46,8 +55,8 @@ func Load() (*Config, error) { JWTSecret: []byte(secret), AdminUser: env("ADMIN_USER", ""), AdminPassword: env("ADMIN_PASSWORD", ""), - BooksDir: env("BOOKS_DIR", "/data/books"), - CacheDir: env("CACHE_DIR", "/data/cache"), + BooksDir: resolveDir(env("BOOKS_DIR", "/data/books")), + CacheDir: resolveDir(env("CACHE_DIR", "/data/cache")), ScanInterval: time.Duration(scanSec) * time.Second, UploadMaxMB: uploadMB, }, nil