Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b09bcf5772 | ||
|
|
e0783e5e6a | ||
|
|
93638ed647 | ||
|
|
12f6e82308 | ||
|
|
4b40dd669b | ||
|
|
6adbab44c3 | ||
|
|
f7c27272ba | ||
|
|
94b215c2b3 | ||
|
|
a11bf28155 | ||
|
|
04095b5438 |
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
.git
|
||||||
|
library/
|
||||||
|
.superpowers/
|
||||||
|
backend/booklib*
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
JWT_SECRET=change-me-openssl-rand-hex-32
|
||||||
|
ADMIN_USER=admin
|
||||||
|
ADMIN_PASSWORD=change-me-min-8
|
||||||
|
SCAN_INTERVAL_SEC=60
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Book & Comic Library
|
||||||
|
|
||||||
|
个人书库/漫画库:Go+Gin 后端(扫描/上传入库、多用户 JWT、阅读进度、磁盘+Redis 缓存)+ Docker Compose 部署。设计见 `docs/superpowers/specs/2026-09-04-book-comic-library-design.md`。
|
||||||
|
|
||||||
|
## 跑起来(生产形态)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # 填 JWT_SECRET、ADMIN_USER、ADMIN_PASSWORD(≥8 位,低于 8 位 seed 会跳过并 log)
|
||||||
|
docker compose up -d --build
|
||||||
|
./scripts/smoke.sh # 端到端验收(登录、建库、上传、扫描、进度、删除、immutable 头)
|
||||||
|
```
|
||||||
|
|
||||||
|
- web: `http://localhost:8080`,API 走 nginx `/api/` 前缀反代到无状态 api 副本(`--scale api=N`)。
|
||||||
|
- 原始书放在 `./library/`(挂到 `/data/books`),scanner 周期入库(默认 60s)。
|
||||||
|
|
||||||
|
## 可信代理与限流
|
||||||
|
|
||||||
|
- nginx 在 compose 网络内,api 的 `ClientIP` 只信 `TRUSTED_PROXY_CIDRS`(逗号分隔 CIDR,默认 `172.16.0.0/12`,即 compose 网段)。外部伪造 `X-Forwarded-For` 换不掉限流桶;换部署网络时改这个 env。
|
||||||
|
- 登录限流 5 次/分钟/IP **按尝试计数,成功登录也计**——爆破和正常高频登录同账。
|
||||||
|
|
||||||
|
## 开发 / 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/docker-compose.dev.yml up -d # PG :5433, Redis :6380(避开本机默认端口)
|
||||||
|
cd backend
|
||||||
|
export DATABASE_URL='postgres://lib:lib@localhost:5433/lib?sslmode=disable'
|
||||||
|
export REDIS_URL='redis://localhost:6380'
|
||||||
|
go vet ./... && gofmt -l .
|
||||||
|
go test -p 1 -count=1 ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
`-p 1` 是必须的:集成测试共用同一个 PG 库,各自 `DELETE FROM ...` 清表——并行跑会互相删数据导致随机失败。
|
||||||
|
|
||||||
|
无 PG/Redis 时依赖它们的测试自动 skip;Redis 挂掉不影响功能(全链路降级为 miss/放行,见 spec §9)。
|
||||||
|
|
||||||
|
## 改 schema 前必读
|
||||||
|
|
||||||
|
`db.Migrate` 只执行 `schema.sql` 的 `CREATE TABLE IF NOT EXISTS`——对已存在的库**加列/改列不会生效**。任何列变更之前,必须先引入 `schema_migrations` 版本表 + 有序迁移脚本,否则老部署会静默跑在旧结构上。
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"booklib/internal/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2
|
||||||
github.com/redis/go-redis/v9 v9.22.0
|
github.com/redis/go-redis/v9 v9.22.0
|
||||||
golang.org/x/crypto v0.56.0
|
golang.org/x/crypto v0.56.0
|
||||||
)
|
)
|
||||||
@@ -25,7 +26,6 @@ require (
|
|||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/puddle/v2"
|
||||||
|
|
||||||
"booklib/internal/auth"
|
"booklib/internal/auth"
|
||||||
"booklib/internal/config"
|
"booklib/internal/config"
|
||||||
@@ -24,6 +31,21 @@ func err(c *gin.Context, status int, code, msg string) {
|
|||||||
c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"code": code, "message": msg}})
|
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 (a *api) authMw() gin.HandlerFunc {
|
func (a *api) authMw() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
h := c.GetHeader("Authorization")
|
h := c.GetHeader("Authorization")
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,8 +27,7 @@ func (a *api) login(c *gin.Context) {
|
|||||||
u, qerr := a.st.GetUserByName(c, req.Username)
|
u, qerr := a.st.GetUserByName(c, req.Username)
|
||||||
if qerr != nil {
|
if qerr != nil {
|
||||||
if !errors.Is(qerr, pgx.ErrNoRows) {
|
if !errors.Is(qerr, pgx.ErrNoRows) {
|
||||||
log.Printf("db: %v", qerr)
|
dbErr(c, qerr)
|
||||||
err(c, http.StatusInternalServerError, "internal", "db error")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 用户不存在也走一次 bcrypt,防用户名枚举时序差
|
// 用户不存在也走一次 bcrypt,防用户名枚举时序差
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -111,6 +112,26 @@ func TestLoginMe(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 伪造 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) {
|
func TestMemberCannotWriteUsers(t *testing.T) {
|
||||||
_, _, h, _ := setupAPI(t)
|
_, _, h, _ := setupAPI(t)
|
||||||
w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "bob", "password": testPW})
|
w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "bob", "password": testPW})
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func (a *api) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
|
|||||||
err(c, http.StatusNotFound, "not_found", "no such book")
|
err(c, http.StatusNotFound, "not_found", "no such book")
|
||||||
return store.Book{}, false
|
return store.Book{}, false
|
||||||
}
|
}
|
||||||
err(c, http.StatusInternalServerError, "internal", "db error")
|
dbErr(c, e)
|
||||||
return store.Book{}, false
|
return store.Book{}, false
|
||||||
}
|
}
|
||||||
return b, true
|
return b, true
|
||||||
@@ -42,9 +42,13 @@ func (a *api) bookFromParam(c *gin.Context) (store.Book, bool) {
|
|||||||
func (a *api) getLibRow(c *gin.Context, id int64) (store.Library, bool) {
|
func (a *api) getLibRow(c *gin.Context, id int64) (store.Library, bool) {
|
||||||
l, e := a.st.GetLibrary(c, id)
|
l, e := a.st.GetLibrary(c, id)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
|
if errors.Is(e, pgx.ErrNoRows) {
|
||||||
err(c, http.StatusNotFound, "not_found", "no such library")
|
err(c, http.StatusNotFound, "not_found", "no such library")
|
||||||
return store.Library{}, false
|
return store.Library{}, false
|
||||||
}
|
}
|
||||||
|
dbErr(c, e)
|
||||||
|
return store.Library{}, false
|
||||||
|
}
|
||||||
return l, true
|
return l, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +91,7 @@ func (a *api) listBooks(c *gin.Context) {
|
|||||||
libID, _ := strconv.ParseInt(c.Query("library"), 10, 64)
|
libID, _ := strconv.ParseInt(c.Query("library"), 10, 64)
|
||||||
views, e := a.st.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c))
|
views, e := a.st.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c))
|
||||||
if e != nil {
|
if e != nil {
|
||||||
err(c, http.StatusInternalServerError, "internal", "db error")
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]gin.H, 0, len(views))
|
out := make([]gin.H, 0, len(views))
|
||||||
@@ -102,8 +106,16 @@ func (a *api) getBook(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p, _ := a.st.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent
|
p, e := a.st.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent
|
||||||
lib, _ := a.st.GetLibrary(c, b.LibraryID)
|
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))
|
c.JSON(http.StatusOK, bookJSON(b, p.Percent, lib.Name))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +145,7 @@ func (a *api) deleteBook(c *gin.Context) {
|
|||||||
os.RemoveAll(bookfile.CoverDir(a.cfg.CacheDir, key))
|
os.RemoveAll(bookfile.CoverDir(a.cfg.CacheDir, key))
|
||||||
os.RemoveAll(bookfile.PagesDir(a.cfg.CacheDir, key))
|
os.RemoveAll(bookfile.PagesDir(a.cfg.CacheDir, key))
|
||||||
if e := a.st.DeleteBook(c, b.ID); e != nil {
|
if e := a.st.DeleteBook(c, b.ID); e != nil {
|
||||||
err(c, http.StatusInternalServerError, "internal", "db error")
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
|
|||||||
@@ -36,8 +36,42 @@ func (a *api) serveCover(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
a.immutable(c)
|
a.immutable(c)
|
||||||
dir := bookfile.CoverDir(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
|
dir := bookfile.CoverDir(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
|
||||||
if entries, e := os.ReadDir(dir); e == nil && len(entries) > 0 {
|
if entries, e := os.ReadDir(dir); e == nil {
|
||||||
http.ServeFile(c.Writer, c.Request, filepath.Join(dir, entries[0].Name()))
|
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 := a.bookRoot(c, b); ok {
|
||||||
|
if f, size, ok := a.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
|
return
|
||||||
}
|
}
|
||||||
c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover))
|
c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -47,6 +48,29 @@ func TestCoverCBZAndPlaceholder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 不经 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) {
|
func TestPages(t *testing.T) {
|
||||||
h, cbzID, txtID := serveFixture(t)
|
h, cbzID, txtID := serveFixture(t)
|
||||||
tok := adminToken(t, h)
|
tok := adminToken(t, h)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -12,6 +12,7 @@ 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"
|
||||||
@@ -31,8 +32,7 @@ func (a *api) libRoot(c *gin.Context, lib store.Library) (string, bool) {
|
|||||||
func (a *api) listLibraries(c *gin.Context) {
|
func (a *api) listLibraries(c *gin.Context) {
|
||||||
libs, e := a.st.ListLibraries(c)
|
libs, e := a.st.ListLibraries(c)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
log.Printf("db: %v", e)
|
dbErr(c, e)
|
||||||
err(c, http.StatusInternalServerError, "internal", "db error")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]gin.H, 0, len(libs))
|
out := make([]gin.H, 0, len(libs))
|
||||||
@@ -62,7 +62,7 @@ func (a *api) createLibrary(c *gin.Context) {
|
|||||||
err(c, http.StatusConflict, "exists", "root_path taken")
|
err(c, http.StatusConflict, "exists", "root_path taken")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err(c, http.StatusBadRequest, "bad_request", "invalid input")
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": filepath.Clean(req.RootPath)})
|
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": filepath.Clean(req.RootPath)})
|
||||||
@@ -76,9 +76,13 @@ func (a *api) getLibrary(c *gin.Context) (store.Library, bool) {
|
|||||||
}
|
}
|
||||||
lib, e := a.st.GetLibrary(c, id)
|
lib, e := a.st.GetLibrary(c, id)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
|
if errors.Is(e, pgx.ErrNoRows) {
|
||||||
err(c, http.StatusNotFound, "not_found", "no such library")
|
err(c, http.StatusNotFound, "not_found", "no such library")
|
||||||
return store.Library{}, false
|
return store.Library{}, false
|
||||||
}
|
}
|
||||||
|
dbErr(c, e)
|
||||||
|
return store.Library{}, false
|
||||||
|
}
|
||||||
return lib, true
|
return lib, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func (a *api) putProgress(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if e := a.st.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil {
|
if e := a.st.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil {
|
||||||
err(c, http.StatusInternalServerError, "internal", "db error")
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
@@ -42,7 +42,7 @@ func (a *api) putProgress(c *gin.Context) {
|
|||||||
func (a *api) listProgress(c *gin.Context) {
|
func (a *api) listProgress(c *gin.Context) {
|
||||||
rows, e := a.st.ListProgress(c, uid(c))
|
rows, e := a.st.ListProgress(c, uid(c))
|
||||||
if e != nil {
|
if e != nil {
|
||||||
err(c, http.StatusInternalServerError, "internal", "db error")
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]gin.H, 0, len(rows))
|
out := make([]gin.H, 0, len(rows))
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner
|
|||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
a := &api{cfg: cfg, st: st, rdb: rdb, sc: sc}
|
a := &api{cfg: cfg, st: st, rdb: rdb, sc: sc}
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
|
if e := r.SetTrustedProxies(cfg.TrustedProxies); e != nil {
|
||||||
|
panic(e)
|
||||||
|
}
|
||||||
r.Use(gin.Recovery())
|
r.Use(gin.Recovery())
|
||||||
g := r.Group("/api")
|
g := r.Group("/api")
|
||||||
g.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
g.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/puddle/v2"
|
||||||
|
|
||||||
"booklib/internal/config"
|
"booklib/internal/config"
|
||||||
"booklib/internal/redispkg"
|
"booklib/internal/redispkg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testCfg() *config.Config {
|
func testCfg() *config.Config {
|
||||||
return &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), ScanInterval: time.Minute, UploadMaxMB: 200}
|
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) {
|
func TestHealthz(t *testing.T) {
|
||||||
@@ -23,3 +31,23 @@ func TestHealthz(t *testing.T) {
|
|||||||
t.Fatalf("healthz = %d, want 200", w.Code)
|
t.Fatalf("healthz = %d, want 200", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,8 +21,7 @@ func isUnique(e error) bool {
|
|||||||
func (a *api) listUsers(c *gin.Context) {
|
func (a *api) listUsers(c *gin.Context) {
|
||||||
users, e := a.st.ListUsers(c)
|
users, e := a.st.ListUsers(c)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
log.Printf("db: %v", e)
|
dbErr(c, e)
|
||||||
err(c, http.StatusInternalServerError, "internal", "db error")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]gin.H, 0, len(users))
|
out := make([]gin.H, 0, len(users))
|
||||||
@@ -59,7 +57,7 @@ func (a *api) createUser(c *gin.Context) {
|
|||||||
err(c, http.StatusConflict, "exists", "username taken")
|
err(c, http.StatusConflict, "exists", "username taken")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err(c, http.StatusBadRequest, "bad_request", "invalid input")
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusCreated, gin.H{"id": id, "username": req.Username, "role": req.Role})
|
c.JSON(http.StatusCreated, gin.H{"id": id, "username": req.Username, "role": req.Role})
|
||||||
@@ -81,7 +79,7 @@ func (a *api) deleteUser(c *gin.Context) {
|
|||||||
err(c, http.StatusNotFound, "not_found", "no such user")
|
err(c, http.StatusNotFound, "not_found", "no such user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err(c, http.StatusInternalServerError, "internal", "db error")
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if target.Role == "admin" {
|
if target.Role == "admin" {
|
||||||
@@ -92,7 +90,7 @@ func (a *api) deleteUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if e := a.st.DeleteUser(c, id); e != nil {
|
if e := a.st.DeleteUser(c, id); e != nil {
|
||||||
err(c, http.StatusInternalServerError, "internal", "db error")
|
dbErr(c, e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ type Config struct {
|
|||||||
CacheDir string
|
CacheDir string
|
||||||
ScanInterval time.Duration
|
ScanInterval time.Duration
|
||||||
UploadMaxMB int64
|
UploadMaxMB int64
|
||||||
|
TrustedProxies []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -28,6 +30,16 @@ func Load() (*Config, error) {
|
|||||||
}
|
}
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
envList := func(k, def string) []string {
|
||||||
|
v := env(k, def)
|
||||||
|
out := make([]string, 0, len(strings.Split(v, ",")))
|
||||||
|
for _, s := range strings.Split(v, ",") {
|
||||||
|
if s = strings.TrimSpace(s); s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
scanSec, err := strconv.Atoi(env("SCAN_INTERVAL_SEC", "60"))
|
scanSec, err := strconv.Atoi(env("SCAN_INTERVAL_SEC", "60"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SCAN_INTERVAL_SEC: %w", err)
|
return nil, fmt.Errorf("SCAN_INTERVAL_SEC: %w", err)
|
||||||
@@ -59,5 +71,6 @@ func Load() (*Config, error) {
|
|||||||
CacheDir: resolveDir(env("CACHE_DIR", "/data/cache")),
|
CacheDir: resolveDir(env("CACHE_DIR", "/data/cache")),
|
||||||
ScanInterval: time.Duration(scanSec) * time.Second,
|
ScanInterval: time.Duration(scanSec) * time.Second,
|
||||||
UploadMaxMB: uploadMB,
|
UploadMaxMB: uploadMB,
|
||||||
|
TrustedProxies: envList("TRUSTED_PROXY_CIDRS", "172.16.0.0/12"),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoad(t *testing.T) {
|
||||||
|
// 屏蔽外部 env,保证默认值断言自洽(Load 将空串视为未设置)
|
||||||
|
t.Setenv("BOOKS_DIR", "")
|
||||||
|
t.Setenv("CACHE_DIR", "")
|
||||||
|
t.Setenv("ADDR", "")
|
||||||
|
t.Setenv("JWT_SECRET", "")
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Fatal("missing JWT_SECRET must fail")
|
||||||
|
}
|
||||||
|
t.Setenv("JWT_SECRET", "x")
|
||||||
|
t.Setenv("SCAN_INTERVAL_SEC", "abc")
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Fatal("bad interval must fail")
|
||||||
|
}
|
||||||
|
t.Setenv("SCAN_INTERVAL_SEC", "30")
|
||||||
|
t.Setenv("DATABASE_URL", "postgres://x")
|
||||||
|
t.Setenv("TRUSTED_PROXY_CIDRS", "")
|
||||||
|
c, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Load 会 EvalSymlinks 解析 BooksDir(任务10 裁定),这里做同样的期望值解析
|
||||||
|
wantBooks := "/data/books"
|
||||||
|
if r, e := filepath.EvalSymlinks(wantBooks); e == nil {
|
||||||
|
wantBooks = r
|
||||||
|
}
|
||||||
|
if c.ScanInterval != 30*time.Second || c.BooksDir != wantBooks || c.Addr != ":8080" {
|
||||||
|
t.Fatalf("%+v", c)
|
||||||
|
}
|
||||||
|
if len(c.TrustedProxies) != 1 || c.TrustedProxies[0] != "172.16.0.0/12" {
|
||||||
|
t.Fatalf("trusted proxies default: %+v", c.TrustedProxies)
|
||||||
|
}
|
||||||
|
t.Setenv("TRUSTED_PROXY_CIDRS", "10.0.0.0/8, 1.2.3.4")
|
||||||
|
c, err = Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(c.TrustedProxies) != 2 || c.TrustedProxies[0] != "10.0.0.0/8" || c.TrustedProxies[1] != "1.2.3.4" {
|
||||||
|
t.Fatalf("trusted proxies override: %+v", c.TrustedProxies)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,8 +67,12 @@ func (r *R) Lock(ctx context.Context, key string, ttl time.Duration) (func(), bo
|
|||||||
rand.Read(b)
|
rand.Read(b)
|
||||||
tok := hex.EncodeToString(b)
|
tok := hex.EncodeToString(b)
|
||||||
ok, err := r.c.SetNX(ctx, key, tok, ttl).Result()
|
ok, err := r.c.SetNX(ctx, key, tok, ttl).Result()
|
||||||
if err != nil || !ok {
|
if err != nil { // spec §9: Redis 故障降级放行,锁只做尽力去重
|
||||||
return noop, false
|
log.Printf("redis lock %s: %v (proceeding without lock)", key, err)
|
||||||
|
return noop, true
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return noop, false // 锁被持有,别的副本在扫
|
||||||
}
|
}
|
||||||
return func() {
|
return func() {
|
||||||
r.c.Eval(ctx,
|
r.c.Eval(ctx,
|
||||||
|
|||||||
@@ -22,3 +22,12 @@ func TestDisabledIsSafe(t *testing.T) {
|
|||||||
}
|
}
|
||||||
un()
|
un()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeadRedisLockFailsOpen(t *testing.T) {
|
||||||
|
r := New("redis://127.0.0.1:16399") // 死端口
|
||||||
|
un, ok := r.Lock(context.Background(), "lk", time.Second)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Lock on redis error must fail open (spec §9)")
|
||||||
|
}
|
||||||
|
un()
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ func Admin(ctx context.Context, s *store.Store, user, pass string) error {
|
|||||||
if user == "" || pass == "" {
|
if user == "" || pass == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if len(pass) < 8 { // 与 API 建户口令下限一致
|
||||||
|
log.Printf("seed admin skipped: ADMIN_PASSWORD must be >= 8 chars")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
n, err := s.CountUsers(ctx)
|
n, err := s.CountUsers(ctx)
|
||||||
if err != nil || n > 0 {
|
if err != nil || n > 0 {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -21,10 +21,16 @@ func TestSeedOnlyWhenEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
s := store.New(p)
|
s := store.New(p)
|
||||||
p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users")
|
p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users")
|
||||||
if err := Admin(ctx, s, "admin", "pw12345"); err != nil {
|
if err := Admin(ctx, s, "shorty", "pw123"); err != nil { // <8 位 → 跳过 + log,不报错(空库时验证确实没建)
|
||||||
|
t.Fatal("short pw must no-op, got", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetUserByName(ctx, "shorty"); err == nil {
|
||||||
|
t.Fatal("short pw must not create user")
|
||||||
|
}
|
||||||
|
if err := Admin(ctx, s, "admin", "pw123456"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := Admin(ctx, s, "admin2", "pw12345"); err != nil { // 已有用户 → no-op
|
if err := Admin(ctx, s, "admin2", "pw123456"); err != nil { // 已有用户 → no-op
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
n, _ := s.CountUsers(ctx)
|
n, _ := s.CountUsers(ctx)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM golang:1.26-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY backend/go.mod backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY backend/ ./
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -o /server ./cmd/server
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
RUN adduser -D -H app
|
||||||
|
COPY --from=build /server /server
|
||||||
|
RUN mkdir -p /data/cache /data/books && chown app:app /data/cache /data/books
|
||||||
|
USER app
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/server"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY deploy/web-dist /usr/share/nginx/html
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
client_max_body_size 200m;
|
||||||
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
set $upstream http://api:8080; # 变量式 → 每次按 DNS 解析,scale 后轮询到新副本
|
||||||
|
proxy_pass $upstream; # 无 URI 部分:保留 /api 前缀转发
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><title>booklib</title><p>backend up — frontend lands in Plan 2.</p>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: { context: ., dockerfile: deploy/Dockerfile.web }
|
||||||
|
ports: ["8080:80"]
|
||||||
|
depends_on: [api]
|
||||||
|
api:
|
||||||
|
build: { context: ., dockerfile: deploy/Dockerfile.api }
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
ADMIN_USER: ${ADMIN_USER}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
BOOKS_DIR: /data/books
|
||||||
|
CACHE_DIR: /data/cache
|
||||||
|
SCAN_INTERVAL_SEC: ${SCAN_INTERVAL_SEC:-60}
|
||||||
|
volumes:
|
||||||
|
- ./library:/data/books
|
||||||
|
- cache:/data/cache
|
||||||
|
depends_on:
|
||||||
|
postgres: { condition: service_healthy }
|
||||||
|
redis: { condition: service_started }
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib }
|
||||||
|
volumes: [pgdata:/var/lib/postgresql/data]
|
||||||
|
healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 }
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: ["redis-server", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru"]
|
||||||
|
# 故意无 volume:redis 里全是可再生数据(spec §6.2)
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
cache:
|
||||||
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
BASE=${BASE:-http://localhost:8080}
|
||||||
|
API=$BASE/api
|
||||||
|
J=(-H 'content-type: application/json')
|
||||||
|
[ -f .env ] && set -a && . ./.env && set +a
|
||||||
|
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
|
say(){ echo "smoke: $1"; }
|
||||||
|
die(){ echo "SMOKE FAIL: $1"; exit 1; }
|
||||||
|
tokfor(){ curl -fsS "$API/auth/login" "${J[@]}" -d "{\"username\":\"$1\",\"password\":\"$2\"}" | sed -E 's/.*"token":"([^"]+)".*/\1/'; }
|
||||||
|
|
||||||
|
say "healthz"
|
||||||
|
curl -fsS "$API/healthz" >/dev/null || die "healthz down"
|
||||||
|
|
||||||
|
say "login"
|
||||||
|
TOK=$(tokfor "$ADMIN_USER" "$ADMIN_PASSWORD")
|
||||||
|
[ -n "$TOK" ] || die "no token"
|
||||||
|
AUTH="authorization: Bearer $TOK"
|
||||||
|
|
||||||
|
say "member user + role enforcement"
|
||||||
|
curl -fsS "$API/users" "${J[@]}" -H "$AUTH" -d '{"username":"smoke","password":"smokepw123","role":"member"}' >/dev/null || die "create member"
|
||||||
|
MTOK=$(tokfor smoke smokepw123)
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$API/users" "${J[@]}" -H "authorization: Bearer $MTOK" -d '{"username":"x","password":"xpw12345","role":"member"}')
|
||||||
|
[ "$code" = 403 ] || die "member write not blocked ($code)"
|
||||||
|
|
||||||
|
say "library + bad-ext upload rejected + good upload + scan"
|
||||||
|
mkdir -p library/smoke-books
|
||||||
|
LID=$(curl -fsS "$API/libraries" "${J[@]}" -H "$AUTH" -d '{"name":"smoke","root_path":"/data/books/smoke-books"}' | sed -E 's/.*"id":([0-9]+).*/\1/')
|
||||||
|
printf 'x' > "$WORK/f"
|
||||||
|
curl -fsS -o /dev/null "$API/libraries/$LID/upload" -H "$AUTH" -F "file=@$WORK/f;filename=virus.exe" && die "bad ext upload must fail" || true
|
||||||
|
printf 'hello smoke book' > "$WORK/f"
|
||||||
|
curl -fsS -o /dev/null "$API/libraries/$LID/upload" -H "$AUTH" -F "file=@$WORK/f;filename=note.txt" || die "upload failed"
|
||||||
|
curl -fsS -o /dev/null -X POST "$API/libraries/$LID/scan" -H "$AUTH" || die "scan trigger"
|
||||||
|
found=""
|
||||||
|
for _ in $(seq 30); do
|
||||||
|
if curl -fsS "$API/books?library=$LID" -H "$AUTH" | grep -q '"path":"note.txt"'; then found=1; break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[ -n "$found" ] || die "book not indexed after 30s"
|
||||||
|
|
||||||
|
say "read + progress roundtrip"
|
||||||
|
BID=$(curl -fsS "$API/books?library=$LID" -H "$AUTH" | sed -E 's/.*"id":([0-9]+).*/\1/')
|
||||||
|
curl -fsS "$API/books/$BID/file" -H "$AUTH" | grep -q "hello smoke book" || die "file body"
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT "$API/books/$BID/progress" "${J[@]}" -H "authorization: Bearer $MTOK" -d '{"locator":{"scroll":0.5},"percent":0.5}')
|
||||||
|
[ "$code" = 204 ] || die "progress put $code"
|
||||||
|
curl -fsS "$API/progress" -H "authorization: Bearer $MTOK" | grep -q '"percent":0.5' || die "progress read"
|
||||||
|
|
||||||
|
say "immutable cache header"
|
||||||
|
COVER=$(curl -fsS "$API/books/$BID" -H "$AUTH" | sed -E 's/.*"cover_url":"([^"]+)".*/\1/')
|
||||||
|
curl -fsS -o /dev/null -D - "$BASE$COVER" -H "$AUTH" | grep -qi 'cache-control:.*immutable' || die "cover not immutable"
|
||||||
|
|
||||||
|
say "delete book → file gone from host dir"
|
||||||
|
curl -fsS -o /dev/null -X DELETE "$API/books/$BID" -H "$AUTH" || die "delete"
|
||||||
|
[ ! -f library/smoke-books/note.txt ] || die "file survived delete"
|
||||||
|
|
||||||
|
say "ALL SMOKE TESTS PASSED"
|
||||||
Reference in New Issue
Block a user