diff --git a/backend/cmd/webui/api/router.go b/backend/cmd/webui/api/router.go index 33e3ecb..0a195c4 100644 --- a/backend/cmd/webui/api/router.go +++ b/backend/cmd/webui/api/router.go @@ -7,15 +7,18 @@ import ( "booklib/cmd/webui/handlers" "booklib/internal/config" + "booklib/internal/media" "booklib/internal/redispkg" "booklib/internal/scanner" "booklib/internal/store" "booklib/internal/upload" ) -func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner, up *upload.U) *gin.Engine { +// NewRouter 是组合根:接受具体实现,分发给 handlers.New 的小口径 port 字段。 +// *store.Store 满足 5 个 store 接口,*redispkg.R 满足 RateLimiter。 +func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner, med *media.M, up *upload.U) *gin.Engine { gin.SetMode(gin.ReleaseMode) - h := handlers.New(cfg, st, rdb, sc, up) + h := handlers.New(cfg, st, st, st, st, st, rdb, sc, med, up) r := gin.New() if e := r.SetTrustedProxies(cfg.TrustedProxies); e != nil { panic(e) diff --git a/backend/cmd/webui/api/router_test.go b/backend/cmd/webui/api/router_test.go index 3b3a324..f1e1037 100644 --- a/backend/cmd/webui/api/router_test.go +++ b/backend/cmd/webui/api/router_test.go @@ -16,7 +16,7 @@ func testCfg() *config.Config { } func TestHealthz(t *testing.T) { - r := NewRouter(testCfg(), nil, redispkg.New(""), nil, nil) + r := NewRouter(testCfg(), nil, redispkg.New(""), nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/healthz", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) diff --git a/backend/cmd/webui/handlers/auth.go b/backend/cmd/webui/handlers/auth.go index fe9eb1e..2ba9511 100644 --- a/backend/cmd/webui/handlers/auth.go +++ b/backend/cmd/webui/handlers/auth.go @@ -20,11 +20,11 @@ func (h *H) Login(c *gin.Context) { err(c, http.StatusBadRequest, "bad_request", "username and password required") return } - if n := h.rdb.IncrWindow(c, "loginrl:"+c.ClientIP(), loginWindow); n > loginMax { + if n := h.rl.IncrWindow(c, "loginrl:"+c.ClientIP(), loginWindow); n > loginMax { err(c, http.StatusTooManyRequests, "rate_limited", "too many login attempts") return } - u, qerr := h.st.GetUserByName(c, req.Username) + u, qerr := h.users.GetUserByName(c, req.Username) if qerr != nil { if !errors.Is(qerr, pgx.ErrNoRows) { dbErr(c, qerr) @@ -49,7 +49,7 @@ func (h *H) Login(c *gin.Context) { func (h *H) Me(c *gin.Context) { // B7: only no-rows → 401; other errors (PG down) go through dbErr → 503. - u, qerr := h.st.GetUserByID(c, uid(c)) + u, qerr := h.users.GetUserByID(c, uid(c)) if qerr != nil { if errors.Is(qerr, pgx.ErrNoRows) { err(c, http.StatusUnauthorized, "unauthorized", "no such user") diff --git a/backend/cmd/webui/handlers/auth_test.go b/backend/cmd/webui/handlers/auth_test.go index 33d3dae..b4b768e 100644 --- a/backend/cmd/webui/handlers/auth_test.go +++ b/backend/cmd/webui/handlers/auth_test.go @@ -18,6 +18,7 @@ import ( "booklib/internal/auth" "booklib/internal/config" "booklib/internal/db" + "booklib/internal/media" "booklib/internal/redispkg" "booklib/internal/scanner" "booklib/internal/store" @@ -58,9 +59,10 @@ func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, strin cfg.BooksDir = booksDir cfg.CacheDir = t.TempDir() rdb := redispkg.New(os.Getenv("REDIS_URL")) + med := media.New(cfg, rdb) up := upload.New(cfg.BooksDir, cfg.UploadMaxMB) sc := scanner.New(st, cfg, rdb, up) - r := api.NewRouter(cfg, st, rdb, sc, up) + r := api.NewRouter(cfg, st, rdb, sc, med, up) if u := os.Getenv("REDIS_URL"); u != "" { // 测试卫生: 共享 redis 上重置登录限流桶, 防跨测试累计 429 if opt, e := redis.ParseURL(u); e == nil { rc := redis.NewClient(opt) diff --git a/backend/cmd/webui/handlers/bookmarks.go b/backend/cmd/webui/handlers/bookmarks.go index 507f74d..454f391 100644 --- a/backend/cmd/webui/handlers/bookmarks.go +++ b/backend/cmd/webui/handlers/bookmarks.go @@ -27,7 +27,7 @@ func (h *H) ListBookmarks(c *gin.Context) { if !ok { return } - rows, e := h.st.ListBookmarks(c, uid(c), b.LibraryID, b.Path) + rows, e := h.bookmarks.ListBookmarks(c, uid(c), b.LibraryID, b.Path) if e != nil { dbErr(c, e) return @@ -65,7 +65,7 @@ func (h *H) CreateBookmark(c *gin.Context) { 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) + id, e := h.bookmarks.InsertBookmark(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent, req.Note) if e != nil { dbErr(c, e) return @@ -101,7 +101,7 @@ func (h *H) PatchBookmark(c *gin.Context) { err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)") return } - updated, e := h.st.UpdateBookmarkNote(c, uid(c), id, req.Note) + updated, e := h.bookmarks.UpdateBookmarkNote(c, uid(c), id, req.Note) if e != nil { dbErr(c, e) return @@ -118,7 +118,7 @@ func (h *H) DeleteBookmark(c *gin.Context) { if !ok { return } - deleted, e := h.st.DeleteBookmark(c, uid(c), id) + deleted, e := h.bookmarks.DeleteBookmark(c, uid(c), id) if e != nil { dbErr(c, e) return diff --git a/backend/cmd/webui/handlers/books.go b/backend/cmd/webui/handlers/books.go index 1455bb1..04531dc 100644 --- a/backend/cmd/webui/handlers/books.go +++ b/backend/cmd/webui/handlers/books.go @@ -18,7 +18,7 @@ import ( ) func (h *H) getBookRow(c *gin.Context, id int64) (store.Book, bool) { - b, e := h.st.GetBook(c, id) + b, e := h.books.GetBook(c, id) if e != nil { if errors.Is(e, pgx.ErrNoRows) { err(c, http.StatusNotFound, "not_found", "no such book") @@ -31,27 +31,13 @@ func (h *H) getBookRow(c *gin.Context, id int64) (store.Book, bool) { } 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") + id, ok := idParam(c) + if !ok { 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)) @@ -89,7 +75,7 @@ func bookJSON(b store.Book, percent float64, libraryName string) gin.H { 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)) + views, e := h.books.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c)) if e != nil { dbErr(c, e) return @@ -106,12 +92,12 @@ func (h *H) GetBook(c *gin.Context) { if !ok { return } - p, e := h.st.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent + p, e := h.progress.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) + lib, e := h.libs.GetLibrary(c, b.LibraryID) if e != nil && !errors.Is(e, pgx.ErrNoRows) { // 库被并发删则留空 library 名,书仍可见 dbErr(c, e) return @@ -124,7 +110,7 @@ func (h *H) DeleteBook(c *gin.Context) { if !ok { return } - lib, ok := h.getLibRow(c, b.LibraryID) + lib, ok := h.getLib(c, b.LibraryID) if !ok { return } @@ -144,7 +130,7 @@ func (h *H) DeleteBook(c *gin.Context) { 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 { + if e := h.books.DeleteBook(c, b.ID); e != nil { dbErr(c, e) return } diff --git a/backend/cmd/webui/handlers/content.go b/backend/cmd/webui/handlers/content.go index fbfe8d3..34fe433 100644 --- a/backend/cmd/webui/handlers/content.go +++ b/backend/cmd/webui/handlers/content.go @@ -1,15 +1,13 @@ package handlers import ( - "fmt" - "log" + "errors" + "io/fs" "net/http" "os" - "path" "path/filepath" "strconv" "strings" - "time" "github.com/gin-gonic/gin" @@ -20,7 +18,7 @@ import ( const defaultCover = `` func (h *H) bookRoot(c *gin.Context, b store.Book) (string, bool) { - lib, ok := h.getLibRow(c, b.LibraryID) + lib, ok := h.getLib(c, b.LibraryID) if !ok { return "", false } @@ -31,6 +29,26 @@ func (h *H) immutable(c *gin.Context) { c.Header("Cache-Control", "public, max-age=31536000, immutable") } +// checkPath 纵深防御:path 越界 → 403,与原契约一致。 +func checkPath(c *gin.Context, root string, b store.Book) (string, bool) { + abs, perr := absBookPath(root, b) + if perr != nil { + err(c, http.StatusForbidden, "forbidden", "unsafe path") + return "", false + } + return abs, true +} + +// mapContentErr 把 media/bookfile 的文件级失败映射回原契约状态码: +// 文件不在盘上 → 404,其余(坏包等)由调用方决定 422/500。 +func mapContentErr(c *gin.Context, e error) bool { + if errors.Is(e, fs.ErrNotExist) { + err(c, http.StatusNotFound, "not_found", "file missing on disk") + return true + } + return false +} + func (h *H) ServeCover(c *gin.Context) { b, ok := h.bookFromParam(c) if !ok { @@ -48,36 +66,30 @@ func (h *H) ServeCover(c *gin.Context) { } 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 _, ok := checkPath(c, root, b); ok { + e := h.med.EnsureCover(c, b.ID, b.Format, b.FileSize, b.ModTS, root, b.Path) + switch { + case e == nil: + if entries, re := os.ReadDir(dir); re == nil { + for _, en := range entries { + if !strings.Contains(en.Name(), ".tmp") { + http.ServeFile(c.Writer, c.Request, filepath.Join(dir, en.Name())) + return + } } } + case mapContentErr(c, e): + return // 404 已回复 } + // 抽取失败(坏包)→ 落到占位 SVG,与原契约一致 + } else { + return // 403 已回复 } + } else { + return // 404/503 已回复 } } - if c.Writer.Written() { // openBook/bookRoot 已写 403/404/500,不再叠加占位图 + if c.Writer.Written() { return } c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover)) @@ -92,55 +104,32 @@ func (h *H) ServeFile(c *gin.Context) { if !ok { return } - abs, perr := absBookPath(root, b) - if perr != nil { - err(c, http.StatusForbidden, "forbidden", "unsafe path") + abs, ok := checkPath(c, root, b) + if !ok { return } - c.Header("ETag", `"`+bookfile.Hash(b.FileSize, b.ModTS)+`"`) + if _, e := os.Stat(abs); e != nil { + err(c, http.StatusNotFound, "not_found", "file missing on disk") + return + } + c.Header("ETag", `"`+h.med.CacheBuster(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 +// pageIndex 走 media(redis 缓存 + 索引提取);路径校验仍在 handler,保住 403 契约。 +func (h *H) pageIndex(c *gin.Context, b store.Book, root string) ([]string, bool) { + if _, ok := checkPath(c, root, b); !ok { + return nil, 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) + idx, e := h.med.PageIndex(c, b.ID, b.FileSize, b.ModTS, root, b.Path) if e != nil { - return nil, e + if !mapContentErr(c, e) { + err(c, http.StatusUnprocessableEntity, "broken", e.Error()) + } + return nil, false } - if len(idx) > 0 { // 空索引不缓存,否则 warm 命中 "" 会 Split 出幽灵页 - h.rdb.Set(c, key, strings.Join(idx, "\n"), 7*24*time.Hour) - } - return idx, nil + return idx, true } func (h *H) PagesCount(c *gin.Context) { @@ -156,56 +145,11 @@ func (h *H) PagesCount(c *gin.Context) { 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()) + idx, ok := h.pageIndex(c, b, root) + if !ok { 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 + c.JSON(http.StatusOK, gin.H{"count": len(idx), "chapters": h.med.ChaptersOf(idx)}) } func (h *H) Page(c *gin.Context) { @@ -226,47 +170,22 @@ func (h *H) Page(c *gin.Context) { 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()) + idx, ok := h.pageIndex(c, b, root) + if !ok { 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") + // miss → 解压落盘(media 内部唯一 tmp 名 + rename 原子,并发重做同页幂等) + dst, e := h.med.EnsurePage(c, b.ID, b.FileSize, b.ModTS, root, b.Path, n, idx) + if e != nil { + if mapContentErr(c, e) { return } + err(c, http.StatusInternalServerError, "internal", "extract page") + return } h.immutable(c) http.ServeFile(c.Writer, c.Request, dst) diff --git a/backend/cmd/webui/handlers/handlers.go b/backend/cmd/webui/handlers/handlers.go index 3e46aab..ee07e8d 100644 --- a/backend/cmd/webui/handlers/handlers.go +++ b/backend/cmd/webui/handlers/handlers.go @@ -6,6 +6,7 @@ import ( "log" "net" "net/http" + "strconv" "strings" "syscall" @@ -15,28 +16,45 @@ import ( "booklib/internal/auth" "booklib/internal/config" - "booklib/internal/redispkg" - "booklib/internal/scanner" - "booklib/internal/store" - "booklib/internal/upload" + "booklib/internal/ports" ) +// H 只依赖 ports 里的小口径接口(Task 25):具体实现由 main.go 装配, +// 测试可注入手写 fake(portsfake),无需 PG/Redis。 type H struct { - cfg *config.Config - st *store.Store - rdb *redispkg.R - sc *scanner.Scanner - up *upload.U + cfg *config.Config + users ports.UserStore + libs ports.LibraryStore + books ports.BookStore + progress ports.ProgressStore + bookmarks ports.BookmarkStore + rl ports.RateLimiter + sc ports.Scanner + med ports.Media + up ports.UploadSessions } -func New(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner, up *upload.U) *H { - return &H{cfg: cfg, st: st, rdb: rdb, sc: sc, up: up} +func New(cfg *config.Config, users ports.UserStore, libs ports.LibraryStore, books ports.BookStore, + progress ports.ProgressStore, bookmarks ports.BookmarkStore, rl ports.RateLimiter, + sc ports.Scanner, med ports.Media, up ports.UploadSessions) *H { + return &H{cfg: cfg, users: users, libs: libs, books: books, progress: progress, + bookmarks: bookmarks, rl: rl, sc: sc, med: med, up: up} } func err(c *gin.Context, status int, code, msg string) { c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"code": code, "message": msg}}) } +// idParam 解析 :id 路径参数,失败已回复 400。 +func idParam(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 +} + // dbErr 统一处理 store 层失败:记日志;连接类错误 503(Service Unavailable),其余 500 // 注:brief 里的 pgxpool.ErrClosedPool 在 pgx v5 不存在,实际由 puddle 原样透出,用它替代; // PG 停机时池内连接先收到 SQLSTATE 57P01(administrator shutdown),故把 08xx/57Pxx 也归为 503 diff --git a/backend/cmd/webui/handlers/libraries.go b/backend/cmd/webui/handlers/libraries.go index e7c30c1..b576c71 100644 --- a/backend/cmd/webui/handlers/libraries.go +++ b/backend/cmd/webui/handlers/libraries.go @@ -16,6 +16,7 @@ import ( "booklib/internal/bookfile" "booklib/internal/media" + "booklib/internal/ports" "booklib/internal/store" ) @@ -23,7 +24,7 @@ import ( func (h *H) libRoot(c *gin.Context, lib store.Library) (string, bool) { root := filepath.Clean(lib.RootPath) books := filepath.Clean(h.cfg.BooksDir) - if !filepath.IsAbs(root) || (root != books && !strings.HasPrefix(root, books+string(os.PathSeparator))) { + if !filepath.IsAbs(root) || !bookfile.Contains(books, root) { err(c, http.StatusForbidden, "forbidden", "library root outside books dir") return "", false } @@ -34,8 +35,22 @@ func (h *H) libRoot(c *gin.Context, lib store.Library) (string, bool) { return root, true } +// getLib 查库行,404/503/500 已回复(原 getLibrary/getLibRow 合一,Task 25) +func (h *H) getLib(c *gin.Context, id int64) (store.Library, bool) { + l, e := h.libs.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 +} + func (h *H) ListLibraries(c *gin.Context) { - libs, e := h.st.ListLibraries(c) + libs, e := h.libs.ListLibraries(c) if e != nil { dbErr(c, e) return @@ -68,9 +83,9 @@ func (h *H) CreateLibrary(c *gin.Context) { return } root := filepath.Join(filepath.Clean(h.cfg.BooksDir), safe) - id, e := h.st.CreateLibrary(c, req.Name, root) + id, e := h.libs.CreateLibrary(c, req.Name, root) if e != nil { - if isUnique(e) { + if ports.IsUniqueViolation(e) { err(c, http.StatusConflict, "exists", "name taken") return } @@ -80,26 +95,12 @@ func (h *H) CreateLibrary(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": root}) } -func (h *H) getLibrary(c *gin.Context) (store.Library, bool) { - id, e := strconv.ParseInt(c.Param("id"), 10, 64) - if e != nil { - err(c, http.StatusBadRequest, "bad_request", "bad id") - return store.Library{}, false - } - lib, 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 lib, true -} - func (h *H) ScanLibrary(c *gin.Context) { - lib, ok := h.getLibrary(c) + id, ok := idParam(c) + if !ok { + return + } + lib, ok := h.getLib(c, id) if !ok { return } @@ -111,7 +112,11 @@ func (h *H) ScanLibrary(c *gin.Context) { } func (h *H) Upload(c *gin.Context) { - lib, ok := h.getLibrary(c) + id, ok := idParam(c) + if !ok { + return + } + lib, ok := h.getLib(c, id) if !ok { return } @@ -155,7 +160,7 @@ func (h *H) Upload(c *gin.Context) { err(c, http.StatusInternalServerError, "internal", "create tmp") return } - // O_EXCL collision — retry with fresh uniquePath. + // O_EXCL collision — retry with fresh UniquePath. } if out == nil { err(c, http.StatusConflict, "conflict", "too many concurrent uploads with same name") diff --git a/backend/cmd/webui/handlers/progress.go b/backend/cmd/webui/handlers/progress.go index 6c130f4..9a94565 100644 --- a/backend/cmd/webui/handlers/progress.go +++ b/backend/cmd/webui/handlers/progress.go @@ -32,7 +32,7 @@ func (h *H) PutProgress(c *gin.Context) { err(c, http.StatusBadRequest, "bad_request", "locator must be valid json") return } - if e := h.st.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil { + if e := h.progress.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil { dbErr(c, e) return } @@ -40,7 +40,7 @@ func (h *H) PutProgress(c *gin.Context) { } func (h *H) ListProgress(c *gin.Context) { - rows, e := h.st.ListProgress(c, uid(c)) + rows, e := h.progress.ListProgress(c, uid(c)) if e != nil { dbErr(c, e) return diff --git a/backend/cmd/webui/handlers/uploads.go b/backend/cmd/webui/handlers/uploads.go index 4709a81..4627c90 100644 --- a/backend/cmd/webui/handlers/uploads.go +++ b/backend/cmd/webui/handlers/uploads.go @@ -19,7 +19,11 @@ import ( const maxChunkBytes = 32 << 20 func (h *H) UploadInit(c *gin.Context) { - lib, ok := h.getLibrary(c) + id, ok := idParam(c) + if !ok { + return + } + lib, ok := h.getLib(c, id) if !ok { return } @@ -75,7 +79,7 @@ func (h *H) UploadComplete(c *gin.Context) { h.mapUploadErr(c, e) return } - lib, e := h.st.GetLibrary(c, libID) + lib, e := h.libs.GetLibrary(c, libID) if e != nil { dbErr(c, e) return diff --git a/backend/cmd/webui/handlers/users.go b/backend/cmd/webui/handlers/users.go index 5d62377..201c281 100644 --- a/backend/cmd/webui/handlers/users.go +++ b/backend/cmd/webui/handlers/users.go @@ -3,21 +3,18 @@ package handlers import ( "errors" "net/http" - "strconv" "time" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5" "booklib/internal/auth" + "booklib/internal/ports" "booklib/internal/store" ) -// isUnique is a convenience alias for store.IsUniqueViolation. -func isUnique(e error) bool { return store.IsUniqueViolation(e) } - func (h *H) ListUsers(c *gin.Context) { - users, e := h.st.ListUsers(c) + users, e := h.users.ListUsers(c) if e != nil { dbErr(c, e) return @@ -49,9 +46,9 @@ func (h *H) CreateUser(c *gin.Context) { err(c, http.StatusInternalServerError, "internal", "hash") return } - id, e := h.st.CreateUser(c, req.Username, hp, req.Role) + id, e := h.users.CreateUser(c, req.Username, hp, req.Role) if e != nil { - if isUnique(e) { + if ports.IsUniqueViolation(e) { err(c, http.StatusConflict, "exists", "username taken") return } @@ -62,9 +59,8 @@ func (h *H) CreateUser(c *gin.Context) { } func (h *H) DeleteUser(c *gin.Context) { - id, e := strconv.ParseInt(c.Param("id"), 10, 64) - if e != nil { - err(c, http.StatusBadRequest, "bad_request", "bad id") + id, ok := idParam(c) + if !ok { return } if id == uid(c) { @@ -72,7 +68,7 @@ func (h *H) DeleteUser(c *gin.Context) { return } // B5: transactional last-admin check eliminates TOCTOU race. - if e := h.st.DeleteUser(c, id); e != nil { + if e := h.users.DeleteUser(c, id); e != nil { if errors.Is(e, pgx.ErrNoRows) { err(c, http.StatusNotFound, "not_found", "no such user") return diff --git a/backend/cmd/webui/main.go b/backend/cmd/webui/main.go index b880708..1e960fd 100644 --- a/backend/cmd/webui/main.go +++ b/backend/cmd/webui/main.go @@ -12,6 +12,7 @@ import ( "booklib/cmd/webui/api" "booklib/internal/config" "booklib/internal/db" + "booklib/internal/media" "booklib/internal/redispkg" "booklib/internal/scanner" "booklib/internal/seed" @@ -40,12 +41,13 @@ func main() { log.Fatalf("seed: %v", err) } rdb := redispkg.New(cfg.RedisURL) + med := media.New(cfg, rdb) up := upload.New(cfg.BooksDir, cfg.UploadMaxMB) sc := scanner.New(st, cfg, rdb, up) // B16: sweep rides the scan ticker go sc.Run(ctx) serveErr := make(chan error, 1) - srv := &http.Server{Addr: cfg.Addr, Handler: api.NewRouter(cfg, st, rdb, sc, up), + srv := &http.Server{Addr: cfg.Addr, Handler: api.NewRouter(cfg, st, rdb, sc, med, up), ReadHeaderTimeout: 10 * time.Second} go func() { log.Printf("listening on %s", cfg.Addr) diff --git a/backend/internal/media/media.go b/backend/internal/media/media.go index daf4f28..55f0522 100644 --- a/backend/internal/media/media.go +++ b/backend/internal/media/media.go @@ -13,6 +13,7 @@ import ( "booklib/internal/bookfile" "booklib/internal/config" + "booklib/internal/ports" "booklib/internal/redispkg" ) @@ -22,6 +23,9 @@ type M struct { rdb *redispkg.R } +// compile-time proof that *M satisfies the consumer-side interface. +var _ ports.Media = (*M)(nil) + func New(cfg *config.Config, rdb *redispkg.R) *M { return &M{cfg: cfg, rdb: rdb} } @@ -79,7 +83,7 @@ func (m *M) PageIndex(ctx context.Context, bookID int64, size, modTS int64, root } // ChaptersOf derives chapters from a page index by grouping pages by parent directory. -func (m *M) ChaptersOf(idx []string) []Chapter { +func (m *M) ChaptersOf(idx []string) []ports.Chapter { type grp struct { dir string start int @@ -101,9 +105,9 @@ func (m *M) ChaptersOf(idx []string) []Chapter { return nil } titles := make(map[string]int) - out := make([]Chapter, len(grps)) + out := make([]ports.Chapter, len(grps)) for i, g := range grps { - out[i] = Chapter{Title: path.Base(g.dir), Start: g.start} + out[i] = ports.Chapter{Title: path.Base(g.dir), Start: g.start} titles[out[i].Title]++ } for i, g := range grps { @@ -114,14 +118,8 @@ func (m *M) ChaptersOf(idx []string) []Chapter { return out } -// Chapter represents a CBZ chapter. -type Chapter struct { - Title string `json:"title"` - Start int `json:"start"` -} - // EnsurePage extracts a single page to the cache. Returns the cache file path. -func (m *M) EnsurePage(bookID int64, size, modTS int64, root, rel string, n int, idx []string) (string, error) { +func (m *M) EnsurePage(_ context.Context, bookID int64, size, modTS int64, root, rel string, n int, idx []string) (string, error) { if n >= len(idx) { return "", fmt.Errorf("page %d out of range", n) } diff --git a/backend/internal/ports/ports.go b/backend/internal/ports/ports.go index ec4f698..976a8e8 100644 --- a/backend/internal/ports/ports.go +++ b/backend/internal/ports/ports.go @@ -78,6 +78,9 @@ type Scanner interface { type UploadSessions interface { Init(ctx context.Context, libID int64, name string, size, chunkSize int64) (string, error) + // LibraryID returns the target library recorded in the session, so the + // handler can resolve+validate the library root before Complete. + LibraryID(ctx context.Context, uploadID string) (int64, error) Status(ctx context.Context, uploadID string) ([]int64, error) PutPart(ctx context.Context, uploadID string, index int64, body io.Reader, maxSize int64) error Complete(ctx context.Context, uploadID string, root string) (string, error) @@ -86,7 +89,12 @@ type UploadSessions interface { } type Media interface { + // EnsureCover extracts+caches the cover if not already cached. Returns nil + // for formats without cover support. EnsureCover(ctx context.Context, bookID int64, format string, size, modTS int64, root, rel string) error + // EnsurePage extracts page n (from a pre-fetched idx) to the cache and + // returns the cache file path. + EnsurePage(ctx context.Context, bookID int64, size, modTS int64, root, rel string, n int, idx []string) (string, error) ChaptersOf(idx []string) []Chapter PageIndex(ctx context.Context, bookID int64, size, modTS int64, root, rel string) ([]string, error) CacheBuster(size, modTS int64) string