- 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)
198 lines
5.3 KiB
Go
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))})
|
|
}
|