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:

TypeOutput
singlemd.md attachment in the active language
mdbook.zip named <folder>.zip, containing 01-<slug>.md, 02-<slug>.md, … (zero-padded for sort stability)
fileRaw bytes served under the original filename
Anything elseHTTP 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-useruid@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:

KeyEffectEmpty / 0 / missing
public_originBase URL used in canonical, og:url, hreflang alternates, and sitemap entriesThose SEO tags are suppressed (not emitted with empty values)
default_iconFallback icon path for nodes without an icon fieldNo icon rendered (row left iconless)
default_og_imageSite-wide og:image fallbackog:image is omitted entirely
backup_interval_secsSeconds between snapshotsBackup writer disabled — no task spawned
backup_retainMax number of backup-*.zip archives kept; older ones pruned oldest-firstBackup writer disabled
backup_dirWhere archives land. Relative paths resolve from the working directory, so this can point at an external mountDefaults 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.jsonpublic_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:

[
    "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:

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.