OpenExhibit is a single-binary, server-rendered portfolio CMS. It binds one Actix-Web HTTP server, talks to one PostgreSQL database, and renders every page on the server with Askama templates. There is no JavaScript in the served UI — features that elsewhere depend on JS (autoplaying slideshows, lazy thumbnails, lightboxes) use SCSS-only behavior or on-demand server-side generation.
Generated from source. Re-run
scripts/gen-docs.shafter substantive changes tosrc/main.rs,src/routes/,src/formats/mod.rs, orsrc/models/.
src/main.rs is the entry point. On startup it:
- Loads
.envviadotenvyand initializestracing_subscriber. - Builds a
config::Configfrom the environment (config::Config::from_env). - Opens a PostgreSQL pool and runs
sqlx::migrate!("./migrations")(src/db.rs). - Calls
auth::ensure_first_admin(&pool)— provisions anadminuser on a fresh database and prints a randomly generated password to stdout once. - Launches
HttpServerwith the middleware stack and route configurators described below.
Constructed in src/main.rs::main:
| Layer | Purpose |
|---|---|
TracingLogger::default() |
Per-request structured logs. |
DefaultHeaders |
Sets X-Content-Type-Options, X-Frame-Options, Referrer-Policy, a strict Content-Security-Policy, and Permissions-Policy: interest-cohort=(). |
SessionMiddleware |
actix-session with CookieSessionStore. Cookie name ndxz_session, HTTP-only, SameSite=Lax, Secure driven by COOKIE_SECURE, 7-day persistent lifetime. |
app_data |
Shares Config, PgPool, and RateLimiter with handlers. |
| Route configurators | The lazy derivative endpoint (/files/dimgs/...) is registered ahead of the static Files handler so it wins the match. Public /{path:.*} is registered last so admin and static routes resolve first. |
Admin routes get an inner DefaultHeaders layer
(src/routes/admin/mod.rs) that sets Cache-Control: private, no-store
and Pragma: no-cache so CSRF-token pages don't land in shared caches.
routes::public::catch_allmatches via#[get("/{path:.*}")].- Loads
Settings, then looks up the exhibit by URL (Exhibit::find_by_url). The lookup tries/{path}/then/{path}. stats::log_hitspawns a fire-and-forget INSERT intostats.- The format's
intercepthook runs.external_linkreturns a 302; other formats fall through. gated_renderchecksexhibit.password. If non-empty and the session doesn't carryunlocked_exhibit_{id} = true, the password gate template is returned.render_exhibitloads media, builds the nav viabuild_nav, assemblesBaseFields, and callsformats::render(&exhibit, &media, base, settings.enable_greentext).formats::render:- Maps each
Mediarow to aMediaViewwith file/thumb URLs. - Calls
wire_lightbox_linksso lightbox prev/next is computed server-side. - Pushes
exhibit.contentthrough the markup pipeline. - Dispatches to the format's
render, which renders an Askama template undertemplates/public/formats/<key>.html.
- Maps each
routes::public::unlock_post:
- CSRF token verified via
csrf::verify. - Rate-limited per peer-IP (bucket
unlock, 8 attempts / 60 s). - Password verified against
exhibits.password(argon2 PHC viaauth::verify_password). - On success,
unlocked_exhibit_{id} = trueis stored in the session.
Every admin handler calls auth::require_admin(&session, &pool) first.
It reads user_id from the session and confirms the user is active
and an admin. Failure returns a 302 to /admin/login. Mutating
handlers then call csrf::verify before touching the database.
routes::public::tag_get runs one query joining exhibits, tagged,
and tags, fetches a thumbnail per result, and renders
templates/public/tag.html. Reuses BaseFields so the nav and theme
bar match the rest of the public site.
- Session storage — encrypted cookie via
actix-session'sCookieSessionStore. Key isConfig.session_key(envSESSION_KEY, validated byconfig::validate_session_key). - Cookie attributes — name
ndxz_session,HttpOnly,SameSite=Lax,SecureiffCOOKIE_SECURE=true. The app refuses to start when a non-loopback bind is paired withCOOKIE_SECURE=falseunlessALLOW_INSECURE_HTTP=trueis set. - CSRF — double-submit using the session as the secret store.
csrf::get_or_createlazily creates a 32-byte URL-safe token; templates render it as a hidden_csrfinput;csrf::verifyconstant-time-compares the submitted value against the session. - Password hashing — argon2id PHC strings via
auth::hash_password.auth::dummy_hash()returns a process-lifetime cached real PHC, used during login to keep verify-time uniform. - First admin —
auth::ensure_first_adminprovisions on first boot with a 24-char password from an unambiguous alphabet (no0/O/I/l/1). Printed to stderr in a single banner. - Password reset —
/admin/forgotwrites a 32-byte token and 1-hour expiry intousers.reset_token/reset_expires, then sends an email viamail::send. Lookups are constant-time across all unexpired candidates. - Rate limiting —
src/ratelimit.rs::RateLimiter, in-process, per-IP, sliding window. Buckets:login(10/60 s),forgot(5/300 s),reset(10/60 s),unlock(8/60 s). Peer IP comes fromratelimit::peer_ipwhich only honorsX-Forwarded-Forwhen the immediate peer is inConfig.trusted_proxies.
src/models/ mirrors the database with sqlx::FromRow structs.
Column provenance and types are documented in
database-schema.md.
| Model | Source | Notes |
|---|---|---|
Exhibit |
models/exhibit.rs |
One row per public page. Loaders: find_home, find_by_url, list_for_section, list_top_of_each_section. |
Media |
models/media.rs |
One row per uploaded file. MediaListRow is a thinner join used by the cross-exhibit admin browser. |
Section |
models/section.rs |
Top-level nav buckets. hide_title lets a section render as a bare child list. |
Settings |
models/settings.rs |
Singleton at id = 1. Carries SMTP, theme colors, custom CSS, greentext toggle. |
Tag |
models/tag.rs |
One row per tag. The tagged join table has no struct — handlers pivot it directly. |
User |
models/user.rs |
argon2 PHC in password_hash. Reset token + expiry are nullable. |
Pool initialization (db::init) opens up to 16 connections with a
5-second acquire timeout and runs all migrations at startup.
The exhibit-format abstraction lives in src/formats/mod.rs. Each
format is a unit struct implementing ExhibitFormat, with a stable
key(), a display_name() and description() shown in the admin
picker, an optional capabilities() that hides irrelevant admin
fields, and three behavior hooks:
| Hook | Default | When overridden |
|---|---|---|
intercept(&Exhibit) -> Option<HttpResponse> |
None |
external_link returns a 302 to its link column. |
nav_href(&Exhibit) -> NavHref |
Internal URL, same tab | external_link returns the external URL with open_in_new_tab = exhibit.link_target. |
render(...) |
Required | Renders a per-format Askama template under templates/public/formats/<key>.html. |
The registry is a &'static [&'static dyn ExhibitFormat] slice named
FORMATS. formats::find(key) looks up by stored key and falls back
to visual_index for unknown values, so a hand-edited or stale row
still renders. formats::registry() returns the whole slice (the admin
picker uses this so adding a format requires no UI changes).
The shipped formats and the contributor walkthrough live in
exhibit-formats.md; registry invariants are
checked by src/formats/mod.rs::tests.
src/markup.rs::render_with is the only path admin-authored text
takes on its way to a public page. It runs on exhibits.content,
media.caption, settings.obj_itop, and settings.obj_ibot. The
pipeline:
- Greentext pre-pass (optional, gated by
settings.enable_greentext). Lines starting with>become<p class="greentext">…</p>. Off →>becomes a Markdown blockquote. - BBCode pre-pass (
bbcode_to_html). Converts[b],[i],[u],[s],[url=…],[url],[img],[quote],[code],[hr], and[list]…[*]…[/list](ordered when[list=1|a|A|i|I]) to inline HTML. - CommonMark via
pulldown-cmarkwith tables, strikethrough, tasklists, and smart punctuation enabled. Embedded HTML passes through. - Sanitization via
ammoniawith a cached allowlist that expands the default set withs,u,mark,kbd,sub,sup,del,ins.classis allowed onspan/div/p/code/preso pulldown-cmark's fenced-codeclass="language-rust"survives. Scripts, event handlers,javascript:URLs,<iframe>,<object>are stripped.
Empty input returns empty output. Unit tests in
src/markup.rs::tests cover BBCode variants, greentext, raw-HTML
passthrough, and sanitizer behavior.
Originals live at $FILES_DIR/gimgs/{ref_id}/{file}. Derivatives
(thumbnails, cropped shapes) live at
$FILES_DIR/dimgs/{ref_id}/{shape}_{size}_{file} and are generated
on first request by routes/admin/media.rs::derivative_get, mounted
at GET /files/dimgs/{ref_id}/{filename} ahead of the static Files
handler.
Pipeline (src/images.rs):
- Shapes (
Shape::parse):proportional,square,four_three,three_two,cinematic.target_dimscomputes the output box;proportionalpreserves aspect ratio, others useresize_to_fillwith Lanczos3 filtering. - EXIF orientation —
load_orientedre-reads the file's magic bytes (so tempfiles without an extension still decode) and applies the EXIF Orientation tag (1–8). - Upload (
routes/admin/media.rs::save_one) — magic-byte sniffs the MIME, SHA-256-hashes the raw bytes, skips writes if the same hash already exists in this exhibit (media.sha256dedupe), re-encodes images through theimagecrate, and stores width/height + size inmedia. Per-file cap 50 MiB, total per request 250 MiB. - Derivative request —
derivative_getparses{shape}_{size}_{name}, rejects path traversal, clampssizeto16..=4096, then delegates toimages::ensure_derivative. Responses getCache-Control: public, max-age=31536000, immutable. - Delete — when a media row is deleted, the original in
gimgs/and every matching derivative indimgs/are removed.is_derivative_ofmatches the precise{shape}_{size}_{file}pattern.
src/crypto.rs provides encrypt / decrypt over
ChaCha20-Poly1305, keyed by
SHA-256("openexhibit/v1/smtp-password" || SESSION_KEY). Today the
only consumer is the SMTP relay password in settings.smtp_pass
(encrypted in routes/admin/settings.rs, decrypted at send-time by
mail::send). Ciphertexts carry an enc: prefix; legacy plaintext
rows pass through unmodified. Rotating SESSION_KEY invalidates
these ciphertexts.
src/error.rs::AppError carries the typical 404/403/400 cases plus
Db, Template, Io, Image, and Internal(anyhow).
ResponseError is implemented so handlers can ?-propagate.
Internal errors log via tracing::error!. Every error renders
through templates/public/error.html.
| Mount | Source | Notes |
|---|---|---|
/static/* |
Config.static_dir (default ./static) |
SCSS-compiled CSS, fonts, icons. |
/files/gimgs/{ref_id}/{file} |
Config.files_dir/gimgs/... |
Originals. |
/files/dimgs/{ref_id}/{shape}_{size}_{file} |
Config.files_dir/dimgs/... |
Lazy derivatives (see above). Wired ahead of Files in main.rs. |
SCSS is compiled at build time by build.rs — there is no Sass
runtime in the binary.