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

This commit is contained in:
2026-09-07 21:56:59 +08:00
parent ca64bc0d73
commit 45eefd6741
9 changed files with 80 additions and 146 deletions
+23 -114
View File
@@ -1,125 +1,34 @@
# AGENTS.md # 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) ## Docker deployment (dev & release)
- 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.
### Deployment mode - 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.
Mode differences (dev = `deploy/docker-compose.dev.yml`, release = `deploy/docker-compose.yml`): - 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/`.
- 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. - Tear down with `down`, not `rm`, or the web image's anonymous `node_modules` volume gets orphaned.
- 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 ## 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 ### 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.
├── docs # project documentation (see Documents layout) - Build output (`dist/`) is disposable and git-ignored; only `src`/`public` are committed.
├── backend # golang services (see backend layout) - Dev runs from the source mount with `node_modules` provided by the image — install new deps inside the container and commit the lockfile.
├── 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 ### deploy
- Only config templates/examples are tracked by git; rendered `*.conf` and `logs/` are git-ignored.
```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. - Service-specific host dirs (storage, config) go under `deploy/{$service_name}/`, never loose at the repo root.
+1 -1
View File
@@ -20,7 +20,7 @@ func newLibrary(t *testing.T, st *store.Store, h http.Handler, tok, booksDir, na
t.Helper() t.Helper()
root := filepath.Join(booksDir, name) root := filepath.Join(booksDir, name)
os.MkdirAll(filepath.Join(root, "series-a"), 0o755) 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 { if w.Code != 201 {
t.Fatalf("create lib %d %s", w.Code, w.Body) t.Fatalf("create lib %d %s", w.Code, w.Body)
} }
+10 -8
View File
@@ -47,29 +47,31 @@ func (h *H) ListLibraries(c *gin.Context) {
c.JSON(http.StatusOK, out) c.JSON(http.StatusOK, out)
} }
// CreateLibrary: root_path 由服务端生成(BooksDir/SafeName(name)),不接受客户端指定
func (h *H) CreateLibrary(c *gin.Context) { func (h *H) CreateLibrary(c *gin.Context) {
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
RootPath string `json:"root_path"`
} }
if c.ShouldBindJSON(&req) != nil || req.Name == "" || req.RootPath == "" { if c.ShouldBindJSON(&req) != nil {
err(c, http.StatusBadRequest, "bad_request", "name and root_path required") err(c, http.StatusBadRequest, "bad_request", "name required")
return return
} }
if !filepath.IsAbs(req.RootPath) { safe := bookfile.SafeName(req.Name)
err(c, http.StatusBadRequest, "bad_request", "root_path must be absolute") if safe == "" || safe == ".." {
err(c, http.StatusBadRequest, "bad_request", "bad name")
return 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 e != nil {
if isUnique(e) { if isUnique(e) {
err(c, http.StatusConflict, "exists", "root_path taken") err(c, http.StatusConflict, "exists", "name taken")
return return
} }
dbErr(c, e) dbErr(c, e)
return return
} }
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": filepath.Clean(req.RootPath)}) c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": root})
} }
func (h *H) getLibrary(c *gin.Context) (store.Library, bool) { func (h *H) getLibrary(c *gin.Context) (store.Library, bool) {
+18 -6
View File
@@ -14,23 +14,35 @@ import (
func TestLibraryCreateListUpload(t *testing.T) { func TestLibraryCreateListUpload(t *testing.T) {
_, _, h, booksDir := setupAPI(t) _, _, h, booksDir := setupAPI(t)
tok := adminToken(t, h) tok := adminToken(t, h)
root := filepath.Join(booksDir, "lib1") // 必须落在解析过软链的 booksDir 内 root := filepath.Join(booksDir, "comics") // 服务端自动拼接 BooksDir/<清洗后的库名>
os.MkdirAll(root, 0o755) w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics"})
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics", "root_path": root})
if w.Code != 201 { if w.Code != 201 {
t.Fatalf("create lib %d %s", w.Code, w.Body) t.Fatalf("create lib %d %s", w.Code, w.Body)
} }
var lib map[string]any var lib map[string]any
json.Unmarshal(w.Body.Bytes(), &lib) 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"]) libID := itoa(lib["id"])
w = do(h, "GET", "/api/libraries", tok, nil) w = do(h, "GET", "/api/libraries", tok, nil)
if !strings.Contains(w.Body.String(), `"comics"`) { if !strings.Contains(w.Body.String(), `"comics"`) {
t.Fatalf("list: %s", w.Body) t.Fatalf("list: %s", w.Body)
} }
// 相对路径 root 必须 400(前缀校验的根) // 恶意库名必须清洗,root 仍在 booksDir 内
w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": "x", "root_path": "relative/path"}) 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 { 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")) body, mw := uploadBody("my 01.cbz", []byte("zipbytes"))
+9 -3
View File
@@ -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] ## [Unreleased]
### Changed ### Changed / 变更
- API: `POST /api/libraries` now takes only `{name}`; `root_path` is generated server-side as `BOOKS_DIR/<sanitized name>` (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. - 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`. - Docs: `docs/README.md` is now the English primary; previous Chinese README mirrored to `docs/README_zh.md`.
+11 -2
View File
@@ -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] ## [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/<name>` automatically).
- 库管理页建库不再要求填写服务端绝对路径,只需库名(目录自动落在 `BOOKS_DIR/<库名>` 下)。
_No WebUI user-visible changes in this release; the structural refactor (`web/` → `frontend/`) does not affect the served app._ _No WebUI user-visible changes in this release; the structural refactor (`web/` → `frontend/`) does not affect the served app._
@@ -77,7 +77,7 @@ POST /api/users ★ {username,password,role}
DELETE /api/users/{id} ★ DELETE /api/users/{id} ★
GET /api/libraries 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}/scan ★ → 202 异步
POST /api/libraries/{id}/upload multipart ★ → 202 POST /api/libraries/{id}/upload multipart ★ → 202
+1 -2
View File
@@ -124,8 +124,7 @@ export const api = {
deleteUser: (id: number) => apiFetch<void>(`/users/${id}`, { method: "DELETE" }), deleteUser: (id: number) => apiFetch<void>(`/users/${id}`, { method: "DELETE" }),
listLibraries: () => apiFetch<Library[]>("/libraries"), listLibraries: () => apiFetch<Library[]>("/libraries"),
createLibrary: (name: string, root_path: string) => createLibrary: (name: string) => apiFetch<Library>("/libraries", { method: "POST", body: { name } }),
apiFetch<Library>("/libraries", { method: "POST", body: { name, root_path } }),
scanLibrary: (id: number) => apiFetch<{ accepted: boolean }>(`/libraries/${id}/scan`, { method: "POST" }), scanLibrary: (id: number) => apiFetch<{ accepted: boolean }>(`/libraries/${id}/scan`, { method: "POST" }),
uploadBook: (id: number, file: File) => { uploadBook: (id: number, file: File) => {
const fd = new FormData(); const fd = new FormData();
+5 -8
View File
@@ -9,15 +9,13 @@ export default function AdminLibraries() {
const qc = useQueryClient(); const qc = useQueryClient();
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries }); const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
const [name, setName] = useState(""); const [name, setName] = useState("");
const [root, setRoot] = useState("");
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({}); const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
const create = useMutation({ const create = useMutation({
mutationFn: () => api.createLibrary(name.trim(), root.trim()), mutationFn: () => api.createLibrary(name.trim()),
onSuccess: (lib) => { onSuccess: (lib) => {
toast("ok", `库「${lib.name}」已创建`); toast("ok", `库「${lib.name}」已创建`);
setName(""); setName("");
setRoot("");
void qc.invalidateQueries({ queryKey: ["libraries"] }); void qc.invalidateQueries({ queryKey: ["libraries"] });
}, },
onError: (e) => toast("err", e instanceof Error ? e.message : "建库失败"), onError: (e) => toast("err", e instanceof Error ? e.message : "建库失败"),
@@ -54,14 +52,13 @@ export default function AdminLibraries() {
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<h1 className="mb-4 text-lg font-semibold">库管理</h1> <h1 className="mb-4 text-lg font-semibold">库管理</h1>
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}> <form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}>
<input className={input} placeholder="库名(如 comics)" value={name} onChange={(e) => setName(e.target.value)} />
<input <input
className={input + " flex-1"} className={input + " flex-1"}
placeholder="root_path(服务端绝对路径,须位于 BOOKS_DIR 下,如 /data/books/comics)" placeholder="库名(如 comics,服务端目录自动落在 BOOKS_DIR/<库名> 下)"
value={root} value={name}
onChange={(e) => setRoot(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
<button className={btnPrimary} disabled={create.isPending || !name.trim() || !root.startsWith("/")}> <button className={btnPrimary} disabled={create.isPending || !name.trim()}>
{create.isPending ? "创建中…" : "建库"} {create.isPending ? "创建中…" : "建库"}
</button> </button>
</form> </form>