Route: edit

The editor — verb-mode ?view=edit. Called by Route: view (dispatch) when the query string says view=edit. edit::handle is not registered as an endpoint of its own; it lives in src/routes/edit.rs and is invoked from dispatch.

Verb mode

The route is reached via ?view=edit on any resource URL. Both bare URLs (/policies/?view=edit) and language-prefixed URLs (/zh/policies/?view=edit) hit the same handler because they share dispatch. On GET, the handler renders an HTML form via render_editor; on POST, it parses the submitted form, applies the change, and 302-redirects.

Permission gate

GET requires Role::Write via guard::require(req, &resource_path, Role::Write) — only writers can see the editor at all. POST re-runs guard::require against required_role(&action), which escalates per action:

  • delete_chapter, delete_self, remove_item, remove_card, delete_section -> Role::Delete
  • add_chapter, create_child -> Role::Append
  • everything else (rename, body edits, reorder, icon, desc, upload) -> Role::Write

On denial, guard::require returns sfx::op::forbidden_response(req, None) — a 403 with the standard forbidden page. The caller short-circuits with return resp and never touches disk.

parse_form behavior

parse_form reads a fixed key list (name, slug, type, file_slug, body, from, to, index, chapter, desc, url, icon, section_index, item_index) from the urlencoded form first. If action came back empty from urlencoded, it then re-asks req.files_or_default().await for the same keys as multipart text fields.

The fallback exists because multipart-upload forms still carry text fields. The file editor’s Upload file form has enctype="multipart/form-data" so it can carry bytes; its action=upload_file, the language slot, and every other text input ride along as multipart parts. Without the fallback, multipart submissions would arrive with no action, no file_slug, no name — every text knob the file editor needs. So the rule is: try urlencoded; on action == "", top up from multipart text.

Per-type dispatch

After permission and form parsing, the handler matches on ctx.get("type") and forwards to a per-type function. Each function owns its own action vocabulary:

typeHandlerActions
mdbookhandle_mdbookrename, add_chapter, delete_chapter, move_chapter_up, move_chapter_down, update_chapter_body, delete_self
singlemdhandle_singlemdrename, update_body, update_icon, update_desc, delete_self
listhandle_listrename, create_child, remove_item, reorder, etc.
boardhandle_boardrename, create_child, remove_card, section ops, etc.
board_listhandle_board_listsection + item CRUD, reorder
linkhandle_linkrename, update_url, update_icon, delete_self
filehandle_filerename, update_filename, update_icon, update_desc, toggle_download_only, upload_file, delete_self

Per-type field schemas live in the type chapters: Type: board, Type: list, Type: board_group, Type: board_list, Type: singlemd, Type: mdbook, Type: file, Type: link.

Anything not in the table returns 400 Editing not supported for type "...".

render_file and handle_file

The file-type editor exposes six cards plus a danger zone:

  1. Display namerename action, per-language.
  2. Upload file — multipart form; upload_file action. The card explicitly tells the admin “once a file is uploaded, the linked filename for this language will also be updated to the uploaded file’s original name.”
  3. Rename file slotupdate_filename action. Override the stored on-disk filename without uploading. Submitting an empty value clears the language’s slot entirely (via FileContent::set_file_slug’s empty-slug branch, which rebuilds the LangDict without that key).
  4. Iconupdate_icon.
  5. Descriptionupdate_desc. Markdown shown above the Download button on the resource page (inline mode only).
  6. Delivery modetoggle_download_only. Flips between inline rendering (desc + Download button) and direct streaming.

The card layout intentionally puts upload before manual rename: most admins upload once, accept the original filename, and never touch the rename card. The rename card exists for the “OS gave me an ugly name” override case.

Multipart upload pipeline

let files = req.files_or_default().await;
let Some(file) = files.get_first_file("upload") else {
    return bad("no file uploaded");
};
let bytes = file.data();
if bytes.is_empty() { return bad("uploaded file is empty"); }
let raw_name = file.filename().unwrap_or_default();
let slug = sanitize_upload_filename(&raw_name);
if slug.is_empty() { return bad("uploaded file has no valid filename"); }
item.set_file_slug(lang, &slug);
if let Err(e) = item.write_bytes(lang, bytes) { /* bad */ }
item.save()

sanitize_upload_filename is the gatekeeper that converts an arbitrary browser-supplied name into something safe to store. It:

  • splits on / and \ and keeps only the final segment (browsers on Windows sometimes send full paths);
  • trims whitespace;
  • strips control characters and NULs;
  • rejects the result if it is empty, ., .., or starts with .. (so ..foo is out too — no dotfile-ish leakage).

The returned name is what gets stored in ctx.json’s file field and used as the on-disk filename, so it must never escape the resource folder.

FileContent::write_bytes regression

FileContent::write_bytes must route through fop::write_file_bytes, not std::fs::write. The doc comment in src/content/file.rs calls this out explicitly:

Goes through fop::write_file_bytes so the programfiles/content/ root prefix is applied (otherwise the write resolves relative to the process CWD and fails with ENOENT).

The fop helper is:

pub fn write_file_bytes<T: Into<String>>(path: T, bytes: &[u8]) -> std::io::Result<()> {
    let full_path = combine_path("programfiles/content/", path);
    std::fs::write(full_path, bytes)
}

Resource paths are URL-shaped (policies/members/sf_members.pdf) and have no implicit content-root. A direct std::fs::write(self.path.join(slug), bytes) once regressed with ENOENT against exactly such URL-shaped relative paths — the bytes resolved against the process CWD instead of programfiles/content/. If you add a new write call site, mirror the existing pattern: fop::write_file_bytes(fop::combine_path(&self.path, &slug), bytes).

Redirect on success

POST handlers end with ok_redirect(req, target), which prepends crate::current_prefix(req) so the active /<lang> segment survives the round-trip. Targets are bare resource URLs without ?view=edit — the user lands on the rendered page after each save, which gives immediate visual confirmation. The chapter-body editor is an exception (it redirects back into ?view=edit&chapter=<slug> so the admin can keep editing the same chapter).

For delete_self, the redirect target is parent_of(resource_path) — the resource just deleted no longer exists, so the user is sent up one level.

Editor palette convention

The editor templates follow a fixed Bootstrap-button palette so the visual semantics stay consistent across every type:

  • Primary action (Save, Upload, Set role): btn-pink
  • Secondary / neutral (move up, move down, Cancel): btn-outline-secondary
  • Destructive (Delete chapter, Delete resource, Remove): btn-outline-danger
  • Non-primary affirmative (Edit body, Toggle delivery mode): btn-outline-pink

No btn-success, no btn-warning. Green/yellow are reserved for status indicators in the surrounding chrome; the editor itself uses pink for “do the thing” and red only when destructive.