package api import ( "context" "io" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/gin-gonic/gin" "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 } 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 } err(c, http.StatusBadRequest, "bad_request", "invalid input") 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 { err(c, http.StatusNotFound, "not_found", "no such library") 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 } } }