- 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)
193 lines
4.9 KiB
Go
193 lines
4.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"booklib/internal/bookfile"
|
|
"booklib/internal/store"
|
|
)
|
|
|
|
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.getLib(c, b.LibraryID)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return h.libRoot(c, lib)
|
|
}
|
|
|
|
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 {
|
|
return
|
|
}
|
|
h.immutable(c)
|
|
dir := bookfile.CoverDir(h.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
|
|
if entries, e := os.ReadDir(dir); e == nil {
|
|
for _, en := range entries { // 跳过写一半的 .tmp 落盘中间态
|
|
if !strings.Contains(en.Name(), ".tmp") {
|
|
http.ServeFile(c.Writer, c.Request, filepath.Join(dir, en.Name()))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if b.Format == "cbz" || b.Format == "epub" { // 自愈:缓存丢了就地抽封面(重启/卷漂移/扫描器还没跑到)
|
|
if root, ok := h.bookRoot(c, b); ok {
|
|
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() {
|
|
return
|
|
}
|
|
c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover))
|
|
}
|
|
|
|
func (h *H) ServeFile(c *gin.Context) {
|
|
b, ok := h.bookFromParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
root, ok := h.bookRoot(c, b)
|
|
if !ok {
|
|
return
|
|
}
|
|
abs, ok := checkPath(c, root, b)
|
|
if !ok {
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
idx, e := h.med.PageIndex(c, b.ID, b.FileSize, b.ModTS, root, b.Path)
|
|
if e != nil {
|
|
if !mapContentErr(c, e) {
|
|
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
|
|
}
|
|
return nil, false
|
|
}
|
|
return idx, true
|
|
}
|
|
|
|
func (h *H) PagesCount(c *gin.Context) {
|
|
b, ok := h.bookFromParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if b.Format != "cbz" {
|
|
err(c, http.StatusBadRequest, "bad_request", "pages only for cbz")
|
|
return
|
|
}
|
|
root, ok := h.bookRoot(c, b)
|
|
if !ok {
|
|
return
|
|
}
|
|
idx, ok := h.pageIndex(c, b, root)
|
|
if !ok {
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"count": len(idx), "chapters": h.med.ChaptersOf(idx)})
|
|
}
|
|
|
|
func (h *H) Page(c *gin.Context) {
|
|
b, ok := h.bookFromParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if b.Format != "cbz" {
|
|
err(c, http.StatusBadRequest, "bad_request", "pages only for cbz")
|
|
return
|
|
}
|
|
n, e := strconv.Atoi(c.Param("n"))
|
|
if e != nil || n < 0 {
|
|
err(c, http.StatusBadRequest, "bad_request", "bad page number")
|
|
return
|
|
}
|
|
root, ok := h.bookRoot(c, b)
|
|
if !ok {
|
|
return
|
|
}
|
|
idx, ok := h.pageIndex(c, b, root)
|
|
if !ok {
|
|
return
|
|
}
|
|
if n >= len(idx) {
|
|
err(c, http.StatusNotFound, "not_found", "no such page")
|
|
return
|
|
}
|
|
// 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)
|
|
}
|