refactor(repo): conform structure to AGENTS.md (web->frontend, internal/api->cmd/webui/{api,handlers}, docs/README+CHANGELOGs, module .gitignores, drop stray library/ and committed debug bins)

This commit is contained in:
2026-09-07 21:37:30 +08:00
parent 961de429f9
commit ca64bc0d73
33 changed files with 324 additions and 205 deletions
+180
View File
@@ -0,0 +1,180 @@
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)
}
func (h *H) CreateLibrary(c *gin.Context) {
var req struct {
Name string `json:"name"`
RootPath string `json:"root_path"`
}
if c.ShouldBindJSON(&req) != nil || req.Name == "" || req.RootPath == "" {
err(c, http.StatusBadRequest, "bad_request", "name and root_path required")
return
}
if !filepath.IsAbs(req.RootPath) {
err(c, http.StatusBadRequest, "bad_request", "root_path must be absolute")
return
}
id, e := h.st.CreateLibrary(c, req.Name, filepath.Clean(req.RootPath))
if e != nil {
if isUnique(e) {
err(c, http.StatusConflict, "exists", "root_path taken")
return
}
dbErr(c, e)
return
}
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": filepath.Clean(req.RootPath)})
}
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 {
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
}
if _, e := io.Copy(out, src); e != nil {
out.Close()
os.Remove(tmp)
err(c, http.StatusRequestEntityTooLarge, "too_large", "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
}
}
}