feat(backend): cover/file/pages endpoints — immutable URLs, Range, disk+redis caches
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"booklib/internal/bookfile"
|
||||
"booklib/internal/store"
|
||||
)
|
||||
|
||||
const defaultCover = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="170"><rect width="120" height="170" rx="6" fill="#2a2a33"/><path d="M30 25h60v120H30z" fill="#3a3a45"/><path d="M30 25h60M60 25v120" stroke="#555" stroke-width="2"/></svg>`
|
||||
|
||||
func (a *api) bookRoot(c *gin.Context, b store.Book) (string, bool) {
|
||||
lib, ok := a.getLibRow(c, b.LibraryID)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return a.libRoot(c, lib)
|
||||
}
|
||||
|
||||
func (a *api) immutable(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
|
||||
func (a *api) serveCover(c *gin.Context) {
|
||||
b, ok := a.bookFromParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.immutable(c)
|
||||
dir := bookfile.CoverDir(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
|
||||
if entries, err := os.ReadDir(dir); err == nil && len(entries) > 0 {
|
||||
http.ServeFile(c.Writer, c.Request, filepath.Join(dir, entries[0].Name()))
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover))
|
||||
}
|
||||
|
||||
func (a *api) serveFile(c *gin.Context) {
|
||||
b, ok := a.bookFromParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
root, ok := a.bookRoot(c, b)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
abs, perr := absBookPath(root, b)
|
||||
if perr != nil {
|
||||
err(c, http.StatusForbidden, "forbidden", "unsafe path")
|
||||
return
|
||||
}
|
||||
c.Header("ETag", `"`+bookfile.Hash(b.FileSize, b.ModTS)+`"`)
|
||||
c.Header("Cache-Control", "private, must-revalidate")
|
||||
http.ServeFile(c.Writer, c.Request, abs)
|
||||
}
|
||||
|
||||
func (a *api) openBook(c *gin.Context, b store.Book, root string) (*os.File, int64, bool) {
|
||||
abs, perr := absBookPath(root, b)
|
||||
if perr != nil {
|
||||
err(c, http.StatusForbidden, "forbidden", "unsafe path")
|
||||
return nil, 0, false
|
||||
}
|
||||
f, perr := os.Open(abs)
|
||||
if perr != nil {
|
||||
err(c, http.StatusNotFound, "not_found", "file missing on disk")
|
||||
return nil, 0, false
|
||||
}
|
||||
st, perr := f.Stat()
|
||||
if perr != nil {
|
||||
f.Close()
|
||||
err(c, http.StatusInternalServerError, "internal", "stat")
|
||||
return nil, 0, false
|
||||
}
|
||||
return f, st.Size(), true
|
||||
}
|
||||
|
||||
func (a *api) pageIndex(c *gin.Context, b store.Book, root string) ([]string, error) {
|
||||
hash := bookfile.Hash(b.FileSize, b.ModTS)
|
||||
key := fmt.Sprintf("pagesidx:%d:%s", b.ID, hash)
|
||||
if v, ok := a.rdb.Get(c, key); ok {
|
||||
return strings.Split(v, "\n"), nil
|
||||
}
|
||||
f, size, ok := a.openBook(c, b, root)
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
defer f.Close()
|
||||
idx, e := bookfile.PageIndex(f, size)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
a.rdb.Set(c, key, strings.Join(idx, "\n"), 7*24*time.Hour)
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func (a *api) pagesCount(c *gin.Context) {
|
||||
b, ok := a.bookFromParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if b.Format != "cbz" {
|
||||
err(c, http.StatusBadRequest, "bad_request", "pages only for cbz")
|
||||
return
|
||||
}
|
||||
root, ok := a.bookRoot(c, b)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idx, e := a.pageIndex(c, b, root)
|
||||
if e != nil {
|
||||
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"count": len(idx)})
|
||||
}
|
||||
|
||||
func (a *api) page(c *gin.Context) {
|
||||
b, ok := a.bookFromParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if b.Format != "cbz" {
|
||||
err(c, http.StatusBadRequest, "bad_request", "pages only for cbz")
|
||||
return
|
||||
}
|
||||
n, e := strconv.Atoi(c.Param("n"))
|
||||
if e != nil || n < 0 {
|
||||
err(c, http.StatusBadRequest, "bad_request", "bad page number")
|
||||
return
|
||||
}
|
||||
root, ok := a.bookRoot(c, b)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idx, e := a.pageIndex(c, b, root)
|
||||
if e != nil {
|
||||
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
|
||||
return
|
||||
}
|
||||
if n >= len(idx) {
|
||||
err(c, http.StatusNotFound, "not_found", "no such page")
|
||||
return
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(idx[n]))
|
||||
dir := bookfile.PagesDir(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
|
||||
dst := filepath.Join(dir, strconv.Itoa(n)+ext)
|
||||
if _, e := os.Stat(dst); e != nil { // miss → 解压落盘(并发重做同页幂等,唯一 tmp 名 + rename 原子)
|
||||
f, size, ok := a.openBook(c, b, root)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
data, e := bookfile.ReadEntry(f, size, idx[n])
|
||||
if e != nil {
|
||||
err(c, http.StatusInternalServerError, "internal", "extract page")
|
||||
return
|
||||
}
|
||||
if e := os.MkdirAll(dir, 0o755); e != nil {
|
||||
err(c, http.StatusInternalServerError, "internal", "cache dir")
|
||||
return
|
||||
}
|
||||
tmp := fmt.Sprintf("%s.tmp-%d", dst, time.Now().UnixNano())
|
||||
if e := os.WriteFile(tmp, data, 0o644); e != nil {
|
||||
os.Remove(tmp)
|
||||
err(c, http.StatusInternalServerError, "internal", "write cache")
|
||||
return
|
||||
}
|
||||
if e := os.Rename(tmp, dst); e != nil {
|
||||
os.Remove(tmp)
|
||||
err(c, http.StatusInternalServerError, "internal", "rename cache")
|
||||
return
|
||||
}
|
||||
}
|
||||
a.immutable(c)
|
||||
http.ServeFile(c.Writer, c.Request, dst)
|
||||
}
|
||||
Reference in New Issue
Block a user