195 lines
5.2 KiB
Go
195 lines
5.2 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/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) || (root != books && !strings.HasPrefix(root, books+string(os.PathSeparator))) {
|
|
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
|
|
}
|
|
|
|
func (h *H) ListLibraries(c *gin.Context) {
|
|
libs, e := h.st.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
|
|
}
|
|
root := filepath.Join(filepath.Clean(h.cfg.BooksDir), safe)
|
|
id, e := h.st.CreateLibrary(c, req.Name, root)
|
|
if e != nil {
|
|
if isUnique(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) 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)
|
|
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) {
|
|
lib, ok := h.getLibrary(c)
|
|
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
|
|
}
|
|
dst, e := h.uniquePath(root, name)
|
|
if e != nil {
|
|
err(c, http.StatusForbidden, "forbidden", e.Error())
|
|
return
|
|
}
|
|
src, e := fh.Open()
|
|
if e != nil {
|
|
err(c, http.StatusInternalServerError, "internal", "open upload")
|
|
return
|
|
}
|
|
defer src.Close()
|
|
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 {
|
|
err(c, http.StatusInternalServerError, "internal", "create tmp")
|
|
return
|
|
}
|
|
// 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))})
|
|
}
|
|
|
|
// uniquePath 清洗后的 name 必须仍在 root 内;重名加 " (n)" 后缀
|
|
func (h *H) uniquePath(root, name string) (string, error) {
|
|
ext := filepath.Ext(name)
|
|
base := strings.TrimSuffix(name, ext)
|
|
for i := 0; ; i++ {
|
|
cand := base + ext
|
|
if i > 0 {
|
|
cand = base + " (" + strconv.Itoa(i) + ")" + ext
|
|
}
|
|
p := filepath.Join(root, cand)
|
|
if filepath.Clean(p) != filepath.Join(root, filepath.Clean(cand)) ||
|
|
!strings.HasPrefix(filepath.Clean(p), root+string(os.PathSeparator)) {
|
|
return "", os.ErrInvalid
|
|
}
|
|
if _, e := os.Stat(p); os.IsNotExist(e) {
|
|
return p, nil
|
|
} else if e != nil {
|
|
return "", e
|
|
}
|
|
if i > 999 {
|
|
return "", os.ErrExist
|
|
}
|
|
}
|
|
}
|