From 45eefd6741a0a37d4b781a44091c4d60bd4ab839 Mon Sep 17 00:00:00 2001 From: Fendy Date: Mon, 7 Sep 2026 21:56:59 +0800 Subject: [PATCH] feat(libs): create library by name only; root_path derived server-side as BOOKS_DIR/SafeName(name), validated at creation; drop root_path input from admin UI; align changelogs to bilingual template; trim AGENTS.md to rules not derivable from repo layout --- AGENTS.md | 137 +++--------------- backend/cmd/webui/handlers/books_test.go | 2 +- backend/cmd/webui/handlers/libraries.go | 20 +-- backend/cmd/webui/handlers/libraries_test.go | 24 ++- docs/CHANGELOG.md | 12 +- docs/CHANGELOG_web.md | 13 +- .../2026-09-04-book-comic-library-design.md | 2 +- frontend/src/api/client.ts | 3 +- frontend/src/pages/admin/Libraries.tsx | 13 +- 9 files changed, 80 insertions(+), 146 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3739a26..f08998e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,125 +1,34 @@ # AGENTS.md -@github.com/XingfenD/AGENTS.md:content/basic_agents_md/AGENTS.md +## Safety Rules +- Dev branch naming: `{feat|fix|docs|chore}/{branch-name}` (e.g. `feat/file-tag-done`, `fix/tree-render`). +- Before `git commit`: run `git branch --show-current`. If on `master`, do NOT commit — ask user for a branch name (suggest one based on the changes), create it, commit there. +- Every user-visible change gets a changelog entry: WebUI changes (files under `frontend/`) → `docs/CHANGELOG_web.md`, everything else → `docs/CHANGELOG.md`; never duplicate an entry across both. Higher versions on top. +- CHANGELOG entry format: same entry has English line then Chinese line on consecutive lines (no blank line between them); different entries are separated by a blank line. +- `docs/README.md` and `docs/README_zh.md` stay content-equivalent; update them in the same change. ## 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. +- dev = `deploy/docker-compose.dev.yml`: source bind-mounted (`../backend:/app`, `../frontend:/app` with an anonymous volume on `/app/node_modules`), `target: dev` images; api runs `go mod download && dlv debug ./cmd/webui` (delve on 2345), web runs `npm run dev` (hot reload, no rebuild needed); infra ports exposed to host (postgres 5432, redis 6379); startup gated on healthchecks. +- release = `deploy/docker-compose.yml`: multi-stage `*.prod` Dockerfiles (`target: runner`) with compiled binaries, no source mounts; infra ports stay on the internal network only. +- Persisted state lives only in named volumes `booklib_{postgres,redis}_data` — same names in both files, so dev and release see the same data. Only `down -v` wipes them. +- Books on disk: host `deploy/api/storage` is mounted as `/data/books` (`BOOKS_DIR`); uploads land there and the scanner picks them up. +- Rendered configs `deploy/nginx/nginx.conf`, `deploy/nginx/conf.d/default.conf`, `deploy/redis/redis.conf` (mounted `:ro`) come from their `*.tpl` via `deploy/prepare.sh` — edit the template, rerun `prepare.sh`, restart; stale files in a container mean you forgot to rerun. Host logs under `deploy/logs/`. +- Tear down with `down`, not `rm`, or the web image's anonymous `node_modules` volume gets orphaned. ## Project structure +Root: `docs` (docs + changelogs), `backend` (Go webui service), `frontend` (node app, served via nginx in release), `deploy` (compose stacks, rendered configs, host logs). Actual layout is visible via `ls`; only rules not derivable from it are listed here. -Basically, the project consists of the directories `docs`, `backend`, `frontend`, `deploy`, plus optional `collab*` services: +### backend +- `cmd/`: each subdir is exactly one `main` package; `main.go` only bootstraps (load config, wire deps, serve, graceful shutdown) — no business logic, and nothing outside `cmd` imports `cmd`. +- `cmd/webui/api` is the single source of truth for the endpoint contract; the frontend aligns with it. +- Handlers stay thin (bind/validate, call `internal`, map errors) — never touch the DB or implement domain rules; SQL and business rules live behind `internal` boundaries. +- Business code defaults to `internal/`; `pkg/` is opt-in public surface shared with other repos — keep it small, never leak `internal` types through it. -```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. +### frontend +- Talks to the backend only over the HTTP APIs in `backend/cmd/webui/api` — no direct infra access (DB, redis) from the browser app. +- Build output (`dist/`) is disposable and git-ignored; only `src`/`public` are committed. +- Dev runs from the source mount with `node_modules` provided by the image — 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. +- Only config templates/examples are tracked by git; rendered `*.conf` and `logs/` are git-ignored. - Service-specific host dirs (storage, config) go under `deploy/{$service_name}/`, never loose at the repo root. diff --git a/backend/cmd/webui/handlers/books_test.go b/backend/cmd/webui/handlers/books_test.go index 6710211..a3e621b 100644 --- a/backend/cmd/webui/handlers/books_test.go +++ b/backend/cmd/webui/handlers/books_test.go @@ -20,7 +20,7 @@ func newLibrary(t *testing.T, st *store.Store, h http.Handler, tok, booksDir, na 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}) + w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": name}) if w.Code != 201 { t.Fatalf("create lib %d %s", w.Code, w.Body) } diff --git a/backend/cmd/webui/handlers/libraries.go b/backend/cmd/webui/handlers/libraries.go index 5c09752..e87733a 100644 --- a/backend/cmd/webui/handlers/libraries.go +++ b/backend/cmd/webui/handlers/libraries.go @@ -47,29 +47,31 @@ func (h *H) ListLibraries(c *gin.Context) { c.JSON(http.StatusOK, out) } +// CreateLibrary: root_path 由服务端生成(BooksDir/SafeName(name)),不接受客户端指定 func (h *H) CreateLibrary(c *gin.Context) { var req struct { - Name string `json:"name"` - RootPath string `json:"root_path"` + Name string `json:"name"` } - if c.ShouldBindJSON(&req) != nil || req.Name == "" || req.RootPath == "" { - err(c, http.StatusBadRequest, "bad_request", "name and root_path required") + if c.ShouldBindJSON(&req) != nil { + err(c, http.StatusBadRequest, "bad_request", "name required") return } - if !filepath.IsAbs(req.RootPath) { - err(c, http.StatusBadRequest, "bad_request", "root_path must be absolute") + safe := bookfile.SafeName(req.Name) + if safe == "" || safe == ".." { + err(c, http.StatusBadRequest, "bad_request", "bad name") return } - id, e := h.st.CreateLibrary(c, req.Name, filepath.Clean(req.RootPath)) + root := filepath.Join(filepath.Clean(h.cfg.BooksDir), safe) + id, e := h.st.CreateLibrary(c, req.Name, root) if e != nil { if isUnique(e) { - err(c, http.StatusConflict, "exists", "root_path taken") + err(c, http.StatusConflict, "exists", "name taken") return } dbErr(c, e) return } - c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": filepath.Clean(req.RootPath)}) + c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": root}) } func (h *H) getLibrary(c *gin.Context) (store.Library, bool) { diff --git a/backend/cmd/webui/handlers/libraries_test.go b/backend/cmd/webui/handlers/libraries_test.go index 575e025..aaa228f 100644 --- a/backend/cmd/webui/handlers/libraries_test.go +++ b/backend/cmd/webui/handlers/libraries_test.go @@ -14,23 +14,35 @@ import ( 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}) + root := filepath.Join(booksDir, "comics") // 服务端自动拼接 BooksDir/<清洗后的库名> + w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics"}) 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) + if lib["root_path"] != root { + t.Fatalf("root_path want %q got %v", root, lib["root_path"]) + } 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"}) + // 恶意库名必须清洗,root 仍在 booksDir 内 + w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": "../../etc/passwd"}) + if w.Code != 201 || !strings.Contains(w.Body.String(), filepath.Join(booksDir, "passwd")) { + t.Fatalf("traversal name want sanitized 201 got %d %s", w.Code, w.Body) + } + // "." / ".." 清洗后非法 → 400 + w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": ".."}) if w.Code != 400 { - t.Fatalf("relative root want 400 got %d", w.Code) + t.Fatalf("'..' want 400 got %d", w.Code) + } + // 重名(同 root)→ 409 + w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics"}) + if w.Code != 409 { + t.Fatalf("dup want 409 got %d", w.Code) } // 上传:白名单 + 防穿越 + 原子落盘 body, mw := uploadBody("my 01.cbz", []byte("zipbytes")) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 276292b..1c49d8a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,10 +1,16 @@ -# Changelog +# Changelog / 更新日志 -All non-WebUI user-visible changes. Newest version on top. +All notable non-WebUI user-visible changes should be documented in this file, newest version on top. +本文件记录所有非 WebUI 的重要用户可见变更,最新版本在最上方。 + +The format loosely follows Keep a Changelog and can be adapted to the team's habits. +本文档参考了 Keep a Changelog 的思路,也可以根据团队习惯调整。 ## [Unreleased] -### Changed +### Changed / 变更 +- API: `POST /api/libraries` now takes only `{name}`; `root_path` is generated server-side as `BOOKS_DIR/` (no client-supplied paths, validated at creation). +- API:`POST /api/libraries` 只需 `{name}`;`root_path` 由服务端生成为 `BOOKS_DIR/<清洗后的库名>`(不再接受客户端指定路径,创建时即校验)。 - Repo structure conformed to `AGENTS.md`: `web/` renamed to `frontend/`; backend HTTP layer moved from `internal/api` to `cmd/webui/{api,handlers}` (`cmd/server` → `cmd/webui`); README/CHANGELOGs relocated under `docs/` (`README_zh.md` added as Chinese mirror); module-level `.gitignore`s added (`backend/`, `deploy/`); stray root `library/` removed (book files live in `deploy/api/storage/`); debug binaries untracked. - Docs: `docs/README.md` is now the English primary; previous Chinese README mirrored to `docs/README_zh.md`. diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 9395fee..726d590 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -1,7 +1,16 @@ -# Changelog (WebUI) +# Changelog (WebUI) / 更新日志 -All WebUI user-visible changes. Newest version on top. +All notable WebUI user-visible changes should be documented in this file, newest version on top. +本文件记录所有 WebUI 的重要用户可见变更,最新版本在最上方。 + +The format loosely follows Keep a Changelog and can be adapted to the team's habits. +本文档参考了 Keep a Changelog 的思路,也可以根据团队习惯调整。 ## [Unreleased] +### Changed / 变更 + +- Creating a library on the admin Libraries page no longer asks for a server-side absolute path; just a name (the directory lands at `BOOKS_DIR/` automatically). +- 库管理页建库不再要求填写服务端绝对路径,只需库名(目录自动落在 `BOOKS_DIR/<库名>` 下)。 + _No WebUI user-visible changes in this release; the structural refactor (`web/` → `frontend/`) does not affect the served app._ diff --git a/docs/superpowers/specs/2026-09-04-book-comic-library-design.md b/docs/superpowers/specs/2026-09-04-book-comic-library-design.md index 9cd11f5..fa912fe 100644 --- a/docs/superpowers/specs/2026-09-04-book-comic-library-design.md +++ b/docs/superpowers/specs/2026-09-04-book-comic-library-design.md @@ -77,7 +77,7 @@ POST /api/users ★ {username,password,role} DELETE /api/users/{id} ★ GET /api/libraries -POST /api/libraries ★ {name,root_path} +POST /api/libraries ★ {name} → root_path 由服务端生成(BooksDir/清洗后的库名) POST /api/libraries/{id}/scan ★ → 202 异步 POST /api/libraries/{id}/upload multipart ★ → 202 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c568391..3c9cc0e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -124,8 +124,7 @@ export const api = { deleteUser: (id: number) => apiFetch(`/users/${id}`, { method: "DELETE" }), listLibraries: () => apiFetch("/libraries"), - createLibrary: (name: string, root_path: string) => - apiFetch("/libraries", { method: "POST", body: { name, root_path } }), + createLibrary: (name: string) => apiFetch("/libraries", { method: "POST", body: { name } }), scanLibrary: (id: number) => apiFetch<{ accepted: boolean }>(`/libraries/${id}/scan`, { method: "POST" }), uploadBook: (id: number, file: File) => { const fd = new FormData(); diff --git a/frontend/src/pages/admin/Libraries.tsx b/frontend/src/pages/admin/Libraries.tsx index 0b2cb8f..818ee5d 100644 --- a/frontend/src/pages/admin/Libraries.tsx +++ b/frontend/src/pages/admin/Libraries.tsx @@ -9,15 +9,13 @@ export default function AdminLibraries() { const qc = useQueryClient(); const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries }); const [name, setName] = useState(""); - const [root, setRoot] = useState(""); const fileRefs = useRef>({}); const create = useMutation({ - mutationFn: () => api.createLibrary(name.trim(), root.trim()), + mutationFn: () => api.createLibrary(name.trim()), onSuccess: (lib) => { toast("ok", `库「${lib.name}」已创建`); setName(""); - setRoot(""); void qc.invalidateQueries({ queryKey: ["libraries"] }); }, onError: (e) => toast("err", e instanceof Error ? e.message : "建库失败"), @@ -54,14 +52,13 @@ export default function AdminLibraries() {

库管理

- setName(e.target.value)} /> setRoot(e.target.value)} + placeholder="库名(如 comics,服务端目录自动落在 BOOKS_DIR/<库名> 下)" + value={name} + onChange={(e) => setName(e.target.value)} /> -