A file resource wraps an arbitrary uploaded blob — PDF, ZIP, image, text — with a markdown description and a Download button. Implementation: src/content/file.rs.
Surface a single downloadable file inside the doc tree. The view page shows the resource’s desc rendered as markdown plus a Download <filename> button; ?view=download streams the raw bytes. When download_only=true, the resource URL itself streams the bytes inline (no template wrapper).
Single-file form:
{
"type": "file",
"name": {
"en": "Members directory",
"zh": "成员名册"
},
"desc": {
"en": "PDF list of current members.",
"zh": "现任成员 PDF 名册。"
},
"file": "sf_members.pdf",
"download_only": false
}
Per-language file form:
{
"type": "file",
"name": { "en": "Onboarding kit" },
"file": {
"en": "onboarding_en.zip",
"ja": "onboarding_ja.zip",
"zh": "onboarding_zh.zip"
}
}
| Field | Type | Required | Notes |
|---|---|---|---|
type | string — must be "file" | yes | Discriminator. |
name | LangDict | yes | See Common: Header and LangDict. |
icon | string | optional | URL or path. |
desc | OptionLangDict (markdown) | optional | Rendered above the Download button via fop::compile_markdown. |
banner | OptionLangDict | optional | Banner image URL. |
file | string OR LangDict | yes | Full filename including extension. Per-language form supports different files per locale. |
download_only | bool | optional, default false | When true, GET on the resource URL streams the bytes inline (no page chrome). When false, the URL shows the desc + Download button. |
file differs from the singlemd field of the same name: here the extension is included in the value (sf_members.pdf, not sf_members).
programfiles/content/<resource_folder>/
ctx.json
<file> ← e.g. sf_members.pdf, or onboarding_en.zip etc.
The blob lives at programfiles/content/<resource_folder>/<file>. Path-prefixing is done by fop::write_file_bytes and fop::read_file_bytes — they prepend programfiles/content/ to any path passed in. Never bypass these helpers with a direct std::fs::write on a resource-shaped path: the path is URL-shaped (no programfiles/content/ prefix), so a direct write resolves against the process CWD and fails with ENOENT. This is a real regression — write_file_bytes exists exactly to prevent it.
content.rs::render checks download_only first:
download_only == true — call binary_response(bytes, fname, mime, attachment=false). The page chrome is bypassed entirely. The URL behaves like a direct file link.download_only == false — call FileContent::render, which produces:<div class="container-func">
<div class="markdown-content">{compiled-desc-markdown}</div>
<a class="btn btn-pink mt-3" href="?view=download">Download {file_name}</a>
</div>
Then wrap in banner + breadcrumb + SEO meta via general.html.
?view=edit (see Route: edit) allows:
rename).set_icon — empty string clears).set_desc — empty clears that language; clearing the last language drops the whole desc key from disk).set_file_slug — empty removes that language entry; setting introduces a new one).download_only.file[lang] at a sibling filename without uploading.sanitize_upload_filename, which strips path components (anything before / or \) and rejects ., .., and any ..* traversal patterns.file slug for the active language to the sanitized filename.fop::write_file_bytes.The active language is set via the navbar dropdown before upload — uploading in JA mode updates file.ja, not file.en.
?view=download streams the bytes with the original filename and a MIME guessed by content::guess_mime (PNG, JPG, GIF, SVG, PDF, ZIP, TXT, MD, JSON → known types; everything else → application/octet-stream). Content-Disposition is attachment for explicit downloads and inline when download_only=true. See Route: download.
FileContent::save() rebuilds ctx.json from scratch (it does not preserve unknown keys, unlike singlemd/mdbook). The serialised JSON contains:
type: "file"name always; icon/desc/banner only when set)file — current LangDict valuedownload_only: true — only when true; the field is omitted when false, so a freshly defaulted resource looks clean.Rebuilding from scratch is deliberate: it ensures cleared optional fields actually disappear from disk instead of lingering from the previous read.
file slug in JSON without renaming or replacing the blob on disk. The Download button will 404 — read_file_bytes returns an empty Vec and the response streams zero bytes.file. Unlike singlemd, file resources need the full filename. Without .pdf, MIME detection drops to application/octet-stream and browsers download a name-less file.fop::write_file_bytes. Don’t. The function exists to prefix programfiles/content/; without it you get ENOENT.sanitize_upload_filename catches ., .., and ..*. Don’t disable it.download_only on a non-renderable file. The dispatcher serves bytes with inline disposition. Browsers will try to render — they’ll succeed for PDFs and images, and offer download for unknown types.