refactor(handlers): consume port interfaces, wire media+upload assembly (Task 25+26)

- H now holds only small port interfaces (UserStore/LibraryStore/BookStore/
  ProgressStore/BookmarkStore/RateLimiter/Scanner/Media/UploadSessions);
  NewRouter is the composition root distributing *store.Store and *redispkg.R
- ports.Media gains EnsurePage; ChaptersOf returns ports.Chapter (media's
  local duplicate dropped); *media.M now provably satisfies ports.Media;
  ports.UploadSessions gains LibraryID for root validation before Complete
- content.go: duplicated page-index cache + cover self-heal + page extract
  logic removed in favor of media service — same redis keys, same contract;
  path traversal check stays in handler (403 semantics preserved)
- getLibrary/getLibRow merged into getLib(c, id); idParam helper dedupes
  :id parsing; isUnique replaced by ports.IsUniqueViolation (Task 28 partial)
- main.go assembles media + upload and passes upload.U as scanner Sweeper

Full gate green: gofmt, vet, go test -p 1 (real PG+Redis, 0 skip)
This commit is contained in:
2026-09-14 22:56:15 +08:00
parent b9b7022212
commit 7baadedeb4
15 changed files with 186 additions and 245 deletions
+5 -2
View File
@@ -7,15 +7,18 @@ import (
"booklib/cmd/webui/handlers" "booklib/cmd/webui/handlers"
"booklib/internal/config" "booklib/internal/config"
"booklib/internal/media"
"booklib/internal/redispkg" "booklib/internal/redispkg"
"booklib/internal/scanner" "booklib/internal/scanner"
"booklib/internal/store" "booklib/internal/store"
"booklib/internal/upload" "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) 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() r := gin.New()
if e := r.SetTrustedProxies(cfg.TrustedProxies); e != nil { if e := r.SetTrustedProxies(cfg.TrustedProxies); e != nil {
panic(e) panic(e)
+1 -1
View File
@@ -16,7 +16,7 @@ func testCfg() *config.Config {
} }
func TestHealthz(t *testing.T) { 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) req := httptest.NewRequest(http.MethodGet, "/api/healthz", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
+3 -3
View File
@@ -20,11 +20,11 @@ func (h *H) Login(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "username and password required") err(c, http.StatusBadRequest, "bad_request", "username and password required")
return 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") err(c, http.StatusTooManyRequests, "rate_limited", "too many login attempts")
return return
} }
u, qerr := h.st.GetUserByName(c, req.Username) u, qerr := h.users.GetUserByName(c, req.Username)
if qerr != nil { if qerr != nil {
if !errors.Is(qerr, pgx.ErrNoRows) { if !errors.Is(qerr, pgx.ErrNoRows) {
dbErr(c, qerr) dbErr(c, qerr)
@@ -49,7 +49,7 @@ func (h *H) Login(c *gin.Context) {
func (h *H) Me(c *gin.Context) { func (h *H) Me(c *gin.Context) {
// B7: only no-rows → 401; other errors (PG down) go through dbErr → 503. // 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 qerr != nil {
if errors.Is(qerr, pgx.ErrNoRows) { if errors.Is(qerr, pgx.ErrNoRows) {
err(c, http.StatusUnauthorized, "unauthorized", "no such user") err(c, http.StatusUnauthorized, "unauthorized", "no such user")
+3 -1
View File
@@ -18,6 +18,7 @@ import (
"booklib/internal/auth" "booklib/internal/auth"
"booklib/internal/config" "booklib/internal/config"
"booklib/internal/db" "booklib/internal/db"
"booklib/internal/media"
"booklib/internal/redispkg" "booklib/internal/redispkg"
"booklib/internal/scanner" "booklib/internal/scanner"
"booklib/internal/store" "booklib/internal/store"
@@ -58,9 +59,10 @@ func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, strin
cfg.BooksDir = booksDir cfg.BooksDir = booksDir
cfg.CacheDir = t.TempDir() cfg.CacheDir = t.TempDir()
rdb := redispkg.New(os.Getenv("REDIS_URL")) rdb := redispkg.New(os.Getenv("REDIS_URL"))
med := media.New(cfg, rdb)
up := upload.New(cfg.BooksDir, cfg.UploadMaxMB) up := upload.New(cfg.BooksDir, cfg.UploadMaxMB)
sc := scanner.New(st, cfg, rdb, up) 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 u := os.Getenv("REDIS_URL"); u != "" { // 测试卫生: 共享 redis 上重置登录限流桶, 防跨测试累计 429
if opt, e := redis.ParseURL(u); e == nil { if opt, e := redis.ParseURL(u); e == nil {
rc := redis.NewClient(opt) rc := redis.NewClient(opt)
+4 -4
View File
@@ -27,7 +27,7 @@ func (h *H) ListBookmarks(c *gin.Context) {
if !ok { if !ok {
return 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 { if e != nil {
dbErr(c, e) dbErr(c, e)
return return
@@ -65,7 +65,7 @@ func (h *H) CreateBookmark(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)") err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)")
return 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 { if e != nil {
dbErr(c, e) dbErr(c, e)
return return
@@ -101,7 +101,7 @@ func (h *H) PatchBookmark(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)") err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)")
return 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 { if e != nil {
dbErr(c, e) dbErr(c, e)
return return
@@ -118,7 +118,7 @@ func (h *H) DeleteBookmark(c *gin.Context) {
if !ok { if !ok {
return return
} }
deleted, e := h.st.DeleteBookmark(c, uid(c), id) deleted, e := h.bookmarks.DeleteBookmark(c, uid(c), id)
if e != nil { if e != nil {
dbErr(c, e) dbErr(c, e)
return return
+8 -22
View File
@@ -18,7 +18,7 @@ import (
) )
func (h *H) getBookRow(c *gin.Context, id int64) (store.Book, bool) { 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 e != nil {
if errors.Is(e, pgx.ErrNoRows) { if errors.Is(e, pgx.ErrNoRows) {
err(c, http.StatusNotFound, "not_found", "no such book") 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) { func (h *H) bookFromParam(c *gin.Context) (store.Book, bool) {
id, e := strconv.ParseInt(c.Param("id"), 10, 64) id, ok := idParam(c)
if e != nil { if !ok {
err(c, http.StatusBadRequest, "bad_request", "bad id")
return store.Book{}, false return store.Book{}, false
} }
return h.getBookRow(c, id) 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 永远相对且不含 ..;拼接后二次前缀校验(纵深防御) // absBookPath: books.path 永远相对且不含 ..;拼接后二次前缀校验(纵深防御)
func absBookPath(root string, b store.Book) (string, error) { func absBookPath(root string, b store.Book) (string, error) {
abs := filepath.Join(root, filepath.FromSlash(b.Path)) 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) { func (h *H) ListBooks(c *gin.Context) {
libID, _ := strconv.ParseInt(c.Query("library"), 10, 64) 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 { if e != nil {
dbErr(c, e) dbErr(c, e)
return return
@@ -106,12 +92,12 @@ func (h *H) GetBook(c *gin.Context) {
if !ok { if !ok {
return 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) { if e != nil && !errors.Is(e, pgx.ErrNoRows) {
dbErr(c, e) dbErr(c, e)
return 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 名,书仍可见 if e != nil && !errors.Is(e, pgx.ErrNoRows) { // 库被并发删则留空 library 名,书仍可见
dbErr(c, e) dbErr(c, e)
return return
@@ -124,7 +110,7 @@ func (h *H) DeleteBook(c *gin.Context) {
if !ok { if !ok {
return return
} }
lib, ok := h.getLibRow(c, b.LibraryID) lib, ok := h.getLib(c, b.LibraryID)
if !ok { if !ok {
return return
} }
@@ -144,7 +130,7 @@ func (h *H) DeleteBook(c *gin.Context) {
key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)) key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))
os.RemoveAll(bookfile.CoverDir(h.cfg.CacheDir, key)) os.RemoveAll(bookfile.CoverDir(h.cfg.CacheDir, key))
os.RemoveAll(bookfile.PagesDir(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) dbErr(c, e)
return return
} }
+67 -148
View File
@@ -1,15 +1,13 @@
package handlers package handlers
import ( import (
"fmt" "errors"
"log" "io/fs"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -20,7 +18,7 @@ import (
const defaultCover = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="170"><rect width="120" height="170" rx="6" fill="#2a2a33"/><path d="M30 25h60v120H30z" fill="#3a3a45"/><path d="M30 25h60M60 25v120" stroke="#555" stroke-width="2"/></svg>` const defaultCover = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="170"><rect width="120" height="170" rx="6" fill="#2a2a33"/><path d="M30 25h60v120H30z" fill="#3a3a45"/><path d="M30 25h60M60 25v120" stroke="#555" stroke-width="2"/></svg>`
func (h *H) bookRoot(c *gin.Context, b store.Book) (string, bool) { 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 { if !ok {
return "", false return "", false
} }
@@ -31,6 +29,26 @@ func (h *H) immutable(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=31536000, immutable") 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) { func (h *H) ServeCover(c *gin.Context) {
b, ok := h.bookFromParam(c) b, ok := h.bookFromParam(c)
if !ok { if !ok {
@@ -48,36 +66,30 @@ func (h *H) ServeCover(c *gin.Context) {
} }
if b.Format == "cbz" || b.Format == "epub" { // 自愈:缓存丢了就地抽封面(重启/卷漂移/扫描器还没跑到) if b.Format == "cbz" || b.Format == "epub" { // 自愈:缓存丢了就地抽封面(重启/卷漂移/扫描器还没跑到)
if root, ok := h.bookRoot(c, b); ok { if root, ok := h.bookRoot(c, b); ok {
if f, size, ok := h.openBook(c, b, root); ok { if _, ok := checkPath(c, root, b); ok {
defer f.Close() e := h.med.EnsureCover(c, b.ID, b.Format, b.FileSize, b.ModTS, root, b.Path)
var img []byte switch {
var ext string case e == nil:
var e error if entries, re := os.ReadDir(dir); re == nil {
if b.Format == "cbz" { for _, en := range entries {
img, ext, e = bookfile.CBZCover(f, size) 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 { } else {
img, ext, e = bookfile.EPUBCover(f, size) return // 403 已回复
} }
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 { } else {
http.ServeFile(c.Writer, c.Request, dst) return // 404/503 已回复
} }
} }
} if c.Writer.Written() {
}
}
}
if c.Writer.Written() { // openBook/bookRoot 已写 403/404/500,不再叠加占位图
return return
} }
c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover)) c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover))
@@ -92,55 +104,32 @@ func (h *H) ServeFile(c *gin.Context) {
if !ok { if !ok {
return return
} }
abs, perr := absBookPath(root, b) abs, ok := checkPath(c, root, b)
if perr != nil { if !ok {
err(c, http.StatusForbidden, "forbidden", "unsafe path")
return 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") c.Header("Cache-Control", "private, must-revalidate")
http.ServeFile(c.Writer, c.Request, abs) http.ServeFile(c.Writer, c.Request, abs)
} }
func (h *H) openBook(c *gin.Context, b store.Book, root string) (*os.File, int64, bool) { // pageIndex 走 media(redis 缓存 + 索引提取);路径校验仍在 handler,保住 403 契约。
abs, perr := absBookPath(root, b) func (h *H) pageIndex(c *gin.Context, b store.Book, root string) ([]string, bool) {
if perr != nil { if _, ok := checkPath(c, root, b); !ok {
err(c, http.StatusForbidden, "forbidden", "unsafe path") return nil, false
return nil, 0, false
} }
f, perr := os.Open(abs) idx, e := h.med.PageIndex(c, b.ID, b.FileSize, b.ModTS, root, b.Path)
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)
if e != nil { if e != nil {
return nil, e if !mapContentErr(c, e) {
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
} }
if len(idx) > 0 { // 空索引不缓存,否则 warm 命中 "" 会 Split 出幽灵页 return nil, false
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) { func (h *H) PagesCount(c *gin.Context) {
@@ -156,56 +145,11 @@ func (h *H) PagesCount(c *gin.Context) {
if !ok { if !ok {
return return
} }
idx, e := h.pageIndex(c, b, root) idx, ok := h.pageIndex(c, b, root)
if e != nil { if !ok {
if c.Writer.Written() {
return // openBook 已写 403/404,不再叠加 422
}
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
return return
} }
c.JSON(http.StatusOK, gin.H{"count": len(idx), "chapters": chaptersOf(idx)}) c.JSON(http.StatusOK, gin.H{"count": len(idx), "chapters": h.med.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
} }
func (h *H) Page(c *gin.Context) { func (h *H) Page(c *gin.Context) {
@@ -226,48 +170,23 @@ func (h *H) Page(c *gin.Context) {
if !ok { if !ok {
return return
} }
idx, e := h.pageIndex(c, b, root) idx, ok := h.pageIndex(c, b, root)
if e != nil { if !ok {
if c.Writer.Written() {
return // openBook 已写 403/404,不再叠加 422
}
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
return return
} }
if n >= len(idx) { if n >= len(idx) {
err(c, http.StatusNotFound, "not_found", "no such page") err(c, http.StatusNotFound, "not_found", "no such page")
return return
} }
ext := strings.ToLower(filepath.Ext(idx[n])) // miss → 解压落盘(media 内部唯一 tmp 名 + rename 原子,并发重做同页幂等)
dir := bookfile.PagesDir(h.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))) dst, e := h.med.EnsurePage(c, b.ID, b.FileSize, b.ModTS, root, b.Path, n, idx)
dst := filepath.Join(dir, strconv.Itoa(n)+ext) if e != nil {
if _, e := os.Stat(dst); e != nil { // miss → 解压落盘(并发重做同页幂等,唯一 tmp 名 + rename 原子) if mapContentErr(c, e) {
f, size, ok := h.openBook(c, b, root)
if !ok {
return return
} }
defer f.Close()
data, e := bookfile.ReadEntry(f, size, idx[n])
if e != nil {
err(c, http.StatusInternalServerError, "internal", "extract page") err(c, http.StatusInternalServerError, "internal", "extract page")
return 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
}
}
h.immutable(c) h.immutable(c)
http.ServeFile(c.Writer, c.Request, dst) http.ServeFile(c.Writer, c.Request, dst)
} }
+28 -10
View File
@@ -6,6 +6,7 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"strconv"
"strings" "strings"
"syscall" "syscall"
@@ -15,28 +16,45 @@ import (
"booklib/internal/auth" "booklib/internal/auth"
"booklib/internal/config" "booklib/internal/config"
"booklib/internal/redispkg" "booklib/internal/ports"
"booklib/internal/scanner"
"booklib/internal/store"
"booklib/internal/upload"
) )
// H 只依赖 ports 里的小口径接口(Task 25):具体实现由 main.go 装配,
// 测试可注入手写 fake(portsfake),无需 PG/Redis。
type H struct { type H struct {
cfg *config.Config cfg *config.Config
st *store.Store users ports.UserStore
rdb *redispkg.R libs ports.LibraryStore
sc *scanner.Scanner books ports.BookStore
up *upload.U 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 { func New(cfg *config.Config, users ports.UserStore, libs ports.LibraryStore, books ports.BookStore,
return &H{cfg: cfg, st: st, rdb: rdb, sc: sc, up: up} 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) { func err(c *gin.Context, status int, code, msg string) {
c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"code": code, "message": msg}}) 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 // dbErr 统一处理 store 层失败:记日志;连接类错误 503(Service Unavailable),其余 500
// 注:brief 里的 pgxpool.ErrClosedPool 在 pgx v5 不存在,实际由 puddle 原样透出,用它替代; // 注:brief 里的 pgxpool.ErrClosedPool 在 pgx v5 不存在,实际由 puddle 原样透出,用它替代;
// PG 停机时池内连接先收到 SQLSTATE 57P01(administrator shutdown),故把 08xx/57Pxx 也归为 503 // PG 停机时池内连接先收到 SQLSTATE 57P01(administrator shutdown),故把 08xx/57Pxx 也归为 503
+30 -25
View File
@@ -16,6 +16,7 @@ import (
"booklib/internal/bookfile" "booklib/internal/bookfile"
"booklib/internal/media" "booklib/internal/media"
"booklib/internal/ports"
"booklib/internal/store" "booklib/internal/store"
) )
@@ -23,7 +24,7 @@ import (
func (h *H) libRoot(c *gin.Context, lib store.Library) (string, bool) { func (h *H) libRoot(c *gin.Context, lib store.Library) (string, bool) {
root := filepath.Clean(lib.RootPath) root := filepath.Clean(lib.RootPath)
books := filepath.Clean(h.cfg.BooksDir) 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") err(c, http.StatusForbidden, "forbidden", "library root outside books dir")
return "", false return "", false
} }
@@ -34,8 +35,22 @@ func (h *H) libRoot(c *gin.Context, lib store.Library) (string, bool) {
return root, true 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) { func (h *H) ListLibraries(c *gin.Context) {
libs, e := h.st.ListLibraries(c) libs, e := h.libs.ListLibraries(c)
if e != nil { if e != nil {
dbErr(c, e) dbErr(c, e)
return return
@@ -68,9 +83,9 @@ func (h *H) CreateLibrary(c *gin.Context) {
return return
} }
root := filepath.Join(filepath.Clean(h.cfg.BooksDir), safe) 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 e != nil {
if isUnique(e) { if ports.IsUniqueViolation(e) {
err(c, http.StatusConflict, "exists", "name taken") err(c, http.StatusConflict, "exists", "name taken")
return 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}) 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) { 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 { if !ok {
return return
} }
@@ -111,7 +112,11 @@ func (h *H) ScanLibrary(c *gin.Context) {
} }
func (h *H) Upload(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 { if !ok {
return return
} }
@@ -155,7 +160,7 @@ func (h *H) Upload(c *gin.Context) {
err(c, http.StatusInternalServerError, "internal", "create tmp") err(c, http.StatusInternalServerError, "internal", "create tmp")
return return
} }
// O_EXCL collision — retry with fresh uniquePath. // O_EXCL collision — retry with fresh UniquePath.
} }
if out == nil { if out == nil {
err(c, http.StatusConflict, "conflict", "too many concurrent uploads with same name") err(c, http.StatusConflict, "conflict", "too many concurrent uploads with same name")
+2 -2
View File
@@ -32,7 +32,7 @@ func (h *H) PutProgress(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "locator must be valid json") err(c, http.StatusBadRequest, "bad_request", "locator must be valid json")
return 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) dbErr(c, e)
return return
} }
@@ -40,7 +40,7 @@ func (h *H) PutProgress(c *gin.Context) {
} }
func (h *H) ListProgress(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 { if e != nil {
dbErr(c, e) dbErr(c, e)
return return
+6 -2
View File
@@ -19,7 +19,11 @@ import (
const maxChunkBytes = 32 << 20 const maxChunkBytes = 32 << 20
func (h *H) UploadInit(c *gin.Context) { 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 { if !ok {
return return
} }
@@ -75,7 +79,7 @@ func (h *H) UploadComplete(c *gin.Context) {
h.mapUploadErr(c, e) h.mapUploadErr(c, e)
return return
} }
lib, e := h.st.GetLibrary(c, libID) lib, e := h.libs.GetLibrary(c, libID)
if e != nil { if e != nil {
dbErr(c, e) dbErr(c, e)
return return
+7 -11
View File
@@ -3,21 +3,18 @@ package handlers
import ( import (
"errors" "errors"
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"booklib/internal/auth" "booklib/internal/auth"
"booklib/internal/ports"
"booklib/internal/store" "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) { func (h *H) ListUsers(c *gin.Context) {
users, e := h.st.ListUsers(c) users, e := h.users.ListUsers(c)
if e != nil { if e != nil {
dbErr(c, e) dbErr(c, e)
return return
@@ -49,9 +46,9 @@ func (h *H) CreateUser(c *gin.Context) {
err(c, http.StatusInternalServerError, "internal", "hash") err(c, http.StatusInternalServerError, "internal", "hash")
return 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 e != nil {
if isUnique(e) { if ports.IsUniqueViolation(e) {
err(c, http.StatusConflict, "exists", "username taken") err(c, http.StatusConflict, "exists", "username taken")
return return
} }
@@ -62,9 +59,8 @@ func (h *H) CreateUser(c *gin.Context) {
} }
func (h *H) DeleteUser(c *gin.Context) { func (h *H) DeleteUser(c *gin.Context) {
id, e := strconv.ParseInt(c.Param("id"), 10, 64) id, ok := idParam(c)
if e != nil { if !ok {
err(c, http.StatusBadRequest, "bad_request", "bad id")
return return
} }
if id == uid(c) { if id == uid(c) {
@@ -72,7 +68,7 @@ func (h *H) DeleteUser(c *gin.Context) {
return return
} }
// B5: transactional last-admin check eliminates TOCTOU race. // 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) { if errors.Is(e, pgx.ErrNoRows) {
err(c, http.StatusNotFound, "not_found", "no such user") err(c, http.StatusNotFound, "not_found", "no such user")
return return
+3 -1
View File
@@ -12,6 +12,7 @@ import (
"booklib/cmd/webui/api" "booklib/cmd/webui/api"
"booklib/internal/config" "booklib/internal/config"
"booklib/internal/db" "booklib/internal/db"
"booklib/internal/media"
"booklib/internal/redispkg" "booklib/internal/redispkg"
"booklib/internal/scanner" "booklib/internal/scanner"
"booklib/internal/seed" "booklib/internal/seed"
@@ -40,12 +41,13 @@ func main() {
log.Fatalf("seed: %v", err) log.Fatalf("seed: %v", err)
} }
rdb := redispkg.New(cfg.RedisURL) rdb := redispkg.New(cfg.RedisURL)
med := media.New(cfg, rdb)
up := upload.New(cfg.BooksDir, cfg.UploadMaxMB) up := upload.New(cfg.BooksDir, cfg.UploadMaxMB)
sc := scanner.New(st, cfg, rdb, up) // B16: sweep rides the scan ticker sc := scanner.New(st, cfg, rdb, up) // B16: sweep rides the scan ticker
go sc.Run(ctx) go sc.Run(ctx)
serveErr := make(chan error, 1) 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} ReadHeaderTimeout: 10 * time.Second}
go func() { go func() {
log.Printf("listening on %s", cfg.Addr) log.Printf("listening on %s", cfg.Addr)
+8 -10
View File
@@ -13,6 +13,7 @@ import (
"booklib/internal/bookfile" "booklib/internal/bookfile"
"booklib/internal/config" "booklib/internal/config"
"booklib/internal/ports"
"booklib/internal/redispkg" "booklib/internal/redispkg"
) )
@@ -22,6 +23,9 @@ type M struct {
rdb *redispkg.R 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 { func New(cfg *config.Config, rdb *redispkg.R) *M {
return &M{cfg: cfg, rdb: rdb} 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. // 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 { type grp struct {
dir string dir string
start int start int
@@ -101,9 +105,9 @@ func (m *M) ChaptersOf(idx []string) []Chapter {
return nil return nil
} }
titles := make(map[string]int) titles := make(map[string]int)
out := make([]Chapter, len(grps)) out := make([]ports.Chapter, len(grps))
for i, g := range 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]++ titles[out[i].Title]++
} }
for i, g := range grps { for i, g := range grps {
@@ -114,14 +118,8 @@ func (m *M) ChaptersOf(idx []string) []Chapter {
return out 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. // 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) { if n >= len(idx) {
return "", fmt.Errorf("page %d out of range", n) return "", fmt.Errorf("page %d out of range", n)
} }
+8
View File
@@ -78,6 +78,9 @@ type Scanner interface {
type UploadSessions interface { type UploadSessions interface {
Init(ctx context.Context, libID int64, name string, size, chunkSize int64) (string, error) 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) Status(ctx context.Context, uploadID string) ([]int64, error)
PutPart(ctx context.Context, uploadID string, index int64, body io.Reader, maxSize 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) Complete(ctx context.Context, uploadID string, root string) (string, error)
@@ -86,7 +89,12 @@ type UploadSessions interface {
} }
type Media 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 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 ChaptersOf(idx []string) []Chapter
PageIndex(ctx context.Context, bookID int64, size, modTS int64, root, rel string) ([]string, error) PageIndex(ctx context.Context, bookID int64, size, modTS int64, root, rel string) ([]string, error)
CacheBuster(size, modTS int64) string CacheBuster(size, modTS int64) string