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.
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.
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::Deleteadd_chapter, create_child -> Role::AppendRole::WriteOn 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 behaviorparse_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.
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:
type | Handler | Actions |
|---|---|---|
mdbook | handle_mdbook | rename, add_chapter, delete_chapter, move_chapter_up, move_chapter_down, update_chapter_body, delete_self |
singlemd | handle_singlemd | rename, update_body, update_icon, update_desc, delete_self |
list | handle_list | rename, create_child, remove_item, reorder, etc. |
board | handle_board | rename, create_child, remove_card, section ops, etc. |
board_list | handle_board_list | section + item CRUD, reorder |
link | handle_link | rename, update_url, update_icon, delete_self |
file | handle_file | rename, 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_fileThe file-type editor exposes six cards plus a danger zone:
rename action, per-language.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.”update_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).update_icon.update_desc. Markdown shown above the Download button on the resource page (inline mode only).toggle_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.
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:
/ and \ and keeps only the final segment (browsers on Windows sometimes send full paths);., .., 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 regressionFileContent::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_bytesso theprogramfiles/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).
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.
The editor templates follow a fixed Bootstrap-button palette so the visual semantics stay consistent across every type:
btn-pinkbtn-outline-secondarybtn-outline-dangerbtn-outline-pinkNo 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.