Files
book-comic-library/backend/cmd/webui/handlers/libraries.go
T
XingfenD 7baadedeb4 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)
2026-09-14 22:56:15 +08:00

198 lines
5.3 KiB
Go

package handlers
import (
"context"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5"
"booklib/internal/bookfile"
"booklib/internal/media"
"booklib/internal/ports"
"booklib/internal/store"
)
// resolveLibRoot: root_path 必须绝对且落在 BooksDir 内(spec §7 前缀校验)
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) || !bookfile.Contains(books, root) {
err(c, http.StatusForbidden, "forbidden", "library root outside books dir")
return "", false
}
if e := os.MkdirAll(root, 0o755); e != nil { // 注册库时目录可能尚未落盘,自愈
err(c, http.StatusInternalServerError, "internal", "library root")
return "", false
}
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.libs.ListLibraries(c)
if e != nil {
dbErr(c, e)
return
}
out := make([]gin.H, 0, len(libs))
for _, l := range libs {
out = append(out, gin.H{"id": l.ID, "name": l.Name, "root_path": l.RootPath,
"created_at": l.CreatedAt.Format(time.RFC3339)})
}
c.JSON(http.StatusOK, out)
}
// CreateLibrary: root_path 由服务端生成(BooksDir/SafeName(name)),不接受客户端指定
func (h *H) CreateLibrary(c *gin.Context) {
var req struct {
Name string `json:"name"`
}
if c.ShouldBindJSON(&req) != nil {
err(c, http.StatusBadRequest, "bad_request", "name required")
return
}
safe := bookfile.SafeName(req.Name)
if safe == "" || safe == ".." {
err(c, http.StatusBadRequest, "bad_request", "bad name")
return
}
// B8: reject reserved names that conflict with system directories.
if media.IsReservedName(safe) {
err(c, http.StatusBadRequest, "bad_request", "reserved_name")
return
}
root := filepath.Join(filepath.Clean(h.cfg.BooksDir), safe)
id, e := h.libs.CreateLibrary(c, req.Name, root)
if e != nil {
if ports.IsUniqueViolation(e) {
err(c, http.StatusConflict, "exists", "name taken")
return
}
dbErr(c, e)
return
}
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": root})
}
func (h *H) ScanLibrary(c *gin.Context) {
id, ok := idParam(c)
if !ok {
return
}
lib, ok := h.getLib(c, id)
if !ok {
return
}
if _, ok := h.libRoot(c, lib); !ok {
return
}
go h.sc.ScanLibraryByID(context.WithoutCancel(c), lib.ID)
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
}
func (h *H) Upload(c *gin.Context) {
id, ok := idParam(c)
if !ok {
return
}
lib, ok := h.getLib(c, id)
if !ok {
return
}
root, ok := h.libRoot(c, lib)
if !ok {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.cfg.UploadMaxMB<<20)
fh, e := c.FormFile("file")
if e != nil {
var mbe *http.MaxBytesError
if errors.As(e, &mbe) {
err(c, http.StatusRequestEntityTooLarge, "too_large", "file exceeds upload limit of "+strconv.FormatInt(h.cfg.UploadMaxMB, 10)+"MB")
return
}
err(c, http.StatusBadRequest, "bad_request", "multipart field 'file' required")
return
}
name := bookfile.SafeName(fh.Filename)
if bookfile.FormatFromExt(name) == "" {
err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md")
return
}
// B12: retry on O_EXCL collision — concurrent uploads with the same name
// can both get the same candidate from UniquePath (stat-then-create race).
var dst, tmp string
var out *os.File
for attempt := 0; attempt < 5; attempt++ {
var e error
dst, e = h.up.UniquePath(root, name)
if e != nil {
err(c, http.StatusForbidden, "forbidden", e.Error())
return
}
tmp = dst + ".upload-" + strconv.FormatInt(time.Now().UnixNano(), 36)
out, e = os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if e == nil {
break
}
if !os.IsExist(e) {
err(c, http.StatusInternalServerError, "internal", "create tmp")
return
}
// O_EXCL collision — retry with fresh UniquePath.
}
if out == nil {
err(c, http.StatusConflict, "conflict", "too many concurrent uploads with same name")
return
}
src, e := fh.Open()
if e != nil {
out.Close()
os.Remove(tmp)
err(c, http.StatusInternalServerError, "internal", "open upload")
return
}
defer src.Close()
// B6: only MaxBytesError returns 413; other io.Copy failures (disk full,
// connection drop) return 500.
if _, e := io.Copy(out, src); e != nil {
out.Close()
os.Remove(tmp)
var mbe *http.MaxBytesError
if errors.As(e, &mbe) {
err(c, http.StatusRequestEntityTooLarge, "too_large", "file exceeds upload limit")
return
}
err(c, http.StatusInternalServerError, "internal", "upload failed")
return
}
out.Close()
if e := os.Rename(tmp, dst); e != nil { // 原子落盘,scanner 自动收编
os.Remove(tmp)
err(c, http.StatusInternalServerError, "internal", "rename")
return
}
c.JSON(http.StatusAccepted, gin.H{"accepted": true, "path": strings.TrimPrefix(dst, root+string(os.PathSeparator))})
}