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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = `<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) {
|
||||
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)
|
||||
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 {
|
||||
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 {
|
||||
http.ServeFile(c.Writer, c.Request, dst)
|
||||
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())
|
||||
}
|
||||
if len(idx) > 0 { // 空索引不缓存,否则 warm 命中 "" 会 Split 出幽灵页
|
||||
h.rdb.Set(c, key, strings.Join(idx, "\n"), 7*24*time.Hour)
|
||||
return nil, false
|
||||
}
|
||||
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,48 +170,23 @@ 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 {
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
h.immutable(c)
|
||||
http.ServeFile(c.Writer, c.Request, dst)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user