153 lines
3.9 KiB
Go
153 lines
3.9 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"booklib/internal/bookfile"
|
|
"booklib/internal/store"
|
|
)
|
|
|
|
func (a *api) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
|
|
b, e := a.st.GetBook(c, id)
|
|
if e != nil {
|
|
if errors.Is(e, pgx.ErrNoRows) {
|
|
err(c, http.StatusNotFound, "not_found", "no such book")
|
|
return store.Book{}, false
|
|
}
|
|
dbErr(c, e)
|
|
return store.Book{}, false
|
|
}
|
|
return b, true
|
|
}
|
|
|
|
func (a *api) bookFromParam(c *gin.Context) (store.Book, bool) {
|
|
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if e != nil {
|
|
err(c, http.StatusBadRequest, "bad_request", "bad id")
|
|
return store.Book{}, false
|
|
}
|
|
return a.getBookRow(c, id)
|
|
}
|
|
|
|
func (a *api) getLibRow(c *gin.Context, id int64) (store.Library, bool) {
|
|
l, 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 l, true
|
|
}
|
|
|
|
// absBookPath: books.path 永远相对且不含 ..;拼接后二次前缀校验(纵深防御)
|
|
func absBookPath(root string, b store.Book) (string, error) {
|
|
abs := filepath.Join(root, filepath.FromSlash(b.Path))
|
|
if filepath.Clean(abs) != abs || !hasPrefixDir(abs, root) {
|
|
return "", os.ErrPermission
|
|
}
|
|
return abs, nil
|
|
}
|
|
|
|
func hasPrefixDir(p, dir string) bool {
|
|
rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(p))
|
|
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
|
}
|
|
|
|
func bookJSON(b store.Book, percent float64, libraryName string) gin.H {
|
|
h := bookfile.Hash(b.FileSize, b.ModTS)
|
|
j := gin.H{
|
|
"id": b.ID, "library_id": b.LibraryID, "path": b.Path, "title": b.Title,
|
|
"format": b.Format, "size": b.FileSize, "mtime": b.ModTS, "pages": b.PageCount,
|
|
"state": b.State, "error": b.ErrMsg, "added_at": b.AddedAt.Format(time.RFC3339),
|
|
"percent": percent,
|
|
"cover_url": fmt.Sprintf("/api/books/%d/cover?v=%s", b.ID, h),
|
|
}
|
|
if b.Format == "cbz" {
|
|
j["pages_url"] = fmt.Sprintf("/api/books/%d/pages", b.ID)
|
|
j["page_url_fmt"] = fmt.Sprintf("/api/books/%d/pages/%%d?v=%s", b.ID, h)
|
|
} else {
|
|
j["file_url"] = fmt.Sprintf("/api/books/%d/file?v=%s", b.ID, h)
|
|
}
|
|
if libraryName != "" {
|
|
j["library"] = libraryName
|
|
}
|
|
return j
|
|
}
|
|
|
|
func (a *api) listBooks(c *gin.Context) {
|
|
libID, _ := strconv.ParseInt(c.Query("library"), 10, 64)
|
|
views, e := a.st.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c))
|
|
if e != nil {
|
|
dbErr(c, e)
|
|
return
|
|
}
|
|
out := make([]gin.H, 0, len(views))
|
|
for _, v := range views {
|
|
out = append(out, bookJSON(v.Book, v.Percent, v.LibraryName))
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
func (a *api) getBook(c *gin.Context) {
|
|
b, ok := a.bookFromParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
p, e := a.st.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent
|
|
if e != nil && !errors.Is(e, pgx.ErrNoRows) {
|
|
dbErr(c, e)
|
|
return
|
|
}
|
|
lib, e := a.st.GetLibrary(c, b.LibraryID)
|
|
if e != nil && !errors.Is(e, pgx.ErrNoRows) { // 库被并发删则留空 library 名,书仍可见
|
|
dbErr(c, e)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, bookJSON(b, p.Percent, lib.Name))
|
|
}
|
|
|
|
func (a *api) deleteBook(c *gin.Context) {
|
|
b, ok := a.bookFromParam(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
lib, ok := a.getLibRow(c, b.LibraryID)
|
|
if !ok {
|
|
return
|
|
}
|
|
root, ok := a.libRoot(c, lib)
|
|
if !ok {
|
|
return
|
|
}
|
|
abs, e := absBookPath(root, b)
|
|
if e != nil {
|
|
err(c, http.StatusForbidden, "forbidden", "unsafe path")
|
|
return
|
|
}
|
|
if e := os.Remove(abs); e != nil && !os.IsNotExist(e) {
|
|
err(c, http.StatusInternalServerError, "internal", "remove file")
|
|
return
|
|
}
|
|
key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))
|
|
os.RemoveAll(bookfile.CoverDir(a.cfg.CacheDir, key))
|
|
os.RemoveAll(bookfile.PagesDir(a.cfg.CacheDir, key))
|
|
if e := a.st.DeleteBook(c, b.ID); e != nil {
|
|
dbErr(c, e)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|