Compare commits

...
6 Commits
27 changed files with 398 additions and 94 deletions
+5 -1
View File
@@ -1,7 +1,11 @@
.env .env
.git .git
library/
.superpowers/ .superpowers/
deploy/logs/
deploy/nginx/nginx.conf
deploy/nginx/conf.d/default.conf
deploy/redis/redis.conf
deploy/api/storage/
backend/booklib* backend/booklib*
web/node_modules web/node_modules
web/dist web/dist
-4
View File
@@ -1,4 +0,0 @@
JWT_SECRET=change-me-openssl-rand-hex-32
ADMIN_USER=admin
ADMIN_PASSWORD=change-me-min-8
SCAN_INTERVAL_SEC=60
+6 -1
View File
@@ -1,4 +1,9 @@
.env .env
library/
.superpowers/ .superpowers/
backend/server backend/server
deploy/nginx/nginx.conf
deploy/nginx/conf.d/default.conf
deploy/redis/redis.conf
deploy/logs/
deploy/api/storage/*
!deploy/api/storage/.gitkeep
+125
View File
@@ -0,0 +1,125 @@
# AGENTS.md
@github.com/XingfenD/AGENTS.md:content/basic_agents_md/AGENTS.md
## Docker deployment (dev & release)
### Deployment mode
Mode differences (dev = `deploy/docker-compose.dev.yml`, release = `deploy/docker-compose.yml`):
- dev: source dirs bind-mounted into containers (`../backend:/app`, `../frontend:/app`, `../collab*`), `target: dev` images, workers run `go mod download && go run ./cmd/...`, hot reload without rebuild; also exposes infra ports to the host (postgres 5432, redis 6379, ES 9200, rabbit 5672) and a delve debugger on 2345, and gates startup on healthchecks.
- release: multi-stage `*.prod` Dockerfiles (`target: runner`) with compiled binaries, no source mounts — Go services only get config files mounted; infra ports stay on the internal network only.
### Volume mounts
- Named volumes (both modes, shared): `{$project_name}_{postgres,redis,consul,rabbitmq,minio,elasticsearch}_data` persist infra state to Docker volumes — identical names across the two compose files, so dev and release see the same data. Only `down -v` (i.e. `rebuild`/`clear`) wipes them.
- Config/log bind mounts (both modes): rendered outputs `deploy/nginx/nginx.conf`, `deploy/nginx/conf.d/default.conf`, `deploy/redis/redis.conf`, `deploy/rabbitmq/rabbitmq.conf` (`:ro`) plus `deploy/logs/{nginx,rabbitmq}` are mounted from the host — these come from `prepare.sh`, so stale files in the container mean you forgot to rerun it.
- Source mounts (dev only): `../backend:/app` (which run `go mod download && go run ./cmd/...`), `../frontend:/app` with an anonymous volume on `/app/node_modules` (so the image's deps survive the mount — delete containers with `down`, not `rm`, to avoid orphaning it).
- The File Storage mounts: if the backend stores uploaded files directly instead of in an object storage, mount `deploy/{$service_name}/storage` as the storage path into the container.
## Project structure
Basically, the project consists of the directories `docs`, `backend`, `frontend`, `deploy`, plus optional `collab*` services:
```plaintext
.
├── docs # project documentation (see Documents layout)
├── backend # golang services (see backend layout)
├── frontend # node web app, built and served via nginx (see frontend layout)
├── collab* # optional collaboration services, same layout as backend
├── deploy # compose stacks, rendered configs, host-side logs (see deploy layout)
├── .gitignore # repo-wide ignores only; module-specific ignores live inside each module
├── AGENTS.md # agent guidance for this repo
└── LICENSE
```
### Documents layout
The documents' layout is expected to be like below:
```plaintext
docs
├── CHANGELOG.md # general (non-WebUI) changes; highest version on top
├── CHANGELOG_web.md # WebUI changes only; highest version on top
├── README.md # primary (English) entry doc
└── README_zh.md # Chinese mirror of README.md — keep in sync
```
Constraints:
- Every user-visible change gets a changelog entry: WebUI changes → `CHANGELOG_web.md`, everything else → `CHANGELOG.md`; never duplicate an entry across both.
- `README.md` and `README_zh.md` stay content-equivalent; update them in the same change.
- Documentation lives only under `docs/` — no stray `*.md` at the repo root besides `AGENTS.md` and `LICENSE`.
### backend (golang)
```plaintext
backend
├── cmd # executable entry points, one dir per binary
├── internal # private packages: importable only within this module
├── pkg # public packages: intentionally shared with other repos
└── .gitignore # .gitignore inside backend
```
Constraints:
- `cmd`: each subdirectory holds exactly one `main` package; `main.go` only bootstraps — load config, wire dependencies, start the server/CLI, handle graceful shutdown. No business logic here, and nothing outside `cmd` imports `cmd`.
- `internal`: default home for all business code (domain, services, storage, config). If code is not meant to be imported by other repos, it goes here, not in `pkg`.
- `pkg`: opt-in public API surface — keep it small and stable, and never leak `internal` types through its APIs.
```plaintext
cmd
├── cli # command-line binary
│ ├── commands # one file/package per subcommand: flag parsing + dispatch into internal
│ └── main.go # arg parsing and subcommand dispatch only
└── webui # HTTP server binary
├── api # route registration + request/response DTOs — the endpoint contract, no logic
├── constant # webui-only constants (routes, keys, limits)
├── handlers # thin HTTP handlers: bind/validate input, call internal services, map errors
├── main.go # boot the HTTP server
├── static # assets served as-is by webui
└── templates # server-side HTML templates rendered by webui
```
Constraints:
- Handlers never touch the database or implement domain rules — they delegate to `internal`; SQL and business rules live behind `internal` boundaries.
- `api` is the single source of truth for the endpoint contract; the frontend aligns with it.
- Code shared between `cli` and `webui` belongs in `internal` (or `pkg`), never imported from one command by the other.
### frontend (node)
```plaintext
frontend
├── src # all hand-written app source: pages, components, state, API client
├── public # static assets copied into the build output as-is
├── package.json # dev/build/lint/test scripts, runnable unchanged inside the container
└── .gitignore # node_modules/ and build output (e.g. dist/) never committed
```
Constraints (framework-agnostic — any stack that keeps the layout above):
- The app talks to the backend only over the HTTP APIs defined in `backend/cmd/webui/api` — no direct access to infra (DB, ES, rabbit) from the frontend.
- Build output is disposable and git-ignored; committed sources live only in `src`/`public`; nginx serves the built assets in release.
- Dev runs from the source mount (`../frontend:/app`) with `node_modules` provided by the image's anonymous volume — install new deps inside the container and commit the lockfile.
### deploy
```plaintext
deploy
├── docker-compose.yml # release stack: multi-stage *.prod images, infra internal-only
├── docker-compose.dev.yml # dev stack: source mounts, target: dev, exposed ports, delve 2345
├── prepare.sh # renders the config files below; rerun after changing templates
├── nginx
│ ├── nginx.conf # main config, mounted :ro
│ └── conf.d/default.conf # site config, mounted :ro
├── redis/redis.conf # mounted :ro
├── rabbitmq/rabbitmq.conf # mounted :ro
├── logs # host-side dirs bind-mounted into containers ({nginx,rabbitmq})
├── {$service_name}/storage # file-storage path for services not using object storage
└── .gitignore # ignores actual/rendered configs and logs/; tracks only *.example files
```
Constraints:
- Only config examples (e.g. `*.conf.example`) are tracked by git; actual configs (rendered by `prepare.sh`) and log files are git-ignored.
- Everything under `deploy/` that containers mount (`*.conf` and `prepare.sh` outputs) is config, not app code: edit it here, rerun `prepare.sh`, restart — never edit configs inside a running container.
- Infra ports are exposed to the host only in `docker-compose.dev.yml`; release keeps them internal-network-only.
- Persisted state lives only in the named `{$project_name}_*` volumes; bind-mounts are reserved for source, config, logs, and file storage.
- Service-specific host dirs (storage, config) go under `deploy/{$service_name}/`, never loose at the repo root.
+56 -34
View File
@@ -1,53 +1,75 @@
# Book & Comic Library # Book & Comic Library
个人书库/漫画库:Go+Gin 后端(扫描/上传入库、多用户 JWT、阅读进度、磁盘+Redis 缓存)+ Docker Compose 部署。设计见 `docs/superpowers/specs/2026-09-04-book-comic-library-design.md`。 个人书库/漫画库:Go+Gin 后端(扫描/上传入库、多用户 JWT、阅读进度、磁盘+Redis 缓存)+ Docker Compose 部署。设计见 `docs/superpowers/specs/2026-09-04-book-comic-library-design.md`;部署规范见 `docs/superpowers/specs/2026-09-07-docker-deploy-spec-design.md`。
## Deploy mode
- release:`deploy/docker-compose.yml` — 多阶段 `backend|web/Dockerfile.prod`(target runner)编译产物,不挂源码;infra 端口只在容器网络。
- dev:`deploy/docker-compose.dev.yml` — 四服务全容器化,源码挂 `../backend:/app`、`../web:/app`(web 带匿名 `node_modules` 卷),`target: dev` 镜像;api 跑 `go mod download && dlv debug ./cmd/server`(热重启不 rebuild,delve :2345);infra 暴露宿主 PG 5432 / Redis 6379;healthcheck 门控。
## Volume Mount
- 共享 named volumes:`booklib_postgres_data`、`booklib_redis_data`(两个 compose 同名,dev/release 看到同一份数据;仅 `down -v` 清除)。
- 配置/日志绑定挂载:`deploy/nginx/{nginx.conf,conf.d/default.conf}`、`deploy/redis/redis.conf`(`:ro`)与 `deploy/logs/nginx` —— 全部来自 `deploy/prepare.sh`(模板在各产物同目录的 `*.tpl`),**容器里配置不对/缺失 = 忘了重跑它**。
- 文件存储:`deploy/api/storage` → 容器 `/data/books`(缓存写 `/data/books/cache`)。
## 跑起来(生产形态) ## 跑起来(生产形态)
```bash ```bash
cp .env.example .env # 填 JWT_SECRET、ADMIN_USER、ADMIN_PASSWORD(≥8 位,低于 8 位 seed 会跳过并 log) cp deploy/.env.example deploy/.env # 填 JWT_SECRET、ADMIN_USER、ADMIN_PASSWORD(≥8 位,低于 8 位 seed 会跳过并 log)
docker compose up -d --build deploy/prepare.sh
./scripts/smoke.sh # 端到端验收(登录、建库、上传、扫描、进度、删除、immutable 头) docker compose -f deploy/docker-compose.yml up -d --build
bash scripts/smoke.sh && bash scripts/smoke-web.sh
``` ```
- web: `http://localhost:8080`,API 走 nginx `/api/` 前缀反代到无状态 api 副本(`--scale api=N`)。 - web: `http://localhost:8080`(`WEB_PORT` 可改),API 走 nginx `/api/` 前缀反代到无状态 api 副本(`--scale api=N`)。
- 原始书放在 `./library/`(挂到 `/data/books`),scanner 周期入库(默认 60s)。 - 原始书放进 `deploy/api/storage/`(挂到 `/data/books`),scanner 周期入库(默认 60s)。
- nginx access/error 日志:`deploy/logs/nginx/`。
## 开发
```bash
deploy/prepare.sh
docker compose -f deploy/docker-compose.dev.yml up -d --build
```
- 前端: http://localhost:5173(vite,HMR 直接生效;`/api` 代理到容器内 api)。
- Go 改码后:`docker compose -f deploy/docker-compose.dev.yml restart api`(重编译挂载源码,无需 rebuild)。
- 断点调试:delve headless 在 `localhost:2345`(VSCode launch:`{"type":"go","request":"attach","mode":"remote","host":"localhost","port":2345,"substitutePath":[{"from":"${workspaceFolder}/backend","to":"/app"}]}`;命中断点后用 dlv 命令继续)。
- 直连基础设施跑测试:PG `localhost:5432`(lib/lib/lib)、Redis `localhost:6379`:
```bash
cd backend
export DATABASE_URL='postgres://lib:lib@localhost:5432/lib?sslmode=disable'
export REDIS_URL='redis://localhost:6379'
go vet ./... && gofmt -l .
go test -p 1 -count=1 ./...
```
`-p 1` 是必须的:集成测试共用同一个 PG 库,各自 `DELETE FROM ...` 清表——并行跑会互相删数据导致随机失败。dev 栈起停用 `down`(不是 `rm`),否则匿名 node_modules 卷成孤儿。无 PG/Redis 时依赖它们的测试自动 skip;Redis 挂掉不影响功能(全链路降级为 miss/放行,见 spec §9)。
前端门槛:`cd web && npm run check`(tsc + vitest + vite build)。
## 旧卷迁移(一次性,升级自上一版部署)
```bash
# 老 PG 数据 → 新共享卷
docker run --rm -v book-comic-library_pgdata:/from -v booklib_postgres_data:/to alpine cp -a /from/. /to/
# 老 cache 卷是封面/解压派生数据,直接丢弃(自动重建)
docker volume rm book-comic-library_pgdata book-comic-library_cache
```
书库文件:原宿主 `./library/` 的内容移入 `deploy/api/storage/`。
## 可信代理与限流 ## 可信代理与限流
- nginx 在 compose 网络内,api 的 `ClientIP` 只信 `TRUSTED_PROXY_CIDRS`(逗号分隔 CIDR,默认 `172.16.0.0/12`,即 compose 网段)。外部伪造 `X-Forwarded-For` 换不掉限流桶;换部署网络时改这个 env。 - nginx 在 compose 网络内,api 的 `ClientIP` 只信 `TRUSTED_PROXY_CIDRS`(逗号分隔 CIDR,默认 `172.16.0.0/12`,即 compose 网段)。外部伪造 `X-Forwarded-For` 换不掉限流桶;换部署网络时改这个 env。
- 登录限流 5 次/分钟/IP **按尝试计数,成功登录也计**——爆破和正常高频登录同账。 - 登录限流 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 前必读 ## 改 schema 前必读
`db.Migrate` 只执行 `schema.sql` 的 `CREATE TABLE IF NOT EXISTS`——对已存在的库**加列/改列不会生效**。任何列变更之前,必须先引入 `schema_migrations` 版本表 + 有序迁移脚本,否则老部署会静默跑在旧结构上。 `db.Migrate` 只执行 `schema.sql` 的 `CREATE TABLE IF NOT EXISTS`——对已存在的库**加列/改列不会生效**。任何列变更之前,必须先引入 `schema_migrations` 版本表 + 有序迁移脚本,否则老部署会静默跑在旧结构上。
## 前端开发(web/) ## PWA
- `cd web && npm install`;`npm run dev`(:5173,`/api` 代理到 :8080)。 不可变资源(封面/CBZ 页/原文件)SW cache-first,读过的内容离线可翻;登出会清 SW 缓存。
- 后端起法:`docker compose -f deploy/docker-compose.dev.yml up -d --wait` 起 PG(:5433)/Redis(:6380),
然后 `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable \
REDIS_URL=redis://localhost:6380 JWT_SECRET=dev go run ./cmd/server`。
- 门槛:`npm run check`(tsc + vitest + vite build)。
## 生产部署(含前端)
- `.env` 配 `JWT_SECRET/ADMIN_USER/ADMIN_PASSWORD` 后 `docker compose up -d --build`;
打开 http://localhost:8080(web=SPA+nginx,/api 反代 api:8080)。
- PWA:不可变资源(封面/CBZ 页/原文件)SW cache-first,读过的内容离线可翻;登出会清 SW 缓存。
- 冒烟:`bash scripts/smoke.sh`(后端直连)、`bash scripts/smoke-web.sh`(经 nginx 全栈,需 :8080 空闲)。
+10
View File
@@ -0,0 +1,10 @@
FROM golang:1.26 AS base
WORKDIR /app
FROM base AS dev
# 源码由 compose 挂载进 /app;镜像只带工具链 + delve
# 国内直连 proxy.golang.org 会超时,goproxy.cn 走直连
ENV GOPROXY=https://goproxy.cn,direct
RUN go install github.com/go-delve/delve/cmd/dlv@latest
EXPOSE 8080 2345
CMD ["sh", "-c", "go mod download && dlv debug ./cmd/server --headless --listen=0.0.0.0:2345 --api-version=2 --accept-multiclient --continue --log"]
@@ -5,10 +5,11 @@ RUN go mod download
COPY backend/ ./ COPY backend/ ./
RUN CGO_ENABLED=0 go build -trimpath -o /server ./cmd/server RUN CGO_ENABLED=0 go build -trimpath -o /server ./cmd/server
FROM alpine:3.20 FROM alpine:3.20 AS runner
RUN adduser -D -H app RUN adduser -D -H app
COPY --from=build /server /server COPY --from=build /server /server
RUN mkdir -p /data/cache /data/books && chown app:app /data/cache /data/books # /data/books 由宿主 bind(./api/storage)覆盖;/data 下目录预建并授权,兼容 podman
RUN mkdir -p /data && chown app:app /data
USER app USER app
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/server"] ENTRYPOINT ["/server"]
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+4
View File
@@ -26,6 +26,10 @@ func (a *api) libRoot(c *gin.Context, lib store.Library) (string, bool) {
err(c, http.StatusForbidden, "forbidden", "library root outside books dir") err(c, http.StatusForbidden, "forbidden", "library root outside books dir")
return "", false return "", false
} }
if e := os.MkdirAll(root, 0o755); e != nil { // 注册库时目录可能尚未落盘,自愈
err(c, http.StatusInternalServerError, "internal", "library root")
return "", false
}
return root, true return root, true
} }
+11
View File
@@ -0,0 +1,11 @@
JWT_SECRET=change-me-openssl-rand-hex-32
ADMIN_USER=admin
ADMIN_PASSWORD=change-me-min-8
SCAN_INTERVAL_SEC=60
# ---- 部署参数(deploy/prepare.sh 渲染模板 / compose 插值用) ----
WEB_PORT=8080
NGINX_CLIENT_MAX_BODY_SIZE=200m
REDIS_MAXMEMORY=128mb
REDIS_MAXMEMORY_POLICY=allkeys-lru
DELVE_PORT=2345
View File
+41 -2
View File
@@ -1,9 +1,48 @@
# dev:源码热挂载 + target: dev 镜像 + delve :2345,infra 端口暴露宿主,healthcheck 门控
# 起停用 down(不是 rm),否则 web 的匿名 node_modules 卷会成孤儿
name: booklib
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib } environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib }
ports: ["5433:5432"] ports: ["5432:5432"]
volumes: [postgres_data:/var/lib/postgresql/data]
healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 } healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 }
redis: redis:
image: redis:7-alpine image: redis:7-alpine
ports: ["6380:6379"] command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
ports: ["6379:6379"]
volumes:
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
- redis_data:/data
api:
build: { context: .., dockerfile: backend/Dockerfile.dev, target: dev }
command: sh -c "go mod download && dlv debug ./cmd/server --headless --listen=0.0.0.0:2345 --api-version=2 --accept-multiclient --continue --log"
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/books/cache
SCAN_INTERVAL_SEC: ${SCAN_INTERVAL_SEC:-60}
volumes:
- ../backend:/app
- ./api/storage:/data/books
ports: ["${DELVE_PORT:-2345}:2345"]
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
web:
build: { context: .., dockerfile: web/Dockerfile.dev, target: dev }
command: npm run dev -- --host 0.0.0.0
environment: { VITE_PROXY_TARGET: http://api:8080 }
volumes:
- ../web:/app
- /app/node_modules
ports: ["5173:5173"]
depends_on: [api]
volumes:
postgres_data:
redis_data:
+41
View File
@@ -0,0 +1,41 @@
# release:编译产物、无源码挂载、infra 端口不出宿主机
name: booklib
services:
web:
build: { context: .., dockerfile: web/Dockerfile.prod, target: runner }
ports: ["${WEB_PORT:-8080}:80"]
volumes:
- ./nginx/nginx.conf:/etc/booklib/nginx.conf:ro
- ./nginx/conf.d/default.conf:/etc/booklib/default.conf:ro
- ./logs/nginx:/var/log/nginx
depends_on: [api]
api:
build: { context: .., dockerfile: backend/Dockerfile.prod, target: runner }
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/books/cache
SCAN_INTERVAL_SEC: ${SCAN_INTERVAL_SEC:-60}
volumes:
- ./api/storage:/data/books
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: [postgres_data:/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", "/usr/local/etc/redis/redis.conf"]
volumes:
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
- redis_data:/data
volumes:
postgres_data:
redis_data:
+7
View File
@@ -3,11 +3,18 @@
# (Docker=127.0.0.11,podman aardvark=网络网关,见容器 /etc/resolv.conf)。 # (Docker=127.0.0.11,podman aardvark=网络网关,见容器 /etc/resolv.conf)。
# 启动前取 resolv.conf 首个 nameserver 注入 nginx 配置,保住 spec §11 的 # 启动前取 resolv.conf 首个 nameserver 注入 nginx 配置,保住 spec §11 的
# 变量式 proxy_pass 运行时重解析(valid=10s,scale/重建后秒级感知新 IP)。 # 变量式 proxy_pass 运行时重解析(valid=10s,scale/重建后秒级感知新 IP)。
# 配置以 :ro 挂在 /etc/booklib/(deploy/prepare.sh 渲染产物),先拷入原位再改写。
set -eu set -eu
for f in nginx.conf default.conf; do
[ -r "/etc/booklib/$f" ] || { echo "entrypoint-resolver: missing /etc/booklib/$f — 先跑 deploy/prepare.sh" >&2; exit 1; }
done
cp /etc/booklib/nginx.conf /etc/nginx/nginx.conf
cp /etc/booklib/default.conf /etc/nginx/conf.d/default.conf
RESOLVER=$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null || true) RESOLVER=$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null || true)
[ -n "$RESOLVER" ] || RESOLVER=127.0.0.11 [ -n "$RESOLVER" ] || RESOLVER=127.0.0.11
CONF=/etc/nginx/conf.d/default.conf CONF=/etc/nginx/conf.d/default.conf
sed -i "s#resolver [0-9a-fA-F:.]* valid=#resolver ${RESOLVER} valid=#" "$CONF" sed -i "s#resolver [0-9a-fA-F:.]* valid=#resolver ${RESOLVER} valid=#" "$CONF"
echo "entrypoint-resolver: resolver=${RESOLVER} injected into ${CONF}" echo "entrypoint-resolver: resolver=${RESOLVER} injected into ${CONF}"
nginx -t
if [ $# -gt 0 ]; then exec "$@"; fi if [ $# -gt 0 ]; then exec "$@"; fi
exec nginx -g 'daemon off;' exec nginx -g 'daemon off;'
@@ -1,7 +1,7 @@
server { server {
listen 80; listen 80;
client_max_body_size 200m; client_max_body_size {{NGINX_CLIENT_MAX_BODY_SIZE}};
# 地址为占位默认值(127.0.0.11=Docker 内嵌 DNS);镜像 entrypoint 启动时会按 # 地址为占位默认值(127.0.0.11=Docker 内嵌 DNS);容器 entrypoint 启动时会按
# /etc/resolv.conf 的首个 nameserver 重写本行,兼容 podman aardvark-dns。 # /etc/resolv.conf 的首个 nameserver 重写本行,兼容 podman aardvark-dns。
resolver 127.0.0.11 valid=10s; resolver 127.0.0.11 valid=10s;
+24
View File
@@ -0,0 +1,24 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
include /etc/nginx/conf.d/*.conf;
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# 渲染各目录下的 *.tpl → 同位置产物(nginx/nginx.conf 等),并创建运行所需目录。
# 约定:每次 up 之前(或改模板/.env 后)必须重跑本脚本。幂等,可反复执行。
set -eu
cd "$(dirname "$0")"
if [ -f .env ]; then
set -a; . ./.env; set +a
fi
NGINX_CLIENT_MAX_BODY_SIZE=${NGINX_CLIENT_MAX_BODY_SIZE:-200m}
REDIS_MAXMEMORY=${REDIS_MAXMEMORY:-128mb}
REDIS_MAXMEMORY_POLICY=${REDIS_MAXMEMORY_POLICY:-allkeys-lru}
# 模板与渲染产物同目录:改模板即改在产物旁边,产物文件名 = 模板名去 .tpl
for t in nginx/nginx.conf.tpl nginx/conf.d/default.conf.tpl redis/redis.conf.tpl; do
[ -f "$t" ] || { echo "prepare.sh: missing template $t" >&2; exit 1; }
done
mkdir -p logs/nginx api/storage
sed -e "s|{{NGINX_CLIENT_MAX_BODY_SIZE}}|${NGINX_CLIENT_MAX_BODY_SIZE}|g" \
nginx/nginx.conf.tpl > nginx/nginx.conf
sed -e "s|{{NGINX_CLIENT_MAX_BODY_SIZE}}|${NGINX_CLIENT_MAX_BODY_SIZE}|g" \
nginx/conf.d/default.conf.tpl > nginx/conf.d/default.conf
sed -e "s|{{REDIS_MAXMEMORY}}|${REDIS_MAXMEMORY}|g" \
-e "s|{{REDIS_MAXMEMORY_POLICY}}|${REDIS_MAXMEMORY_POLICY}|g" \
redis/redis.conf.tpl > redis/redis.conf
echo "prepare.sh: rendered nginx($(pwd)/nginx), redis($(pwd)/redis), logs($(pwd)/logs/nginx), storage($(pwd)/api/storage)"
+4
View File
@@ -0,0 +1,4 @@
# booklib redis 配置 — 由 deploy/prepare.sh 从本目录 redis.conf.tpl 渲染,勿直接编辑产物 redis.conf
maxmemory {{REDIS_MAXMEMORY}}
maxmemory-policy {{REDIS_MAXMEMORY_POLICY}}
dir /data
-34
View File
@@ -1,34 +0,0 @@
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:
+4 -1
View File
@@ -3,7 +3,10 @@
# 前提:.env 里有 JWT_SECRET/ADMIN_USER/ADMIN_PASSWORD;宿主 :8080 空闲。 # 前提:.env 里有 JWT_SECRET/ADMIN_USER/ADMIN_PASSWORD;宿主 :8080 空闲。
set -euo pipefail set -euo pipefail
BASE=${BASE:-http://localhost:8080} BASE=${BASE:-http://localhost:8080}
[ -f .env ] && set -a && . ./.env && set +a ENV_FILE=${ENV_FILE:-deploy/.env}
[ -f "$ENV_FILE" ] || ENV_FILE=.env
[ -f "$ENV_FILE" ] && set -a && . "./$ENV_FILE" && set +a
export COMPOSE_FILE=${COMPOSE_FILE:-deploy/docker-compose.yml}
: "${JWT_SECRET:?JWT_SECRET 未设置}"; : "${ADMIN_USER:?ADMIN_USER 未设置}"; : "${ADMIN_PASSWORD:?ADMIN_PASSWORD 未设置}" : "${JWT_SECRET:?JWT_SECRET 未设置}"; : "${ADMIN_USER:?ADMIN_USER 未设置}"; : "${ADMIN_PASSWORD:?ADMIN_PASSWORD 未设置}"
say(){ echo "smoke-web: $1"; } say(){ echo "smoke-web: $1"; }
+10 -5
View File
@@ -3,7 +3,9 @@ set -euo pipefail
BASE=${BASE:-http://localhost:8080} BASE=${BASE:-http://localhost:8080}
API=$BASE/api API=$BASE/api
J=(-H 'content-type: application/json') J=(-H 'content-type: application/json')
[ -f .env ] && set -a && . ./.env && set +a ENV_FILE=${ENV_FILE:-deploy/.env}
[ -f "$ENV_FILE" ] || ENV_FILE=.env
[ -f "$ENV_FILE" ] && set -a && . "./$ENV_FILE" && set +a
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
say(){ echo "smoke: $1"; } say(){ echo "smoke: $1"; }
@@ -19,14 +21,17 @@ TOK=$(tokfor "$ADMIN_USER" "$ADMIN_PASSWORD")
AUTH="authorization: Bearer $TOK" AUTH="authorization: Bearer $TOK"
say "member user + role enforcement" say "member user + role enforcement"
curl -fsS "$API/users" "${J[@]}" -H "$AUTH" -d '{"username":"smoke","password":"smokepw123","role":"member"}' >/dev/null || die "create member" curl -fsS "$API/users" "${J[@]}" -H "$AUTH" -d '{"username":"smoke","password":"smokepw123","role":"member"}' >/dev/null || say "member smoke 已存在(重跑),复用"
MTOK=$(tokfor smoke smokepw123) MTOK=$(tokfor smoke smokepw123)
[ -n "$MTOK" ] || die "member login"
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=$(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)" [ "$code" = 403 ] || die "member write not blocked ($code)"
say "library + bad-ext upload rejected + good upload + scan" say "library + bad-ext upload rejected + good upload + scan"
mkdir -p library/smoke-books mkdir -p deploy/api/storage/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/') LID=$(curl -sf "$API/libraries" "${J[@]}" -H "$AUTH" -d '{"name":"smoke","root_path":"/data/books/smoke-books"}' | sed -E 's/.*"id":([0-9]+).*/\1/' || true)
[ -n "$LID" ] || LID=$(curl -fsS "$API/libraries" -H "$AUTH" | grep -oE '"id":[0-9]+,"name":"smoke"' | cut -d: -f2 | cut -d, -f1)
[ -n "$LID" ] || die "no library id"
printf 'x' > "$WORK/f" 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 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" printf 'hello smoke book' > "$WORK/f"
@@ -52,6 +57,6 @@ curl -fsS -o /dev/null -D - "$BASE$COVER" -H "$AUTH" | grep -qi 'cache-control:.
say "delete book → file gone from host dir" say "delete book → file gone from host dir"
curl -fsS -o /dev/null -X DELETE "$API/books/$BID" -H "$AUTH" || die "delete" curl -fsS -o /dev/null -X DELETE "$API/books/$BID" -H "$AUTH" || die "delete"
[ ! -f library/smoke-books/note.txt ] || die "file survived delete" [ ! -f deploy/api/storage/smoke-books/note.txt ] || die "file survived delete"
say "ALL SMOKE TESTS PASSED" say "ALL SMOKE TESTS PASSED"
+6
View File
@@ -0,0 +1,6 @@
FROM node:22 AS dev
WORKDIR /app
COPY web/package.json web/package-lock.json ./
RUN npm ci
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
@@ -5,9 +5,9 @@ RUN npm ci
COPY web/ ./ COPY web/ ./
RUN npm run build RUN npm run build
FROM nginx:1.27-alpine FROM nginx:1.27-alpine AS runner
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --chmod=755 deploy/entrypoint-resolver.sh /entrypoint-resolver.sh COPY --chmod=755 deploy/entrypoint-resolver.sh /entrypoint-resolver.sh
COPY --from=build /src/dist /usr/share/nginx/html COPY --from=build /src/dist /usr/share/nginx/html
# entrypoint 先按 /etc/resolv.conf 注入 resolver(Docker/podman 双运行时),再执行继承的 nginx CMD # nginx 配置不 bake 进镜像:由 compose 挂到 /etc/booklib/(渲染产物),
# entrypoint 启动时拷入原位、注入运行时 resolver 再 exec nginx
ENTRYPOINT ["/entrypoint-resolver.sh"] ENTRYPOINT ["/entrypoint-resolver.sh"]
+3 -2
View File
@@ -3,9 +3,10 @@ import type { Book, Library, Me, ProgressRow, User } from "./types";
export const TOKEN_KEY = "booklib.token"; export const TOKEN_KEY = "booklib.token";
export const LOGOUT_EVENT = "booklib:logout"; export const LOGOUT_EVENT = "booklib:logout";
// 无 localStorage 环境(vitest node)退化为内存,仅测试路径生效 // 无 localStorage 环境(vitest node)退化为内存,仅测试路径生效。
// 探测方法而非 typeof:node 22+ 的 experimental webstorage 会暴露无方法的 localStorage 桩。
const mem = new Map<string, string>(); const mem = new Map<string, string>();
const hasLS = typeof localStorage !== "undefined"; const hasLS = typeof globalThis.localStorage?.setItem === "function";
export function getToken(): string | null { export function getToken(): string | null {
return hasLS ? localStorage.getItem(TOKEN_KEY) : mem.get(TOKEN_KEY) ?? null; return hasLS ? localStorage.getItem(TOKEN_KEY) : mem.get(TOKEN_KEY) ?? null;
+2 -2
View File
@@ -83,7 +83,7 @@ export default function AdminLibraries() {
}} }}
type="file" type="file"
multiple multiple
accept=".cbz,.pdf,.epub,.txt,.md" accept=".cbz,.zip,.pdf,.epub,.txt,.md"
className="hidden" className="hidden"
onChange={(e) => { onChange={(e) => {
void onFiles(l.id, e.target.files); void onFiles(l.id, e.target.files);
@@ -93,7 +93,7 @@ export default function AdminLibraries() {
<button className={btn} onClick={() => fileRefs.current[l.id]?.click()}> <button className={btn} onClick={() => fileRefs.current[l.id]?.click()}>
上传文件 上传文件
</button> </button>
<span className="text-xs text-zinc-600">白名单:cbz/pdf/epub/txt/md</span> <span className="text-xs text-zinc-600">白名单:cbz/zip/pdf/epub/txt/md</span>
</div> </div>
</div> </div>
))} ))}
+1 -1
View File
@@ -46,5 +46,5 @@ export default defineConfig({
}, },
}), }),
], ],
server: { proxy: { "/api": "http://localhost:8080" } }, server: { proxy: { "/api": process.env.VITE_PROXY_TARGET ?? "http://localhost:8080" } },
}); });