Files
volumen/docs/ARCHITECTURE.md
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

14 KiB

Architecture

How Volumen is put together. Every node, package and arrow below exists in the source tree; nothing is aspirational.

Overview

flowchart TD
    cli["cmd/volumen, the flag dispatcher"] --> app["internal/app, server assembly and middleware chain"]
    cli --> backup["internal/backup, the tar.gz archive"]
    cli --> scheduler["internal/scheduler, the publish_at sweep"]
    app --> api["internal/httpapi, the public JSON API"]
    app --> admin["internal/admin, the server-rendered admin"]
    admin --> backup
    api --> preview["internal/preview, the signed preview link"]
    admin --> preview
    api --> store["internal/store, snapshot cache, atomic writes, revisions"]
    admin --> store
    scheduler --> store
    store --> post["internal/post, the domain object"]
    store --> disk[("content directory: posts, media, revisions")]
    post --> markdown["internal/markdown, scriptorium then bluemonday"]
    admin --> i18n["internal/i18n, the admin interface catalogue"]
    admin --> diff["internal/diff, the revision comparison"]
    app --> config["internal/config, TOML over built-in defaults"]
    admin --> fediverse["internal/fediverse, the @user@host rule"]
    config --> fediverse
    admin --> users["internal/users, internal/tokens, internal/templates"]

cmd/volumen is the only entry point. serve assembles internal/app, which builds the file-backed stores from the configuration, wires the middleware chain in front of the public API and the admin, and starts the scheduler loop when the configuration enables it; a deployment is founded through the admin itself, by the first-run wizard. export, import and publish-due reach the filesystem and the archive without starting a server. Volumen does not render the public site: the front end consumes the JSON API, and the admin is the only HTML it serves.

Packages

Package Responsibility
config Reads config.toml into a typed value over the built-in defaults, applies the command-line overrides and validates it; with no file the defaults move the state under the user's home. It owns the commented template (template.go) shipped as config.toml.example and nothing about the content.
store Owns the content directory through one os.Root: reading and writing posts, the snapshot cache, per-target write locks, the revision archive and its tombstones, and the media library. It decides where a file goes and never what a post means.
imagefile The upload rule: which extensions are accepted, the WebP, AVIF and SVG signatures, the extension a byte string is, and the pixel size the header carries (the WebP chunks, the AVIF ispe box, the SVG root's size attributes or viewBox), so the media library can show it. The store asks it what an upload is; the media route asks it what a name may be.
post The Post domain object: frontmatter metadata, the raw Markdown body, and the lazily rendered HTML and table of contents. It derives the slug, language, date, excerpt, reading time and publication status, and it clones deeply, because the store hands one instance to several readers.
frontmatter Parses and serialises the +++ block on an interpres Document, so a save keeps what the author wrote: the key order at every level, the comments, and whether a table is a header section or an inline table.
markdown Owns rendering and sanitisation: scriptorium renders the body (CommonMark with the GFM extensions, footnotes and definition lists), a pre-render scan lifts $$…$$ and $…$ runs out of the source and splices the MathML back after rendering, fenced mermaid blocks become their SVG, headings gain ids and a table of contents, a figure pass wraps titled images, and the bluemonday allowlist sanitises the result. This is the only place that turns a body into HTML.
feeds Renders RSS 2.0, Atom 1.0, JSON Feed 1.1 and the sitemap from the same posts the API serves.
payloads Builds the API's wire shapes: site metadata, post summaries and details, tag and series listings, pagination in both modes, and the validation used by the write endpoints and the admin forms.
httpapi Serves /api/volumen/*: reads, feeds, the sitemap, and the token-authenticated writes. It never writes a file; it calls store.
admin Serves /admin: the first-run wizard that founds the installation, login, the dashboard, post CRUD, the editor and its preview, import and download, history, media, settings, users, tokens, webhooks, backups and self-update, with the session, role and CSRF guards.
users, tokens, templates The file-backed stores beside users.toml: accounts with roles (the first one created by the wizard's serialised AddFirst), API token digests with scopes, and named post templates. They own their files and their atomic writes.
session The signed session cookie: load, verify, sign, expire. The cookie also carries a fingerprint of the account's password hash, so changing a password retires every session issued before the change.
preview The shared preview-link rule: an HMAC over the slug and an expiry stamp. The admin issues links, the API honours them, and neither implements the rule itself.
fediverse The @user@host rule, a leaf so that the configuration, the admin account form and the post payload validation share one validator.
identifiers The DOI and ORCID rules, a leaf like fediverse: syntax and normalisation for a DOI, shape and ISO 7064 check digit for an ORCID, and the resolver URLs both are published under.
biblio The bibliography leaf: the refs frontmatter parsed into numbered entries, inline [n] citations linked to them, DOIs, arXiv ids and ORCIDs turned into resolver links, and the [[refs]] marker replaced by the rendered list. It imports no other domain package; the post annotates the same-instance links.
app Assembles the server: stores, route tree, middleware chain, and the health, robots, favicon, media and 404 handlers.
web The shared middleware (gzip, cross-origin refusal, security headers, the request logger and its id, the client address) and access to the embedded templates and assets.
i18n The admin interface catalogue: English source strings, Czech translations, and the plural rules both languages need. The public API's messages stay English by contract.
diff The line-based comparison behind the revision history's diff view, with a context collapse and a bounded table, so an oversized input degrades to a whole-text replacement instead of burning memory.
ratelimit The sliding-window counter behind both the public API limit and the login limit, with a key bound enforced on every request.
webhooks Delivers signed JSON events to the configured endpoints, with a bounded number in flight, retries, and an in-memory delivery history. Hooks come from config.toml and from the admin-managed webhooks.toml, whose changes apply through SetHooks without a restart.
backup The one writer and reader of the tar.gz archive, shared by the CLI and the admin. It owns the layout and the containment of a restore.
audit The append-only JSON-lines audit log.
scheduler Publishes posts whose publish_at has arrived, from the in-app loop or the CLI, and reports each one to the webhook sink.
updater The Gitea release check, the checksum-verified download, and the in-place replacement of the running binary.
password scrypt hashing and verification with fixed parameters.
tomlfile The shared atomic TOML writer (temp file, fsync, rename, mode 0600).
version Reports the version the toolchain recorded in the build information. Nothing writes a version number.

Data flow

sequenceDiagram
    participant C as Client
    participant G as web.Gzip
    participant X as web.CrossOrigin
    participant H as web.SecurityHeaders
    participant R as apiRateLimit
    participant M as session.Middleware
    participant L as web.RequestLogger
    participant A as httpapi handleSingle
    participant S as store
    participant P as the rendering pipeline

    C->>G: GET /api/volumen/posts/hello-world
    G->>X: buffers the body when the client accepts gzip
    X->>H: refuses a state-changing request from another origin
    H->>R: baseline security headers, CSP nonce for /admin
    alt over the limit
        R-->>C: 429 with Retry-After
    else within the limit
        R->>M: X-RateLimit-Limit and X-RateLimit-Remaining set
        M->>L: the signed session cookie is loaded and verified
        L->>A: the request id is assigned and carried in the context
        A->>S: Find(slug, lang)
        S->>S: compare the path, mtime and size snapshot with the cache
        S-->>A: the post, an alias to redirect, or nothing
        A->>P: render, splice mathematics and diagrams, sanitise
        P-->>A: the HTML, cached on the post
        A-->>C: 200 with CORS, an ETag and Cache-Control
    end

The chain is built outermost first, so a request passes web.Gzip, web.CrossOrigin, web.SecurityHeaders, apiRateLimit, session.Middleware and web.RequestLogger before the route mux. The cross-origin gate is the outer one of the two write guards: it refuses a request a browser sent from another site before a handler runs, and the admin's per-session CSRF token is the inner one, which also covers a same-site request from another port. The request logger assigns a 16-character id, answers with it in X-Request-Id, puts a logger carrying it into the context so a handler's own lines share it, and writes one access line when the request finishes. The media route is split off before all of it and wrapped only in the security headers, because the session and gzip layers buffer a whole response and would hold entire images in memory.

Errors are produced close to their cause and mapped once, at the edge: the store returns an error for a failed write, the API turns it into an error envelope, and the admin renders the same message in the form it came from. A post file that cannot be parsed is never an error at the edge; the store skips it, records it, and volumen validate, volumen doctor and /healthz report it.

Admin handlers add requireLogin or requireAdmin in front of the handler and validate the CSRF token before doing any work. Write endpoints authenticate a Bearer token and check its scope. Both paths converge on the same store calls, so a post saved from the admin and one saved from the API land on disk the same way, including a rename, which moves the file and tombstones the old one.

State and lifetime

  • The content directory is the only durable state. Posts, media, revisions, users.toml, templates.toml, tokens.toml, webhooks.toml and the audit log all live on disk; a restart loses only the in-memory caches, the sitemap memo and the webhook delivery history.
  • One process, one content directory. Every cache and limiter is in-process, so two servers must never share a content directory. The lockers are for concurrent requests inside one process, not for two writers on one tree.
  • Cached posts are shared. store.All and store.Find return the same *post.Post to several readers, so a writer clones first (Post.Clone deep-copies the metadata). The rendered HTML is cached on the instance with a sync.Once, which is safe for concurrent readers by construction.
  • Locks. The store holds one mutex over its cache and snapshot, a reference-counted mutex per write target, and its own guard for that map. The users, tokens, templates and audit stores each hold one mutex; the rate limiters hold one each and bound their key sets.
  • Sessions live in the cookie, signed with the secret from [admin].session_key, or from the secret.key the server generated beside the users file when the config leaves the key empty, and bound to the account's password hash by a fingerprint, so a password change ends every session issued before it while the device that made the change re-signs itself. A request that carries a valid cookie is authenticated without server state, so signing out expires the browser's copy rather than revoking the value, and rotating the key is what ends every session at once.
  • Long-lived goroutines are the scheduler loop and the HTTP server; both are joined on shutdown, which drains in-flight requests for up to 15 seconds after SIGINT or SIGTERM. Webhook deliveries and the release check are bounded, detached, and die with the process.
  • Post files are written atomically: a temp file in the target directory, fsync, rename, directory fsync. The previous version is archived before the rename, and a delete is a move into that archive, so both are reversible.
  • The content directory is reached through an os.Root. Every read, write and delete inside it goes through the handle, which refuses a path that would escape the tree through .. or a symlink, so confinement is a property of the store rather than a check each caller has to remember.

Dependencies

The direct requires in go.mod are the whole list, and each is there because the standard library does not do the job:

  • sourcedock.dev/petrbalvin/scriptorium renders the Markdown body, the TeX mathematics and the Mermaid diagrams, deterministically and on the standard library alone.
  • bluemonday sanitises the rendered HTML against an allowlist. It is the reason a post body can be treated as untrusted even though its author is authenticated.
  • sourcedock.dev/petrbalvin/interpres/v2 parses TOML. It keeps a real date a date, rather than turning date = 2026-01-15 into a locale-dependent guess, and its Document keeps key order and comments so a save round-trips the author's own frontmatter.
  • golang.org/x/crypto provides scrypt, which the standard library does not ship, for password hashing.
  • golang.org/x/text provides the NFKC normalisation applied to passwords before hashing, so a password typed with a different Unicode form still verifies.

Everything else is the standard library: net/http for the server and its routing, html/template and embed for the admin, archive/tar and compress/gzip for the archive, encoding/json/v2 for the API, and log/slog for diagnostics.