## Editing the Docs

This guide is for site admins of an FDS Documents instance — people who log in to edit content, manage permissions, change server config, and run backups. The body is intentionally English-only: it references Rust source paths and JSON keys that do not translate cleanly, and a single source of truth keeps drift out of the operational instructions. The page's title and description still localize to JA and ZH so the navigation card looks right in every language.

### Access and verb modes

Every editing action is reached by appending `?view=<mode>` to a page URL, dispatched in `src/routes/view.rs`:

- `?view=edit` — the content editor. Requires the `Write` role on the resource.
- `?view=download` — export the resource (see the downloads section).
- `?view=permissions` — per-resource access control. Requires the `Owner` role.
- No `?view=` parameter renders the page normally.

Log in first; role gates are checked against your session against the `perms.json` next to each resource. If you see a page but not its `Edit` link, you have `Read` but not `Write`.

### Editor by content type

The editor in `src/routes/edit.rs` varies by `type` in `ctx.json`:

- **board, list, board_group, board_list** — rename per language, set/clear `icon`, set/clear `desc`, add a child slug, remove a child, reorder children.
- **singlemd** — edit the markdown body for the active language. If `file` is a string, every language shares the same `.md`; if it is a `{en, ja, zh}` dict, each language gets its own.
- **mdbook** — same multi-language rename/icon/desc affordances, plus add, remove, reorder, and rename chapters. `content` is a list of chapter entries with `name` and `file`.
- **file** — link an existing file already under the node, or upload a new one. **Uploads preserve the original filename, auto-set the slug to that filename, and update the active language's `file` field to point at the new slug.** Switch language before uploading the JA or ZH variant.

### Multi-language fields

`name` and `desc` accept either:

- a plain string (every language renders it), or
- a `{en, ja, zh}` dict (per-language values).

Prefer the dict for anything user-visible — navigation labels, descriptions, chapter names. A plain string is fine for purely structural nodes that have no good translation, but it leaves JA/ZH visitors reading English in their sidebar. The renderer escapes `<`, `&`, `"`, and `'` at the boundary, so names containing those characters are safe.

### Markdown rules

Content markdown is CommonMark. Two rules to remember:

- Use `##` and `###` (and `####` if needed) — never `#`. The breadcrumb supplies the page's `<h1>`; a second `<h1>` is an SEO regression.
- Tables, fenced code, and standard inline markup work. Names and descs are escaped at the renderer boundary, so HTML-sensitive characters in headings or list items rendered through the templating layer are safe.

### Adding a node

To add a new resource:

1. Create a subdirectory under the intended parent in `programfiles/content/`.
2. Drop a `ctx.json` into it with the right `type`, multi-language `name`, optional `desc`, and any type-specific fields.
3. Open the **parent's** editor (`?view=edit`) and add the new directory's slug to the parent's `content` array. The node will not appear in navigation until the parent lists it.

For `singlemd`, also create the `.md` file(s) referenced by `file`. For `mdbook`, create each chapter file as you add it to the `content` list.

### File assets and uploads

Uploads land at `programfiles/content/<resource_path>/<slug>`. The file writer (`fop::write_file_bytes`) prepends `programfiles/content/` internally, so paths in `ctx.json` are relative to that root. Filenames are sanitized: basename only (no directory traversal), `.` and `..` are rejected outright, and a leading `.` is rejected (no hidden files). If a name fails sanitization the upload is refused; rename and retry.

### Downloads (`?view=download`)

Implemented in `src/routes/download.rs`, gated by the same `Read` role as viewing:

| Type | Output |
|---|---|
| `singlemd` | `.md` attachment in the active language |
| `mdbook` | `.zip` named `<folder>.zip`, containing `01-<slug>.md`, `02-<slug>.md`, … (zero-padded for sort stability) |
| `file` | Raw bytes served under the original filename |
| Anything else | HTTP 400 |

### Permissions (`?view=permissions`)

Owner-only editor for the `perms.json` stored next to each resource (`src/routes/permissions.rs`). Four scopes:

- **Public** — what unauthenticated visitors can do.
- **Authenticated** — minimum role for any logged-in user.
- **Per-user** — `uid@server` (e.g. `1@local`) mapped to a role.
- **Per-group** — stored for forward compatibility but **not yet enforced at runtime**; the UI warns. Do not rely on it for security today.

Roles, low to high: `None` < `Read` < `Append` < `Write` < `Delete` < `Owner`. To restrict a resource to specific users: set Public and Authenticated to `None`, then grant individual users `Read` or `Write` per-user.

### Server configuration (`config.json`)

Server-wide settings live at `programfiles/op/config.json` and are edited directly on disk — there is **no** `?view=edit` for them. Current keys:

| Key | Effect | Empty / 0 / missing |
|---|---|---|
| `public_origin` | Base URL used in `canonical`, `og:url`, hreflang alternates, and sitemap entries | Those SEO tags are suppressed (not emitted with empty values) |
| `default_icon` | Fallback icon path for nodes without an `icon` field | No icon rendered (row left iconless) |
| `default_og_image` | Site-wide `og:image` fallback | `og:image` is omitted entirely |
| `backup_interval_secs` | Seconds between snapshots | Backup writer disabled — no task spawned |
| `backup_retain` | Max number of `backup-*.zip` archives kept; older ones pruned oldest-first | Backup writer disabled |
| `backup_dir` | Where archives land. Relative paths resolve from the working directory, so this can point at an external mount | Defaults to `programfiles/backup` |

Invalid JSON in `config.json` silently falls back to defaults — **validate before saving** (a JSON linter is enough). Backup keys are re-read each cycle, so they take effect at the next interval without a restart. `public_origin`, `default_icon`, and `default_og_image` are read at request time, but some template caches make a restart the safer choice when changing them.

### Deploying to a domain

Going from `cargo run` on localhost to `https://your-domain/` touches four files under `programfiles/op/` plus one piece of infrastructure (the reverse proxy). None of it requires a code change.

#### `config.json` → `public_origin`

Set this to the full canonical URL of the deploy, **with scheme**, no trailing slash. Examples: `"https://doc.fds.moe"`, `"https://docs.example.org"`. This value is interpolated into:

- `<link rel="canonical">` and the page's `<link rel="alternate" hreflang>` entries
- `<meta property="og:url">`, `<meta property="og:image">` (when relative)
- sitemap `<loc>` entries
- the breadcrumb JSON-LD `item` URLs

If you leave `public_origin` empty or set the wrong scheme, those tags don't break — they simply get suppressed, and search engines lose the canonical signal. Always set it before announcing the URL externally.

#### `hosts.json` → the trusted-origin list

A JSON array of hostnames the server treats as itself. Used by SFX's CSRF / `is_trusted(host)` checks. For a single-host deploy:

```jsonc
[
    "doc.fds.moe"
]
```

The first entry is the *default host* (`op::get_default_host()`), so put your canonical host first. The string `"local"` is always implicitly trusted in addition to whatever you list — keeps `cargo run` working without re-edits.

If you front the same backend with multiple domains (e.g. `doc.fds.moe` and `docs.fds.moe`), list them all here; otherwise the second one will be refused on POST.

#### `binding.txt` → where the process listens

A one-line file with `host:port`. **Do not put a trailing newline** — SFX's parser does not strip it, and the misparse falls back to `localhost:3003`. Use `printf` not `echo` if you regenerate the file from the shell:

```bash
printf '127.0.0.1:3031' > programfiles/op/binding.txt
```

Two deployment shapes:

- **Behind a reverse proxy (recommended).** Keep `127.0.0.1:3031` (or any free localhost port). The proxy — nginx, Caddy, Cloudflare Tunnel, Traefik — terminates TLS, forwards `X-Forwarded-*` headers, and proxies to localhost. The app itself only speaks plain HTTP, which is fine because it's bound to loopback.
- **Direct exposure (rare).** Bind to `0.0.0.0:<port>`. You'll still need TLS upstream — the framework doesn't ship an HTTPS listener — so this only makes sense if something else (a load balancer, a managed cert offload) terminates TLS in front. Avoid for production.

#### TLS / HTTPS

The app does not terminate TLS. Configure your reverse proxy to:

- listen on 443 with a valid cert (Caddy / Traefik handle Let's Encrypt automatically; nginx needs `certbot --nginx`)
- forward `Host`, `X-Forwarded-Proto`, `X-Forwarded-For` headers
- `proxy_pass` to whatever `binding.txt` says (default `http://127.0.0.1:3031`)
- redirect plain `http://` → `https://`

Once that's in place, set `public_origin` to the `https://` form. Cookies in turn auto-scope to the deploy host so they don't leak between subdomains.

#### Backup writer in production

`backup_interval_secs` and `backup_retain` are the only knobs needed; the defaults `86400` (daily) and `7` (one week) are reasonable. Consider:

- pointing `backup_dir` at a mounted volume outside the deploy directory so a `git clean` or container rebuild doesn't wipe history
- syncing the backup dir off-host (rsync to S3, etc.) on a separate cron — the app's own writer is in-process and best treated as a *warm spare*, not disaster recovery

#### Pre-deploy checklist

Run through this before announcing the URL:

- [ ] `config.json` `public_origin` set to the `https://` canonical URL
- [ ] `hosts.json` lists the deploy domain as the first (canonical) entry
- [ ] `binding.txt` matches what the reverse proxy is forwarding to, with no trailing newline
- [ ] Reverse proxy terminates TLS and forwards to the binding port
- [ ] `curl -s https://your-domain/sitemap.xml | head` returns a sitemap with `<loc>https://your-domain/...` (not `http://`, not localhost)
- [ ] `curl -sI https://your-domain/policies/` returns `200` with `Content-Type: text/html`
- [ ] Light-test the lang switcher: `/op/switch_lang/zh-Hans` 302s to `/zh-Hans/...` with `Set-Cookie: lang=zh-Hans; Path=/`
- [ ] `backup_dir` is writable by the server process and writes survive a restart
- [ ] Admin login (`/auth/login`) works from the deploy host
- [ ] `/robots.txt` returns the expected directives (currently SFX's default — see Bug 3 in `SFX_TICKET.md` for the override story)

#### What does *not* change for a new domain

- Resource `ctx.json` files — all internal links are relative and lang-tag-keyed.
- Lang prefix endpoints — generated from `support_lang.json` at build time, host-independent.
- `navbar.json` / `footer.json` — display strings, not host-specific.
- Database / auth state — domain change doesn't invalidate logins; cookies just rescope to the new host on next login.

### Backups

The background task in `src/backup.rs` (spawned from `main.rs` alongside the sitemap writer) archives `programfiles/content/` and `programfiles/local_auth/` into `backup-<unix_timestamp>.zip`. `programfiles/op/` is **not** archived — config is checked in and lossless to recreate.

Writes are atomic: the task writes to a `.tmp` file and renames, so a half-written zip is never visible to other processes. An initial snapshot is taken at startup, so a fresh restart always has a recent backup. Pruning keeps the newest `backup_retain` archives by lexical order; the unix-timestamp filenames make that the same as chronological order.

- **To disable**: set `backup_interval_secs` or `backup_retain` to 0 (or remove the key).
- **To restore**: stop the server, run `unzip backup-<timestamp>.zip` at the repo root (archive paths recreate the `programfiles/content` and `programfiles/local_auth` layouts), then restart.
- **Off-host backups**: point `backup_dir` at a mounted external location — relative paths resolve against the working directory.

### Brand and JA conventions

Keep the brand spelled "FDS Documents" in English, "FDS 文書" in JA, and "FDS 文档" in ZH — consistently. For JA section names, prefer kanji-mixed forms over full-katakana transliterations (e.g. 案内 over ナビゲーション, 言語 over ランゲージ). Loanwords that are customary in Japanese — ダウンロード, Cookie — are fine.

### Known limits today

- No search.
- No preview before save.
- No revision history; the editor writes in place.
- Group permissions are stored in `perms.json` but **not enforced** at runtime.
