181 lines
4.8 KiB
Go
181 lines
4.8 KiB
Go
package api
|
|
|
|
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 (a *api) libRoot(c *gin.Context, lib store.Library) (string, bool) {
|
|
root := filepath.Clean(lib.RootPath)
|
|
books := filepath.Clean(a.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 (a *api) listLibraries(c *gin.Context) {
|
|
libs, e := a.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 (a *api) 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 := a.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 (a *api) 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 := a.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 (a *api) scanLibrary(c *gin.Context) {
|
|
lib, ok := a.getLibrary(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, ok := a.libRoot(c, lib); !ok {
|
|
return
|
|
}
|
|
go a.sc.ScanLibraryByID(context.WithoutCancel(c), lib.ID)
|
|
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
|
|
}
|
|
|
|
func (a *api) upload(c *gin.Context) {
|
|
lib, ok := a.getLibrary(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
root, ok := a.libRoot(c, lib)
|
|
if !ok {
|
|
return
|
|
}
|
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, a.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 := a.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 (a *api) 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
|
|
}
|
|
}
|
|
}
|