diff --git a/backend/cmd/webui/api/router.go b/backend/cmd/webui/api/router.go index 3c86f0f..98fe02a 100644 --- a/backend/cmd/webui/api/router.go +++ b/backend/cmd/webui/api/router.go @@ -53,5 +53,9 @@ func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner p.GET("/books/:id/pages/:n", h.Page) p.PUT("/books/:id/progress", h.PutProgress) p.GET("/progress", h.ListProgress) + p.GET("/books/:id/bookmarks", h.ListBookmarks) + p.POST("/books/:id/bookmarks", h.CreateBookmark) + p.PATCH("/bookmarks/:id", h.PatchBookmark) + p.DELETE("/bookmarks/:id", h.DeleteBookmark) return r } diff --git a/backend/cmd/webui/handlers/bookmarks.go b/backend/cmd/webui/handlers/bookmarks.go new file mode 100644 index 0000000..507f74d --- /dev/null +++ b/backend/cmd/webui/handlers/bookmarks.go @@ -0,0 +1,131 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + "unicode/utf8" + + "github.com/gin-gonic/gin" + + "booklib/internal/store" +) + +const maxNoteRunes = 500 + +func bookmarkJSON(b store.Bookmark) gin.H { + return gin.H{ + "id": b.ID, "library_id": b.LibraryID, "path": b.BookPath, + "locator": json.RawMessage(b.Locator), "percent": b.Percent, + "note": b.Note, "created_at": b.CreatedAt.Format(time.RFC3339), + } +} + +func (h *H) ListBookmarks(c *gin.Context) { + b, ok := h.bookFromParam(c) + if !ok { + return + } + rows, e := h.st.ListBookmarks(c, uid(c), b.LibraryID, b.Path) + if e != nil { + dbErr(c, e) + return + } + out := make([]gin.H, 0, len(rows)) + for _, r := range rows { + out = append(out, bookmarkJSON(r)) + } + c.JSON(http.StatusOK, out) +} + +func (h *H) CreateBookmark(c *gin.Context) { + b, ok := h.bookFromParam(c) + if !ok { + return + } + var req struct { + Locator json.RawMessage `json:"locator"` + Percent float64 `json:"percent"` + Note string `json:"note"` + } + if e := c.ShouldBindJSON(&req); e != nil { + err(c, http.StatusBadRequest, "bad_request", "json body required") + return + } + if len(req.Locator) == 0 || string(req.Locator) == "null" || !json.Valid(req.Locator) { + err(c, http.StatusBadRequest, "bad_request", "locator must be valid json") + return + } + if req.Percent < 0 || req.Percent > 1 { + err(c, http.StatusBadRequest, "bad_request", "percent must be in [0,1]") + return + } + if utf8.RuneCountInString(req.Note) > maxNoteRunes { + err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)") + return + } + id, e := h.st.InsertBookmark(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent, req.Note) + if e != nil { + dbErr(c, e) + return + } + c.JSON(http.StatusCreated, bookmarkJSON(store.Bookmark{ + ID: id, LibraryID: b.LibraryID, BookPath: b.Path, + Locator: req.Locator, Percent: req.Percent, Note: req.Note, CreatedAt: time.Now(), + })) +} + +func bookmarkID(c *gin.Context) (int64, bool) { + id, e := strconv.ParseInt(c.Param("id"), 10, 64) + if e != nil { + err(c, http.StatusBadRequest, "bad_request", "bad id") + return 0, false + } + return id, true +} + +func (h *H) PatchBookmark(c *gin.Context) { + id, ok := bookmarkID(c) + if !ok { + return + } + var req struct { + Note string `json:"note"` + } + if e := c.ShouldBindJSON(&req); e != nil { + err(c, http.StatusBadRequest, "bad_request", "json body required") + return + } + if utf8.RuneCountInString(req.Note) > maxNoteRunes { + err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)") + return + } + updated, e := h.st.UpdateBookmarkNote(c, uid(c), id, req.Note) + if e != nil { + dbErr(c, e) + return + } + if !updated { + err(c, http.StatusNotFound, "not_found", "no such bookmark") + return + } + c.JSON(http.StatusOK, gin.H{"id": id, "note": req.Note}) +} + +func (h *H) DeleteBookmark(c *gin.Context) { + id, ok := bookmarkID(c) + if !ok { + return + } + deleted, e := h.st.DeleteBookmark(c, uid(c), id) + if e != nil { + dbErr(c, e) + return + } + if !deleted { + err(c, http.StatusNotFound, "not_found", "no such bookmark") + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/cmd/webui/handlers/bookmarks_test.go b/backend/cmd/webui/handlers/bookmarks_test.go new file mode 100644 index 0000000..fed34ef --- /dev/null +++ b/backend/cmd/webui/handlers/bookmarks_test.go @@ -0,0 +1,88 @@ +package handlers_test + +import ( + "encoding/json" + "fmt" + "strings" + "testing" +) + +func TestBookmarkCRUDAndOwnership(t *testing.T) { + st, sc, h, booksDir := setupAPI(t) + tok := adminToken(t, h) + lib, root := newLibrary(t, st, h, tok, booksDir, "bm") + writeCBZ(t, root+"/x.cbz", 5) + scanNow(t, sc, lib) + bs := []map[string]any{} + json.Unmarshal(do(h, "GET", "/api/books?q=x", tok, nil).Body.Bytes(), &bs) + bid := itoa(bs[0]["id"]) + + w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok, + map[string]any{"locator": map[string]int{"page": 3}, "percent": 0.6, "note": "伏笔"}) + if w.Code != 201 { + t.Fatalf("create %d %s", w.Code, w.Body) + } + var bm map[string]any + json.Unmarshal(w.Body.Bytes(), &bm) + bmID := itoa(bm["id"]) + if bm["note"] != "伏笔" { + t.Fatalf("echo: %s", w.Body) + } + + // 第二本书 + 第二条书签:列表按 percent 升序且不跨书泄漏 + writeCBZ(t, fmt.Sprintf("%s/y.cbz", root), 5) + scanNow(t, sc, lib) + bs = nil + json.Unmarshal(do(h, "GET", "/api/books?q=y", tok, nil).Body.Bytes(), &bs) + yid := itoa(bs[0]["id"]) + do(h, "POST", "/api/books/"+bid+"/bookmarks", tok, + map[string]any{"locator": map[string]int{"page": 1}, "percent": 0.2}) + arr := []map[string]any{} + json.Unmarshal(do(h, "GET", "/api/books/"+bid+"/bookmarks", tok, nil).Body.Bytes(), &arr) + if len(arr) != 2 || arr[0]["percent"].(float64) > arr[1]["percent"].(float64) { + t.Fatalf("list order/scope: %v", arr) + } + if w := do(h, "GET", "/api/books/"+yid+"/bookmarks", tok, nil); strings.TrimSpace(w.Body.String()) != "[]" { + t.Fatalf("other book leak: %s", w.Body) + } + + if w := do(h, "PATCH", "/api/bookmarks/"+bmID, tok, map[string]string{"note": "改了"}); w.Code != 200 { + t.Fatalf("patch %d %s", w.Code, w.Body) + } + + // 校验:note 超长 / percent 越界 / locator 缺失 + if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok, + map[string]any{"locator": map[string]int{"page": 1}, "percent": 0.5, "note": strings.Repeat("字", 501)}); w.Code != 400 { + t.Fatalf("long note want 400 got %d", w.Code) + } + if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok, + map[string]any{"locator": map[string]int{"page": 1}, "percent": 1.5}); w.Code != 400 { + t.Fatalf("percent want 400 got %d", w.Code) + } + if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok, + map[string]any{"percent": 0.5}); w.Code != 400 { + t.Fatalf("locator required got %d", w.Code) + } + + // 所有权:member carl 看不见/改不了/删不了 alice 的书签 + do(h, "POST", "/api/users", tok, map[string]string{"username": "carl", "password": testPW, "role": "member"}) + lr := map[string]string{} + json.Unmarshal(do(h, "POST", "/api/auth/login", "", map[string]string{"username": "carl", "password": testPW}).Body.Bytes(), &lr) + bt := lr["token"] + if w := do(h, "GET", "/api/books/"+bid+"/bookmarks", bt, nil); strings.TrimSpace(w.Body.String()) != "[]" { + t.Fatalf("cross-user leak: %s", w.Body) + } + if w := do(h, "PATCH", "/api/bookmarks/"+bmID, bt, map[string]string{"note": "抢"}); w.Code != 404 { + t.Fatalf("cross-user patch want 404 got %d", w.Code) + } + if w := do(h, "DELETE", "/api/bookmarks/"+bmID, bt, nil); w.Code != 404 { + t.Fatalf("cross-user delete want 404 got %d", w.Code) + } + + if w := do(h, "DELETE", "/api/bookmarks/"+bmID, tok, nil); w.Code != 204 { + t.Fatalf("delete %d", w.Code) + } + if w := do(h, "DELETE", "/api/bookmarks/"+bmID, tok, nil); w.Code != 404 { + t.Fatalf("re-delete want 404 got %d", w.Code) + } +} diff --git a/backend/internal/db/schema.sql b/backend/internal/db/schema.sql index 6fa08a2..53d0cab 100644 --- a/backend/internal/db/schema.sql +++ b/backend/internal/db/schema.sql @@ -19,3 +19,11 @@ CREATE TABLE IF NOT EXISTS reading_progress ( locator JSONB NOT NULL DEFAULT '{}', percent DOUBLE PRECISION NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (user_id, library_id, book_path)); +CREATE TABLE IF NOT EXISTS bookmarks ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + library_id BIGINT NOT NULL, book_path TEXT NOT NULL, + locator JSONB NOT NULL, percent DOUBLE PRECISION NOT NULL DEFAULT 0, + note TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now()); +CREATE INDEX IF NOT EXISTS bookmarks_user_book_idx ON bookmarks (user_id, library_id, book_path); diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 4b98dbe..f365f97 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -314,6 +314,57 @@ func (s *Store) ListProgress(ctx context.Context, userID int64) ([]Progress, err return out, rows.Err() } +// ---------- bookmarks ---------- + +type Bookmark struct { + ID int64 + LibraryID int64 + BookPath string + Locator []byte + Percent float64 + Note string + CreatedAt time.Time +} + +func (s *Store) InsertBookmark(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64, note string) (int64, error) { + var id int64 + err := s.P.QueryRow(ctx, + `INSERT INTO bookmarks (user_id, library_id, book_path, locator, percent, note) + VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, + userID, libID, bookPath, locator, percent, note).Scan(&id) + return id, err +} + +func (s *Store) ListBookmarks(ctx context.Context, userID, libID int64, bookPath string) ([]Bookmark, error) { + rows, err := s.P.Query(ctx, + `SELECT id, library_id, book_path, locator, percent, note, created_at + FROM bookmarks WHERE user_id=$1 AND library_id=$2 AND book_path=$3 + ORDER BY percent ASC, id ASC`, userID, libID, bookPath) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Bookmark + for rows.Next() { + var b Bookmark + if err := rows.Scan(&b.ID, &b.LibraryID, &b.BookPath, &b.Locator, &b.Percent, &b.Note, &b.CreatedAt); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +func (s *Store) UpdateBookmarkNote(ctx context.Context, userID, id int64, note string) (bool, error) { + res, err := s.P.Exec(ctx, `UPDATE bookmarks SET note=$3 WHERE id=$1 AND user_id=$2`, id, userID, note) + return res.RowsAffected() > 0, err +} + +func (s *Store) DeleteBookmark(ctx context.Context, userID, id int64) (bool, error) { + res, err := s.P.Exec(ctx, `DELETE FROM bookmarks WHERE id=$1 AND user_id=$2`, id, userID) + return res.RowsAffected() > 0, err +} + func (s *Store) GetProgress(ctx context.Context, userID, libID int64, bookPath string) (Progress, error) { var pr Progress err := s.P.QueryRow(ctx,