feat(backend): cover/file/pages endpoints — immutable URLs, Range, disk+redis caches
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -10,15 +11,20 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
"booklib/internal/bookfile"
|
"booklib/internal/bookfile"
|
||||||
"booklib/internal/store"
|
"booklib/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *api) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
|
func (a *api) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
|
||||||
b, perr := a.st.GetBook(c, id)
|
b, e := a.st.GetBook(c, id)
|
||||||
if perr != nil {
|
if e != nil {
|
||||||
err(c, http.StatusNotFound, "not_found", "no such book")
|
if errors.Is(e, pgx.ErrNoRows) {
|
||||||
|
err(c, http.StatusNotFound, "not_found", "no such book")
|
||||||
|
return store.Book{}, false
|
||||||
|
}
|
||||||
|
err(c, http.StatusInternalServerError, "internal", "db error")
|
||||||
return store.Book{}, false
|
return store.Book{}, false
|
||||||
}
|
}
|
||||||
return b, true
|
return b, true
|
||||||
|
|||||||
@@ -144,6 +144,23 @@ func loginAs(t *testing.T, h http.Handler, user, pass string) string {
|
|||||||
return v.Token
|
return v.Token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteUnsafePath403(t *testing.T) {
|
||||||
|
st, _, h, booksDir := setupAPI(t)
|
||||||
|
atok := adminToken(t, h)
|
||||||
|
lib, _ := newLibrary(t, st, h, atok, booksDir, "libs")
|
||||||
|
id, e := st.InsertBook(context.Background(), lib.ID, "../../x.cbz", "x", "cbz", 1, 1, 0)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
w := do(h, "DELETE", "/api/books/"+itoa(id), atok, nil)
|
||||||
|
if w.Code != 403 {
|
||||||
|
t.Fatalf("unsafe delete want 403 got %d %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
if _, e := st.GetBook(context.Background(), id); e != nil { // 403 提前返回,行必须保留
|
||||||
|
t.Fatalf("row must survive: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAbsBookPathTraversalRejected(t *testing.T) {
|
func TestAbsBookPathTraversalRejected(t *testing.T) {
|
||||||
root := "/data/books/lib" // 纯路径逻辑,不碰文件系统,无需 DB
|
root := "/data/books/lib" // 纯路径逻辑,不碰文件系统,无需 DB
|
||||||
for _, bad := range []string{"../../etc/passwd", "a/../../../etc/x", "../sibling"} {
|
for _, bad := range []string{"../../etc/passwd", "a/../../../etc/x", "../sibling"} {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func serveFixture(t *testing.T) (http.Handler, string, string) {
|
||||||
|
st, sc, h, booksDir := setupAPI(t)
|
||||||
|
atok := adminToken(t, h)
|
||||||
|
lib, root := newLibrary(t, st, h, atok, booksDir, "comics")
|
||||||
|
writeCBZ(t, filepath.Join(root, "s", "one.cbz"), 3)
|
||||||
|
os.WriteFile(filepath.Join(root, "two.txt"), []byte("plain text body"), 0o644)
|
||||||
|
scanNow(t, sc, lib)
|
||||||
|
w := do(h, "GET", "/api/books?q=one", atok, nil)
|
||||||
|
var bs []map[string]any
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &bs)
|
||||||
|
cbzID := itoa(bs[0]["id"])
|
||||||
|
w = do(h, "GET", "/api/books?q=two", atok, nil) // 检索走 title(文件名去扩展名),不是 path
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &bs)
|
||||||
|
txtID := itoa(bs[0]["id"])
|
||||||
|
return h, cbzID, txtID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverCBZAndPlaceholder(t *testing.T) {
|
||||||
|
h, cbzID, txtID := serveFixture(t)
|
||||||
|
tok := adminToken(t, h)
|
||||||
|
w := do(h, "GET", "/api/books/"+cbzID+"/cover", tok, nil)
|
||||||
|
if w.Code != 200 || !strings.Contains(w.Header().Get("Content-Type"), "image/") {
|
||||||
|
t.Fatalf("cbz cover %d %s %q", w.Code, w.Body, w.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Header().Get("Cache-Control"), "immutable") {
|
||||||
|
t.Fatal("cover must be immutable")
|
||||||
|
}
|
||||||
|
w = do(h, "GET", "/api/books/"+txtID+"/cover", tok, nil)
|
||||||
|
if w.Code != 200 || w.Header().Get("Content-Type") != "image/svg+xml" {
|
||||||
|
t.Fatalf("placeholder cover %d %q", w.Code, w.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
w = do(h, "GET", "/api/books/999999/cover", tok, nil)
|
||||||
|
if w.Code != 404 {
|
||||||
|
t.Fatalf("missing book cover want 404 got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPages(t *testing.T) {
|
||||||
|
h, cbzID, txtID := serveFixture(t)
|
||||||
|
tok := adminToken(t, h)
|
||||||
|
w := do(h, "GET", "/api/books/"+cbzID+"/pages", tok, nil)
|
||||||
|
var v struct{ Count int }
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &v)
|
||||||
|
if w.Code != 200 || v.Count != 3 {
|
||||||
|
t.Fatalf("pages %d %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
w = do(h, "GET", "/api/books/"+cbzID+"/pages/1", tok, nil)
|
||||||
|
if w.Code != 200 || !strings.Contains(w.Body.String(), "IMG") {
|
||||||
|
t.Fatalf("page 1 %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Header().Get("Cache-Control"), "immutable") {
|
||||||
|
t.Fatal("page must be immutable")
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"4", "-1", "abc"} {
|
||||||
|
if w = do(h, "GET", "/api/books/"+cbzID+"/pages/"+bad, tok, nil); w.Code != 404 && w.Code != 400 {
|
||||||
|
t.Fatalf("pages/%s want 404/400 got %d", bad, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w = do(h, "GET", "/api/books/"+txtID+"/pages", tok, nil); w.Code != 400 {
|
||||||
|
t.Fatalf("pages on txt want 400 got %d", w.Code)
|
||||||
|
}
|
||||||
|
// 二次命中磁盘缓存(服务仍 200,字节一致)
|
||||||
|
w2 := do(h, "GET", "/api/books/"+cbzID+"/pages/2", tok, nil)
|
||||||
|
w3 := do(h, "GET", "/api/books/"+cbzID+"/pages/2", tok, nil)
|
||||||
|
if w2.Code != 200 || w2.Body.String() != w3.Body.String() {
|
||||||
|
t.Fatal("page cache inconsistent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrokenCBZ(t *testing.T) {
|
||||||
|
st, sc, h, booksDir := setupAPI(t)
|
||||||
|
atok := adminToken(t, h)
|
||||||
|
lib, root := newLibrary(t, st, h, atok, booksDir, "comics")
|
||||||
|
os.MkdirAll(filepath.Join(root, "b"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(root, "b", "bad.cbz"), []byte("not a zip at all"), 0o644)
|
||||||
|
scanNow(t, sc, lib)
|
||||||
|
w := do(h, "GET", "/api/books?q=bad", atok, nil)
|
||||||
|
var bs []map[string]any
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &bs)
|
||||||
|
id := itoa(bs[0]["id"])
|
||||||
|
if w = do(h, "GET", "/api/books/"+id+"/pages", atok, nil); w.Code != 422 {
|
||||||
|
t.Fatalf("broken pages want 422 got %d", w.Code)
|
||||||
|
}
|
||||||
|
// 封面目录缺失(坏 cbz 抽不出封面)→ 占位 SVG 兜底
|
||||||
|
if w = do(h, "GET", "/api/books/"+id+"/cover", atok, nil); w.Code != 200 || w.Header().Get("Content-Type") != "image/svg+xml" {
|
||||||
|
t.Fatalf("broken cover want placeholder svg got %d %q", w.Code, w.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileRangeETag(t *testing.T) {
|
||||||
|
h, _, txtID := serveFixture(t)
|
||||||
|
tok := adminToken(t, h)
|
||||||
|
w := do(h, "GET", "/api/books/"+txtID+"/file", tok, nil)
|
||||||
|
if w.Code != 200 || w.Body.String() != "plain text body" {
|
||||||
|
t.Fatalf("file %d %q", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
if w.Header().Get("ETag") == "" {
|
||||||
|
t.Fatal("no etag")
|
||||||
|
}
|
||||||
|
if etag := w.Header().Get("ETag"); !strings.HasPrefix(etag, `"`) || !strings.HasSuffix(etag, `"`) {
|
||||||
|
t.Fatalf("etag must be quoted: %q", etag)
|
||||||
|
}
|
||||||
|
if w.Header().Get("Accept-Ranges") != "bytes" {
|
||||||
|
t.Fatal("no accept-ranges")
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("GET", "/api/books/"+txtID+"/file", nil)
|
||||||
|
req.Header.Set("Range", "bytes=0-4")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
ww := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(ww, req)
|
||||||
|
if ww.Code != 206 || ww.Body.String() != "plain" {
|
||||||
|
t.Fatalf("range %d %q", ww.Code, ww.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,5 +37,9 @@ func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner
|
|||||||
p.GET("/books", a.listBooks)
|
p.GET("/books", a.listBooks)
|
||||||
p.GET("/books/:id", a.getBook)
|
p.GET("/books/:id", a.getBook)
|
||||||
p.DELETE("/books/:id", a.adminOnly(), a.deleteBook)
|
p.DELETE("/books/:id", a.adminOnly(), a.deleteBook)
|
||||||
|
p.GET("/books/:id/cover", a.serveCover)
|
||||||
|
p.GET("/books/:id/file", a.serveFile)
|
||||||
|
p.GET("/books/:id/pages", a.pagesCount)
|
||||||
|
p.GET("/books/:id/pages/:n", a.page)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user