merge: booklib backend Plan 1 (scaffold→scanner→API→deploy, e2e smoke green)
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
module booklib
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
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
|
||||||
|
golang.org/x/crypto v0.56.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
golang.org/x/net v0.57.0 // indirect
|
||||||
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/text v0.41.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
)
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
|
||||||
|
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||||
|
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||||
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
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 api struct {
|
||||||
|
cfg *config.Config
|
||||||
|
st *store.Store
|
||||||
|
rdb *redispkg.R
|
||||||
|
sc *scanner.Scanner
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (a *api) authMw() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
h := c.GetHeader("Authorization")
|
||||||
|
tok, ok := strings.CutPrefix(h, "Bearer ")
|
||||||
|
if !ok {
|
||||||
|
err(c, http.StatusUnauthorized, "unauthorized", "missing bearer token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cl, perr := auth.Parse(a.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 (a *api) 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,57 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
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 (a *api) 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 := a.rdb.IncrWindow(c, "loginrl:"+c.ClientIP(), loginWindow); n > loginMax {
|
||||||
|
err(c, http.StatusTooManyRequests, "rate_limited", "too many login attempts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, qerr := a.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(a.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 (a *api) me(c *gin.Context) {
|
||||||
|
u, qerr := a.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,145 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
"booklib/internal/auth"
|
||||||
|
"booklib/internal/db"
|
||||||
|
"booklib/internal/redispkg"
|
||||||
|
"booklib/internal/scanner"
|
||||||
|
"booklib/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 := 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 api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"booklib/internal/bookfile"
|
||||||
|
"booklib/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *api) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
|
||||||
|
b, e := a.st.GetBook(c, id)
|
||||||
|
if e != nil {
|
||||||
|
if errors.Is(e, pgx.ErrNoRows) {
|
||||||
|
err(c, http.StatusNotFound, "not_found", "no such book")
|
||||||
|
return store.Book{}, false
|
||||||
|
}
|
||||||
|
dbErr(c, e)
|
||||||
|
return store.Book{}, false
|
||||||
|
}
|
||||||
|
return b, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) bookFromParam(c *gin.Context) (store.Book, bool) {
|
||||||
|
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if e != nil {
|
||||||
|
err(c, http.StatusBadRequest, "bad_request", "bad id")
|
||||||
|
return store.Book{}, false
|
||||||
|
}
|
||||||
|
return a.getBookRow(c, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) getLibRow(c *gin.Context, id int64) (store.Library, bool) {
|
||||||
|
l, e := a.st.GetLibrary(c, id)
|
||||||
|
if e != nil {
|
||||||
|
if errors.Is(e, pgx.ErrNoRows) {
|
||||||
|
err(c, http.StatusNotFound, "not_found", "no such library")
|
||||||
|
return store.Library{}, false
|
||||||
|
}
|
||||||
|
dbErr(c, e)
|
||||||
|
return store.Library{}, false
|
||||||
|
}
|
||||||
|
return l, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// absBookPath: books.path 永远相对且不含 ..;拼接后二次前缀校验(纵深防御)
|
||||||
|
func absBookPath(root string, b store.Book) (string, error) {
|
||||||
|
abs := filepath.Join(root, filepath.FromSlash(b.Path))
|
||||||
|
if filepath.Clean(abs) != abs || !hasPrefixDir(abs, root) {
|
||||||
|
return "", os.ErrPermission
|
||||||
|
}
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPrefixDir(p, dir string) bool {
|
||||||
|
rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(p))
|
||||||
|
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
||||||
|
}
|
||||||
|
|
||||||
|
func bookJSON(b store.Book, percent float64, libraryName string) gin.H {
|
||||||
|
h := bookfile.Hash(b.FileSize, b.ModTS)
|
||||||
|
j := gin.H{
|
||||||
|
"id": b.ID, "library_id": b.LibraryID, "path": b.Path, "title": b.Title,
|
||||||
|
"format": b.Format, "size": b.FileSize, "mtime": b.ModTS, "pages": b.PageCount,
|
||||||
|
"state": b.State, "error": b.ErrMsg, "added_at": b.AddedAt.Format(time.RFC3339),
|
||||||
|
"percent": percent,
|
||||||
|
"cover_url": fmt.Sprintf("/api/books/%d/cover?v=%s", b.ID, h),
|
||||||
|
}
|
||||||
|
if b.Format == "cbz" {
|
||||||
|
j["pages_url"] = fmt.Sprintf("/api/books/%d/pages", b.ID)
|
||||||
|
j["page_url_fmt"] = fmt.Sprintf("/api/books/%d/pages/%%d?v=%s", b.ID, h)
|
||||||
|
} else {
|
||||||
|
j["file_url"] = fmt.Sprintf("/api/books/%d/file?v=%s", b.ID, h)
|
||||||
|
}
|
||||||
|
if libraryName != "" {
|
||||||
|
j["library"] = libraryName
|
||||||
|
}
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) listBooks(c *gin.Context) {
|
||||||
|
libID, _ := strconv.ParseInt(c.Query("library"), 10, 64)
|
||||||
|
views, e := a.st.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c))
|
||||||
|
if e != nil {
|
||||||
|
dbErr(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]gin.H, 0, len(views))
|
||||||
|
for _, v := range views {
|
||||||
|
out = append(out, bookJSON(v.Book, v.Percent, v.LibraryName))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) getBook(c *gin.Context) {
|
||||||
|
b, ok := a.bookFromParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, e := a.st.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent
|
||||||
|
if e != nil && !errors.Is(e, pgx.ErrNoRows) {
|
||||||
|
dbErr(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lib, e := a.st.GetLibrary(c, b.LibraryID)
|
||||||
|
if e != nil && !errors.Is(e, pgx.ErrNoRows) { // 库被并发删则留空 library 名,书仍可见
|
||||||
|
dbErr(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, bookJSON(b, p.Percent, lib.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) deleteBook(c *gin.Context) {
|
||||||
|
b, ok := a.bookFromParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lib, ok := a.getLibRow(c, b.LibraryID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root, ok := a.libRoot(c, lib)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, e := absBookPath(root, b)
|
||||||
|
if e != nil {
|
||||||
|
err(c, http.StatusForbidden, "forbidden", "unsafe path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e := os.Remove(abs); e != nil && !os.IsNotExist(e) {
|
||||||
|
err(c, http.StatusInternalServerError, "internal", "remove file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))
|
||||||
|
os.RemoveAll(bookfile.CoverDir(a.cfg.CacheDir, key))
|
||||||
|
os.RemoveAll(bookfile.PagesDir(a.cfg.CacheDir, key))
|
||||||
|
if e := a.st.DeleteBook(c, b.ID); e != nil {
|
||||||
|
dbErr(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"booklib/internal/bookfile"
|
||||||
|
"booklib/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultCover = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="170"><rect width="120" height="170" rx="6" fill="#2a2a33"/><path d="M30 25h60v120H30z" fill="#3a3a45"/><path d="M30 25h60M60 25v120" stroke="#555" stroke-width="2"/></svg>`
|
||||||
|
|
||||||
|
func (a *api) bookRoot(c *gin.Context, b store.Book) (string, bool) {
|
||||||
|
lib, ok := a.getLibRow(c, b.LibraryID)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return a.libRoot(c, lib)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) immutable(c *gin.Context) {
|
||||||
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) serveCover(c *gin.Context) {
|
||||||
|
b, ok := a.bookFromParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.immutable(c)
|
||||||
|
dir := bookfile.CoverDir(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
|
||||||
|
if entries, 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 := 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
|
||||||
|
}
|
||||||
|
c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) serveFile(c *gin.Context) {
|
||||||
|
b, ok := a.bookFromParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root, ok := a.bookRoot(c, b)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, perr := absBookPath(root, b)
|
||||||
|
if perr != nil {
|
||||||
|
err(c, http.StatusForbidden, "forbidden", "unsafe path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("ETag", `"`+bookfile.Hash(b.FileSize, b.ModTS)+`"`)
|
||||||
|
c.Header("Cache-Control", "private, must-revalidate")
|
||||||
|
http.ServeFile(c.Writer, c.Request, abs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) openBook(c *gin.Context, b store.Book, root string) (*os.File, int64, bool) {
|
||||||
|
abs, perr := absBookPath(root, b)
|
||||||
|
if perr != nil {
|
||||||
|
err(c, http.StatusForbidden, "forbidden", "unsafe path")
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
f, perr := os.Open(abs)
|
||||||
|
if perr != nil {
|
||||||
|
err(c, http.StatusNotFound, "not_found", "file missing on disk")
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
st, perr := f.Stat()
|
||||||
|
if perr != nil {
|
||||||
|
f.Close()
|
||||||
|
err(c, http.StatusInternalServerError, "internal", "stat")
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
return f, st.Size(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) pageIndex(c *gin.Context, b store.Book, root string) ([]string, error) {
|
||||||
|
hash := bookfile.Hash(b.FileSize, b.ModTS)
|
||||||
|
key := fmt.Sprintf("pagesidx:%d:%s", b.ID, hash)
|
||||||
|
if v, ok := a.rdb.Get(c, key); ok && v != "" {
|
||||||
|
return strings.Split(v, "\n"), nil
|
||||||
|
}
|
||||||
|
f, size, ok := a.openBook(c, b, root)
|
||||||
|
if !ok {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
idx, e := bookfile.PageIndex(f, size)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
if len(idx) > 0 { // 空索引不缓存,否则 warm 命中 "" 会 Split 出幽灵页
|
||||||
|
a.rdb.Set(c, key, strings.Join(idx, "\n"), 7*24*time.Hour)
|
||||||
|
}
|
||||||
|
return idx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) pagesCount(c *gin.Context) {
|
||||||
|
b, ok := a.bookFromParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.Format != "cbz" {
|
||||||
|
err(c, http.StatusBadRequest, "bad_request", "pages only for cbz")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root, ok := a.bookRoot(c, b)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx, e := a.pageIndex(c, b, root)
|
||||||
|
if e != nil {
|
||||||
|
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 (a *api) page(c *gin.Context) {
|
||||||
|
b, ok := a.bookFromParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.Format != "cbz" {
|
||||||
|
err(c, http.StatusBadRequest, "bad_request", "pages only for cbz")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, e := strconv.Atoi(c.Param("n"))
|
||||||
|
if e != nil || n < 0 {
|
||||||
|
err(c, http.StatusBadRequest, "bad_request", "bad page number")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root, ok := a.bookRoot(c, b)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx, e := a.pageIndex(c, b, root)
|
||||||
|
if e != nil {
|
||||||
|
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(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
|
||||||
|
dst := filepath.Join(dir, strconv.Itoa(n)+ext)
|
||||||
|
if _, e := os.Stat(dst); e != nil { // miss → 解压落盘(并发重做同页幂等,唯一 tmp 名 + rename 原子)
|
||||||
|
f, size, ok := a.openBook(c, b, root)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
data, e := bookfile.ReadEntry(f, size, idx[n])
|
||||||
|
if e != nil {
|
||||||
|
err(c, http.StatusInternalServerError, "internal", "extract page")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e := os.MkdirAll(dir, 0o755); e != nil {
|
||||||
|
err(c, http.StatusInternalServerError, "internal", "cache dir")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmp := fmt.Sprintf("%s.tmp-%d", dst, time.Now().UnixNano())
|
||||||
|
if e := os.WriteFile(tmp, data, 0o644); e != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
err(c, http.StatusInternalServerError, "internal", "write cache")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e := os.Rename(tmp, dst); e != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
err(c, http.StatusInternalServerError, "internal", "rename cache")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.immutable(c)
|
||||||
|
http.ServeFile(c.Writer, c.Request, dst)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
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,176 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
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 (a *api) libRoot(c *gin.Context, lib store.Library) (string, bool) {
|
||||||
|
root := filepath.Clean(lib.RootPath)
|
||||||
|
books := filepath.Clean(a.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
|
||||||
|
}
|
||||||
|
return root, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) listLibraries(c *gin.Context) {
|
||||||
|
libs, e := a.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 (a *api) 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 := a.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 (a *api) 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 := a.st.GetLibrary(c, id)
|
||||||
|
if e != nil {
|
||||||
|
if errors.Is(e, pgx.ErrNoRows) {
|
||||||
|
err(c, http.StatusNotFound, "not_found", "no such library")
|
||||||
|
return store.Library{}, false
|
||||||
|
}
|
||||||
|
dbErr(c, e)
|
||||||
|
return store.Library{}, false
|
||||||
|
}
|
||||||
|
return lib, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) scanLibrary(c *gin.Context) {
|
||||||
|
lib, ok := a.getLibrary(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := a.libRoot(c, lib); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go a.sc.ScanLibraryByID(context.WithoutCancel(c), lib.ID)
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) upload(c *gin.Context) {
|
||||||
|
lib, ok := a.getLibrary(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root, ok := a.libRoot(c, lib)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, a.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 := a.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 (a *api) 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 api
|
||||||
|
|
||||||
|
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 api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *api) putProgress(c *gin.Context) {
|
||||||
|
b, ok := a.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 := a.st.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil {
|
||||||
|
dbErr(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) listProgress(c *gin.Context) {
|
||||||
|
rows, e := a.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 api
|
||||||
|
|
||||||
|
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,50 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"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)
|
||||||
|
a := &api{cfg: cfg, st: st, rdb: rdb, sc: 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", a.login)
|
||||||
|
|
||||||
|
p := g.Group("", a.authMw())
|
||||||
|
p.GET("/auth/me", a.me)
|
||||||
|
|
||||||
|
users := p.Group("/users", a.adminOnly())
|
||||||
|
users.GET("", a.listUsers)
|
||||||
|
users.POST("", a.createUser)
|
||||||
|
users.DELETE("/:id", a.deleteUser)
|
||||||
|
|
||||||
|
libs := p.Group("/libraries")
|
||||||
|
libs.GET("", a.listLibraries)
|
||||||
|
libs.POST("", a.adminOnly(), a.createLibrary)
|
||||||
|
libs.POST("/:id/scan", a.adminOnly(), a.scanLibrary)
|
||||||
|
libs.POST("/:id/upload", a.adminOnly(), a.upload)
|
||||||
|
|
||||||
|
p.GET("/books", a.listBooks)
|
||||||
|
p.GET("/books/:id", a.getBook)
|
||||||
|
p.DELETE("/books/:id", a.adminOnly(), a.deleteBook)
|
||||||
|
p.GET("/books/:id/cover", a.serveCover)
|
||||||
|
p.GET("/books/:id/file", a.serveFile)
|
||||||
|
p.GET("/books/:id/pages", a.pagesCount)
|
||||||
|
p.GET("/books/:id/pages/:n", a.page)
|
||||||
|
p.PUT("/books/:id/progress", a.putProgress)
|
||||||
|
p.GET("/progress", a.listProgress)
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/puddle/v2"
|
||||||
|
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,97 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
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 (a *api) listUsers(c *gin.Context) {
|
||||||
|
users, e := a.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 (a *api) 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
|
||||||
|
}
|
||||||
|
h, e := auth.HashPassword(req.Password)
|
||||||
|
if e != nil {
|
||||||
|
err(c, http.StatusInternalServerError, "internal", "hash")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, e := a.st.CreateUser(c, req.Username, h, 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 (a *api) 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 := a.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, _ := a.st.CountAdmins(c) // 防删光最后一个 admin
|
||||||
|
if n <= 1 {
|
||||||
|
err(c, http.StatusBadRequest, "bad_request", "cannot delete the last admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e := a.st.DeleteUser(c, id); e != nil {
|
||||||
|
dbErr(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
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,54 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrToken = errors.New("invalid token")
|
||||||
|
|
||||||
|
func HashPassword(plain string) (string, error) {
|
||||||
|
b, err := bcrypt.GenerateFromPassword([]byte(plain), 12)
|
||||||
|
return string(b), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckPassword(hash, plain string) bool {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UID int64 `json:"uid"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func Sign(secret []byte, uid int64, role string) (string, error) {
|
||||||
|
return SignWithTTL(secret, uid, role, 72*time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SignWithTTL(secret []byte, uid int64, role string, ttl time.Duration) (string, error) {
|
||||||
|
t := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
|
||||||
|
UID: uid, Role: role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl))},
|
||||||
|
})
|
||||||
|
return t.SignedString(secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Parse(secret []byte, token string) (*Claims, error) {
|
||||||
|
c := &Claims{}
|
||||||
|
parsed, err := jwt.ParseWithClaims(token, c, func(t *jwt.Token) (any, error) {
|
||||||
|
m, ok := t.Method.(*jwt.SigningMethodHMAC)
|
||||||
|
if !ok || subtle.ConstantTimeCompare([]byte(m.Alg()), []byte("HS256")) != 1 {
|
||||||
|
return nil, ErrToken
|
||||||
|
}
|
||||||
|
return secret, nil
|
||||||
|
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||||
|
if err != nil || !parsed.Valid {
|
||||||
|
return nil, ErrToken
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHashCheck(t *testing.T) {
|
||||||
|
h, err := HashPassword("hunter2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(h, "$2a$") || !CheckPassword(h, "hunter2") || CheckPassword(h, "wrong") {
|
||||||
|
t.Fatal("bcrypt wrong")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignParse(t *testing.T) {
|
||||||
|
secret := []byte("k")
|
||||||
|
tok, err := Sign(secret, 7, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, err := Parse(secret, tok)
|
||||||
|
if err != nil || c.UID != 7 || c.Role != "admin" {
|
||||||
|
t.Fatalf("parse: %v %+v", err, c)
|
||||||
|
}
|
||||||
|
if _, err := Parse([]byte("other"), tok); err == nil {
|
||||||
|
t.Fatal("must reject wrong secret")
|
||||||
|
}
|
||||||
|
expired, _ := SignWithTTL(secret, 1, "member", -time.Hour)
|
||||||
|
if _, err := Parse(secret, expired); err == nil {
|
||||||
|
t.Fatal("must reject expired")
|
||||||
|
}
|
||||||
|
// alg 混淆攻击:none 必须拒
|
||||||
|
if _, err := Parse(secret, "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1aWQiOjF9."); err == nil {
|
||||||
|
t.Fatal("must reject alg=none")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package bookfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
func FormatFromExt(name string) string {
|
||||||
|
switch strings.ToLower(filepath.Ext(name)) {
|
||||||
|
case ".cbz", ".zip":
|
||||||
|
return "cbz"
|
||||||
|
case ".pdf":
|
||||||
|
return "pdf"
|
||||||
|
case ".epub":
|
||||||
|
return "epub"
|
||||||
|
case ".txt":
|
||||||
|
return "txt"
|
||||||
|
case ".md":
|
||||||
|
return "md"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func SafeName(s string) string {
|
||||||
|
s = strings.Map(func(r rune) rune {
|
||||||
|
if unicode.IsControl(r) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, s)
|
||||||
|
s = strings.ReplaceAll(s, "\\", "/")
|
||||||
|
s = filepath.Base(filepath.ToSlash(s))
|
||||||
|
if s == "." || s == "/" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) > 200 {
|
||||||
|
s = strings.TrimSpace(s[:200])
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package bookfile
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestFormatFromExt(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"a.cbz": "cbz", "B.PDF": "pdf", "x.epub": "epub", "y.txt": "txt", "z.md": "md",
|
||||||
|
"w.rar": "", "noext": "", "cbr.cbZ": "cbz", "tar.gz": "",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := FormatFromExt(in); got != want {
|
||||||
|
t.Errorf("FormatFromExt(%q)=%q want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeName(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"my book.cbz": "my book.cbz",
|
||||||
|
"../../etc/passwd": "passwd",
|
||||||
|
"/abs/name.pdf": "name.pdf",
|
||||||
|
"a\\b\\c.epub": "c.epub",
|
||||||
|
" spaced .txt ": "spaced .txt",
|
||||||
|
"con\ntl.bin": "contl.bin",
|
||||||
|
"": "",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := SafeName(in); got != want {
|
||||||
|
t.Errorf("SafeName(%q)=%q want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package bookfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DirKey(id int64, hash string) string { return fmt.Sprintf("%d-%s", id, hash) }
|
||||||
|
|
||||||
|
func CoverDir(cacheDir, key string) string { return filepath.Join(cacheDir, "covers", key) }
|
||||||
|
func PagesDir(cacheDir, key string) string { return filepath.Join(cacheDir, "pages", key) }
|
||||||
|
|
||||||
|
func SweepStale(cacheDir string, live map[string]bool) (int, error) {
|
||||||
|
removed := 0
|
||||||
|
for _, kind := range []string{"covers", "pages"} {
|
||||||
|
entries, err := os.ReadDir(filepath.Join(cacheDir, kind))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue // 首次运行还没有该层目录,不算错
|
||||||
|
}
|
||||||
|
return removed, err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() || live[e.Name()] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(filepath.Join(cacheDir, kind, e.Name())); err != nil {
|
||||||
|
return removed, err
|
||||||
|
}
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package bookfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mk(t *testing.T, cacheDir, kind, key, file string) {
|
||||||
|
t.Helper()
|
||||||
|
d := filepath.Join(cacheDir, kind, key)
|
||||||
|
os.MkdirAll(d, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(d, file), []byte("x"), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSweepStale(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
mk(t, dir, "covers", "1-aaa", "cover.jpg")
|
||||||
|
mk(t, dir, "covers", "2-bbb", "cover.png")
|
||||||
|
mk(t, dir, "pages", "1-aaa", "0.jpg")
|
||||||
|
mk(t, dir, "pages", "3-ccc", "0.jpg")
|
||||||
|
os.WriteFile(filepath.Join(dir, "covers", "stray.txt"), []byte("x"), 0o644) // 非目录,忽略
|
||||||
|
|
||||||
|
n, err := SweepStale(dir, map[string]bool{"1-aaa": true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("removed %d want 2", n)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "covers", "1-aaa")); err != nil {
|
||||||
|
t.Fatal("live dir removed")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "pages", "1-aaa")); err != nil {
|
||||||
|
t.Fatal("live pages removed")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "covers", "2-bbb")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("stale cover remains")
|
||||||
|
}
|
||||||
|
// cacheDir 不存在 = 首次运行,不算错
|
||||||
|
if n, err := SweepStale(filepath.Join(dir, "nope"), nil); n != 0 || err != nil {
|
||||||
|
t.Fatalf("%d %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaths(t *testing.T) {
|
||||||
|
if DirKey(12, "ab") != "12-ab" {
|
||||||
|
t.Fatal("DirKey")
|
||||||
|
}
|
||||||
|
if CoverDir("/c", "12-ab") != filepath.Join("/c", "covers", "12-ab") {
|
||||||
|
t.Fatal("CoverDir")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package bookfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CBZCover(f io.ReaderAt, size int64) ([]byte, string, error) {
|
||||||
|
idx, err := PageIndex(f, size)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if len(idx) == 0 {
|
||||||
|
return nil, "", errors.New("no page images")
|
||||||
|
}
|
||||||
|
img, err := ReadEntry(f, size, idx[0])
|
||||||
|
return img, strings.ToLower(path.Ext(idx[0])), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readZipEntry(zr *zip.Reader, name string) ([]byte, error) {
|
||||||
|
for _, zf := range zr.File {
|
||||||
|
if zf.Name == name {
|
||||||
|
rc, err := zf.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
return io.ReadAll(io.LimitReader(rc, 8<<20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("entry not found: " + name)
|
||||||
|
}
|
||||||
|
|
||||||
|
type opfDoc struct {
|
||||||
|
XMLName xml.Name `xml:"package"`
|
||||||
|
Metadata struct {
|
||||||
|
Meta []struct {
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Content string `xml:"content,attr"`
|
||||||
|
} `xml:"meta"`
|
||||||
|
} `xml:"metadata"`
|
||||||
|
Manifest struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `xml:"id,attr"`
|
||||||
|
Href string `xml:"href,attr"`
|
||||||
|
Properties string `xml:"properties,attr"`
|
||||||
|
MediaType string `xml:"media-type,attr"`
|
||||||
|
} `xml:"item"`
|
||||||
|
} `xml:"manifest"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func EPUBCover(f io.ReaderAt, size int64) ([]byte, string, error) {
|
||||||
|
zr, err := zip.NewReader(f, size)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", ErrNotZip
|
||||||
|
}
|
||||||
|
cont, err := readZipEntry(zr, "META-INF/container.xml")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
var root struct {
|
||||||
|
XMLName xml.Name `xml:"container"`
|
||||||
|
RootFiles []struct {
|
||||||
|
FullPath string `xml:"full-path,attr"`
|
||||||
|
} `xml:"rootfiles>rootfile"`
|
||||||
|
}
|
||||||
|
if err := xml.Unmarshal(cont, &root); err != nil || len(root.RootFiles) == 0 {
|
||||||
|
return nil, "", errors.New("bad container.xml")
|
||||||
|
}
|
||||||
|
opfPath := root.RootFiles[0].FullPath
|
||||||
|
opfData, err := readZipEntry(zr, opfPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
var doc opfDoc
|
||||||
|
if err := xml.Unmarshal(opfData, &doc); err != nil {
|
||||||
|
return nil, "", errors.New("bad opf")
|
||||||
|
}
|
||||||
|
metaCoverID := ""
|
||||||
|
for _, m := range doc.Metadata.Meta {
|
||||||
|
if m.Name == "cover" {
|
||||||
|
metaCoverID = m.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
href := ""
|
||||||
|
for _, it := range doc.Manifest.Items {
|
||||||
|
if strings.Contains(it.Properties, "cover-image") {
|
||||||
|
href = it.Href
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if metaCoverID != "" && it.ID == metaCoverID {
|
||||||
|
href = it.Href
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if href == "" {
|
||||||
|
for _, it := range doc.Manifest.Items {
|
||||||
|
if strings.HasPrefix(it.MediaType, "image/") && strings.Contains(strings.ToLower(it.ID), "cover") {
|
||||||
|
href = it.Href
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if href == "" {
|
||||||
|
return nil, "", errors.New("no cover found")
|
||||||
|
}
|
||||||
|
unescaped, e := url.PathUnescape(href)
|
||||||
|
if e == nil {
|
||||||
|
href = unescaped
|
||||||
|
}
|
||||||
|
name := path.Join(path.Dir(opfPath), href)
|
||||||
|
if name == "." {
|
||||||
|
name = href
|
||||||
|
}
|
||||||
|
img, err := readZipEntry(zr, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return img, strings.ToLower(path.Ext(name)), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package bookfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCBZCoverIsFirstPage(t *testing.T) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
zw := zip.NewWriter(buf)
|
||||||
|
w, _ := zw.Create("02.jpg")
|
||||||
|
w.Write([]byte("second"))
|
||||||
|
w, _ = zw.Create("01.jpg")
|
||||||
|
w.Write([]byte("first"))
|
||||||
|
zw.Close()
|
||||||
|
r := bytes.NewReader(buf.Bytes())
|
||||||
|
img, ext, err := CBZCover(r, int64(r.Len()))
|
||||||
|
if err != nil || string(img) != "first" || ext != ".jpg" {
|
||||||
|
t.Fatalf("%q %q %v", img, ext, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func zipEntries(t *testing.T, kv map[string]string) *bytes.Reader {
|
||||||
|
t.Helper()
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
zw := zip.NewWriter(buf)
|
||||||
|
for name, data := range kv {
|
||||||
|
w, _ := zw.Create(name)
|
||||||
|
w.Write([]byte(data))
|
||||||
|
}
|
||||||
|
zw.Close()
|
||||||
|
return bytes.NewReader(buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerXML = `<?xml version="1.0"?><container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>`
|
||||||
|
|
||||||
|
func opf(manifest, spine string) string {
|
||||||
|
return `<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf"><metadata>` +
|
||||||
|
spine + `</metadata><manifest>` + manifest + `</manifest></package>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEPUBCoverProperties(t *testing.T) {
|
||||||
|
r := zipEntries(t, map[string]string{
|
||||||
|
"META-INF/container.xml": containerXML,
|
||||||
|
"OEBPS/content.opf": opf(`<item id="c" href="img/cover%20one.png" properties="cover-image" media-type="image/png"/><item id="t" href="t.xhtml" media-type="application/xhtml+xml"/>`, ""),
|
||||||
|
"OEBPS/img/cover one.png": "PNGDATA",
|
||||||
|
})
|
||||||
|
img, ext, err := EPUBCover(r, int64(r.Len()))
|
||||||
|
if err != nil || string(img) != "PNGDATA" || ext != ".png" {
|
||||||
|
t.Fatalf("%q %q %v", img, ext, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEPUBCoverMetaID(t *testing.T) {
|
||||||
|
r := zipEntries(t, map[string]string{
|
||||||
|
"META-INF/container.xml": containerXML,
|
||||||
|
"OEBPS/content.opf": opf(`<item id="cv" href="art.jpg" media-type="image/jpeg"/><item id="t" href="t.xhtml" media-type="application/xhtml+xml"/>`, `<meta name="cover" content="cv"/>`),
|
||||||
|
"OEBPS/art.jpg": "JPGDATA",
|
||||||
|
})
|
||||||
|
img, _, err := EPUBCover(r, int64(r.Len()))
|
||||||
|
if err != nil || string(img) != "JPGDATA" {
|
||||||
|
t.Fatalf("%q %v", img, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEPUBNoCover(t *testing.T) {
|
||||||
|
r := zipEntries(t, map[string]string{
|
||||||
|
"META-INF/container.xml": containerXML,
|
||||||
|
"OEBPS/content.opf": opf(`<item id="t" href="t.xhtml" media-type="application/xhtml+xml"/>`, ""),
|
||||||
|
})
|
||||||
|
if _, _, err := EPUBCover(r, int64(r.Len())); err == nil {
|
||||||
|
t.Fatal("want no-cover error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package bookfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Hash(size, modTS int64) string {
|
||||||
|
sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%d", size, modTS)))
|
||||||
|
return hex.EncodeToString(sum[:])[:16]
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package bookfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotZip = errors.New("not a readable zip")
|
||||||
|
var ErrUnsafeZip = errors.New("unsafe zip entry")
|
||||||
|
|
||||||
|
func unsafeEntry(n string) bool {
|
||||||
|
return strings.HasPrefix(n, "/") || strings.Contains(n, "..") || strings.ContainsRune(n, '\\') || strings.ContainsAny(n, "\n\r")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isImage(name string) bool {
|
||||||
|
switch strings.ToLower(path.Ext(name)) {
|
||||||
|
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func PageIndex(f io.ReaderAt, size int64) ([]string, error) {
|
||||||
|
zr, err := zip.NewReader(f, size)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrNotZip
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, zf := range zr.File {
|
||||||
|
if unsafeEntry(zf.Name) {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnsafeZip, zf.Name)
|
||||||
|
}
|
||||||
|
if isImage(zf.Name) {
|
||||||
|
names = append(names, zf.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(names, func(i, j int) bool { return NaturalLess(names[i], names[j]) })
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadEntry(f io.ReaderAt, size int64, name string) ([]byte, error) {
|
||||||
|
zr, err := zip.NewReader(f, size)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrNotZip
|
||||||
|
}
|
||||||
|
for _, zf := range zr.File {
|
||||||
|
if zf.Name == name {
|
||||||
|
rc, err := zf.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
return io.ReadAll(io.LimitReader(rc, 64<<20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("no such entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
func NaturalLess(a, b string) bool {
|
||||||
|
i, j := 0, 0
|
||||||
|
for i < len(a) && j < len(b) {
|
||||||
|
da, db := isDigit(a[i]), isDigit(b[j])
|
||||||
|
switch {
|
||||||
|
case da && db:
|
||||||
|
si, sj := i, j
|
||||||
|
for i < len(a) && isDigit(a[i]) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
for j < len(b) && isDigit(b[j]) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
na, _ := strconv.Atoi(a[si:i])
|
||||||
|
nb, _ := strconv.Atoi(b[sj:j])
|
||||||
|
if na != nb {
|
||||||
|
return na < nb
|
||||||
|
}
|
||||||
|
if a[si:i] != b[sj:j] {
|
||||||
|
return a[si:i] < b[sj:j]
|
||||||
|
}
|
||||||
|
case !da && !db:
|
||||||
|
if a[i] != b[j] {
|
||||||
|
return a[i] < b[j]
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
j++
|
||||||
|
default:
|
||||||
|
return da // 数字段排在字母前
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return j < len(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package bookfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func zipOf(t *testing.T, names ...string) *bytes.Reader {
|
||||||
|
t.Helper()
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
zw := zip.NewWriter(buf)
|
||||||
|
for i, n := range names {
|
||||||
|
w, err := zw.Create(n)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Write([]byte{byte(i)})
|
||||||
|
}
|
||||||
|
zw.Close()
|
||||||
|
return bytes.NewReader(buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPageIndexSortAndFilter(t *testing.T) {
|
||||||
|
r := zipOf(t, "page10.jpg", "page2.jpg", "page1.jpg", "cover.PNG", "meta.xml", "sub/3.webp", "readme.txt")
|
||||||
|
idx, err := PageIndex(r, int64(r.Len()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"cover.PNG", "page1.jpg", "page2.jpg", "page10.jpg", "sub/3.webp"}
|
||||||
|
if len(idx) != len(want) {
|
||||||
|
t.Fatalf("got %v", idx)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if idx[i] != want[i] {
|
||||||
|
t.Fatalf("got %v want %v", idx, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPageIndexRejectsSlip(t *testing.T) {
|
||||||
|
for _, bad := range []string{"../evil.jpg", "/etc/passwd.jpg", "a\\..\\b.jpg", "pag\ne.jpg", "pag\re.jpg"} {
|
||||||
|
r := zipOf(t, bad)
|
||||||
|
if _, err := PageIndex(r, int64(r.Len())); err == nil {
|
||||||
|
t.Fatalf("entry %q must be rejected", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadEntry(t *testing.T) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
zw := zip.NewWriter(buf)
|
||||||
|
w, _ := zw.Create("p1.jpg")
|
||||||
|
w.Write([]byte("jpegbytes"))
|
||||||
|
zw.Close()
|
||||||
|
r := bytes.NewReader(buf.Bytes())
|
||||||
|
got, err := ReadEntry(r, int64(r.Len()), "p1.jpg")
|
||||||
|
if err != nil || string(got) != "jpegbytes" {
|
||||||
|
t.Fatalf("%q %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := ReadEntry(r, int64(r.Len()), "nope.jpg"); err == nil {
|
||||||
|
t.Fatal("missing entry must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNaturalLess(t *testing.T) {
|
||||||
|
pairs := [][2]string{{"2", "10"}, {"a2b", "a10b"}, {"007", "7"}, {"a", "b"}, {"A", "a"}}
|
||||||
|
for _, p := range pairs {
|
||||||
|
if !NaturalLess(p[0], p[1]) {
|
||||||
|
t.Errorf("NaturalLess(%q,%q) want true", p[0], p[1])
|
||||||
|
}
|
||||||
|
if NaturalLess(p[1], p[0]) {
|
||||||
|
t.Errorf("NaturalLess(%q,%q) want false", p[1], p[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHash(t *testing.T) {
|
||||||
|
if Hash(100, 200) == Hash(101, 200) || Hash(100, 200) != Hash(100, 200) {
|
||||||
|
t.Fatal("hash broken")
|
||||||
|
}
|
||||||
|
if len(Hash(1, 2)) != 16 {
|
||||||
|
t.Fatal("hash length")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Addr string
|
||||||
|
DatabaseURL string
|
||||||
|
RedisURL string
|
||||||
|
JWTSecret []byte
|
||||||
|
AdminUser string
|
||||||
|
AdminPassword string
|
||||||
|
BooksDir string
|
||||||
|
CacheDir string
|
||||||
|
ScanInterval time.Duration
|
||||||
|
UploadMaxMB int64
|
||||||
|
TrustedProxies []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
env := func(k, def string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
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"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SCAN_INTERVAL_SEC: %w", err)
|
||||||
|
}
|
||||||
|
uploadMB, err := strconv.ParseInt(env("UPLOAD_MAX_MB", "200"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("UPLOAD_MAX_MB: %w", err)
|
||||||
|
}
|
||||||
|
secret := os.Getenv("JWT_SECRET")
|
||||||
|
if secret == "" {
|
||||||
|
return nil, fmt.Errorf("JWT_SECRET required")
|
||||||
|
}
|
||||||
|
// resolveDir: macOS 开发机上 /var、/tmp 是指向 /private 的软链,
|
||||||
|
// 启动时解析一次才能让 root_path 前缀校验对上真实路径;目录不存在/出错则保留原值(Docker 路径不受影响)
|
||||||
|
resolveDir := func(dir string) string {
|
||||||
|
if r, e := filepath.EvalSymlinks(dir); e == nil {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
return &Config{
|
||||||
|
Addr: env("ADDR", ":8080"),
|
||||||
|
DatabaseURL: env("DATABASE_URL", ""),
|
||||||
|
RedisURL: env("REDIS_URL", ""),
|
||||||
|
JWTSecret: []byte(secret),
|
||||||
|
AdminUser: env("ADMIN_USER", ""),
|
||||||
|
AdminPassword: env("ADMIN_PASSWORD", ""),
|
||||||
|
BooksDir: resolveDir(env("BOOKS_DIR", "/data/books")),
|
||||||
|
CacheDir: resolveDir(env("CACHE_DIR", "/data/cache")),
|
||||||
|
ScanInterval: time.Duration(scanSec) * time.Second,
|
||||||
|
UploadMaxMB: uploadMB,
|
||||||
|
TrustedProxies: envList("TRUSTED_PROXY_CIDRS", "172.16.0.0/12"),
|
||||||
|
}, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed schema.sql
|
||||||
|
var schema string
|
||||||
|
|
||||||
|
func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) {
|
||||||
|
cfg, err := pgxpool.ParseConfig(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.MaxConns = 10
|
||||||
|
return pgxpool.NewWithConfig(ctx, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Migrate(ctx context.Context, p *pgxpool.Pool) error {
|
||||||
|
if _, err := p.Exec(ctx, schema); err != nil {
|
||||||
|
return fmt.Errorf("migrate: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id BIGSERIAL PRIMARY KEY, username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('admin','member')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now());
|
||||||
|
CREATE TABLE IF NOT EXISTS libraries (
|
||||||
|
id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, root_path TEXT UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now());
|
||||||
|
CREATE TABLE IF NOT EXISTS books (
|
||||||
|
id BIGSERIAL PRIMARY KEY, library_id BIGINT NOT NULL REFERENCES libraries(id),
|
||||||
|
path TEXT NOT NULL, title TEXT NOT NULL,
|
||||||
|
format TEXT NOT NULL CHECK (format IN ('cbz','pdf','epub','txt','md')),
|
||||||
|
file_size BIGINT NOT NULL, mod_ts BIGINT NOT NULL, page_count INT NOT NULL DEFAULT 0,
|
||||||
|
state TEXT NOT NULL DEFAULT 'ready' CHECK (state IN ('ready','error')),
|
||||||
|
error_msg TEXT NOT NULL DEFAULT '', added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (library_id, path));
|
||||||
|
CREATE TABLE IF NOT EXISTS reading_progress (
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
library_id BIGINT NOT NULL, book_path TEXT NOT NULL,
|
||||||
|
locator JSONB NOT NULL DEFAULT '{}', percent DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, library_id, book_path));
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package redispkg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
type R struct{ c *redis.Client }
|
||||||
|
|
||||||
|
func New(url string) *R {
|
||||||
|
if url == "" {
|
||||||
|
return &R{}
|
||||||
|
}
|
||||||
|
opt, err := redis.ParseURL(url)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("bad REDIS_URL (%v): redis disabled", err)
|
||||||
|
return &R{}
|
||||||
|
}
|
||||||
|
return &R{c: redis.NewClient(opt)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *R) Get(ctx context.Context, key string) (string, bool) {
|
||||||
|
if r.c == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
v, err := r.c.Get(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return "", false // 故障=miss
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *R) Set(ctx context.Context, key, val string, ttl time.Duration) {
|
||||||
|
if r.c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.c.Set(ctx, key, val, ttl).Err(); err != nil {
|
||||||
|
log.Printf("redis set %s: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *R) IncrWindow(ctx context.Context, key string, ttl time.Duration) int {
|
||||||
|
if r.c == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
n, err := r.c.Incr(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if n == 1 {
|
||||||
|
r.c.Expire(ctx, key, ttl)
|
||||||
|
}
|
||||||
|
return int(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *R) Lock(ctx context.Context, key string, ttl time.Duration) (func(), bool) {
|
||||||
|
noop := func() {}
|
||||||
|
if r.c == nil {
|
||||||
|
return noop, true
|
||||||
|
}
|
||||||
|
b := make([]byte, 8)
|
||||||
|
rand.Read(b)
|
||||||
|
tok := hex.EncodeToString(b)
|
||||||
|
ok, err := r.c.SetNX(ctx, key, tok, ttl).Result()
|
||||||
|
if err != nil { // spec §9: Redis 故障降级放行,锁只做尽力去重
|
||||||
|
log.Printf("redis lock %s: %v (proceeding without lock)", key, err)
|
||||||
|
return noop, true
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return noop, false // 锁被持有,别的副本在扫
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
r.c.Eval(ctx,
|
||||||
|
"if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end",
|
||||||
|
[]string{key}, tok)
|
||||||
|
}, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package redispkg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDisabledIsSafe(t *testing.T) {
|
||||||
|
r := New("")
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, ok := r.Get(ctx, "x"); ok {
|
||||||
|
t.Fatal("disabled Get must miss")
|
||||||
|
}
|
||||||
|
r.Set(ctx, "x", "y", time.Second) // 不 panic
|
||||||
|
if n := r.IncrWindow(ctx, "k", time.Second); n != 1 {
|
||||||
|
t.Fatal("disabled IncrWindow must allow")
|
||||||
|
}
|
||||||
|
un, ok := r.Lock(ctx, "lk", time.Second)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("disabled Lock must always acquire")
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package scanner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"booklib/internal/bookfile"
|
||||||
|
"booklib/internal/config"
|
||||||
|
"booklib/internal/redispkg"
|
||||||
|
"booklib/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Scanner struct {
|
||||||
|
st *store.Store
|
||||||
|
cfg *config.Config
|
||||||
|
rdb *redispkg.R
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(st *store.Store, cfg *config.Config, rdb *redispkg.R) *Scanner {
|
||||||
|
return &Scanner{st: st, cfg: cfg, rdb: rdb}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) Run(ctx context.Context) {
|
||||||
|
t := time.NewTicker(s.cfg.ScanInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
libs, err := s.st.ListLibraries(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("scan: list libraries: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, l := range libs {
|
||||||
|
s.ScanLibrary(ctx, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) ScanLibraryByID(ctx context.Context, id int64) {
|
||||||
|
lib, err := s.st.GetLibrary(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("scan: library %d: %v", id, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.ScanLibrary(ctx, lib)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) ScanLibrary(ctx context.Context, lib store.Library) {
|
||||||
|
// ponytail: 5min lock TTL; a scan longer than this lets another replica join — refresh mid-walk if libs ever outgrow it
|
||||||
|
unlock, ok := s.rdb.Lock(ctx, fmt.Sprintf("scan:%d", lib.ID), 5*time.Minute)
|
||||||
|
if !ok {
|
||||||
|
return // 别的副本在扫
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
root, err := filepath.EvalSymlinks(filepath.Clean(lib.RootPath))
|
||||||
|
if err != nil || !inside(s.cfg.BooksDir, root) {
|
||||||
|
log.Printf("scan: library %d root %q rejected", lib.ID, lib.RootPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
disk, err := walk(root)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("scan: walk %s: %v", root, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbMeta, err := s.st.ListBookMeta(ctx, lib.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("scan: list books: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for rel, ds := range disk {
|
||||||
|
old, exists := dbMeta[rel]
|
||||||
|
delete(dbMeta, rel)
|
||||||
|
switch {
|
||||||
|
case !exists:
|
||||||
|
s.add(ctx, lib.ID, root, rel, ds)
|
||||||
|
case old.Size != ds.size || old.ModTS != ds.modTS:
|
||||||
|
s.update(ctx, lib.ID, old.ID, root, rel, ds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for rel := range dbMeta { // 只剩被删的文件
|
||||||
|
if err := s.st.DeleteBookByPath(ctx, lib.ID, rel); err != nil {
|
||||||
|
log.Printf("scan: delete %s: %v", rel, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.sweepCache(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
type diskStat struct{ size, modTS int64 }
|
||||||
|
|
||||||
|
func inside(booksDir, root string) bool {
|
||||||
|
b := filepath.Clean(booksDir)
|
||||||
|
return root == b || strings.HasPrefix(root, b+string(os.PathSeparator))
|
||||||
|
}
|
||||||
|
|
||||||
|
func walk(root string) (map[string]diskStat, error) {
|
||||||
|
out := map[string]diskStat{}
|
||||||
|
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("scan: walk %s: %v", p, err)
|
||||||
|
return nil // 单点失败不中断
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if bookfile.FormatFromExt(d.Name()) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out[filepath.ToSlash(rel)] = diskStat{info.Size(), info.ModTime().Unix()}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleOf(rel string) string {
|
||||||
|
base := filepath.Base(rel)
|
||||||
|
return strings.TrimSpace(strings.ReplaceAll(strings.TrimSuffix(base, filepath.Ext(base)), "_", " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// cbz 完整性判定集中在 add/update:PageIndex 失败 → state=error。
|
||||||
|
// InsertBook/UpdateBookFile 的 SQL 已把 state 重置为 ready(Task 2),无需显式清 error。
|
||||||
|
func (s *Scanner) add(ctx context.Context, libID int64, root, rel string, ds diskStat) {
|
||||||
|
format := bookfile.FormatFromExt(filepath.Base(rel))
|
||||||
|
pageCount := 0
|
||||||
|
var idxErr error
|
||||||
|
if format == "cbz" {
|
||||||
|
idx, err := s.zipIndex(root, rel)
|
||||||
|
pageCount = len(idx)
|
||||||
|
idxErr = err
|
||||||
|
}
|
||||||
|
id, err := s.st.InsertBook(ctx, libID, rel, titleOf(rel), format, ds.size, ds.modTS, pageCount)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("scan: insert %s: %v", rel, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if idxErr != nil {
|
||||||
|
s.st.SetBookState(ctx, id, "error", idxErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cover(ctx, id, root, rel, format, ds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) update(ctx context.Context, libID, bookID int64, root, rel string, ds diskStat) {
|
||||||
|
format := bookfile.FormatFromExt(filepath.Base(rel))
|
||||||
|
pageCount := 0
|
||||||
|
var idxErr error
|
||||||
|
if format == "cbz" {
|
||||||
|
idx, err := s.zipIndex(root, rel)
|
||||||
|
pageCount = len(idx)
|
||||||
|
idxErr = err
|
||||||
|
}
|
||||||
|
if err := s.st.UpdateBookFile(ctx, bookID, ds.size, ds.modTS, pageCount); err != nil {
|
||||||
|
log.Printf("scan: update %s: %v", rel, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if idxErr != nil {
|
||||||
|
s.st.SetBookState(ctx, bookID, "error", idxErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cover(ctx, bookID, root, rel, format, ds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) zipIndex(root, rel string) ([]string, error) {
|
||||||
|
f, err := os.Open(filepath.Join(root, filepath.FromSlash(rel)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
st, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return bookfile.PageIndex(f, st.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
// cover 失败(坏 epub、无图等)只 log — 书的 state 由 PageIndex 判定,封面缺了有占位 SVG 兜底
|
||||||
|
func (s *Scanner) cover(ctx context.Context, id int64, root, rel, format string, ds diskStat) {
|
||||||
|
var img []byte
|
||||||
|
var ext string
|
||||||
|
var err error
|
||||||
|
switch format {
|
||||||
|
case "cbz":
|
||||||
|
img, ext, err = s.readCover(root, rel, bookfile.CBZCover)
|
||||||
|
case "epub":
|
||||||
|
img, ext, err = s.readCover(root, rel, bookfile.EPUBCover)
|
||||||
|
default:
|
||||||
|
return // pdf/txt/md 用占位 SVG,不落盘
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("scan: cover %s: %v", rel, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir := bookfile.CoverDir(s.cfg.CacheDir, bookfile.DirKey(id, bookfile.Hash(ds.size, ds.modTS)))
|
||||||
|
if e := os.MkdirAll(dir, 0o755); e != nil {
|
||||||
|
log.Printf("scan: coverdir %s: %v", rel, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmp := filepath.Join(dir, "cover"+ext+".tmp")
|
||||||
|
dst := filepath.Join(dir, "cover"+ext)
|
||||||
|
if e := os.WriteFile(tmp, img, 0o644); e == nil {
|
||||||
|
os.Rename(tmp, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) readCover(root, rel string, fn func(io.ReaderAt, int64) ([]byte, string, error)) ([]byte, string, error) {
|
||||||
|
f, err := os.Open(filepath.Join(root, filepath.FromSlash(rel)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
st, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return fn(f, st.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) sweepCache(ctx context.Context) {
|
||||||
|
hashes, err := s.st.BookHashes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
live := map[string]bool{}
|
||||||
|
for id, v := range hashes {
|
||||||
|
live[bookfile.DirKey(id, bookfile.Hash(v[0], v[1]))] = true
|
||||||
|
}
|
||||||
|
if n, err := bookfile.SweepStale(s.cfg.CacheDir, live); err != nil {
|
||||||
|
log.Printf("scan: sweep: %v", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
log.Printf("scan: swept %d stale cache dirs", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package scanner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"booklib/internal/bookfile"
|
||||||
|
"booklib/internal/config"
|
||||||
|
"booklib/internal/db"
|
||||||
|
"booklib/internal/redispkg"
|
||||||
|
"booklib/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 返回 scanner、library、解析过符号链接的 root、以及一个满足 progress FK 的 uid
|
||||||
|
func setupLib(t *testing.T) (*Scanner, store.Library, string, int64) {
|
||||||
|
t.Helper()
|
||||||
|
url := os.Getenv("DATABASE_URL")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
p, _ := db.Connect(ctx, url)
|
||||||
|
db.Migrate(ctx, p)
|
||||||
|
st := store.New(p)
|
||||||
|
p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users")
|
||||||
|
books := t.TempDir()
|
||||||
|
cache := t.TempDir()
|
||||||
|
resolved := mustResolve(t, books)
|
||||||
|
libID, err := st.CreateLibrary(ctx, "t", filepath.Join(resolved, "lib"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
uid, err := st.CreateUser(ctx, "scantest", "h", "member")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
root := filepath.Join(resolved, "lib")
|
||||||
|
os.MkdirAll(filepath.Join(root, "series-a"), 0o755)
|
||||||
|
cfg := &config.Config{BooksDir: resolved, CacheDir: cache, ScanInterval: time.Minute}
|
||||||
|
lib, _ := st.GetLibrary(ctx, libID)
|
||||||
|
return New(st, cfg, redispkg.New(os.Getenv("REDIS_URL"))), lib, root, uid
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustResolve(t *testing.T, p string) string {
|
||||||
|
r, err := filepath.EvalSymlinks(p) // macOS 上 t.TempDir 是 /var→/private 符号链接
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
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(fmt.Sprintf("%02d.jpg", i))
|
||||||
|
w.Write(bytes.Repeat([]byte("JPG"), 100))
|
||||||
|
}
|
||||||
|
zw.Close()
|
||||||
|
os.WriteFile(path, buf.Bytes(), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanFullLifecycle(t *testing.T) {
|
||||||
|
sc, lib, root, uid := setupLib(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
writeCBZ(t, filepath.Join(root, "series-a", "vol_01.cbz"), 3)
|
||||||
|
os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hi"), 0o644)
|
||||||
|
sc.ScanLibrary(ctx, lib)
|
||||||
|
|
||||||
|
meta, _ := sc.st.ListBookMeta(ctx, lib.ID)
|
||||||
|
if len(meta) != 2 {
|
||||||
|
t.Fatalf("want 2 books got %v", meta)
|
||||||
|
}
|
||||||
|
b, err := sc.st.GetBook(ctx, meta["series-a/vol_01.cbz"].ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b.Format != "cbz" || b.PageCount != 3 || b.Title != "vol 01" {
|
||||||
|
t.Fatalf("bad book %+v", b)
|
||||||
|
}
|
||||||
|
key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))
|
||||||
|
covers, _ := os.ReadDir(bookfile.CoverDir(sc.cfg.CacheDir, key))
|
||||||
|
if len(covers) == 0 {
|
||||||
|
t.Fatal("cover not built")
|
||||||
|
}
|
||||||
|
// 二次扫描:无变化 → 不动
|
||||||
|
before := b.AddedAt
|
||||||
|
sc.ScanLibrary(ctx, lib)
|
||||||
|
b2, _ := sc.st.GetBook(ctx, b.ID)
|
||||||
|
if !b2.AddedAt.Equal(before) || b2.FileSize != b.FileSize {
|
||||||
|
t.Fatal("unchanged file must be untouched")
|
||||||
|
}
|
||||||
|
// 修改:page_count 变、hash 变、旧缓存被扫尾清掉
|
||||||
|
writeCBZ(t, filepath.Join(root, "series-a", "vol_01.cbz"), 5)
|
||||||
|
os.Chtimes(filepath.Join(root, "series-a", "vol_01.cbz"), time.Now(), time.Now())
|
||||||
|
sc.ScanLibrary(ctx, lib)
|
||||||
|
b3, _ := sc.st.GetBook(ctx, b.ID)
|
||||||
|
if b3.PageCount != 5 {
|
||||||
|
t.Fatalf("not updated: %+v", b3)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(bookfile.CoverDir(sc.cfg.CacheDir, key)); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("stale cover cache remains")
|
||||||
|
}
|
||||||
|
// 删除:行没了,进度按 path 还在(spec §4)
|
||||||
|
if err := sc.st.UpsertProgress(ctx, uid, lib.ID, "notes.txt", []byte("{}"), 0.5); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(root, "notes.txt")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sc.ScanLibrary(ctx, lib)
|
||||||
|
if _, err := sc.st.GetBook(ctx, b.ID); err != nil {
|
||||||
|
t.Fatal("cbz vanished wrongly")
|
||||||
|
}
|
||||||
|
meta2, _ := sc.st.ListBookMeta(ctx, lib.ID)
|
||||||
|
if _, ok := meta2["notes.txt"]; ok {
|
||||||
|
t.Fatal("deleted file still in db")
|
||||||
|
}
|
||||||
|
if pr, err := sc.st.GetProgress(ctx, uid, lib.ID, "notes.txt"); err != nil || pr.Percent != 0.5 {
|
||||||
|
t.Fatalf("progress must survive book removal: %+v %v", pr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanBrokenCBZStateError(t *testing.T) {
|
||||||
|
sc, lib, root, _ := setupLib(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
os.WriteFile(filepath.Join(root, "bad.cbz"), []byte("not a zip"), 0o644)
|
||||||
|
sc.ScanLibrary(ctx, lib)
|
||||||
|
meta, _ := sc.st.ListBookMeta(ctx, lib.ID)
|
||||||
|
b, _ := sc.st.GetBook(ctx, meta["bad.cbz"].ID)
|
||||||
|
if b.State != "error" || b.ErrMsg == "" {
|
||||||
|
t.Fatalf("want error state, got %+v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package seed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
"booklib/internal/auth"
|
||||||
|
"booklib/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Admin(ctx context.Context, s *store.Store, user, pass string) error {
|
||||||
|
if user == "" || pass == "" {
|
||||||
|
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)
|
||||||
|
if err != nil || n > 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h, err := auth.HashPassword(pass)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.CreateUser(ctx, user, h, "admin"); err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
log.Printf("seed admin %q may already exist: %v", user, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("seeded initial admin user %q", user)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package seed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"booklib/internal/db"
|
||||||
|
"booklib/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSeedOnlyWhenEmpty(t *testing.T) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
s := store.New(p)
|
||||||
|
p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if err := Admin(ctx, s, "admin2", "pw123456"); err != nil { // 已有用户 → no-op
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n, _ := s.CountUsers(ctx)
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("want 1 user got %d", n)
|
||||||
|
}
|
||||||
|
u, err := s.GetUserByName(ctx, "admin")
|
||||||
|
if err != nil || u.Role != "admin" {
|
||||||
|
t.Fatalf("bad admin: %+v %v", u, err)
|
||||||
|
}
|
||||||
|
if err := Admin(ctx, s, "", ""); err != nil {
|
||||||
|
t.Fatal("empty creds must no-op, got", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct{ P *pgxpool.Pool }
|
||||||
|
|
||||||
|
func New(p *pgxpool.Pool) *Store { return &Store{P: p} }
|
||||||
|
|
||||||
|
// ---------- types ----------
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int64
|
||||||
|
Username string
|
||||||
|
PasswordHash string
|
||||||
|
Role string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
type Library struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
RootPath string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
type Book struct {
|
||||||
|
ID, LibraryID int64
|
||||||
|
Path string
|
||||||
|
Title string
|
||||||
|
Format string
|
||||||
|
FileSize int64
|
||||||
|
ModTS int64
|
||||||
|
PageCount int // 0 表示未知(pdf/epub/txt)
|
||||||
|
State string
|
||||||
|
ErrMsg string
|
||||||
|
AddedAt time.Time
|
||||||
|
}
|
||||||
|
type BookMeta struct {
|
||||||
|
ID int64
|
||||||
|
Size int64
|
||||||
|
ModTS int64
|
||||||
|
Format string
|
||||||
|
}
|
||||||
|
type BookView struct {
|
||||||
|
Book
|
||||||
|
LibraryName string
|
||||||
|
Percent float64
|
||||||
|
}
|
||||||
|
type Progress struct {
|
||||||
|
LibraryID int64
|
||||||
|
LibraryName string
|
||||||
|
BookPath string
|
||||||
|
Title string // 书已删时为空
|
||||||
|
Locator []byte
|
||||||
|
Percent float64
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- users ----------
|
||||||
|
|
||||||
|
const userCols = "id, username, password_hash, role, created_at"
|
||||||
|
|
||||||
|
func (s *Store) CountUsers(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := s.P.QueryRow(ctx, "SELECT count(*) FROM users").Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateUser(ctx context.Context, username, hash, role string) (int64, error) {
|
||||||
|
var id int64
|
||||||
|
err := s.P.QueryRow(ctx,
|
||||||
|
"INSERT INTO users (username, password_hash, role) VALUES ($1,$2,$3) RETURNING id",
|
||||||
|
username, hash, role).Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetUserByName(ctx context.Context, username string) (User, error) {
|
||||||
|
return scanUser(s.P.QueryRow(ctx, "SELECT "+userCols+" FROM users WHERE username=$1", username))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||||
|
return scanUser(s.P.QueryRow(ctx, "SELECT "+userCols+" FROM users WHERE id=$1", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
|
||||||
|
rows, err := s.P.Query(ctx, "SELECT "+userCols+" FROM users ORDER BY id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []User
|
||||||
|
for rows.Next() {
|
||||||
|
var u User
|
||||||
|
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
|
||||||
|
_, err := s.P.Exec(ctx, "DELETE FROM users WHERE id=$1", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanUser(row pgx.Row) (User, error) {
|
||||||
|
var u User
|
||||||
|
err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountAdmins 供 Task 5 的"最后一个 admin 不可删"保护
|
||||||
|
func (s *Store) CountAdmins(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := s.P.QueryRow(ctx, "SELECT count(*) FROM users WHERE role='admin'").Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- libraries ----------
|
||||||
|
|
||||||
|
func (s *Store) CreateLibrary(ctx context.Context, name, root string) (int64, error) {
|
||||||
|
var id int64
|
||||||
|
err := s.P.QueryRow(ctx,
|
||||||
|
"INSERT INTO libraries (name, root_path) VALUES ($1,$2) RETURNING id", name, root).Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListLibraries(ctx context.Context) ([]Library, error) {
|
||||||
|
rows, err := s.P.Query(ctx, "SELECT id, name, root_path, created_at FROM libraries ORDER BY id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Library
|
||||||
|
for rows.Next() {
|
||||||
|
var l Library
|
||||||
|
if err := rows.Scan(&l.ID, &l.Name, &l.RootPath, &l.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetLibrary(ctx context.Context, id int64) (Library, error) {
|
||||||
|
var l Library
|
||||||
|
err := s.P.QueryRow(ctx,
|
||||||
|
"SELECT id, name, root_path, created_at FROM libraries WHERE id=$1", id).
|
||||||
|
Scan(&l.ID, &l.Name, &l.RootPath, &l.CreatedAt)
|
||||||
|
return l, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- books ----------
|
||||||
|
|
||||||
|
const bookCols = "id, library_id, path, title, format, file_size, mod_ts, page_count, state, error_msg, added_at"
|
||||||
|
|
||||||
|
func (s *Store) InsertBook(ctx context.Context, libID int64, path, title, format string, size, modTS int64, pageCount int) (int64, error) {
|
||||||
|
var id int64
|
||||||
|
err := s.P.QueryRow(ctx,
|
||||||
|
`INSERT INTO books (library_id, path, title, format, file_size, mod_ts, page_count)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
|
||||||
|
libID, path, title, format, size, modTS, pageCount).Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetBook(ctx context.Context, id int64) (Book, error) {
|
||||||
|
var b Book
|
||||||
|
err := s.P.QueryRow(ctx, "SELECT "+bookCols+" FROM books WHERE id=$1", id).Scan(
|
||||||
|
&b.ID, &b.LibraryID, &b.Path, &b.Title, &b.Format,
|
||||||
|
&b.FileSize, &b.ModTS, &b.PageCount, &b.State, &b.ErrMsg, &b.AddedAt)
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListBookMeta(ctx context.Context, libID int64) (map[string]BookMeta, error) {
|
||||||
|
rows, err := s.P.Query(ctx,
|
||||||
|
"SELECT id, path, file_size, mod_ts, format FROM books WHERE library_id=$1", libID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]BookMeta{}
|
||||||
|
for rows.Next() {
|
||||||
|
var m BookMeta
|
||||||
|
var path string
|
||||||
|
if err := rows.Scan(&m.ID, &path, &m.Size, &m.ModTS, &m.Format); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[path] = m
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateBookFile(ctx context.Context, id, size, modTS int64, pageCount int) error {
|
||||||
|
_, err := s.P.Exec(ctx,
|
||||||
|
`UPDATE books SET file_size=$2, mod_ts=$3, page_count=$4, state='ready', error_msg='' WHERE id=$1`,
|
||||||
|
id, size, modTS, pageCount)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteBookByPath(ctx context.Context, libID int64, path string) error {
|
||||||
|
_, err := s.P.Exec(ctx, "DELETE FROM books WHERE library_id=$1 AND path=$2", libID, path)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteBook(ctx context.Context, id int64) error {
|
||||||
|
_, err := s.P.Exec(ctx, "DELETE FROM books WHERE id=$1", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetBookState(ctx context.Context, id int64, state, msg string) error {
|
||||||
|
_, err := s.P.Exec(ctx, "UPDATE books SET state=$2, error_msg=$3 WHERE id=$1", id, state, msg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListBookIDs(ctx context.Context) ([]int64, error) {
|
||||||
|
rows, err := s.P.Query(ctx, "SELECT id FROM books")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListBooks(ctx context.Context, libID int64, q, prefix string, userID int64) ([]BookView, error) {
|
||||||
|
rows, err := s.P.Query(ctx,
|
||||||
|
`SELECT b.id, b.library_id, b.path, b.title, b.format, b.file_size, b.mod_ts,
|
||||||
|
b.page_count, b.state, b.error_msg, b.added_at, l.name, COALESCE(p.percent, 0)
|
||||||
|
FROM books b JOIN libraries l ON l.id = b.library_id
|
||||||
|
LEFT JOIN reading_progress p ON p.user_id = $4 AND p.library_id = b.library_id AND p.book_path = b.path
|
||||||
|
WHERE ($1 = 0 OR b.library_id = $1)
|
||||||
|
AND ($2 = '' OR lower(b.title) LIKE '%' || lower($2) || '%')
|
||||||
|
AND ($3 = '' OR b.path LIKE $3 || '%')
|
||||||
|
ORDER BY l.name, b.path`, libID, q, prefix, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []BookView
|
||||||
|
for rows.Next() {
|
||||||
|
var v BookView
|
||||||
|
err := rows.Scan(&v.ID, &v.LibraryID, &v.Path, &v.Title, &v.Format,
|
||||||
|
&v.FileSize, &v.ModTS, &v.PageCount, &v.State, &v.ErrMsg, &v.AddedAt,
|
||||||
|
&v.LibraryName, &v.Percent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) BookHashes(ctx context.Context) (map[int64][2]int64, error) {
|
||||||
|
rows, err := s.P.Query(ctx, "SELECT id, file_size, mod_ts FROM books")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[int64][2]int64{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var v [2]int64
|
||||||
|
if err := rows.Scan(&id, &v[0], &v[1]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[id] = v
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- progress ----------
|
||||||
|
|
||||||
|
func (s *Store) UpsertProgress(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64) error {
|
||||||
|
_, err := s.P.Exec(ctx,
|
||||||
|
`INSERT INTO reading_progress (user_id, library_id, book_path, locator, percent, updated_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,now())
|
||||||
|
ON CONFLICT (user_id, library_id, book_path)
|
||||||
|
DO UPDATE SET locator=$4, percent=$5, updated_at=now()`,
|
||||||
|
userID, libID, bookPath, locator, percent)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListProgress(ctx context.Context, userID int64) ([]Progress, error) {
|
||||||
|
rows, err := s.P.Query(ctx,
|
||||||
|
`SELECT p.library_id, l.name, p.book_path, COALESCE(b.title, ''), p.locator, p.percent, p.updated_at
|
||||||
|
FROM reading_progress p JOIN libraries l ON l.id = p.library_id
|
||||||
|
LEFT JOIN books b ON b.library_id = p.library_id AND b.path = p.book_path
|
||||||
|
WHERE p.user_id = $1 ORDER BY p.updated_at DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Progress
|
||||||
|
for rows.Next() {
|
||||||
|
var pr Progress
|
||||||
|
if err := rows.Scan(&pr.LibraryID, &pr.LibraryName, &pr.BookPath, &pr.Title,
|
||||||
|
&pr.Locator, &pr.Percent, &pr.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, pr)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetProgress(ctx context.Context, userID, libID int64, bookPath string) (Progress, error) {
|
||||||
|
var pr Progress
|
||||||
|
err := s.P.QueryRow(ctx,
|
||||||
|
`SELECT library_id, book_path, locator, percent, updated_at
|
||||||
|
FROM reading_progress WHERE user_id=$1 AND library_id=$2 AND book_path=$3`,
|
||||||
|
userID, libID, bookPath).
|
||||||
|
Scan(&pr.LibraryID, &pr.BookPath, &pr.Locator, &pr.Percent, &pr.UpdatedAt)
|
||||||
|
return pr, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"booklib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setup(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
url := os.Getenv("DATABASE_URL")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
p, err := db.Connect(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Migrate(ctx, p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := New(p)
|
||||||
|
for _, q := range []string{
|
||||||
|
"DELETE FROM reading_progress", "DELETE FROM books", "DELETE FROM libraries", "DELETE FROM users"} {
|
||||||
|
if _, err := p.Exec(ctx, q); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBooksDiffLifecycle(t *testing.T) {
|
||||||
|
s := setup(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
libID, err := s.CreateLibrary(ctx, "comics", "/data/books/comics")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bid, err := s.InsertBook(ctx, libID, "series-a/01.cbz", "01", "cbz", 100, 1000, 24)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
meta, err := s.ListBookMeta(ctx, libID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m := meta["series-a/01.cbz"]; m.ID != bid || m.Size != 100 {
|
||||||
|
t.Fatalf("bad meta %+v", m)
|
||||||
|
}
|
||||||
|
if err := s.UpdateBookFile(ctx, bid, 200, 2000, 25); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, _ := s.GetBook(ctx, bid)
|
||||||
|
if b.FileSize != 200 || b.PageCount != 25 {
|
||||||
|
t.Fatalf("update failed: %+v", b)
|
||||||
|
}
|
||||||
|
if err := s.SetBookState(ctx, bid, "error", "boom"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, _ = s.GetBook(ctx, bid)
|
||||||
|
if b.State != "error" || b.ErrMsg != "boom" {
|
||||||
|
t.Fatal("state not set")
|
||||||
|
}
|
||||||
|
// changed + deleted 的 diff 输入验证
|
||||||
|
meta, _ = s.ListBookMeta(ctx, libID)
|
||||||
|
if _, ok := meta["nope.cbz"]; ok {
|
||||||
|
t.Fatal("unexpected row")
|
||||||
|
}
|
||||||
|
if err := s.DeleteBookByPath(ctx, libID, "series-a/01.cbz"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetBook(ctx, bid); err == nil {
|
||||||
|
t.Fatal("book still exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBookHashes(t *testing.T) {
|
||||||
|
s := setup(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
libID, _ := s.CreateLibrary(ctx, "l", "/data/books/l")
|
||||||
|
bid, _ := s.InsertBook(ctx, libID, "a.cbz", "a", "cbz", 100, 2000, 1)
|
||||||
|
m, err := s.BookHashes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v := m[bid]; v[0] != 100 || v[1] != 2000 {
|
||||||
|
t.Fatalf("bad hashes %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressUpsertAndJoin(t *testing.T) {
|
||||||
|
s := setup(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
uid, _ := s.CreateUser(ctx, "u1", "h", "member")
|
||||||
|
libID, _ := s.CreateLibrary(ctx, "l", "/data/books/l")
|
||||||
|
bid, err := s.InsertBook(ctx, libID, "x/a.cbz", "a", "cbz", 1, 1, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.UpsertProgress(ctx, uid, libID, "x/a.cbz", []byte(`{"page":2}`), 0.5); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.UpsertProgress(ctx, uid, libID, "x/a.cbz", []byte(`{"page":3}`), 0.9); err != nil {
|
||||||
|
t.Fatal(err) // upsert 不报错
|
||||||
|
}
|
||||||
|
p, err := s.GetProgress(ctx, uid, libID, "x/a.cbz")
|
||||||
|
// jsonb 规范输出: 冒号后有空格
|
||||||
|
if err != nil || p.Percent != 0.9 || string(p.Locator) != `{"page": 3}` {
|
||||||
|
t.Fatalf("bad progress %+v %v", p, err)
|
||||||
|
}
|
||||||
|
list, _ := s.ListProgress(ctx, uid)
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("want 1 got %d", len(list))
|
||||||
|
}
|
||||||
|
// 删书行,进度保留(spec §4)
|
||||||
|
if err := s.DeleteBook(ctx, bid); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetProgress(ctx, uid, libID, "x/a.cbz"); err != nil {
|
||||||
|
t.Fatal("progress lost with book")
|
||||||
|
}
|
||||||
|
// 列表联查带本人 percent
|
||||||
|
_, _ = s.InsertBook(ctx, libID, "x/a.cbz", "a", "cbz", 1, 1, 3)
|
||||||
|
views, _ := s.ListBooks(ctx, 0, "", "", uid)
|
||||||
|
if len(views) != 1 || views[0].Percent != 0.9 {
|
||||||
|
t.Fatalf("views %+v", views)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,9 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib }
|
||||||
|
ports: ["5433:5432"]
|
||||||
|
healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 }
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports: ["6380:6379"]
|
||||||
@@ -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:
|
||||||
@@ -604,7 +604,6 @@ func (s *Store) CountAdmins(ctx context.Context) (int, error) {
|
|||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
```go
|
|
||||||
// ---------- libraries ----------
|
// ---------- libraries ----------
|
||||||
|
|
||||||
func (s *Store) CreateLibrary(ctx context.Context, name, root string) (int64, error) {
|
func (s *Store) CreateLibrary(ctx context.Context, name, root string) (int64, error) {
|
||||||
@@ -3156,7 +3155,7 @@ func (a *api) scanLibrary(c *gin.Context) {
|
|||||||
|
|
||||||
- [ ] **Step 5: 跑全部后端测试确认通过;Commit**
|
- [ ] **Step 5: 跑全部后端测试确认通过;Commit**
|
||||||
|
|
||||||
Run: `cd backend && DATABASE_URL=... REDIS_URL=... go test ./... -v`
|
Run: `cd backend && DATABASE_URL=... REDIS_URL=... go test -p 1 ./... -v`
|
||||||
Expected: 全 PASS
|
Expected: 全 PASS
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -4167,12 +4166,12 @@ func dbErr(c *gin.Context, e error) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
把所有 handler 中 `"internal", "db error"` 的 500 分支替换为 `dbErr(c, <该处的 error 变量>)`:auth.go(login 1 处)、users.go(listUsers/createUser/deleteUser 各 1 处)、libraries.go(listLibraries)、books.go(listBooks/deleteBook)、content.go 无 DB 500 分支、progress.go(putProgress/listProgress)。替换后 `go test ./...` 全绿即可(既有测试不覆盖 PG 宕机路径)。
|
把所有 handler 中 `"internal", "db error"` 的 500 分支替换为 `dbErr(c, <该处的 error 变量>)`:auth.go(login 1 处)、users.go(listUsers/createUser/deleteUser 各 1 处)、libraries.go(listLibraries)、books.go(listBooks/deleteBook)、content.go 无 DB 500 分支、progress.go(putProgress/listProgress)。替换后 `go test -p 1 ./...` 全绿即可(既有测试不覆盖 PG 宕机路径)。
|
||||||
|
|
||||||
- [ ] **Step 4: 全量回归 + 本地起服烟测**
|
- [ ] **Step 4: 全量回归 + 本地起服烟测**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend && go vet ./... && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable REDIS_URL=redis://localhost:6380 go test ./...
|
cd backend && go vet ./... && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable REDIS_URL=redis://localhost:6380 go test -p 1 ./...
|
||||||
mkdir -p /tmp/libtest/books/demo && echo hello > /tmp/libtest/books/demo/a.txt
|
mkdir -p /tmp/libtest/books/demo && echo hello > /tmp/libtest/books/demo/a.txt
|
||||||
cd backend && JWT_SECRET=devsecret DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable \
|
cd backend && JWT_SECRET=devsecret DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable \
|
||||||
REDIS_URL=redis://localhost:6380 ADMIN_USER=admin ADMIN_PASSWORD=adminpw12 BOOKS_DIR=/tmp/libtest/books \
|
REDIS_URL=redis://localhost:6380 ADMIN_USER=admin ADMIN_PASSWORD=adminpw12 BOOKS_DIR=/tmp/libtest/books \
|
||||||
@@ -4212,7 +4211,7 @@ git add backend && git commit -m "feat(backend): main wiring, graceful shutdown,
|
|||||||
`deploy/Dockerfile.api`:
|
`deploy/Dockerfile.api`:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
FROM golang:1.23-alpine AS build
|
FROM golang:1.26-alpine AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY backend/go.mod backend/go.sum ./
|
COPY backend/go.mod backend/go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
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