refactor(repo): conform structure to AGENTS.md (web->frontend, internal/api->cmd/webui/{api,handlers}, docs/README+CHANGELOGs, module .gitignores, drop stray library/ and committed debug bins)
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"booklib/cmd/webui/handlers"
|
||||
"booklib/internal/config"
|
||||
"booklib/internal/redispkg"
|
||||
"booklib/internal/scanner"
|
||||
"booklib/internal/store"
|
||||
)
|
||||
|
||||
func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
h := handlers.New(cfg, st, rdb, sc)
|
||||
r := gin.New()
|
||||
if e := r.SetTrustedProxies(cfg.TrustedProxies); e != nil {
|
||||
panic(e)
|
||||
}
|
||||
r.Use(gin.Recovery())
|
||||
g := r.Group("/api")
|
||||
g.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
||||
g.POST("/auth/login", h.Login)
|
||||
|
||||
p := g.Group("", h.AuthMw())
|
||||
p.GET("/auth/me", h.Me)
|
||||
|
||||
users := p.Group("/users", h.AdminOnly())
|
||||
users.GET("", h.ListUsers)
|
||||
users.POST("", h.CreateUser)
|
||||
users.DELETE("/:id", h.DeleteUser)
|
||||
|
||||
libs := p.Group("/libraries")
|
||||
libs.GET("", h.ListLibraries)
|
||||
libs.POST("", h.AdminOnly(), h.CreateLibrary)
|
||||
libs.POST("/:id/scan", h.AdminOnly(), h.ScanLibrary)
|
||||
libs.POST("/:id/upload", h.AdminOnly(), h.Upload)
|
||||
|
||||
p.GET("/books", h.ListBooks)
|
||||
p.GET("/books/:id", h.GetBook)
|
||||
p.DELETE("/books/:id", h.AdminOnly(), h.DeleteBook)
|
||||
p.GET("/books/:id/cover", h.ServeCover)
|
||||
p.GET("/books/:id/file", h.ServeFile)
|
||||
p.GET("/books/:id/pages", h.PagesCount)
|
||||
p.GET("/books/:id/pages/:n", h.Page)
|
||||
p.PUT("/books/:id/progress", h.PutProgress)
|
||||
p.GET("/progress", h.ListProgress)
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"booklib/internal/config"
|
||||
"booklib/internal/redispkg"
|
||||
)
|
||||
|
||||
func testCfg() *config.Config {
|
||||
return &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), ScanInterval: time.Minute, UploadMaxMB: 200,
|
||||
TrustedProxies: []string{"172.16.0.0/12"}} // 与 prod 默认一致: 只有 compose 网段内代理才可信
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
r := NewRouter(testCfg(), nil, redispkg.New(""), nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/healthz", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("healthz = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"booklib/internal/auth"
|
||||
)
|
||||
|
||||
const loginWindow = time.Minute
|
||||
const loginMax = 5
|
||||
|
||||
func (h *H) Login(c *gin.Context) {
|
||||
var req struct{ Username, Password string }
|
||||
if c.ShouldBindJSON(&req) != nil || req.Username == "" || req.Password == "" {
|
||||
err(c, http.StatusBadRequest, "bad_request", "username and password required")
|
||||
return
|
||||
}
|
||||
if n := h.rdb.IncrWindow(c, "loginrl:"+c.ClientIP(), loginWindow); n > loginMax {
|
||||
err(c, http.StatusTooManyRequests, "rate_limited", "too many login attempts")
|
||||
return
|
||||
}
|
||||
u, qerr := h.st.GetUserByName(c, req.Username)
|
||||
if qerr != nil {
|
||||
if !errors.Is(qerr, pgx.ErrNoRows) {
|
||||
dbErr(c, qerr)
|
||||
return
|
||||
}
|
||||
// 用户不存在也走一次 bcrypt,防用户名枚举时序差
|
||||
auth.CheckPassword("$2a$12$V5TmlpkEi9G/DFAmnkt9YunNdGLnnW921/5TcSo4OeE6iaaLDPN1K", req.Password)
|
||||
err(c, http.StatusUnauthorized, "unauthorized", "bad credentials")
|
||||
return
|
||||
}
|
||||
if !auth.CheckPassword(u.PasswordHash, req.Password) {
|
||||
err(c, http.StatusUnauthorized, "unauthorized", "bad credentials")
|
||||
return
|
||||
}
|
||||
tok, serr := auth.Sign(h.cfg.JWTSecret, u.ID, u.Role)
|
||||
if serr != nil {
|
||||
err(c, http.StatusInternalServerError, "internal", "sign")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"token": tok})
|
||||
}
|
||||
|
||||
func (h *H) Me(c *gin.Context) {
|
||||
u, qerr := h.st.GetUserByID(c, uid(c))
|
||||
if qerr != nil {
|
||||
err(c, http.StatusUnauthorized, "unauthorized", "no such user")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"id": u.ID, "username": u.Username, "role": u.Role})
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"booklib/cmd/webui/api"
|
||||
"booklib/internal/auth"
|
||||
"booklib/internal/config"
|
||||
"booklib/internal/db"
|
||||
"booklib/internal/redispkg"
|
||||
"booklib/internal/scanner"
|
||||
"booklib/internal/store"
|
||||
)
|
||||
|
||||
func testCfg() *config.Config {
|
||||
return &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), ScanInterval: time.Minute, UploadMaxMB: 200,
|
||||
TrustedProxies: []string{"172.16.0.0/12"}} // 与 prod 默认一致: 只有 compose 网段内代理才可信
|
||||
}
|
||||
|
||||
func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, string) {
|
||||
t.Helper()
|
||||
url := os.Getenv("DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
p, _ := db.Connect(ctx, url)
|
||||
if err := db.Migrate(ctx, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := store.New(p)
|
||||
p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users")
|
||||
h, _ := auth.HashPassword(testPW)
|
||||
if _, err := st.CreateUser(ctx, "alice", h, "admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.CreateUser(ctx, "bob", h, "member"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
booksParent := t.TempDir()
|
||||
booksDir, err := filepath.EvalSymlinks(booksParent) // macOS 上 /var→/private,root 校验要用真实路径
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := testCfg()
|
||||
cfg.BooksDir = booksDir
|
||||
cfg.CacheDir = t.TempDir()
|
||||
rdb := redispkg.New(os.Getenv("REDIS_URL"))
|
||||
sc := scanner.New(st, cfg, rdb)
|
||||
r := api.NewRouter(cfg, st, rdb, sc)
|
||||
if u := os.Getenv("REDIS_URL"); u != "" { // 测试卫生: 共享 redis 上重置登录限流桶, 防跨测试累计 429
|
||||
if opt, e := redis.ParseURL(u); e == nil {
|
||||
rc := redis.NewClient(opt)
|
||||
rc.Del(ctx, "loginrl:192.0.2.1")
|
||||
rc.Close()
|
||||
}
|
||||
}
|
||||
return st, sc, r, booksDir
|
||||
}
|
||||
|
||||
// testPW 是唯一的 fixture 口令常量: 直接种子 (HashPassword) 与所有登录/建户必须同值, 且 >=8 位
|
||||
const testPW = "pw123456"
|
||||
|
||||
func do(h http.Handler, method, path, token string, body any) *httptest.ResponseRecorder {
|
||||
var r *bytes.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
} else {
|
||||
r = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestLoginMe(t *testing.T) {
|
||||
_, _, h, _ := setupAPI(t)
|
||||
w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "alice", "password": testPW})
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("login %d %s", w.Code, w.Body)
|
||||
}
|
||||
var tok struct{ Token string }
|
||||
json.Unmarshal(w.Body.Bytes(), &tok)
|
||||
if tok.Token == "" {
|
||||
t.Fatal("no token")
|
||||
}
|
||||
w = do(h, "GET", "/api/auth/me", tok.Token, nil)
|
||||
var me map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &me)
|
||||
if w.Code != 200 || me["username"] != "alice" || me["role"] != "admin" {
|
||||
t.Fatalf("me %d %s", w.Code, w.Body)
|
||||
}
|
||||
// 错密码 → 401 统一错误体
|
||||
w = do(h, "POST", "/api/auth/login", "", map[string]string{"username": "alice", "password": "nope"})
|
||||
if w.Code != 401 {
|
||||
t.Fatalf("want 401 got %d", w.Code)
|
||||
}
|
||||
// 无 token / 坏 token 访问受保护端点 → 401(me 已注册;books 路由 Task 10 才有)
|
||||
if w = do(h, "GET", "/api/auth/me", "", nil); w.Code != 401 {
|
||||
t.Fatal("me without token must 401")
|
||||
}
|
||||
if w = do(h, "GET", "/api/auth/me", "garbage", nil); w.Code != 401 {
|
||||
t.Fatal("me with bad token must 401")
|
||||
}
|
||||
}
|
||||
|
||||
// 伪造 XFF 换不了限流桶: peer(192.0.2.1)不在可信代理段 → ClientIP 取 peer,XFF 忽略
|
||||
func TestLoginRateLimitResistsXFFSpoof(t *testing.T) {
|
||||
if os.Getenv("REDIS_URL") == "" {
|
||||
t.Skip("REDIS_URL not set (no redis → IncrWindow always allows)")
|
||||
}
|
||||
_, _, h, _ := setupAPI(t) // setupAPI 已重置 loginrl:192.0.2.1
|
||||
for i := 1; i <= 6; i++ {
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", bytes.NewBufferString(`{"username":"alice","password":"nope"}`))
|
||||
req.Header.Set("X-Forwarded-For", fmt.Sprintf("203.0.113.%d", i))
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if i < 6 && w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("attempt %d: want 401 got %d %s", i, w.Code, w.Body)
|
||||
}
|
||||
if i == 6 && w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("attempt 6: spoofed XFF escaped per-peer limit: want 429 got %d", w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberCannotWriteUsers(t *testing.T) {
|
||||
_, _, h, _ := setupAPI(t)
|
||||
w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "bob", "password": testPW})
|
||||
var v struct{ Token string }
|
||||
json.Unmarshal(w.Body.Bytes(), &v)
|
||||
tok := v.Token
|
||||
w = do(h, "POST", "/api/users", tok, map[string]string{"username": "eve", "password": testPW, "role": "member"})
|
||||
if w.Code != 403 {
|
||||
t.Fatalf("member write users: want 403 got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package handlers
|
||||
|
||||
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 (h *H) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
|
||||
b, e := h.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 (h *H) 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 h.getBookRow(c, id)
|
||||
}
|
||||
|
||||
func (h *H) getLibRow(c *gin.Context, id int64) (store.Library, bool) {
|
||||
l, e := h.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 (h *H) ListBooks(c *gin.Context) {
|
||||
libID, _ := strconv.ParseInt(c.Query("library"), 10, 64)
|
||||
views, e := h.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 (h *H) GetBook(c *gin.Context) {
|
||||
b, ok := h.bookFromParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, e := h.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 := h.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 (h *H) DeleteBook(c *gin.Context) {
|
||||
b, ok := h.bookFromParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
lib, ok := h.getLibRow(c, b.LibraryID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
root, ok := h.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(h.cfg.CacheDir, key))
|
||||
os.RemoveAll(bookfile.PagesDir(h.cfg.CacheDir, key))
|
||||
if e := h.st.DeleteBook(c, b.ID); e != nil {
|
||||
dbErr(c, e)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"booklib/internal/scanner"
|
||||
"booklib/internal/store"
|
||||
)
|
||||
|
||||
func newLibrary(t *testing.T, st *store.Store, h http.Handler, tok, booksDir, name string) (store.Library, string) {
|
||||
t.Helper()
|
||||
root := filepath.Join(booksDir, name)
|
||||
os.MkdirAll(filepath.Join(root, "series-a"), 0o755)
|
||||
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": name, "root_path": root})
|
||||
if w.Code != 201 {
|
||||
t.Fatalf("create lib %d %s", w.Code, w.Body)
|
||||
}
|
||||
var v struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
json.Unmarshal(w.Body.Bytes(), &v)
|
||||
lib, err := st.GetLibrary(context.Background(), v.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return lib, root
|
||||
}
|
||||
|
||||
func writeCBZ(t *testing.T, path string, pages int) {
|
||||
t.Helper()
|
||||
os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
buf := &bytes.Buffer{}
|
||||
zw := zip.NewWriter(buf)
|
||||
for i := 1; i <= pages; i++ {
|
||||
w, _ := zw.Create(i2name(i))
|
||||
w.Write(bytes.Repeat([]byte("IMG"), 64))
|
||||
}
|
||||
zw.Close()
|
||||
os.WriteFile(path, buf.Bytes(), 0o644)
|
||||
}
|
||||
func i2name(i int) string { return fmt.Sprintf("%02d.jpg", i) }
|
||||
|
||||
func scanNow(t *testing.T, sc *scanner.Scanner, lib store.Library) {
|
||||
t.Helper()
|
||||
sc.ScanLibrary(context.Background(), lib)
|
||||
}
|
||||
|
||||
func TestBookListDetailDelete(t *testing.T) {
|
||||
st, sc, h, booksDir := setupAPI(t)
|
||||
atok := adminToken(t, h)
|
||||
|
||||
// member token + 进度前置数据
|
||||
do(h, "POST", "/api/users", atok, map[string]string{"username": "m2", "password": testPW, "role": "member"})
|
||||
mtok := loginAs(t, h, "m2", testPW)
|
||||
|
||||
lib, root := newLibrary(t, st, h, atok, booksDir, "comics")
|
||||
writeCBZ(t, filepath.Join(root, "series-a", "vol_01.cbz"), 4)
|
||||
os.WriteFile(filepath.Join(root, "readme.txt"), []byte("hello world"), 0o644)
|
||||
scanNow(t, sc, lib)
|
||||
|
||||
w := do(h, "GET", "/api/books", mtok, nil)
|
||||
var books []map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &books)
|
||||
if w.Code != 200 || len(books) != 2 {
|
||||
t.Fatalf("list %d %s", w.Code, w.Body)
|
||||
}
|
||||
var cbz map[string]any
|
||||
for _, b := range books {
|
||||
if b["format"] == "cbz" {
|
||||
cbz = b
|
||||
}
|
||||
}
|
||||
if cbz == nil {
|
||||
t.Fatal("cbz missing")
|
||||
}
|
||||
if cbz["pages"].(float64) != 4 || cbz["library"] != "comics" || cbz["percent"].(float64) != 0 {
|
||||
t.Fatalf("bad json %+v", cbz)
|
||||
}
|
||||
if !strings.HasPrefix(cbz["cover_url"].(string), "/api/books/") || !strings.Contains(cbz["cover_url"].(string), "?v=") {
|
||||
t.Fatalf("cover_url %+v", cbz["cover_url"])
|
||||
}
|
||||
if cbz["page_url_fmt"] == nil {
|
||||
t.Fatalf("cbz must have page_url_fmt: %+v", cbz)
|
||||
}
|
||||
id := itoa(cbz["id"])
|
||||
|
||||
// detail
|
||||
w = do(h, "GET", "/api/books/"+id, mtok, nil)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("detail %d", w.Code)
|
||||
}
|
||||
// 进度联动的断言在 Task 12(progress 端点此任务还不存在)
|
||||
|
||||
// member 不能删
|
||||
w = do(h, "DELETE", "/api/books/"+id, mtok, nil)
|
||||
if w.Code != 403 {
|
||||
t.Fatalf("member delete want 403 got %d", w.Code)
|
||||
}
|
||||
// admin 删:行、文件、缓存目录都没;txt 文件保留
|
||||
w = do(h, "DELETE", "/api/books/"+id, atok, nil)
|
||||
if w.Code != 204 {
|
||||
t.Fatalf("admin delete %d %s", w.Code, w.Body)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "series-a", "vol_01.cbz")); !os.IsNotExist(err) {
|
||||
t.Fatal("file not removed")
|
||||
}
|
||||
w = do(h, "GET", "/api/books", atok, nil)
|
||||
json.Unmarshal(w.Body.Bytes(), &books)
|
||||
if len(books) != 1 || books[0]["format"] != "txt" {
|
||||
t.Fatalf("books after delete %+v", books)
|
||||
}
|
||||
// 过滤器
|
||||
w = do(h, "GET", "/api/books?library="+itoa(lib.ID)+"&q=readme&prefix=series/", atok, nil)
|
||||
json.Unmarshal(w.Body.Bytes(), &books)
|
||||
if len(books) != 0 {
|
||||
t.Fatalf("prefix+q filter broken %+v", books)
|
||||
}
|
||||
w = do(h, "GET", "/api/books?q=readme", atok, nil)
|
||||
json.Unmarshal(w.Body.Bytes(), &books)
|
||||
if len(books) != 1 {
|
||||
t.Fatalf("q broken %+v", books)
|
||||
}
|
||||
}
|
||||
|
||||
func loginAs(t *testing.T, h http.Handler, user, pass string) string {
|
||||
t.Helper()
|
||||
w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": user, "password": pass})
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("login %s: %d", user, w.Code)
|
||||
}
|
||||
var v struct {
|
||||
Token string
|
||||
}
|
||||
json.Unmarshal(w.Body.Bytes(), &v)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package handlers
|
||||
|
||||
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 (h *H) bookRoot(c *gin.Context, b store.Book) (string, bool) {
|
||||
lib, ok := h.getLibRow(c, b.LibraryID)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return h.libRoot(c, lib)
|
||||
}
|
||||
|
||||
func (h *H) immutable(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
|
||||
func (h *H) ServeCover(c *gin.Context) {
|
||||
b, ok := h.bookFromParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
h.immutable(c)
|
||||
dir := bookfile.CoverDir(h.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
|
||||
if entries, e := os.ReadDir(dir); e == nil {
|
||||
for _, en := range entries { // 跳过写一半的 .tmp 落盘中间态
|
||||
if !strings.Contains(en.Name(), ".tmp") {
|
||||
http.ServeFile(c.Writer, c.Request, filepath.Join(dir, en.Name()))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if b.Format == "cbz" || b.Format == "epub" { // 自愈:缓存丢了就地抽封面(重启/卷漂移/扫描器还没跑到)
|
||||
if root, ok := h.bookRoot(c, b); ok {
|
||||
if f, size, ok := h.openBook(c, b, root); ok {
|
||||
defer f.Close()
|
||||
var img []byte
|
||||
var ext string
|
||||
var e error
|
||||
if b.Format == "cbz" {
|
||||
img, ext, e = bookfile.CBZCover(f, size)
|
||||
} else {
|
||||
img, ext, e = bookfile.EPUBCover(f, size)
|
||||
}
|
||||
if e == nil {
|
||||
dst := filepath.Join(dir, "cover"+ext)
|
||||
if e := os.MkdirAll(dir, 0o755); e == nil {
|
||||
tmp := fmt.Sprintf("%s.tmp-%d", dst, time.Now().UnixNano()) // 并发幂等:唯一 tmp + rename 原子
|
||||
if e := os.WriteFile(tmp, img, 0o644); e == nil {
|
||||
if e := os.Rename(tmp, dst); e == nil {
|
||||
http.ServeFile(c.Writer, c.Request, dst)
|
||||
}
|
||||
}
|
||||
os.Remove(tmp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.Writer.Written() { // openBook/bookRoot 已写 403/404/500,不再叠加占位图
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover))
|
||||
}
|
||||
|
||||
func (h *H) ServeFile(c *gin.Context) {
|
||||
b, ok := h.bookFromParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
root, ok := h.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 (h *H) 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 (h *H) 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 := h.rdb.Get(c, key); ok && v != "" {
|
||||
return strings.Split(v, "\n"), nil
|
||||
}
|
||||
f, size, ok := h.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
|
||||
}
|
||||
if len(idx) > 0 { // 空索引不缓存,否则 warm 命中 "" 会 Split 出幽灵页
|
||||
h.rdb.Set(c, key, strings.Join(idx, "\n"), 7*24*time.Hour)
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func (h *H) PagesCount(c *gin.Context) {
|
||||
b, ok := h.bookFromParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if b.Format != "cbz" {
|
||||
err(c, http.StatusBadRequest, "bad_request", "pages only for cbz")
|
||||
return
|
||||
}
|
||||
root, ok := h.bookRoot(c, b)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idx, e := h.pageIndex(c, b, root)
|
||||
if e != nil {
|
||||
if c.Writer.Written() {
|
||||
return // openBook 已写 403/404,不再叠加 422
|
||||
}
|
||||
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"count": len(idx)})
|
||||
}
|
||||
|
||||
func (h *H) Page(c *gin.Context) {
|
||||
b, ok := h.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 := h.bookRoot(c, b)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idx, e := h.pageIndex(c, b, root)
|
||||
if e != nil {
|
||||
if c.Writer.Written() {
|
||||
return // openBook 已写 403/404,不再叠加 422
|
||||
}
|
||||
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(h.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 := h.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
|
||||
}
|
||||
}
|
||||
h.immutable(c)
|
||||
http.ServeFile(c.Writer, c.Request, dst)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// 不经 scanner 直接入库(模拟缓存丢失/卷漂移)→ GET cover 就地抽图自愈并落盘(tmp+rename)
|
||||
func TestCoverSelfHeal(t *testing.T) {
|
||||
st, _, h, booksDir := setupAPI(t)
|
||||
atok := adminToken(t, h)
|
||||
lib, root := newLibrary(t, st, h, atok, booksDir, "comics")
|
||||
p := filepath.Join(root, "heal.cbz")
|
||||
writeCBZ(t, p, 2)
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := st.InsertBook(context.Background(), lib.ID, "heal.cbz", "heal", "cbz", fi.Size(), fi.ModTime().Unix(), 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 1; i <= 2; i++ { // 第二次走已缓存目录,均应 200 image/*
|
||||
w := do(h, "GET", "/api/books/"+itoa(id)+"/cover", atok, nil)
|
||||
if w.Code != 200 || !strings.Contains(w.Header().Get("Content-Type"), "image/") {
|
||||
t.Fatalf("self-heal cover pass %d: %d %q", i, w.Code, w.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/puddle/v2"
|
||||
|
||||
"booklib/internal/auth"
|
||||
"booklib/internal/config"
|
||||
"booklib/internal/redispkg"
|
||||
"booklib/internal/scanner"
|
||||
"booklib/internal/store"
|
||||
)
|
||||
|
||||
type H struct {
|
||||
cfg *config.Config
|
||||
st *store.Store
|
||||
rdb *redispkg.R
|
||||
sc *scanner.Scanner
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner) *H {
|
||||
return &H{cfg: cfg, st: st, rdb: rdb, sc: sc}
|
||||
}
|
||||
|
||||
func err(c *gin.Context, status int, code, msg string) {
|
||||
c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"code": code, "message": msg}})
|
||||
}
|
||||
|
||||
// dbErr 统一处理 store 层失败:记日志;连接类错误 503(Service Unavailable),其余 500
|
||||
// 注:brief 里的 pgxpool.ErrClosedPool 在 pgx v5 不存在,实际由 puddle 原样透出,用它替代;
|
||||
// PG 停机时池内连接先收到 SQLSTATE 57P01(administrator shutdown),故把 08xx/57Pxx 也归为 503
|
||||
func dbErr(c *gin.Context, e error) {
|
||||
log.Printf("db: %v", e)
|
||||
status, code := http.StatusInternalServerError, "internal"
|
||||
var pgErr *pgconn.PgError
|
||||
connClass := errors.As(e, &pgErr) && (strings.HasPrefix(pgErr.Code, "08") || strings.HasPrefix(pgErr.Code, "57P"))
|
||||
if connClass || errors.Is(e, syscall.ECONNREFUSED) || errors.Is(e, io.ErrUnexpectedEOF) ||
|
||||
errors.Is(e, net.ErrClosed) || errors.Is(e, puddle.ErrClosedPool) {
|
||||
status, code = http.StatusServiceUnavailable, "unavailable"
|
||||
}
|
||||
err(c, status, code, "db error")
|
||||
}
|
||||
|
||||
func (h *H) AuthMw() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
hdr := c.GetHeader("Authorization")
|
||||
tok, ok := strings.CutPrefix(hdr, "Bearer ")
|
||||
if !ok {
|
||||
err(c, http.StatusUnauthorized, "unauthorized", "missing bearer token")
|
||||
return
|
||||
}
|
||||
cl, perr := auth.Parse(h.cfg.JWTSecret, tok)
|
||||
if perr != nil {
|
||||
err(c, http.StatusUnauthorized, "unauthorized", "invalid token")
|
||||
return
|
||||
}
|
||||
c.Set("uid", cl.UID)
|
||||
c.Set("role", cl.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *H) AdminOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.GetString("role") != "admin" {
|
||||
err(c, http.StatusForbidden, "forbidden", "admin only")
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func uid(c *gin.Context) int64 { return c.GetInt64("uid") }
|
||||
func isAdmin(c *gin.Context) bool { return c.GetString("role") == "admin" }
|
||||
@@ -0,0 +1,49 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/puddle/v2"
|
||||
|
||||
"booklib/internal/store"
|
||||
)
|
||||
|
||||
func TestAbsBookPathTraversalRejected(t *testing.T) {
|
||||
root := "/data/books/lib" // 纯路径逻辑,不碰文件系统,无需 DB
|
||||
for _, bad := range []string{"../../etc/passwd", "a/../../../etc/x", "../sibling"} {
|
||||
if _, e := absBookPath(root, store.Book{Path: bad}); e == nil {
|
||||
t.Fatalf("must reject %q", bad)
|
||||
}
|
||||
}
|
||||
if p, e := absBookPath(root, store.Book{Path: "series-a/vol.cbz"}); e != nil || p != filepath.Join(root, "series-a", "vol.cbz") {
|
||||
t.Fatalf("must accept relative path: %q %v", p, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBErrStatus(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
e error
|
||||
want int
|
||||
}{
|
||||
{&pgconn.PgError{Code: "57P01"}, http.StatusServiceUnavailable}, // PG 停机:池内连接被服务端断
|
||||
{&pgconn.PgError{Code: "08006"}, http.StatusServiceUnavailable},
|
||||
{fmt.Errorf("dial: %w", syscall.ECONNREFUSED), http.StatusServiceUnavailable},
|
||||
{puddle.ErrClosedPool, http.StatusServiceUnavailable},
|
||||
{errors.New("boom"), http.StatusInternalServerError},
|
||||
} {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
dbErr(c, tc.e)
|
||||
if w.Code != tc.want {
|
||||
t.Errorf("dbErr(%v) = %d, want %d", tc.e, w.Code, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package handlers
|
||||
|
||||
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 (h *H) libRoot(c *gin.Context, lib store.Library) (string, bool) {
|
||||
root := filepath.Clean(lib.RootPath)
|
||||
books := filepath.Clean(h.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 (h *H) ListLibraries(c *gin.Context) {
|
||||
libs, e := h.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 (h *H) 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 := h.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 (h *H) 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 := h.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 (h *H) ScanLibrary(c *gin.Context) {
|
||||
lib, ok := h.getLibrary(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := h.libRoot(c, lib); !ok {
|
||||
return
|
||||
}
|
||||
go h.sc.ScanLibraryByID(context.WithoutCancel(c), lib.ID)
|
||||
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
|
||||
}
|
||||
|
||||
func (h *H) Upload(c *gin.Context) {
|
||||
lib, ok := h.getLibrary(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
root, ok := h.libRoot(c, lib)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.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 := h.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 (h *H) 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLibraryCreateListUpload(t *testing.T) {
|
||||
_, _, h, booksDir := setupAPI(t)
|
||||
tok := adminToken(t, h)
|
||||
root := filepath.Join(booksDir, "lib1") // 必须落在解析过软链的 booksDir 内
|
||||
os.MkdirAll(root, 0o755)
|
||||
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics", "root_path": root})
|
||||
if w.Code != 201 {
|
||||
t.Fatalf("create lib %d %s", w.Code, w.Body)
|
||||
}
|
||||
var lib map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &lib)
|
||||
libID := itoa(lib["id"])
|
||||
w = do(h, "GET", "/api/libraries", tok, nil)
|
||||
if !strings.Contains(w.Body.String(), `"comics"`) {
|
||||
t.Fatalf("list: %s", w.Body)
|
||||
}
|
||||
// 相对路径 root 必须 400(前缀校验的根)
|
||||
w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": "x", "root_path": "relative/path"})
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("relative root want 400 got %d", w.Code)
|
||||
}
|
||||
// 上传:白名单 + 防穿越 + 原子落盘
|
||||
body, mw := uploadBody("my 01.cbz", []byte("zipbytes"))
|
||||
req := httptest.NewRequest("POST", "/api/libraries/"+libID+"/upload", body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
ww := httptest.NewRecorder()
|
||||
h.ServeHTTP(ww, req)
|
||||
if ww.Code != 202 {
|
||||
t.Fatalf("upload %d %s", ww.Code, ww.Body)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "my 01.cbz")); err != nil {
|
||||
t.Fatal("uploaded file missing:", err)
|
||||
}
|
||||
body, mw = uploadBody("../../evil.cbz", []byte("x"))
|
||||
req = httptest.NewRequest("POST", "/api/libraries/"+libID+"/upload", body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
ww = httptest.NewRecorder()
|
||||
h.ServeHTTP(ww, req)
|
||||
if ww.Code != 202 { // 名字被清洗成 evil.cbz,落在 root 内
|
||||
t.Fatalf("sanitize upload %d", ww.Code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "evil.cbz")); err != nil {
|
||||
t.Fatal("evil upload not sanitized")
|
||||
}
|
||||
body, mw = uploadBody("virus.exe", []byte("x"))
|
||||
req = httptest.NewRequest("POST", "/api/libraries/"+libID+"/upload", body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
ww = httptest.NewRecorder()
|
||||
h.ServeHTTP(ww, req)
|
||||
if ww.Code != 400 {
|
||||
t.Fatalf("bad ext want 400 got %d", ww.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func uploadBody(filename string, content []byte) (*bytes.Buffer, *multipart.Writer) {
|
||||
buf := &bytes.Buffer{}
|
||||
mw := multipart.NewWriter(buf)
|
||||
fw, _ := mw.CreateFormFile("file", filename)
|
||||
fw.Write(content)
|
||||
mw.Close()
|
||||
return buf, mw
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *H) PutProgress(c *gin.Context) {
|
||||
b, ok := h.bookFromParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Locator json.RawMessage `json:"locator"`
|
||||
Percent float64 `json:"percent"`
|
||||
}
|
||||
if e := c.ShouldBindJSON(&req); e != nil {
|
||||
err(c, http.StatusBadRequest, "bad_request", "json body required")
|
||||
return
|
||||
}
|
||||
if req.Percent < 0 || req.Percent > 1 {
|
||||
err(c, http.StatusBadRequest, "bad_request", "percent must be in [0,1]")
|
||||
return
|
||||
}
|
||||
if len(req.Locator) == 0 {
|
||||
req.Locator = []byte("{}")
|
||||
}
|
||||
if !json.Valid(req.Locator) {
|
||||
err(c, http.StatusBadRequest, "bad_request", "locator must be valid json")
|
||||
return
|
||||
}
|
||||
if e := h.st.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil {
|
||||
dbErr(c, e)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *H) ListProgress(c *gin.Context) {
|
||||
rows, e := h.st.ListProgress(c, uid(c))
|
||||
if e != nil {
|
||||
dbErr(c, e)
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(rows))
|
||||
for _, p := range rows {
|
||||
out = append(out, gin.H{
|
||||
"library_id": p.LibraryID, "library": p.LibraryName, "path": p.BookPath,
|
||||
"title": p.Title, "locator": json.RawMessage(p.Locator),
|
||||
"percent": p.Percent, "updated_at": p.UpdatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProgressRoundTrip(t *testing.T) {
|
||||
st, sc, h, booksDir := setupAPI(t)
|
||||
atok := adminToken(t, h)
|
||||
do(h, "POST", "/api/users", atok, map[string]string{"username": "p1", "password": testPW, "role": "member"})
|
||||
mtok := loginAs(t, h, "p1", testPW)
|
||||
|
||||
lib, root := newLibrary(t, st, h, atok, booksDir, "prog")
|
||||
os.WriteFile(filepath.Join(root, "a.txt"), []byte("aaa"), 0o644)
|
||||
scanNow(t, sc, lib)
|
||||
w := do(h, "GET", "/api/books?q=a", atok, nil) // title="a"(文件名去 .txt)
|
||||
var bs []map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &bs)
|
||||
id := itoa(bs[0]["id"])
|
||||
|
||||
w = do(h, "PUT", "/api/books/"+id+"/progress", mtok,
|
||||
map[string]any{"locator": map[string]int{"page": 12}, "percent": 0.42})
|
||||
if w.Code != 204 {
|
||||
t.Fatalf("put %d %s", w.Code, w.Body)
|
||||
}
|
||||
w = do(h, "GET", "/api/progress", mtok, nil)
|
||||
var rows []map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &rows)
|
||||
if len(rows) != 1 || rows[0]["percent"].(float64) != 0.42 || rows[0]["title"] != "a" {
|
||||
t.Fatalf("get %s", w.Body)
|
||||
}
|
||||
if rows[0]["locator"].(map[string]any)["page"].(float64) != 12 {
|
||||
t.Fatalf("locator %s", w.Body)
|
||||
}
|
||||
// upsert 覆盖
|
||||
do(h, "PUT", "/api/books/"+id+"/progress", mtok, map[string]any{"locator": map[string]int{"page": 20}, "percent": 0.8})
|
||||
w = do(h, "GET", "/api/progress", mtok, nil)
|
||||
json.Unmarshal(w.Body.Bytes(), &rows)
|
||||
if len(rows) != 1 || rows[0]["percent"].(float64) != 0.8 {
|
||||
t.Fatalf("upsert %s", w.Body)
|
||||
}
|
||||
// 别人的进度不可见
|
||||
w = do(h, "GET", "/api/progress", atok, nil)
|
||||
json.Unmarshal(w.Body.Bytes(), &rows)
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("leak %s", w.Body)
|
||||
}
|
||||
// 校验
|
||||
w = do(h, "PUT", "/api/books/"+id+"/progress", mtok, map[string]any{"percent": 1.5})
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("bad percent want 400 got %d", w.Code)
|
||||
}
|
||||
w = do(h, "PUT", "/api/books/999999/progress", mtok, map[string]any{"percent": 0.5})
|
||||
if w.Code != 404 {
|
||||
t.Fatalf("bad book want 404 got %d", w.Code)
|
||||
}
|
||||
// 列表 percent 内嵌(Task 10 预告的断言在此兑现)
|
||||
w = do(h, "GET", "/api/books", mtok, nil)
|
||||
json.Unmarshal(w.Body.Bytes(), &bs)
|
||||
if bs[0]["percent"].(float64) != 0.8 {
|
||||
t.Fatalf("list percent %+v", bs[0])
|
||||
}
|
||||
// 删书,进度按 path 保留,重扫后 0.8 回来
|
||||
do(h, "DELETE", "/api/books/"+id, atok, nil)
|
||||
os.WriteFile(filepath.Join(root, "a.txt"), []byte("aaa"), 0o644)
|
||||
scanNow(t, sc, lib)
|
||||
w = do(h, "GET", "/api/books?q=a", mtok, nil)
|
||||
json.Unmarshal(w.Body.Bytes(), &bs)
|
||||
if bs[0]["percent"].(float64) != 0.8 {
|
||||
t.Fatalf("progress did not survive delete+rescan: %+v", bs[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"booklib/internal/auth"
|
||||
)
|
||||
|
||||
func isUnique(e error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(e, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
|
||||
func (h *H) ListUsers(c *gin.Context) {
|
||||
users, e := h.st.ListUsers(c)
|
||||
if e != nil {
|
||||
dbErr(c, e)
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, gin.H{"id": u.ID, "username": u.Username, "role": u.Role,
|
||||
"created_at": u.CreatedAt.Format(time.RFC3339)})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *H) CreateUser(c *gin.Context) {
|
||||
var req struct{ Username, Password, Role string }
|
||||
if c.ShouldBindJSON(&req) != nil {
|
||||
err(c, http.StatusBadRequest, "bad_request", "json body required")
|
||||
return
|
||||
}
|
||||
if req.Role != "admin" && req.Role != "member" {
|
||||
err(c, http.StatusBadRequest, "bad_request", "role must be admin|member")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
err(c, http.StatusBadRequest, "bad_request", "password too short (min 8)")
|
||||
return
|
||||
}
|
||||
hp, e := auth.HashPassword(req.Password)
|
||||
if e != nil {
|
||||
err(c, http.StatusInternalServerError, "internal", "hash")
|
||||
return
|
||||
}
|
||||
id, e := h.st.CreateUser(c, req.Username, hp, req.Role)
|
||||
if e != nil {
|
||||
if isUnique(e) {
|
||||
err(c, http.StatusConflict, "exists", "username taken")
|
||||
return
|
||||
}
|
||||
dbErr(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"id": id, "username": req.Username, "role": req.Role})
|
||||
}
|
||||
|
||||
func (h *H) DeleteUser(c *gin.Context) {
|
||||
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if e != nil {
|
||||
err(c, http.StatusBadRequest, "bad_request", "bad id")
|
||||
return
|
||||
}
|
||||
if id == uid(c) {
|
||||
err(c, http.StatusBadRequest, "bad_request", "cannot delete yourself")
|
||||
return
|
||||
}
|
||||
target, e := h.st.GetUserByID(c, id)
|
||||
if e != nil {
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
err(c, http.StatusNotFound, "not_found", "no such user")
|
||||
return
|
||||
}
|
||||
dbErr(c, e)
|
||||
return
|
||||
}
|
||||
if target.Role == "admin" {
|
||||
n, _ := h.st.CountAdmins(c) // 防删光最后一个 admin
|
||||
if n <= 1 {
|
||||
err(c, http.StatusBadRequest, "bad_request", "cannot delete the last admin")
|
||||
return
|
||||
}
|
||||
}
|
||||
if e := h.st.DeleteUser(c, id); e != nil {
|
||||
dbErr(c, e)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func adminToken(t *testing.T, h http.Handler) string {
|
||||
t.Helper()
|
||||
return loginAs(t, h, "alice", testPW)
|
||||
}
|
||||
|
||||
func TestUserCRUD(t *testing.T) {
|
||||
_, _, h, _ := setupAPI(t)
|
||||
tok := adminToken(t, h)
|
||||
w := do(h, "POST", "/api/users", tok, map[string]string{"username": "carol", "password": "pw123456", "role": "member"})
|
||||
if w.Code != 201 {
|
||||
t.Fatalf("create %d %s", w.Code, w.Body)
|
||||
}
|
||||
w = do(h, "GET", "/api/users", tok, nil)
|
||||
var users []map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &users)
|
||||
if len(users) != 3 {
|
||||
t.Fatalf("list want 3 got %d: %s", len(users), w.Body)
|
||||
}
|
||||
carolID := findID(users, "carol")
|
||||
// 重名 → 409
|
||||
w = do(h, "POST", "/api/users", tok, map[string]string{"username": "carol", "password": "pw123456", "role": "member"})
|
||||
if w.Code != 409 {
|
||||
t.Fatalf("dup want 409 got %d", w.Code)
|
||||
}
|
||||
// 弱密码 → 400
|
||||
w = do(h, "POST", "/api/users", tok, map[string]string{"username": "dave", "password": "1", "role": "member"})
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("weak want 400 got %d", w.Code)
|
||||
}
|
||||
// 不能删自己:先 me 拿 id
|
||||
w = do(h, "GET", "/api/auth/me", tok, nil)
|
||||
var me map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &me)
|
||||
w = do(h, "DELETE", "/api/users/"+itoa(me["id"]), tok, nil)
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("self-delete want 400 got %d %s", w.Code, w.Body)
|
||||
}
|
||||
// 删 carol → 204,再删 → 404
|
||||
w = do(h, "DELETE", "/api/users/"+itoa(carolID), tok, nil)
|
||||
if w.Code != 204 {
|
||||
t.Fatalf("delete %d %s", w.Code, w.Body)
|
||||
}
|
||||
w = do(h, "DELETE", "/api/users/"+itoa(carolID), tok, nil)
|
||||
if w.Code != 404 {
|
||||
t.Fatalf("redelete want 404 got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadRoleRejected(t *testing.T) {
|
||||
_, _, h, _ := setupAPI(t)
|
||||
tok := adminToken(t, h)
|
||||
w := do(h, "POST", "/api/users", tok, map[string]string{"username": "e", "password": "pw123456", "role": "god"})
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("bad role want 400 got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func findID(rows []map[string]any, name string) float64 {
|
||||
for _, r := range rows {
|
||||
if r["username"] == name {
|
||||
return r["id"].(float64)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func itoa(v any) string {
|
||||
if i, ok := v.(int64); ok {
|
||||
return strconv.FormatInt(i, 10) // DB 行里的 id (如 store.Library.ID)
|
||||
}
|
||||
return strconv.FormatFloat(v.(float64), 'f', 0, 64) // json.Unmarshal 出来的数字
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"booklib/cmd/webui/api"
|
||||
"booklib/internal/config"
|
||||
"booklib/internal/db"
|
||||
"booklib/internal/redispkg"
|
||||
"booklib/internal/scanner"
|
||||
"booklib/internal/seed"
|
||||
"booklib/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
p, err := db.Connect(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("db connect: %v", err)
|
||||
}
|
||||
defer p.Close()
|
||||
if err := db.Migrate(ctx, p); err != nil {
|
||||
log.Fatalf("migrate: %v", err)
|
||||
}
|
||||
st := store.New(p)
|
||||
if err := seed.Admin(ctx, st, cfg.AdminUser, cfg.AdminPassword); err != nil {
|
||||
log.Fatalf("seed: %v", err)
|
||||
}
|
||||
rdb := redispkg.New(cfg.RedisURL)
|
||||
sc := scanner.New(st, cfg, rdb)
|
||||
go sc.Run(ctx)
|
||||
|
||||
srv := &http.Server{Addr: cfg.Addr, Handler: api.NewRouter(cfg, st, rdb, sc),
|
||||
ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() {
|
||||
log.Printf("listening on %s", cfg.Addr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("serve: %v", err)
|
||||
}
|
||||
}()
|
||||
<-ctx.Done()
|
||||
// stop() 先取消 ctx → scanner 循环退出;再等 HTTP 收尾。
|
||||
// 在途 ScanLibraryByID(WithoutCancel)不受 ctx 控制,靠 redis 锁 TTL 兜底(已文档化的上限)。
|
||||
stop()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user