Initial commit
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

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+670
View File
@@ -0,0 +1,670 @@
# API
Volumen serves a JSON API under `/api/volumen`; the admin interface at
`/admin` is server-rendered HTML for humans and is not part of this API.
## HTTP API
Base URL: `{base_url}/api/volumen`, where `base_url` is the `[site].base_url`
setting ([CONFIGURATION.md](CONFIGURATION.md)). Every path below is relative
to that prefix.
Reads need no credential. A write carries `Authorization: Bearer <token>`,
where the token is minted in the admin at **Settings, API tokens** and shown
once, at creation. Two scopes exist: `write` for `POST` and `PUT`, and
`delete` for `DELETE`. A token created with no scope selected is unrestricted,
and a scope list that names no recognised scope is refused rather than turned
into an unrestricted token. There is no read scope, because every read
endpoint is public, so a token can only widen access to the write and delete
operations.
A `HEAD` request is answered as the `GET` of the same path: same status, same
headers, no body.
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/volumen/site` | Site metadata |
| `GET` | `/api/volumen/posts` | Paginated, filterable post list |
| `GET` | `/api/volumen/posts/batch` | Several post details in one request |
| `GET` | `/api/volumen/posts/{slug}` | One post with body and rendered HTML |
| `POST` | `/api/volumen/posts` | Create a post (scope `write`) |
| `PUT` | `/api/volumen/posts/{slug}` | Update a post (scope `write`) |
| `DELETE` | `/api/volumen/posts/{slug}` | Delete a post (scope `delete`) |
| `GET` | `/api/volumen/tags` | Tag cloud with counts |
| `GET` | `/api/volumen/tags/{tag}` | Posts carrying one tag |
| `GET` | `/api/volumen/tags/{tag}/feed.xml` | RSS 2.0 feed for one tag |
| `GET` | `/api/volumen/tags/{tag}/feed.atom` | Atom 1.0 feed for one tag |
| `GET` | `/api/volumen/tags/{tag}/feed.json` | JSON Feed 1.1 for one tag |
| `GET` | `/api/volumen/series` | Series list with post counts |
| `GET` | `/api/volumen/series/{name}` | Posts of one series, in reading order |
| `GET` | `/api/volumen/series/{name}/feed.xml` | RSS 2.0 feed for one series |
| `GET` | `/api/volumen/series/{name}/feed.atom` | Atom 1.0 feed for one series |
| `GET` | `/api/volumen/series/{name}/feed.json` | JSON Feed 1.1 for one series |
| `GET` | `/api/volumen/feed.xml` | RSS 2.0 feed |
| `GET` | `/api/volumen/feed.atom` | Atom 1.0 feed |
| `GET` | `/api/volumen/feed.json` | JSON Feed 1.1 document |
| `GET` | `/api/volumen/sitemap.xml` | XML sitemap |
| `OPTIONS` | `/api/volumen/{rest...}` | CORS preflight for the whole subtree |
### `GET /api/volumen/site`
Site metadata, taken from the configuration. Every value is echoed as
configured and every one of them is a string, so `fediverse_creator` is `""`
when the key is unset rather than `null`.
```sh
curl -s 'https://lab.example.com/api/volumen/site'
```
```json
{
"title": "Research Notes",
"description": "Powered by Volumen.",
"base_url": "https://lab.example",
"language": "en",
"author": "Anonymous",
"fediverse_creator": ""
}
```
The response carries an `ETag`; send it back in `If-None-Match` to receive
`304 Not Modified` with no body. The post list, the post batch, the tag cloud,
a tag's post list and a single post carry one as well. The feeds, the sitemap
and the other endpoints do not.
### `GET /api/volumen/posts`
| Parameter | Type | Default | Effect |
|---|---|---|---|
| `page` | integer | `1` | Page number, `1` to `1000000`. Ignored when `cursor` is set |
| `limit` | integer | `20` | Page size, `1` to `100` |
| `lang` | string | unset | Keep posts whose language is this value, plus posts flagged `all_langs` |
| `tag` | string | unset | Keep posts carrying this tag |
| `q` | string | unset | Case-insensitive search over the title, the tags, the excerpt and the body. Matching posts are ordered by relevance: a title hit outranks a tag hit, an excerpt hit and a body hit, and a title that starts with the query leads its class; equal scores keep the date order |
| `cursor` | string | unset | Slug to start after; switches the response to the cursor shape |
A malformed `page` or `limit` is rejected with `422` and the `validation`
envelope. An unknown slug in `cursor` yields a page with no posts and a `null`
cursor rather than silently rewinding to the first page, so a paging client
never receives posts it already holds.
```sh
curl -s 'https://lab.example.com/api/volumen/posts?limit=20'
```
```json
{
"page_size": 20,
"total": 2,
"posts": [
{
"slug": "alpha",
"title": "Alpha",
"excerpt": "Alpha body with a [link](https://example.com).",
"date": "2026-08-18",
"lang": "cs",
"tags": ["go", "research"],
"author": "Petr",
"fediverse_creator": "@petr@social",
"cover": "/media/c.webp",
"cover_alt": "alt",
"cover_caption": "caption",
"reading_time": 1,
"translations": { "en": "alpha-en" },
"series": "Series",
"series_order": 1,
"url": "/api/volumen/posts/alpha"
},
{
"slug": "beta",
"title": "Beta",
"excerpt": "Beta body.",
"date": "2026-07-01",
"lang": "en",
"tags": ["go"],
"reading_time": 1,
"url": "/api/volumen/posts/beta"
}
],
"page": 1,
"has_next": false,
"has_prev": false
}
```
Without a cursor the response carries `page`, `has_next` and `has_prev`. With
a cursor it carries `next_cursor` instead: the slug of the last post on the
page, or `null` when the list is exhausted. When two posts share a slug (a
post and its translation may) the cursor is `slug.lang`, so the next page
resumes after the exact post rather than after the first variant that
matches. Either way `total` counts every post the filter matched, before
pagination, and `page_size` is the limit the server applied.
```json
{
"page_size": 1,
"total": 2,
"posts": [
{
"slug": "beta",
"title": "Beta",
"excerpt": "Beta body.",
"date": "2026-07-01",
"lang": "en",
"tags": ["go"],
"reading_time": 1,
"url": "/api/volumen/posts/beta"
}
],
"next_cursor": null
}
```
#### Fields
Used by the list endpoints, the tag and series lists, the series detail and
the write responses. The shape is `payloads.Summary`, built by
`payloads.BuildSummary`; the struct tags decide which fields are omitted.
| Field | Type | Presence | Notes |
|---|---|---|---|
| `slug` | string | omitted when empty | Stable post identifier |
| `title` | string | omitted when empty | Post title |
| `excerpt` | string | always present | The frontmatter excerpt, else the first non-heading body paragraph cut to 200 characters, with an ellipsis when anything was dropped |
| `date` | string | omitted when empty | Publication date, `YYYY-MM-DD` |
| `lang` | string | omitted when empty | Post language |
| `tags` | array of strings | omitted when empty | Post tags |
| `author` | string | omitted when empty | Post author from the frontmatter; the site author is not substituted into the payload |
| `fediverse_creator` | string | omitted when empty | `@user@host`, per-post override of the site default |
| `doi` | string | omitted when empty | Digital Object Identifier, bare `10.…/…` form; a stored `doi.org` URL or `doi:` prefix is normalised for display, and a value that fails the syntax rule passes through untouched. Set in the frontmatter or the admin editor, not by the write endpoints |
| `orcid` | string | omitted when empty | The author's ORCID iD, `0000-0000-0000-000X`, check digit verified when the value is written from the admin editor; upper-cased for display when valid, the raw text otherwise. Set in the frontmatter or the admin editor, not by the write endpoints |
| `cover` | string | omitted when empty | Cover image path or URL, typically `/media/...` |
| `cover_alt` | string | omitted when empty | Alt text for the cover |
| `cover_caption` | string | omitted when empty | Caption for the cover |
| `reading_time` | integer | always present | Minutes, `ceil(words / 200)`, at least 1 |
| `translations` | object | omitted when empty | Maps a language code to the slug of a sibling translation |
| `series` | string | omitted when empty | Series name |
| `series_order` | integer | omitted when the frontmatter has none | Position within the series |
| `url` | string | always present | API detail URL, `/api/volumen/posts/{slug}` |
### `GET /api/volumen/posts/batch`
Returns the detail of several posts in one response. `slugs` is a
comma-separated list; entries are trimmed, empty ones dropped, and the list is
capped at 100 slugs. A slug that names no published post is skipped, so the
`posts` array may be shorter than the request and the endpoint never answers
`404`. Without `slugs` the `posts` array is empty.
```sh
curl -s 'https://lab.example.com/api/volumen/posts/batch?slugs=alpha,beta'
```
The entry below is one detail response, printed in full:
```json
{
"posts": [
{
"slug": "alpha",
"title": "Alpha",
"excerpt": "Alpha body with a [link](https://example.com).",
"date": "2026-08-18",
"lang": "cs",
"tags": ["go", "research"],
"author": "Petr",
"fediverse_creator": "@petr@social",
"cover": "/media/c.webp",
"cover_alt": "alt",
"cover_caption": "caption",
"reading_time": 1,
"translations": { "en": "alpha-en" },
"series": "Series",
"series_order": 1,
"url": "/api/volumen/posts/alpha",
"body": "Alpha **body** with a [link](https://example.com).\n",
"html": "<p>Alpha <strong>body</strong> with a <a rel=\"noopener noreferrer\" href=\"https://example.com\">link</a>.</p>\n",
"toc": "<div class=\"toc\">\n<ul></ul>\n</div>\n",
"meta": {
"url": "https://lab.example/alpha",
"json_ld": "{\"@context\":\"https://schema.org\",\"@type\":\"Article\",\"author\":{\"@type\":\"Person\",\"name\":\"Petr\"},\"creator\":{\"@type\":\"Person\",\"name\":\"@petr@social\"},\"dateModified\":\"2026-08-18\",\"datePublished\":\"2026-08-18\",\"description\":\"Alpha body with a [link](https://example.com).\",\"headline\":\"Alpha\",\"image\":[\"/media/c.webp\"],\"inLanguage\":\"cs\",\"keywords\":[\"go\",\"research\"],\"mainEntityOfPage\":{\"@id\":\"https://lab.example/alpha\",\"@type\":\"WebPage\"},\"url\":\"https://lab.example/alpha\"}",
"og": {
"article:author": "Petr",
"article:published_time": "2026-08-18",
"article:tag": ["go", "research"],
"og:description": "Alpha body with a [link](https://example.com).",
"og:image": "/media/c.webp",
"og:locale": "cs",
"og:title": "Alpha",
"og:type": "article",
"og:url": "https://lab.example/alpha"
},
"twitter": {
"twitter:card": "summary_large_image",
"twitter:creator": "@petr@social",
"twitter:description": "Alpha body with a [link](https://example.com).",
"twitter:image": "/media/c.webp",
"twitter:title": "Alpha"
}
}
}
]
}
```
The JSON here is reformatted for the page; the response shape is pinned by
the fixtures under
[`internal/httpapi/testdata/contract/`](../internal/httpapi/testdata/contract/),
which carry a differently configured deployment's values.
### `GET /api/volumen/posts/{slug}`
| Parameter | Type | Default | Effect |
|---|---|---|---|
| `lang` | string | unset | Select the language variant; a post flagged `all_langs` matches every value |
| `preview_token` | string | unset | Signed preview credential for a draft or a scheduled post |
The response is a post detail. A slug that matches an alias is answered with
`301 Moved Permanently` and a `Location` header naming the canonical post. A
draft and a scheduled post are distinguishable on purpose: the error code says
which of the two withheld the post, because the client already knows the slug
and the two states need different handling.
The response carries an `ETag` naming the post's state: send it back in
`If-None-Match` for a `304`, or in `If-Match` on a write to refuse overwriting
a change you never saw.
| Status | Meaning |
|---|---|
| `200` | The post detail |
| `301` | The slug is an alias; `Location` names the canonical slug |
| `304` | The request carried `If-None-Match` matching the current `ETag` |
| `404` | `{"error": "not_found"}`, or `{"error": "draft"}` / `{"error": "scheduled"}` for a withheld post with no valid preview token |
| `500` | `{"error": "render_failed"}` when the Markdown pipeline fails |
A preview link is signed as an HMAC over the slug and an expiry stamp, keyed
with `[admin].session_key`, and stays valid for seven days. Without a session
key no link is issued at all: the admin route `GET
/admin/posts/{slug}/preview-link` then answers `409` with the code
`no_session_key`. A preview response carries `Cache-Control: no-store`: it
holds unpublished content, and a shared cache must not keep it.
#### Fields
Returned by `GET /api/volumen/posts/{slug}` and by the batch endpoint. The
shape is `payloads.Detail`: the summary above, plus:
| Field | Type | Notes |
|---|---|---|
| `body` | string | Raw Markdown source |
| `html` | string | Rendered, sanitised HTML |
| `toc` | string | Sanitised table of contents |
| `meta` | object | SEO and discovery metadata, computed per request from the post and never persisted |
| `references` | array of objects | The structured bibliography from the post's `refs` frontmatter: each entry has `num`, optional `authors` (`{name, orcid}`), `title`, `venue`, `year`, `volume`, `pages`, `doi`, `arxiv`, `url`, or the verbatim `raw` text. When the entry's DOI is published by another post of the same instance, it also carries `internal`: the API address of that post, so a consumer can keep the reader on site. Omitted when the post cites nothing |
| `fields` | object | The frontmatter keys the engine does not consume itself, the author's own: `colour: "#0f0"` lands here as `{"colour": "#0f0"}`. Nested tables, arrays and TOML date-times pass through (dates as their canonical strings). Omitted when every key is a known one |
`html` is rendered by scriptorium (CommonMark, GFM, footnotes, definition lists) with
`$$…$$` and `$…$` mathematics converted to MathML Core and fenced mermaid blocks
(flowcharts and sequence diagrams) drawn as inline SVG, and it is
sanitised by bluemonday against a narrow allowlist: headings keep their `id`
attributes, fenced code blocks carry `class="language-..."`, an image whose
title is set becomes a `figure` with a `figcaption`, a post with `refs` gets a
numbered `<section class="refs" id="references">` spliced at its `[[refs]]`
marker, or appended at the end of the body when it carries none, with every
inline `[n]` citation linked to it. A reference whose DOI
is published by another post of the same instance links to that post's API
address instead of the external resolver, and the same address appears as
the `url` of its JSON-LD citation next to the canonical identifier; a post
citing its own DOI keeps the resolver link. Every link in the
body and in the table of contents gains `rel="noopener noreferrer"`. The
allowlist admits `http`, `https` and `mailto` URLs plus relative ones, so a
body cannot inject a script, a style or an event handler. A mermaid diagram
the renderer does not carry stays a fenced code block, and a mathematics
construct outside the mappable surface stays visible as its verbatim source
in the place it was written.
`toc` is the wrapper `<div class="toc">` with a nested list of heading links.
The wrapper is always present, even when the body has no headings, in which
case it holds an empty list:
```json
"toc": "<div class=\"toc\">\n<ul></ul>\n</div>\n"
```
An empty body has no table of contents at all and yields an empty `toc`
string.
`meta` carries:
| Field | Type | Notes |
|---|---|---|
| `url` | string | Canonical post URL: `base_url` plus the slug, or the API path when no `base_url` is set |
| `json_ld` | string | A Schema.org `Article` document, serialised as a JSON string |
| `og` | object | Open Graph fields, keyed `og:type`, `og:title`, `og:description`, `og:url`, `og:locale`, `article:published_time`, plus `og:image`, `article:tag` and `article:author` when the post carries them |
| `twitter` | object | Card fields, keyed `twitter:card`, `twitter:title`, `twitter:description`, plus `twitter:image` and `twitter:creator` when the post carries them |
### `POST /api/volumen/posts`
Creates a post. The body is a JSON object; fields not listed are ignored. The
response is `201 Created` with the post summary as its body, no `Location`
header, and an `ETag` naming the new state for a later `If-Match` write.
| Field | Type | Effect |
|---|---|---|
| `title` | string | Post title |
| `slug` | string | Post slug; required, and unique |
| `lang` | string | Language code |
| `author` | string | Author name |
| `fediverse_creator` | string | `@user@host` handle |
| `excerpt` | string | Summary text, overrides the derived excerpt |
| `body` | string | Markdown source, at most 1 MiB |
| `tags` | array of strings, or a comma-separated string | Post tags |
| `cover` | string | Cover image path or URL |
| `cover_alt` | string | Cover alt text |
| `cover_caption` | string | Cover caption |
| `series` | string | Series name |
| `series_order` | integer | Position within the series |
| `date` | string | Publication date, `YYYY-MM-DD` |
| `publish_at` | string | Scheduled publication date, `YYYY-MM-DD` |
| `draft` | boolean | Withhold the post |
| `all_langs` | boolean | Serve the post for every requested language |
An empty string or `null` clears an optional field. A value of the wrong type
is rejected with `400` rather than coerced. Validation failures answer
`{"error": "validation", "message": "..."}` with messages such as
`Slug is required.`, `Invalid slug.`, `A post with that slug already exists.`
and `Body must be at most 1048576 bytes.`
```sh
curl -s -X POST 'https://lab.example.com/api/volumen/posts' \
-H 'Authorization: Bearer vol_...' \
-H 'Content-Type: application/json' \
-d '{"slug":"hello","title":"Hello","body":"Hi.","tags":["meta"]}'
```
### `PUT /api/volumen/posts/{slug}`
Updates the post named by the path. Fields omitted from the body keep their
stored value, so the request is a merge rather than a replacement. The
response is `200 OK` with the post summary.
A `slug` in the body that differs from the path renames the post: the file is
written under the new slug and the old file is soft-deleted, so the rename
stays undoable.
The request may carry `If-Match` with the `ETag` a `GET` of the post served:
when it no longer names the stored state, the write is refused with `412`
instead of overwriting a change the client never saw. `If-Match: *` demands
only that the post exists. A write without the header stays unconditional.
The `200` response carries the new state's `ETag`, so edits can chain without
re-reading. The precondition is checked against the post the path names, the
one a `GET` without `lang` serves.
| Status | Meaning |
|---|---|
| `200` | The post summary, with the new `ETag` |
| `400` | `{"error": "invalid_json"}` or the `validation` envelope |
| `401` | `{"error": "unauthorized"}` |
| `403` | `{"error": "forbidden", "message": "Token lacks 'write' scope"}` |
| `404` | `{"error": "not_found"}` |
| `412` | `{"error": "precondition_failed"}` when `If-Match` names an older state |
| `500` | `{"error": "save_failed"}` when the file cannot be written, or `{"error": "render_failed"}` when an `If-Match` precondition has to render a stored post that no longer renders |
### `DELETE /api/volumen/posts/{slug}`
Deletes the post. The response is `204 No Content` with no body. The post is
soft-deleted as a tombstone, so it can be restored from the admin while the
tombstone survives.
An `If-Match` header carrying the `ETag` a `GET` of the post served refuses
the delete with `412` when it names an older state; `*` and a missing header
are unconditional.
| Status | Meaning |
|---|---|
| `204` | Deleted |
| `401` | `{"error": "unauthorized"}` |
| `403` | `{"error": "forbidden", "message": "Token lacks 'delete' scope"}` |
| `404` | `{"error": "not_found"}` |
| `412` | `{"error": "precondition_failed"}` when `If-Match` names an older state |
| `500` | `{"error": "delete_failed"}`, or `{"error": "render_failed"}` when an `If-Match` precondition has to render a stored post that no longer renders |
### `GET /api/volumen/tags`
```json
{
"tags": [
{ "name": "go", "count": 2 },
{ "name": "research", "count": 1 }
]
}
```
Tags are ordered by count, most used first, then by name. Only published posts
are counted. The response carries an `ETag`.
### `GET /api/volumen/tags/{tag}`
A page of the posts carrying one tag, in the shape of
[`GET /api/volumen/posts`](#get-apivolumenposts) with the same `page`,
`limit`, `lang` and `cursor` parameters. A tag that names no published post
answers `404` with `{"error": "not_found"}`.
### `GET /api/volumen/series`
```json
{
"series": [{ "name": "Series", "count": 1 }]
}
```
Ordered by count, most posts first, then by name.
### `GET /api/volumen/series/{name}`
```json
{
"name": "Series",
"count": 1,
"posts": [
{
"slug": "alpha",
"title": "Alpha",
"excerpt": "Alpha body with a [link](https://example.com).",
"date": "2026-08-18",
"lang": "cs",
"tags": ["go", "research"],
"author": "Petr",
"reading_time": 1,
"series": "Series",
"series_order": 1,
"url": "/api/volumen/posts/alpha"
}
]
}
```
The posts are in reading order: by `series_order` first, and a post without
one after every ordered post, then by date and slug. A name that matches no
series answers `404` with `{"error": "not_found"}`.
### Feeds
| Path | Document |
|---|---|
| `/api/volumen/feed.xml` | RSS 2.0, `application/rss+xml` |
| `/api/volumen/feed.atom` | Atom 1.0, `application/atom+xml` |
| `/api/volumen/feed.json` | JSON Feed 1.1, `application/json` |
| `/api/volumen/tags/{tag}/feed.xml`, `/api/volumen/tags/{tag}/feed.atom`, `/api/volumen/tags/{tag}/feed.json` | The same three formats for one tag |
| `/api/volumen/series/{name}/feed.xml`, `/api/volumen/series/{name}/feed.atom`, `/api/volumen/series/{name}/feed.json` | The same three formats for one series |
A feed carries at most 20 posts: the newest 20 for the site feed and a tag
feed, and the first 20 of the series, in its reading order, for a series feed.
Every document names its own address: RSS carries `<atom:link rel="self">`,
Atom carries `<link rel="self">`, and JSON Feed carries `feed_url`, each with
the path that was actually requested, so a tag feed or a series feed reports
its own URL rather than the site feed's. A tag or series feed answers `404`
with `{"error": "not_found"}` when nothing matches.
### `GET /api/volumen/sitemap.xml`
Every published post, truncated to the newest 50 000 (the ceiling the
sitemaps.org protocol sets for one document), with `<lastmod>` from the file's
modification time and, for a post whose file cannot be read, from its date.
The content type is `application/xml`.
### `OPTIONS /api/volumen/{rest...}`
The CORS preflight for the whole API subtree. It advertises
`GET, POST, PUT, DELETE, OPTIONS` with `Content-Type, Authorization`, sets
`Access-Control-Max-Age: 600`, and answers `200` with an empty
`text/plain; charset=utf-8` body. The write methods are listed on purpose: a
browser preflight for a cross-origin `POST` fails when the answer names only
`GET`. The answer carries the write CORS set rather than the read one, so the
allowed origin follows the rule the Headers section states for a
token-authenticated write.
### Flow
The token-authenticated write flow, from the request to the stored file:
```mermaid
sequenceDiagram
participant Client
participant API
participant Tokens as Token store
participant Posts as Content directory
Client->>API: POST /api/volumen/posts with Authorization: Bearer
API->>Tokens: Authenticate the raw token
Tokens->>Tokens: compare the SHA-256 digest
Tokens-->>API: token record with its scopes
API->>API: require the write scope
API->>Posts: Save the post, archiving the previous version
Posts-->>API: the saved path
API-->>Client: 201 Created with the post summary
```
At each step, a failure surfaces in the response: a missing or unknown token
as `401 unauthorized`, a token without the scope as `403 forbidden`, a body
that is not a JSON object as `400 invalid_json`, and a rejected field as `400
validation`. When the post is stored, the `post.created` event fires and every
webhook subscribed to it receives the post summary.
## Notes
The Go structs and their JSON tags are the authority on the response shapes,
and the committed fixtures under
[`internal/httpapi/testdata/contract/`](../internal/httpapi/testdata/contract/)
pin them: a change to a body or a status code fails the contract test until
the fixture is regenerated with `go test ./internal/httpapi -update-contract`
and the change is recorded in the changelog. `go doc ./internal/payloads`
covers the exported types. This file explains what the surface is for; it does
not repeat the struct definitions, because a copy of a signature is a future
lie.
### Errors
Every failure uses one envelope: an `error` code, optionally with a
human-readable `message` and extra fields.
```json
{ "error": "not_found" }
```
```json
{ "error": "validation", "message": "Invalid slug.", "field": "slug" }
```
| Code | Status | Extra fields | Meaning |
|---|---|---|---|
| `not_found` | 404 | | No such post, tag, series, or media file |
| `draft` | 404 | | The post exists but is a draft |
| `scheduled` | 404 | | The post exists but is scheduled for the future |
| `validation` | 400, 422 | `message`, and `field` for a query parameter | The payload or query is invalid |
| `invalid_json` | 400 | | The request body is not a JSON object |
| `payload_too_large` | 413 | | The request body exceeds 10 MiB |
| `precondition_failed` | 412 | | The request's `If-Match` names an older state than the stored one |
| `unauthorized` | 401 | | Missing or invalid bearer token |
| `forbidden` | 403 | `message` | The token lacks the required scope |
| `rate_limited` | 429 | `retry_after` | Too many requests |
| `render_failed` | 500 | | The Markdown pipeline failed |
| `save_failed` | 500 | | The post could not be written to disk |
| `delete_failed` | 500 | | The post could not be deleted |
A `401` carries `WWW-Authenticate: Bearer`. Failures written by the API carry
`Cache-Control: no-store`, so a shared cache never keeps one; the `429` is
written by the rate limiter before the handler and carries the rate-limit
headers instead.
The router answers for itself outside the routes above: a path under
`/api/volumen/` that matches no route, or a method a route does not serve,
gets `405 Method Not Allowed` with an `Allow` header listing the methods the
path does serve, and a plain-text body rather than the JSON envelope.
### Headers and caching
- JSON responses use `Content-Type: application/json`. The XML documents carry
`application/rss+xml`, `application/atom+xml` and `application/xml`.
- A read answers with the permissive set:
`Access-Control-Allow-Origin: *`, `Access-Control-Allow-Methods: GET, POST,
PUT, DELETE, OPTIONS` and `Access-Control-Allow-Headers: Content-Type,
Authorization`. A token-authenticated write echoes the configured `base_url`
as the allowed origin when the request carries an `Origin` header, and falls
back to `*` when no base URL is configured or the request carries no `Origin`
header, so a browser can read a
cross-origin write from the site itself and not from anywhere else. The
router's `405` and the rate limiter's `429` are written outside a handler and
carry only what their own sections note.
- Successful reads carry `Cache-Control: public, max-age=60,
stale-while-revalidate=21600`. Write responses carry `no-store`.
- Bodies of 500 bytes or more are gzip-compressed when the client sends
`Accept-Encoding: gzip`, and such a response carries `Vary:
Accept-Encoding` so a shared cache keys on it.
- Dates are ISO 8601 (`YYYY-MM-DD`).
- Lists are newest first by post date. Drafts and scheduled posts never appear.
- Empty optional fields are omitted rather than sent as `null`; a client must
treat a missing key as "not set". Two fields of a post summary are the
exception and are always present: `excerpt` and `reading_time`. The site
payload is different in kind: it echoes the configured values, and every one
of them is a string, so its `fediverse_creator` is `""` when the key is
unset.
- Bodies are written by the Go 1.27 `encoding/json/v2` encoder with
deterministic member order and no HTML escaping, so `<`, `>` and `&` appear
as themselves and the same content produces the same bytes. That is what
makes the `ETag` round trip above reliable. A request body that names the
same member twice is rejected as `invalid_json` rather than silently taking
one of the two values.
- Malformed query parameters are rejected with `422` and the validation
envelope.
### Rate limiting
With `[api].rate_limit` above zero, every `/api/volumen/*` response carries
`X-RateLimit-Limit` and `X-RateLimit-Remaining`, counted per client address in
a sliding window of `[api].rate_limit_window` seconds. A request over the
budget is answered `429`:
```json
{ "error": "rate_limited", "retry_after": 42 }
```
with a `Retry-After` header carrying the same number of seconds. The limiter
covers the API prefix only: `/media/*`, `/admin/*` and `/healthz` are not
counted. The client address is the last entry of `X-Forwarded-For` when
`[server].trust_proxy` is set and the connection comes from a peer listed in
`[server].trusted_proxies`, and the connection address otherwise. The refusal
carries the read CORS set (`GET, OPTIONS` with `Content-Type`) rather than the
wide one, and no cache header.
### Operational routes
Outside the API prefix, the application serves a few operational routes:
| Route | Behaviour |
|---|---|
| `GET /healthz` | `{"status": "ok", "checks": {...}}` with `Cache-Control: no-store`. The status is `degraded` and the code `503` when the content directory is missing, a post file cannot be parsed, the users, tokens or templates file cannot be read, or the filesystem has under 100 MB free. `content_dir`, `users_file` and `disk` are always present; `content_files`, `tokens_file` and `templates_file` appear only when something is wrong with them. Not rate limited |
| `GET /robots.txt` | Allows all crawlers and points at the sitemap: `Sitemap: {base_url}/api/volumen/sitemap.xml` |
| `GET /sitemap.xml` | `301` redirect to `/api/volumen/sitemap.xml` |
| `GET /favicon.ico` | The bundled SVG icon, `Cache-Control: public, max-age=86400` |
| `GET /media/{path}` | An uploaded image from `<content_dir>/media/`. The file name is a UUID with a `.webp`, `.avif` or `.svg` extension, assigned when the upload is stored, so no uploader chooses a name; only those three extensions resolve, and anything else, including a missing file, answers `404` with `{"error": "not_found"}`. The type is set from the extension rather than sniffed, so a file whose bytes do not match is still served as an image and never as a document; an SVG, the one accepted image that is also a document, is additionally served with a sandboxing `Content-Security-Policy`, so opened at its own URL it runs as no one. Responses carry `Cache-Control: public, max-age=604800`. They are served outside the gzip and session layers, so a large image is never buffered in memory |
| `GET /` | `303` redirect to `/admin/` |
| anything else outside the API prefix | `404` with `{"error": "not_found"}` |
+198
View File
@@ -0,0 +1,198 @@
# Architecture
How Volumen is put together. Every node, package and arrow below exists in the
source tree; nothing is aspirational.
## Overview
```mermaid
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
```mermaid
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.
+92
View File
@@ -0,0 +1,92 @@
# Benchmarking
How **Volumen** is measured. Every number a document, a README or a changelog
quotes comes from here and nowhere else.
## The method
The benchmarks live next to the code they measure. The tree carries exactly
two, and nothing else is measured:
| Benchmark | What it measures |
|---|---|
| `BenchmarkRender` | the Markdown pipeline, from source to sanitised HTML and a table of contents, for a medium body and a large one |
| `BenchmarkServer` | the wired server over real HTTP: a post list, a single post, the tag cloud, a tag feed and the sitemap, against a corpus of five hundred posts |
`BenchmarkRender` in `internal/markdown/markdown_test.go` has two
sub-benchmarks and calls `SetBytes`, so a result reads as input bytes per
second: the medium input is 2400 bytes of Markdown, the large one eighteen
copies of it, 43200 bytes.
`BenchmarkServer` in `internal/app/bench_test.go` builds a server over a
temporary content directory and drives it through an `httptest` server, so it
covers the whole chain rather than one function: routing, the middleware, the
session layer, the store, the payload builders and the renderer. It is the
workload a release is judged on, and the one a profile is recorded from.
- The machine is the development workstation, idle: AMD Ryzen AI MAX+ PRO 395
with Radeon 8060S, 16 cores and 32 threads, 117 GiB of memory, Fedora Linux
44 (`linux/amd64`). A loaded box times whatever else is running, and the
fastest sample can land on the wrong function.
- The toolchain is `go1.27.1 linux/amd64`. Benchmarks run through `go test`,
which builds the package's test binary; the command build's `-trimpath` and
`-buildvcs=true` are not part of a benchmark's build.
- Comparisons run inside one process. A loaded machine and separate processes
of identical binaries differ by more than the effects being measured, so
A/B runs alternate the two sides rather than run one after the other, and
the counts are compared through their medians, with the allocations and the
bytes per operation alongside the times. Differences within a few percent
of the spread between runs are noise; only a difference beyond that is a
result.
- When timing is hopeless, the allocation and byte counts are the result.
- A profile says where the time goes, and it is only read from an idle
machine. Record one with `-cpuprofile` on `BenchmarkServer`, then `go tool
pprof -top` it.
- Profile-guided optimisation: a profile is committed at
`cmd/volumen/default.pgo`, and the toolchain consumes it automatically when
it builds the command (measured: a `-pgo=off` build and a default build of
the same tree produce different binaries). A benchmark's test binary never
sees it, because the profile belongs to the main package, so the early A/B
run of `BenchmarkServer` compared two plain builds and could not have shown
a difference; its numbers stand as the plain build's, and they understate
the shipped binary. The comparison measured on 2026-09-25 against the two
real builds (five hundred posts, the six JSON and feed endpoints
interleaved, rate limiting off, both arms served the same twelve thousand
six hundred requests) puts the profile's effect beyond the noise: server
CPU over the warm mix is about six percent lower, the per-request median
about thirty-two percent lower on `site`, twenty-three percent on the post
list and fifteen percent on a single post, while the tag feed, the sitemap
and the cold store scan are unchanged within the noise. The profile
predates the search-relevance and custom-fields changes to the hot path.
## Running
```sh
just bench
```
The recipe runs the whole module with five counts:
```sh
go test -run '^$' -bench=. -benchmem -count=5 ./...
```
`-benchmem` is not optional: allocations per operation are part of the
result. A first look at one target, before the full battery is worth the
time:
```sh
go test -run '^$' -bench 'BenchmarkServer' -benchmem -benchtime=1x -count=1 ./internal/app
```
The full battery runs once, deliberately, on an idle machine. A benchmark
command is capped at about two minutes per round; longer sweeps are split.
A server benchmark also spends time in the kernel, so read the allocation
numbers alongside the time: a change that halves allocations and leaves the
time flat has moved the cost to the filesystem.
## Reports
The repository stores no benchmark reports. A performance claim in
`CHANGELOG.md` is measured with the method above on the change that makes
it, on the named machine, and the number travels with the claim.
+273
View File
@@ -0,0 +1,273 @@
# Command line
The reference below is taken from the program's own `--help`. If the two
disagree, the program is right and this file is a defect. The same reference
ships as a manual page, [man/volumen.1](../man/volumen.1), and the page moves
in the same commit as the flags it documents.
## Synopsis
```sh
volumen <command> [options]
```
## Commands
| Command | Purpose |
|---|---|
| `serve` | Start the publishing server |
| `status` | Show installation status |
| `doctor` | Check installation health |
| `check-update` | Compare with the latest Gitea release |
| `export` | Export posts, media, and users to a tar.gz |
| `import` | Import a backup archive |
| `publish-due` | Publish scheduled posts whose date arrived |
| `validate` | Validate the content directory |
| `version` | Show version |
## `volumen serve`
```text
volumen serve [--config <path>] [--content <dir>] [--host <addr>] [--port <n>]
```
| Flag | Default | Meaning |
|------|---------|---------|
| `--config` | `/etc/volumen/config.toml`, then `~/.config/volumen/config.toml` | Path to `config.toml` |
| `--content` | from config | Override the posts directory |
| `--host` | from config (`::`) | Override the bind address |
| `--port` | from config (`9091`) | Override the port |
A `--port` outside `1..65535` is rejected rather than ignored, and a positional
argument is an error.
Starts the HTTP server: the public API under `/api/volumen`, the admin under
`/admin`, uploaded media under `/media`, plus `/healthz`, `/robots.txt`,
`/sitemap.xml` and `/favicon.ico`. The configuration is validated at startup;
invalid values abort with exit code `1`, before the socket is bound.
With no `--config` the server reads `/etc/volumen/config.toml` if it exists,
then `~/.config/volumen/config.toml`, and when neither exists it runs on the
built-in defaults. Those defaults put the state under the user's home
(`~/.local/share/volumen`, honouring `XDG_DATA_HOME`), so a plain
`volumen serve` on a fresh machine works without root and without any
configuration file. On the first start the account file is empty and
`/admin` shows the first-run wizard: it creates the administrator account,
the interface language and the colour scheme, and signs the operator in. The
server keeps the session secret it generates in `secret.key` beside the
account file; `[admin].session_key` overrides it.
The server sets a 10 second read-header timeout, a 60 second read timeout, a
120 second write timeout and a 120 second idle timeout, and bounds a request
line and its headers at 1 MiB. `SIGINT` and `SIGTERM` drain in-flight requests
for up to 15 seconds and then exit `0`.
When `[scheduler].enabled = true`, an internal goroutine publishes the due
posts once at start-up and then every `[scheduler].interval` seconds. A
background release check fills the admin update banner without blocking
requests. Logging goes to stderr, either as text or as JSON lines when
`[server].log_format = "json"`; the server's own protocol errors go there too,
through `log/slog`, rather than to a bare stderr line. Every request carries a
16-character id: it is answered in `X-Request-Id`, it is attached to every line
the handlers write for that request, and the request's own access line records
the method, the path, the status and the duration under it.
## `volumen status`
```text
volumen status [--config <path>] [--data <dir>] [--users-file <path>] [--json]
```
Reports whether the config exists, parses and validates, the number of posts
(with the draft count), and the number of users; with no accounts yet the user
count carries the hint `open /admin to run the setup wizard`. `--json` prints
`{"ok": true, "checks": {…}}`, with a `config_validate` entry naming the first
invalid value, a `posts_unreadable` entry when a post file cannot be parsed, and
`users: "unreadable"` when the accounts file cannot be read. Exit code `1` when
the config is missing, unparseable or invalid, when content cannot be parsed, or
when the users file cannot be read.
Read-only: it inspects the content directory without creating or changing
anything, so a mistyped `content_dir` is reported rather than turned into an
empty tree.
## `volumen doctor`
```text
volumen doctor [--config <path>] [--json]
```
Runs the health checks: config presence and validation, whether every post file
can be parsed, whether every post renders, whether the users file can be read,
whether the installation has its first account, and whether any stored scrypt
parameters are below the policy floor. The
checks carry two levels: a missing or broken config, an unreadable content
directory and an unreadable users file are `fail` and exit code `1`; posts
that fail to render, an installation still waiting for the first-run wizard
and weak stored hashes are `warn`, reported without
changing the exit code. `--json` prints
`{"ok": bool, "checks": [{"name", "status", "detail"}]}`.
## `volumen check-update`
```text
volumen check-update [--json]
```
Compares the running version with the latest release published on the Gitea
instance (`https://sourcedock.dev/petrbalvin/volumen/releases`).
| Exit code | Meaning |
|-----------|---------|
| `0` | Up to date (or the local build is newer) |
| `1` | A newer release is available, or the release check failed (offline, timeout, malformed response) |
| `2` | The arguments were wrong |
`--json` prints `{"current": …, "latest": …, "available": bool}` and keeps the
same exit-code contract, so it works as a CI or monitoring probe.
## `volumen export`
```text
volumen export [--config <path>] [--out <archive>]
```
Writes a `.tar.gz` containing `posts/` (including `.revisions/` and `media/`),
`users.toml`, `templates.toml` and `tokens.toml`. Default output:
`volumen-backup.tar.gz`, written mode `0600`.
The config file is deliberately not included: it may hold the session signing
key override, and `volumen import` ignores it anyway. A file that exists but cannot be read
aborts the export rather than producing an archive that looks complete: the
archive is written to a temporary file and renamed into place, so a failure
leaves the previous backup untouched. Exit code `1` on I/O errors.
## `volumen import`
```text
volumen import [--config <path>] <archive>
```
Restores an archive produced by `volumen export` or the admin Backup panel
into the configured locations. An archive entry named `users.toml`,
`templates.toml` or `tokens.toml` is written to the path the deployment
configures for that file, which may be a different name; the post files under
`posts/` are written into the content directory.
Extraction is confined to the content directory, so an entry that tries to
escape it is refused, and only post files, revision archives and images with an
allowed extension are written: the media directory is served from a public
route, so an archive can never plant a document there. The decompressed total
is bounded. Exit code `1` on a malformed archive or one that holds no file the
layout recognises.
## `volumen publish-due`
```text
volumen publish-due [--config <path>] [--dry-run] [--json]
```
Publishes every post whose `publish_at` date is today or earlier: removes the
`publish_at` key and defaults `date` to it when the post has no date. This is
the cron and systemd-timer counterpart to the in-app `[scheduler]`.
- `--dry-run` lists the due slugs without touching files.
- `--json` prints `{"published": […], "failed": n}` (or
`{"due": […], "dry_run": true}`).
- The configured webhooks receive `post.published` for every post this
publishes, the same event the admin delivers.
- Exit code `1` when a post could not be written, so a timer notices.
## `volumen validate`
```text
volumen validate [--config <path>] [--json]
```
Scans the content directory and reports: files that cannot be parsed, posts
that fail to render, duplicate slugs, slug values that do not match the slug
format, missing titles, a `publish_at` that is not a date (which withholds the
post), and alias conflicts (an alias used twice or shadowing a live slug).
Prints `content OK` when clean; exit code `1` with a problem list otherwise.
`--json` prints `{"problems": [{"slug", "path", "error"}]}`, where `path` names
the file when there is no slug to name.
A file that cannot be parsed is invisible to every other command, which is why
it is named here first.
## `volumen version`
```text
volumen version
```
Prints `volumen <version>`: the release the toolchain recorded in the
binary's build information. A release binary reports its tag (`v1.0.0`); a
build from a plain checkout reports a pseudo-version naming the commit; a
build outside version control reports `(devel)`, and a dirty tree appends
`+dirty`. Nothing injects the version, so the reported value cannot go stale.
## Global flags
| Flag | Effect |
|---|---|
| `-h`, `--help`, `help` | prints the usage block and exits `0` |
| `-v`, `--version` | prints `volumen <version>` and exits `0` |
| `-h` on a subcommand | prints that subcommand's flags and exits `0`; `version` prints the version instead |
An unknown command and an unknown flag exit `2`. Every subcommand rejects a
stray positional argument with exit code `2`; `import` is the one command whose
positional argument (`<archive>`) is required.
## Exit codes
| Code | Meaning |
|---|---|
| `0` | success |
| `1` | a failure the program detected: an unreadable config, a failed write, a content directory with a problem, a release check that could not run |
| `2` | the arguments were wrong |
`check-update` is the exception that proves the rule: it exits `1` both when a
newer release exists and when the release check failed, so a monitoring probe
reads its `--json` output rather than its status alone.
## Examples
Serve a checkout against its own content, with the scheduler on:
```sh
volumen serve --config ./config.toml --content ./posts
```
Start a fresh per-user installation and open the wizard:
```sh
volumen serve
```
The server comes up on `http://localhost:9091`; `/admin` shows the
first-run wizard, which creates the administrator account and signs the
operator in. The state lives under `~/.local/share/volumen`, and the
session secret in `secret.key` beside it.
Publish what a timer missed, then check the content directory:
```sh
volumen publish-due --dry-run
volumen publish-due
volumen validate
```
Back up a deployment and restore it into a second one:
```sh
volumen export --config /etc/volumen/config.toml --out /backup/volumen.tar.gz
volumen import --config /srv/staging/config.toml /backup/volumen.tar.gz
```
Find out why a fresh install will not start:
```sh
volumen doctor --config /etc/volumen/config.toml
volumen status --config /etc/volumen/config.toml --json
```
+272
View File
@@ -0,0 +1,272 @@
# Configuration
Volumen reads its configuration from one TOML file:
`/etc/volumen/config.toml` for a system install,
`~/.config/volumen/config.toml` for a per-user install, with the file that
exists tried first when no `--config` is given. With no file anywhere the
server runs on the built-in defaults, which put the state under
`~/.local/share/volumen`. No environment variable is read (the XDG ones only
locate those paths). Flags passed to `volumen serve` override the file, as
described under [Precedence](#precedence).
## File
The file is copied from the commented template (`internal/config/template.go`),
which is committed as `config.toml.example` at the repository root. Every key
the loader accepts is listed there with its
meaning, so an operator sees the whole surface and edits what this deployment
needs rather than discovering keys from a bare table dump. The copy lives at
the config path with mode `600`, because it is deployment state.
```toml
# volumen configuration.
#
# Every key this file accepts is listed here. Copy it to
# /etc/volumen/config.toml (or ~/.config/volumen/config.toml for a
# per-user installation), then edit.
#
# Keys that belong to no table come first, because a key written below a
# [table] header belongs to that table.
# Directory of the Markdown posts (.md with TOML frontmatter).
content_dir = "/var/lib/volumen/posts"
# File holding the admin accounts (managed from the admin Settings page).
users_file = "/var/lib/volumen/users.toml"
# How many previous versions of each post to keep in .revisions/
# (0 keeps none, which also makes deleting a post permanent).
revision_limit = 10
# Where the audit log is appended, or "" to disable auditing. Records
# who changed what, and when, in JSON lines.
audit_log = ""
[server]
# Address to bind, as an IP address: "::" is every interface, "::1" is
# loopback only, which is what a reverse proxy needs.
host = "::"
port = 9091
# Environment label: "development" or "production". It decides the
# startup safety checks (session key length, cookie flags, password
# policy).
env = "development"
# Set true ONLY when a trusted reverse proxy terminates TLS in front of
# volumen. Client addresses are then taken from X-Forwarded-For and
# cookies are marked Secure.
trust_proxy = false
# Addresses whose X-Forwarded-For may be believed, as addresses or CIDR
# prefixes. An empty list never reads the header and always uses the
# connection address; list the proxy so its clients each rate-limit
# under their own address.
trusted_proxies = []
# Set true in production to force the Secure flag on session cookies.
cookie_secure = false
# Log output format: "text" (human readable) or "json" (structured).
log_format = "text"
[site]
title = "Volumen"
description = "Powered by Volumen."
# Absolute URL of the public site, without a trailing slash.
base_url = "https://example.com"
language = "en"
author = "Anonymous"
# Fediverse handle surfaced as the author in feeds and meta tags.
# Leave empty to disable.
fediverse_creator = ""
[admin]
# Secret that signs session cookies (at least 64 bytes in production).
# Leave empty: the server generates one and keeps it in secret.key next
# to users.toml. A value here overrides that file.
session_key = ""
# Session lifetime in seconds (24 hours by default).
session_ttl = 86400
# Minimum password length enforced when a password is set in the admin UI.
min_password_length = 10
# Maximum password length, to bound the scrypt work.
max_password_length = 1024
# Maximum upload size in bytes (10 MB by default).
max_upload_bytes = 10485760
[api]
# Public API rate limit: requests allowed per window per client address.
# 0 disables rate limiting.
rate_limit = 60
# Rate-limit window length in seconds.
rate_limit_window = 60
# Scheduled publishing, for a post whose frontmatter carries publish_at.
# [scheduler]
# enabled = false
# interval = 300
# Outgoing webhooks: POST a signed JSON payload on post changes so a
# front-end can rebuild its cache or static pages. Repeat the block for
# more endpoints; events may be omitted to receive every event.
# [[webhooks]]
# url = "https://example.com/hooks/rebuild"
# secret = "a-long-random-string" # HMAC-SHA256 signing key
# events = ["post.created", "post.updated", "post.deleted", "post.published"]
# enabled = true
```
The template lists the four keys that belong to no table first, before any
header, because TOML puts a key written below a `[table]` header inside that
table. The loader refuses such a file rather than falling back to the default:
a `content_dir` written under `[server]` stops the start with a message naming
the fix, so a misplaced key is never silently ignored.
Five state files live next to `users_file`, in the same directory, and are not
read from the configuration file itself: `users.toml` holds the admin
accounts, started by the first-run wizard; `secret.key` holds the session
secret the server generated on first start (unless `[admin].session_key`
overrides it); `templates.toml` the `[[templates]]` array of post templates offered
in the admin "New post" form, each with `name`, `title`, `slug`, `tags`,
`body`, and an optional `[templates.fields]` table of editor inputs to
pre-fill (`author`, `lang`, `doi`, `orcid`, `series`, `series_order`,
`excerpt`, `cover`, …); `tokens.toml` the API token records, each with `name`, `token_hash`
(the SHA-256 digest, never the raw token), `created`, `last_used` and `scopes`;
and `webhooks.toml` the admin-managed webhook endpoints, of the same shape as
`[[webhooks]]` below. The admin rewrites each atomically at mode `600`, so
editing one by hand while the server runs is not advised. A token record
without a `scopes` list is unrestricted; a token created with a scope list
that names no recognised scope is refused rather than turned into an
unrestricted one; the scopes are `write` and `delete`
([`internal/tokens/tokens.go`](../internal/tokens/tokens.go)). A `webhooks.toml`
that cannot be parsed is logged and ignored, and the config-declared hooks
keep working.
## Keys
| Key | Type | Default | Effect |
|---|---|---|---|
| `content_dir` | string | `"/var/lib/volumen/posts"` | Directory of `.md` files with `+++` TOML frontmatter. Archived revisions live under `<content_dir>/.revisions/`, uploaded media under `<content_dir>/media/`. The parent directory must be writable, checked at startup with a write probe |
| `users_file` | string | `"/var/lib/volumen/users.toml"` | File holding the admin accounts. Its parent directory must be writable. `templates.toml`, `tokens.toml` and `webhooks.toml` are created next to it |
| `revision_limit` | integer | `10` | Archived versions kept per post under `<content_dir>/.revisions/`. `0` disables archiving: a save then keeps no previous version and a delete is permanent |
| `audit_log` | string | `""` | Path of the JSON-lines audit log, or empty to disable auditing. Each line is one JSON object carrying `ts` (RFC 3339, UTC), `action` and `user`, and, where the action knows them, `resource`, `detail` and `ip`. The file is created at mode `600`; a write failure is logged and never fatal |
| `server.host` | string | `"::"` | Bind address, written as an IP address rather than a name: the address is parsed, not resolved, so `"localhost"` is refused. `"::"` listens on every interface with IPv4 dual-stack; `"::1"` or `"127.0.0.1"` binds loopback only, for a reverse proxy in front |
| `server.port` | integer | `9091` | TCP port to bind. `1` to `65535` |
| `server.env` | string | `"development"` | `"development"` or `"production"`. The label decides one thing beyond its own validation: in production the session-key rules are fatal, as the `admin.session_key` row and [Validation](#validation) describe |
| `server.trust_proxy` | boolean | `false` | Take the client address from `X-Forwarded-For` instead of the connection, and mark session cookies `Secure`. The header is read only when the peer address is inside `server.trusted_proxies`, and the address taken is its last entry, the one the proxy appends when it forwards a request; the entries to its left are client-supplied and can be forged to rotate the rate-limit key, so the proxy must append the connecting address rather than pass the header through. Set it only behind a trusted reverse proxy that terminates TLS, and outside production the start logs a warning saying so |
| `server.trusted_proxies` | array of strings | `[]` | Addresses or CIDR prefixes whose `X-Forwarded-For` may be believed. An empty list never reads the header and always uses the connection address, so every client behind the proxy shares one rate-limit budget; a loopback deployment behind nginx lists `["::1", "127.0.0.1"]`. Each entry must parse as an address or a prefix. The address taken is always the header's last entry, the one the trusted proxy appended; with two chained proxies in front of the listener that entry is the front proxy's address, so all of its clients then share one rate-limit bucket, and the deployment must either let only the immediate proxy append the header or size the limit for the aggregate |
| `server.cookie_secure` | boolean | `false` | Mark session cookies `Secure` so a browser sends them over HTTPS only. Cookies also carry the flag when `server.trust_proxy` is set, and either setting makes responses carry `Strict-Transport-Security` |
| `server.log_format` | string | `"text"` | `"text"` for human-readable lines or `"json"` for one structured object per line through `log/slog`. Applied once at startup by `volumen serve` |
| `site.title` | string | `"Volumen"` | Site title, served by `/api/volumen/site` and used in the feeds |
| `site.description` | string | `"Powered by Volumen."` | Site description, used in the RSS channel, the Atom subtitle, the JSON Feed and `/api/volumen/site` |
| `site.base_url` | string | `"https://example.com"` | Canonical site URL, without a trailing slash. Must be an absolute URL. Builds every absolute link: post permalinks in the feeds, `feed_url`, the sitemap, preview links and the `Sitemap:` line in `robots.txt` |
| `site.language` | string | `"en"` | Default language, inherited by a post whose frontmatter and directory carry none. Emitted as `<language>` in RSS and `language` in the JSON Feed. Seeds the admin interface language for the login screen until an account picks its own |
| `site.author` | string | `"Anonymous"` | Site author, served by `/api/volumen/site`. It is not substituted into a post's own `author` field |
| `site.fediverse_creator` | string | `""` | Optional site-wide handle in `@user@host` form, validated against that pattern when set. Served by `/api/volumen/site`, used as the author of a JSON Feed item whose post has none, and offered as the default in the admin post form. A per-post value takes precedence |
| `admin.session_key` | string | `""` | Secret that signs session cookies and preview links. Leave it empty: the server generates a 64-character hex secret on first start and keeps it in `secret.key` beside the users file, so sessions survive restarts without the operator doing anything. A value here overrides that file and must be at least 64 bytes in production; a shorter configured value is refused at startup there, and tolerated in development, where an empty or short key means an ephemeral secret |
| `admin.session_ttl` | integer | `86400` | Session lifetime in seconds, used as the cookie `max-age` and enforced when the cookie is loaded. `1` to `31536000` (24 hours by default, one year at most) |
| `admin.min_password_length` | integer | `10` | Shortest password the admin accepts when an account is created or a password is changed. At least `1` and at most `admin.max_password_length` |
| `admin.max_password_length` | integer | `1024` | Longest password the admin accepts. Bounds the scrypt work, because unbounded input would be a denial-of-service vector. `1` to `1024`; a larger value is refused at startup rather than turning into a failed password change later |
| `admin.max_upload_bytes` | integer | `10485760` | Largest accepted upload in bytes: a media upload (`POST /admin/uploads`, refused with `413` and the code `too_large`), a post import and a profile photo, which report the size in the form instead. `1` to `1073741824` (10 MiB by default, 1 GiB at most) |
| `api.rate_limit` | integer | `60` | Requests allowed per window per client address across `/api/volumen/*`, counted in a sliding window. `0` disables rate limiting: the middleware is not installed and no `X-RateLimit-*` header is sent. Zero or greater. See the [API documentation](API.md#rate-limiting) for the headers and the `429` body |
| `api.rate_limit_window` | integer | `60` | Window length in seconds for the limit above. `1` to `86400` while rate limiting is enabled; a value outside that range is refused at startup |
| `scheduler.enabled` | boolean | `false` | Start the in-process publish loop, which is the alternative to running `volumen publish-due` from cron: with the scheduler enabled, `volumen serve` publishes due posts itself, once at startup and then on the interval. Read once, at startup |
| `scheduler.interval` | integer | `300` | Seconds between runs. At least `1` when the scheduler is enabled |
| `[[webhooks]].url` | string | unset | Endpoint URL, required, absolute `http` or `https`; an entry without it stops the start |
| `[[webhooks]].secret` | string | `""` | HMAC-SHA256 signing key. When set, each request carries `X-Volumen-Signature: sha256=<hex digest of the raw body>` |
| `[[webhooks]].events` | array of strings | `[]` | Events to receive: `post.created`, `post.updated`, `post.deleted`, `post.published`. Empty or absent receives every event |
| `[[webhooks]].enabled` | boolean | `true` | `false` keeps the entry but skips delivery |
Both scheduled-publishing mechanisms do the same work: a post whose
`publish_at` date is today or earlier loses that key and gains a `date` when
it carried none, and each published post fires the `post.published` webhook.
See [DEPLOYMENT.md](DEPLOYMENT.md#scheduled-publishing).
The optional `[[webhooks]]` array registers one outgoing webhook per entry.
When a post is created, updated, deleted or published, Volumen POSTs a signed
JSON body to every matching endpoint. Every request also carries
`X-Volumen-Event` (the event name), `X-Volumen-Delivery` (a unique delivery
id) and `User-Agent: volumen/<version>`. The body carries `event`,
`timestamp`, `version` and, for a post event, a `post` object holding the
post summary. Delivery runs in the background with up to three attempts. The
most recent deliveries are listed in the admin at **Settings, Webhooks**,
where a **Send test** button fires a `ping` event. Endpoints added in the
admin are not written into this file: they live in `webhooks.toml` beside the
users file, and a change applies there without a restart; the config-declared
entries are read-only in the admin and both sets deliver.
## Precedence
Sources, strongest first: the flags of `volumen serve`, then the configuration
file, then the built-in defaults. There is no environment variable and no
second file.
| Flag | Effect |
|---|---|
| `--config PATH` | Selects the file to read. Without it the server tries `/etc/volumen/config.toml`, then `~/.config/volumen/config.toml`, and uses the built-in defaults when neither exists |
| `--content DIR` | Overrides `content_dir` |
| `--host ADDR` | Overrides `server.host` |
| `--port N` | Overrides `server.port`, and must be `1` to `65535` |
```sh
volumen serve --config /etc/volumen/config.toml \
--content /var/lib/volumen/posts \
--host 127.0.0.1 \
--port 9000
```
A key absent from the file takes its default, and a key the loader does not
know is ignored, so a file left over from an older release still starts. A
missing file is not an error either: the defaults are used and one line is
logged saying so.
## Validation
`Config.Validate()` runs at startup, before the server binds.
The file is decoded into typed values, and a key whose TOML type does not match
its field is an error rather than a silent fallback to the default, because a
typo that quietly disables a setting is worse than a refusal to start. The
decoder lives in [`internal/config/config.go`](../internal/config/config.go)
and covers every key in the table above. A key the decoder does not know is
ignored, so a file written for another release still loads, and a root key
written below a table header is refused with the fix in the message, as the
[File](#file) section describes. The message names the key and the type it
found:
```text
volumen serve: parse config /etc/volumen/config.toml: interpres: server.port: cannot assign string to int
```
The remaining rules are value rules:
| Key | Rule |
|---|---|
| `server.host` | Non-empty, and an IP address |
| `server.port` | `1` to `65535` |
| `server.env` | `development` or `production` |
| `server.log_format` | `text` or `json` |
| `server.trusted_proxies` | Each entry an address or a CIDR prefix |
| `site.base_url` | Non-empty, and an absolute URL with a scheme and a host |
| `site.fediverse_creator` | `@user@host` when set |
| `admin.session_ttl` | `1` to `31536000` |
| `admin.min_password_length` | At least `1`, and at most `admin.max_password_length` |
| `admin.max_password_length` | At most `1024` |
| `admin.max_upload_bytes` | `1` to `1073741824` |
| `admin.session_key` | At least 64 bytes when `env = "production"` |
| `revision_limit` | Zero or greater |
| `api.rate_limit` | Zero or greater |
| `api.rate_limit_window` | `1` to `86400` while rate limiting is enabled |
| `scheduler.interval` | At least `1` while the scheduler is enabled |
| `[[webhooks]].url` | Absolute `http` or `https` URL |
| `content_dir` | The parent directory must be creatable and writable |
| `users_file` | The parent directory must be creatable and writable |
A failure stops the process with the message on standard error and exit code
`1`, as the example above shows. `volumen doctor --config PATH` reports the
same check without starting the server:
```text
config ok /etc/volumen/config.toml
config-validate ok
posts-readable ok
posts-render ok
users-file ok
password-hashes ok
```
+748
View File
@@ -0,0 +1,748 @@
# Deployment
How Volumen runs in production.
## Topology
```mermaid
flowchart TD
net["Internet"] -->|HTTPS| nginx
nginx["nginx :443, TLS termination and reverse proxy"] -->|"proxy_pass http://[::1]:9091"| app
app["volumen, one static binary, loopback only"] --> data
app --> etc
data["/var/lib/volumen holding posts/ (with media/ and .revisions/ inside) and users.toml, templates.toml, tokens.toml, webhooks.toml, secret.key"]
etc["/etc/volumen/config.toml"]
```
nginx terminates TLS on the public hostname and proxies `/api/volumen`,
`/admin` and `/media` to the backend on loopback; everything else on that
hostname is the public site, served separately. The engine owns one content
directory (posts, media and revisions inside it) and four files beside it:
`users.toml`, `templates.toml`, `tokens.toml` and `webhooks.toml`. There is
one process per content directory.
## Requirements
- **A supported platform**: the release builds cover Linux on `amd64`,
`arm64`, `loong64`, and `riscv64`, and FreeBSD on `amd64` and
`arm64`. The binary is static
(`CGO_ENABLED=0`), so no runtime libraries are needed.
- **Go 1.27.1** or newer only when building from source: the module declares
it.
- **nginx** and a TLS certificate (e.g. via certbot) for the public hostname.
- A dedicated system user, `volumen` by default, for the service. The
documentation creates it by hand; nothing in the binary creates users.
- **systemd** for the managed service (optional; an rc.d script, a supervisor,
or a plain foreground run all work).
- Nothing else: no interpreter, no virtual environment, no package manager.
The deployment itself is one command: start `volumen serve` and open
`/admin`, where the first-run wizard creates the administrator account. The
commented configuration template, `config.toml.example`, is committed at
the repository root, and the session secret is generated by the server.
## Build
```sh
just build # writes bin/volumen for the host platform
```
Releases are built by the tag pipeline for Linux (`amd64`, `arm64`, `loong64`,
`riscv64`) and FreeBSD (`amd64`, `arm64`), with `CGO_ENABLED=0` and
no build tags, so every artefact is a static binary. `-trimpath` keeps the
checkout's own path out of the binary and `-buildvcs=true` records the
revision it came from, so the same tree builds the same bytes from any
directory and `volumen version` still names the tag or the commit. The version
is recorded by the toolchain at build time; nothing injects it.
## Run
Installing the binary is a copy, and the installation is the wizard: start
`volumen serve`, open `/admin`, and the first-run screen creates the
administrator account, the interface language and the colour scheme. A
system deployment writes the config by copying `config.toml.example`; a
per-user one needs no config at all.
Any account can then add a second factor from Settings, Security: scan the
offered QR with an authenticator application and confirm one code. The
recovery codes shown at that moment open the account when the application
is lost; they work once each and are not shown again, so they belong in a
password manager the moment they appear.
### From a Gitea release
Every release on
[sourcedock.dev](https://sourcedock.dev/petrbalvin/volumen/releases) ships
platform binaries and a `checksums.txt` with their SHA-256 digests. Asset
names follow `volumen-<version>-<os>-<arch>`:
```sh
VERSION=1.0.0
BASE="https://sourcedock.dev/petrbalvin/volumen/releases/download/v${VERSION}"
curl -fLO "${BASE}/volumen-${VERSION}-linux-amd64"
curl -fLO "${BASE}/checksums.txt"
# Verify the download against the published digest.
sha256sum --ignore-missing -c checksums.txt
sudo install -m 0755 "volumen-${VERSION}-linux-amd64" /usr/local/bin/volumen
volumen version
```
Replace `linux-amd64` with `linux-arm64`, `linux-loong64`, `linux-riscv64`,
`freebsd-amd64`, or `freebsd-arm64`
as needed. The release binary carries its version, so `volumen version`
confirms what you installed.
### From source
```sh
git clone https://sourcedock.dev/petrbalvin/volumen.git
cd volumen
just install
sudo install -m 0755 bin/volumen /usr/local/bin/volumen
```
The binary reports the version the toolchain recorded at build time:
`volumen version` names the commit of the checkout it was built from, and a
release build names its tag. There is no version flag to pass and nothing to
inject.
### Bootstrap the deployment
There is no bootstrap command. A deployment is a directory for its state, a
configuration file copied from the template when the defaults do not fit, a
supervisor to keep the server running, and one visit to `/admin`, where the
first-run wizard creates the first account. The wizard stays open until that
account exists: on a machine reachable from the network, start the service and
claim the installation straight away.
### System install (root, systemd)
```sh
# 1. Service account and its group.
sudo useradd --system --user-group --home-dir /var/lib/volumen \
--shell /usr/sbin/nologin volumen
# 2. The config: copy the commented template from the repository root.
sudo mkdir -p /etc/volumen /var/lib/volumen/posts/media
sudo cp config.toml.example /etc/volumen/config.toml
# 3. Edit the copy for production behind a reverse proxy:
# host = "::1", env = "production", trust_proxy = true,
# cookie_secure = true,
# trusted_proxies = ["::1", "127.0.0.1"]
# Leave [admin].session_key empty: the server generates its secret into
# /var/lib/volumen/secret.key on first start and keeps it there.
# 4. Own the data directory so the service can write posts, media, accounts
# and its secret.
sudo chown -R volumen:volumen /var/lib/volumen
# 5. Install the unit (the Service unit section below), then:
sudo systemctl daemon-reload
sudo systemctl enable --now volumen.service
# 6. Open http://localhost:9091/admin/ and complete the wizard.
```
The two proxy settings carry weight: `trust_proxy = true` takes the client
address from the proxy's `X-Forwarded-For`, and `cookie_secure = true` keeps
the session cookie on HTTPS. The config is deployment state and stays mode
`0600`.
### Per-user install (no root)
```sh
volumen serve
```
With no config file the server runs on the per-user paths:
`~/.local/share/volumen/posts` and `~/.local/share/volumen/users.toml`
(honouring `XDG_DATA_HOME`), and it reads `~/.config/volumen/config.toml`
when that file exists. No systemd unit is installed in this mode; run the
server in the foreground or through a supervisor of your choice.
### Configuration locations
| Path | Purpose |
|------|---------|
| `/etc/volumen/config.toml` | configuration: a copy of the commented `config.toml.example`, edited to fit |
| `/var/lib/volumen/posts` | Markdown posts |
| `/var/lib/volumen/posts/media` | uploaded images (WebP / AVIF / SVG) |
| `/var/lib/volumen/posts/.revisions` | archived post versions and delete tombstones |
| `/var/lib/volumen/users.toml` | admin users (created by the first-run wizard or the Settings page) |
| `/var/lib/volumen/secret.key` | the session secret the server generated on first start |
| `/var/lib/volumen/templates.toml` | post templates (created from the admin Settings page) |
| `/var/lib/volumen/tokens.toml` | API access tokens (created from the admin Settings page) |
| `/var/lib/volumen/webhooks.toml` | admin-managed webhook endpoints (created from the admin Settings page) |
| `/var/lib/volumen/audit.log` | audit trail (only when `audit_log` is configured) |
| `/etc/systemd/system/volumen.service` | systemd unit (written by the operator, see the Service unit section) |
| `~/.config/volumen/config.toml` | per-user config, read when it exists |
| `~/.local/share/volumen/` | per-user data dir (the default when no config file exists) |
The users, templates, tokens and secret files carry password hashes, token
digests and the signing key, so never make them world-readable: every write
the server performs re-applies mode `600`, media and post writes are atomic.
Every configuration key is documented in
[CONFIGURATION.md](CONFIGURATION.md).
### Scheduled publishing
Posts may carry a `publish_at` date in their frontmatter; they stay hidden
until that date arrives. Two mechanisms can flip them, and both do the same
thing: drop `publish_at` and set `date` when the post had none.
**From cron** (or a systemd timer):
```text
*/5 * * * * /usr/local/bin/volumen publish-due --config /etc/volumen/config.toml
```
Use the absolute path to the binary, since cron's `PATH` is minimal.
`--dry-run` lists due posts without touching files; `--json` emits
machine-readable output. A real run delivers the `post.published` event to the
configured `[[webhooks]]` for every post it publishes and waits for those
deliveries to finish, so a front end that rebuilds from a webhook hears about
a publish that came from cron. A post whose file could not be saved is
reported on stderr and the command exits `1`, so a timer unit or a monitoring
check can see the failure rather than assume success.
**From the server itself**, via the `[scheduler]` configuration section:
```toml
[scheduler]
enabled = true
interval = 300 # seconds
```
The in-process loop starts with `volumen serve` and needs no external timer.
It sweeps once at start-up, so a post whose date passed while the service was
down is published as soon as it comes back, and then once every `interval`
seconds. An interval below one second never reaches the loop: the
configuration validation refuses it at start-up. The loop delivers the same
`post.published` webhook as the
admin does, and a save failure is logged and skipped, so one bad file cannot
stop the sweep or the loop.
The systemd timer equivalent for the CLI path:
```ini
# /etc/systemd/system/volumen-publish.service
[Unit]
Description=Publish due volumen posts
[Service]
Type=oneshot
User=volumen
ExecStart=/usr/local/bin/volumen publish-due --config /etc/volumen/config.toml
```
```ini
# /etc/systemd/system/volumen-publish.timer
[Unit]
Description=Publish due volumen posts every five minutes
[Timer]
OnCalendar=*:0/5
[Install]
WantedBy=timers.target
```
```sh
sudo systemctl daemon-reload
sudo systemctl enable --now volumen-publish.timer
```
Adjust the `ExecStart` path to match the one in `volumen.service`.
## Service unit
`/etc/systemd/system/volumen.service`, written by the operator (paths and
binary location fitted to the deployment):
```ini
[Unit]
Description=Volumen, a lightweight publishing platform for scientists
After=network.target
[Service]
Type=simple
User=volumen
Group=volumen
WorkingDirectory="/etc/volumen"
ExecStart="/usr/local/bin/volumen" serve --config "/etc/volumen/config.toml" --content "/var/lib/volumen/posts"
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths="/var/lib/volumen"
PrivateTmp=true
[Install]
WantedBy=multi-user.target
```
Quote every path so a layout with spaces in it stays one argument, and double
any `%`, because unit files expand `%X` specifiers.
- `User` / `Group` name the service account (default `volumen`), and
`WorkingDirectory` is the directory holding the config file.
- `ExecStart` names the installed binary.
- `ReadWritePaths` is the **parent of the data directory**, so a layout with
posts at `/srv/volumen/posts` yields `ReadWritePaths=/srv/volumen`: the
service also writes the users file, its templates, tokens, webhooks and
`secret.key` there. With `ProtectSystem=strict` the rest of the filesystem
is read-only to the service.
- `NoNewPrivileges`, `ProtectHome` (`/home`, `/root`, `/run/user`
inaccessible), `PrivateTmp`, and a two-second restart delay on failure.
The server bounds every connection: a 10 second read-header timeout, a 60
second read timeout, a 120 second write timeout, and a 120 second idle
timeout, so a client that opens a socket and dribbles a request cannot hold it
indefinitely. On `SIGINT` or `SIGTERM` (what `systemctl stop` sends) it stops
accepting new connections and drains the requests already in flight, giving
them at most 15 seconds.
### FreeBSD (rc.d)
The `freebsd/amd64` and `freebsd/arm64` release binaries
run natively; nothing is compiled at install time.
```sh
# Install the binary from the release, as above, to /usr/local/bin/volumen.
pw useradd volumen -d /var/db/volumen -s /usr/sbin/nologin -c "Volumen publishing platform"
mkdir -p /usr/local/etc/volumen /var/db/volumen/posts/media
cp config.toml.example /usr/local/etc/volumen/config.toml
chown -R volumen /var/db/volumen
```
Edit the copied config for a production deployment behind a proxy: set the
three paths, `host = "::1"`, `env = "production"`, `trust_proxy = true`,
`cookie_secure = true` and `trusted_proxies = ["::1", "127.0.0.1"]`. Leave
`[admin].session_key` empty; the server writes `secret.key` into
`/var/db/volumen`, which is owned by the `volumen` user.
Conventional FreeBSD paths: config in `/usr/local/etc/volumen/`, data in
`/var/db/volumen/`. Drop an `rc.d` script in
`/usr/local/etc/rc.d/volumen`:
```sh
#!/bin/sh
#
# PROVIDE: volumen
# REQUIRE: NETWORKING
# KEYWORD: shutdown
. /etc/rc.subr
name="volumen"
rcvar="volumen_enable"
load_rc_config $name
: ${volumen_enable:="NO"}
: ${volumen_user:="volumen"}
: ${volumen_config:="/usr/local/etc/volumen/config.toml"}
: ${volumen_content:="/var/db/volumen/posts"}
pidfile="/var/run/${name}.pid"
command="/usr/sbin/daemon"
command_args="-f -r -P ${pidfile} -u ${volumen_user} \
/usr/local/bin/volumen serve --config ${volumen_config} --content ${volumen_content}"
run_rc_command "$1"
```
Enable and start:
```sh
chmod +x /usr/local/etc/rc.d/volumen
sysrc volumen_enable=YES
service volumen start
```
## Production configuration
Volumen only owns `/api/volumen`, `/admin`, and `/media`. Serve your public
site from the same hostname and proxy those prefixes to the backend, so the
admin runs same-origin (session cookies and CSRF work without extra
configuration). A production deployment binds the service to `::1`, so proxy
to `[::1]:9091`:
```nginx
server {
listen 443 ssl http2;
server_name lab.example.com;
ssl_certificate /etc/letsencrypt/live/lab.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/lab.example.com/privkey.pem;
# Public site: any front end, static files or a built one.
root /var/www/site;
location / {
try_files $uri $uri/ /index.html;
}
# volumen API, admin and media.
location ~ ^/(api/volumen|admin|media)(/|$) {
proxy_pass http://[::1]:9091;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name lab.example.com;
return 301 https://$host$request_uri;
}
```
Caddy is the recommended front, and the same shape costs a handful of
lines: certificates and the HTTP redirect are its own work.
```caddyfile
lab.example.com {
root * /var/www/site
@backend path /api/volumen/* /admin /admin/* /media /media/*
handle @backend {
reverse_proxy [::1]:9091
}
handle {
try_files {path} {path}/ /index.html
}
}
```
Caddy appends the connecting address to `X-Forwarded-For` the way
`$proxy_add_x_forwarded_for` does, so the guarantee described below holds
with either front.
With `[server].trust_proxy = true` (a production deployment turns it on), the
client IP used by the API rate limiter and the login limiter is the **last**
entry of `X-Forwarded-For`, and only when the connection itself comes from an
address listed in `[server].trusted_proxies`. Session cookies then always
carry `Secure`.
The last entry is the one the peer that wrote it saw as its client, so the
directives above are safe as written: `$proxy_add_x_forwarded_for` appends
`$remote_addr` after anything the client sent, which means a client cannot
displace its own address by sending a header of its own. The guarantee the
proxy must provide is the other half: only nginx may reach the backend. Bind
it to `::1`, and never expose port 9091,
because anything that can open a connection to the backend directly can set
`X-Forwarded-For` itself and choose the address it is rate-limited under (see
the security notes below).
`[server].trusted_proxies` turns that guarantee into a check rather than a
promise: with `trusted_proxies = ["::1", "127.0.0.1"]`, the forwarded address
is believed only when the connection itself comes from the loopback proxy, and
a request that arrives from anywhere else is measured by its real address.
A loopback proxy behind `nginx` uses exactly that list. An empty list never reads
the header at all: behind a proxy every client is then measured under the
proxy's own connection address and shares one rate-limit budget, so list the
proxy to measure each client by its forwarded address.
### Hardening the login
The engine rate-limits `/admin/login` itself (10 attempts per IP per 60 s;
see [the session and rate-limit rules](ARCHITECTURE.md#state-and-lifetime)),
but nginx is the durable line of defence. Add brute-force throttling (and,
optionally, an IP allowlist) at the proxy.
**1. A rate-limit zone** in the `http { }` context (e.g.
`/etc/nginx/conf.d/volumen.conf`):
```nginx
limit_req_zone $binary_remote_addr zone=volumen_login:10m rate=5r/m;
```
**2. A reusable proxy snippet** at `/etc/nginx/snippets/volumen-proxy.conf`:
```nginx
proxy_pass http://[::1]:9091;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
```
**3. Split the single proxy location** in the `server { }` block into three, namely
public API/media, the throttled login, and the rest of the admin:
```nginx
# Public API and uploaded media, open to everyone.
location ~ ^/(api/volumen|media)(/|$) {
include snippets/volumen-proxy.conf;
}
# Login: throttle brute-force attempts on top of the app-level limit.
location = /admin/login {
# Optional IP allowlist (uncomment to restrict):
# allow 203.0.113.0/24;
# deny all;
limit_req zone=volumen_login burst=10 nodelay;
include snippets/volumen-proxy.conf;
}
# The rest of the admin.
location ^~ /admin {
include snippets/volumen-proxy.conf;
}
```
nginx matches `= /admin/login` (exact) first, then `^~ /admin` (which stops
regex matching), then the `~` regex for API/media. Apply with
`sudo nginx -t && sudo systemctl reload nginx`.
### Security notes
- Bind to `::1` (or `127.0.0.1`); only nginx is public. TLS is terminated by
nginx. The production config sets `host = "::1"`.
- Session cookies are `HttpOnly` and `SameSite=Strict`, and gain the `Secure`
attribute when `[server].cookie_secure = true` or when `trust_proxy = true`.
The session secret signs them and survives restarts: by default the server
generates it into `secret.key` beside the users file, and `[admin].session_key`
(at least 64 bytes, mandatory in production) overrides that file.
- `[server].trusted_proxies` names the addresses whose `X-Forwarded-For` is
believed. A loopback-nginx deployment lists `["::1", "127.0.0.1"]`, or narrows
it to the proxy's address, so a client that reaches the backend directly cannot
choose the address it is rate-limited under. An empty list ignores the
forwarded header, which behind a proxy folds every client into one
rate-limit bucket.
- Keep `config.toml`, `users.toml`, `tokens.toml` and `secret.key` readable only
by the `volumen` user (mode `600`). Every write the server makes enforces this.
- To change a forgotten administrator password, reset it from the admin Settings
page as another admin, or rotate the account in `users.toml`. Never put a
password on the command line.
- The controls themselves, and their limits, are described in
[the architecture](ARCHITECTURE.md#state-and-lifetime): how a body is
sanitised before it is served, how a password is stored, and what the session
cookie does and does not guarantee.
## Upgrade
### From the admin panel
The Version panel in Settings shows the running release and, when the release
check has found a newer one, an "Update now" action. The update endpoint is
admin-only and CSRF-protected. On confirmation the server:
1. queries the latest release from the Gitea releases API
(`/api/v1/repos/petrbalvin/volumen/releases/latest`),
2. downloads the matching asset from
`https://sourcedock.dev/petrbalvin/volumen/releases/download/v<version>/volumen-<version>-<os>-<arch>`,
3. verifies the SHA-256 digest against the release's `checksums.txt`,
4. stages the new binary in the executable's directory and renames it over the
running one,
5. re-executes itself with the original command-line arguments, preserving the
process ID so systemd never observes a restart.
The response page polls `/healthz` until the server is back (up to three
minutes). Nothing is installed without an admin clicking through.
The self-update writes the running binary in place, so the service user needs
write access to that directory. With the standard layout
(`/usr/local/bin/volumen` owned by root, service running as `volumen`) the
rename fails; either grant the service user write access to the install
directory or use the manual path below. A checksum or download error in the
panel means the release asset could not be fetched for the running platform;
the state on disk is untouched.
### From the shell
```sh
sudo systemctl stop volumen
sudo install -m 0755 volumen-1.0.0-linux-amd64 /usr/local/bin/volumen
volumen version
sudo systemctl start volumen
volumen status # confirm: config, posts, users
```
The persistent state (`config.toml`, `users.toml`, posts, revisions) is
untouched by either path.
### Monitoring for updates
```sh
volumen check-update # human-readable
volumen check-update --json # machine-readable
```
Exit codes: `0` when the running version is the latest release, `1` when a
newer release exists, and `1` as well when the release API cannot be reached
(the cause is on stderr, so a monitoring check distinguishes the two by its
output rather than by the code); `2` is a usage error and nothing else.
## Rollback
There is no rehearsed rollback procedure for a running installation, and this
document does not pretend otherwise.
- **A bad upgrade** is undone by putting the previous binary back and
restarting: the release assets are versioned, so an older one is still on the
releases page, and the data is untouched by an upgrade. A restore from a
backup archive is the fallback when data changed as well.
- **A bad content change** does not need a rollback: every save archives the
previous version under `posts/.revisions/<slug>/`, and a delete is a move into
that archive, so the admin's History view restores either one.
- **A bad configuration change** is undone by editing the file and restarting;
a configuration that fails validation stops the process before it binds its
port, so a broken edit cannot half-start the service.
### Backups
All state is files, so any filesystem backup works. Volumen also ships its
own archive commands:
```sh
volumen export --out /backup/volumen-$(date +%F).tar.gz
volumen import /backup/volumen-2026-08-02.tar.gz
```
`export` packs the content directory (posts, media, `.revisions`) under
`posts/`, plus `users.toml`, `templates.toml` and `tokens.toml`, into a
`tar.gz` written at mode `600`; `--out` is the flag, and the default output
path is `volumen-backup.tar.gz` in the working directory. `config.toml` is not
in the archive: the import has no use for it and the session key it carries is
a credential that should not travel in a file copied around. `import` restores
the posts, users, templates and tokens into the locations from the config.
A restore is confined by construction: the archive's entries are matched
against the fixed `posts/`, `users.toml`, `templates.toml` and `tokens.toml`
names, a `posts/` path must be a post, a revision or a media file with an
allowed image extension, and every write goes through an `os.Root` opened on
the content directory, which refuses an escape through `..` or through a
symlink. The decompressed size is bounded at 512 MiB, so a compression bomb
cannot exhaust memory. `.toml` files are written with mode `600`, everything
else with `644`.
The same export and restore is available from the admin Settings page, where
both directions require the **admin** role, because the archive carries the
users file with its password hashes and the tokens file with its digests. The
admin path calls the same two functions as the CLI, so the archive and the
containment rules are one implementation, not two.
## Monitoring
After the first-run wizard, confirm the installation state:
```sh
volumen status # config, content dir, post and user counts
volumen status --json # machine-readable; exit code 1 on any issue
volumen doctor # config validation, post rendering, weak hashes
volumen doctor --json # machine-readable; exit code 1 when a check fails
volumen validate # content check: slugs, titles, aliases, rendering
sudo systemctl status volumen
journalctl -u volumen -f
```
`volumen status` reads the config and reports the post count (with a draft
count) and the number of users; with no accounts yet it names the wizard as
the next step. `volumen doctor` validates the configuration,
renders every post, and warns when a stored password hash uses scrypt
parameters below the current policy floor. The exit code follows the levels:
a missing or broken config, an unreadable content directory and an unreadable
users file fail the command with exit code 1, while an installation still
waiting for its first account, posts that fail to render
and weak stored hashes are warnings that leave the exit code at 0.
`volumen validate` reports
duplicate slugs, invalid slugs, missing titles, aliased collisions, and posts
that fail to render.
Those commands check a deployment. The build of the binary is checked by
`just gates`, which runs `build`, `fmt-check`, `vet`, `test` and `race` in one
pass; the release pipeline runs the same set without the race detector (see
[Pipeline](#pipeline)).
Logs go to stderr and therefore to journald under systemd. For structured
output, set the log format to JSON:
```toml
[server]
log_format = "json"
```
Each line is then one JSON object from `log/slog` (time, level, message, and
the structured attributes of the event), suitable for `journalctl -o json`,
Loki, or similar. The default `"text"` format is human-readable. Every request
is logged once when it finishes, with its method, path, status and duration,
under a 16-character request id; the same id is answered in the `X-Request-Id`
header and attached to every line the handlers write while serving that
request, so one id finds a request and everything it did in the journal. The
server's own protocol errors are written through the same logger rather than
straight to stderr.
Set `audit_log` in the configuration to keep a separate, append-only
JSON-lines record of administrative actions. Logins and logouts are not
recorded; post creation, edits, deletions and bulk actions are, together with
media deletions, user and token management, and backup imports. Each line is
one JSON object carrying the timestamp `ts` (RFC 3339, UTC), the acting `user`
and the `action` as its message, plus the `resource`, the client `ip` and a
`detail` object wherever the action knows them.
## Pipeline
Releases are built by `.gitea/workflows/release.yml`, which runs when a `v*`
tag is pushed. The branch flow is the one in
[CONTRIBUTING.md](../CONTRIBUTING.md): work lands on `development`,
`development` is merged into `main`, and the tag is cut on `main`. The build
happens at the tag, and the toolchain records the tag into the binary's build
information, so the version in the binary is right because of where the build
ran; nothing is injected, and each job derives the version from the tag itself
rather than receiving it from another job.
```mermaid
flowchart TD
subgraph gates["gates job, 10 minute timeout"]
direction TB
g1["validate the tag against the semver pattern"] --> g2["go build ./..."] --> g3["gofmt -l . prints nothing"] --> g4["go vet ./..."] --> g5["go fix -diff ./..."] --> g6["go test with a coverage profile"] --> g7["the coverage floor of 80 per cent"]
end
subgraph builds["build job, 25 minute timeout, six targets in one matrix"]
direction TB
b1["linux on amd64, arm64, loong64, riscv64"] --> b2["freebsd on amd64, arm64"]
b2 --> b3["CGO_ENABLED=0 build with -trimpath and -buildvcs into bin/volumen-VERSION-OS-ARCH"]
b3 --> b4["upload the artefact"] --> b5["linux/amd64 smoke test, the binary reports the tag and no +dirty"]
end
subgraph rel["release job, 15 minute timeout"]
direction TB
r1["download the artefacts"] --> r2["write checksums.txt over them"] --> r3["take the CHANGELOG section for the tag"] --> r4["create the release over the Gitea API"] --> r5["upload the six binaries and checksums.txt"]
end
tag["a v* tag is pushed"] --> gates
gates --> builds
builds --> rel
```
The gates job runs the same set as `just gates` minus the race detector:
build, format check, `go vet` together with `go fix -diff`, the suite, and the
coverage floor of 80 per cent. Race is deliberately absent: the runner is
shared with the forge, and `just gates` races the tree on the machine where
the tag is cut. A push or a pull request to `development` runs the same steps
without race through `.gitea/workflows/test.yml`, and
`.gitea/workflows/race.yml` runs the suite under the race detector when it is
dispatched by hand.
The release is the only publisher: nothing is deployed out of the repository,
and a production installation takes its binary from a release, through the
admin self-update (see [Upgrade](#upgrade)) or a manual install followed by a
service restart.
Each matrix entry builds one static binary named
`volumen-<version>-<os>-<arch>` for the version without its leading `v`, with
`-trimpath` and `-buildvcs=true`, uploads it, and, on linux/amd64 only, runs it
and requires the output of `volumen version` to contain the tag and not
`+dirty`. The release job then
writes `checksums.txt`, one `sha256sum` line per asset, takes the release
notes from the `CHANGELOG.md` section for the tag, creates the release over
the Gitea API, and uploads the six binaries with the checksums file. That
file is part of the update contract: the admin self-update refuses a download
whose SHA-256 does not match it.
The release carries no licence files of its own, because the repository holds
them: `LICENSE` for Volumen, and
[NOTICE.md](../NOTICE.md) for every module the binary
is compiled from. Gitea attaches the tag's own source archive to the release
beside the binaries, and that archive contains both.
+161
View File
@@ -0,0 +1,161 @@
# Development
How to work on **Volumen**.
## Prerequisites
- Go 1.27.1, the newest stable release; the `go` directive in `go.mod`
declares exactly that version, and nothing older builds the module.
- [just](https://github.com/casey/just) for the recipes.
- gcc, but only for the race detector: `just race` needs cgo. The build
itself is pure Go with `CGO_ENABLED=0`.
- Perl for the scripted recipe lines and `scripts/notices.pl`; the base
interpreter with builtins only is enough.
## Setup
```sh
git clone https://sourcedock.dev/petrbalvin/volumen.git
cd volumen
just build
```
`just run` starts the server against `./config.toml` and `./posts` on the
configured host and port (default `[::]:9091`); `just dev` binds it to
`127.0.0.1:9091`. `config.toml` and `users.toml` are git-ignored, because the
config holds secrets: on a fresh clone the server runs on the built-in
defaults and the overridden content directory. Both recipes pass
`-buildvcs=true`, because plain `go run` does not stamp the build, so the
reported version would be `(devel)`. Go has no built-in reload; restart the
process after code changes. The whole binary rebuilds in seconds, which
avoids waiting for a reload cycle.
## Recipes
Every recipe in the project's file, and what it does. Taken from the file
itself, so the names and the list match it exactly, in the order the file
declares them.
| Recipe | What it does |
|---|---|
| `just` | the `default` recipe, `just --list`: every recipe with its one-line summary |
| `just build` | compiles `cmd/volumen` into `bin/volumen` with `CGO_ENABLED=0`, `-trimpath` and `-buildvcs=true`, stripped, zero errors and zero warnings |
| `just test` | the full suite as CI runs it, with the 80 percent coverage floor |
| `just race` | the same suite under the race detector; the expensive one |
| `just unit ./internal/store 'TestName'` | a scoped run for iterating, cached, no race, no coverage |
| `just fuzz <target> <package>` | a time-boxed fuzz of one target in one package, never a gate |
| `just bench` | benchmarks with `-benchmem` and five counts, on an idle machine only |
| `just fmt` | gofmt over the tree, in place |
| `just fmt-check` | zero diff; prints nothing when everything is formatted |
| `just vet` | `go vet` and `go fix -diff` |
| `just gates` | the definition of done in one command: `build`, `fmt-check`, `vet`, `test`, `race`, in that order |
| `just clean` | removes `bin/` and `coverage.out` |
| `just install` | builds, then copies the binary into `~/.local/bin` |
| `just uninstall` | removes the installed binary |
| `just run` | runs the server against `./config.toml` and `./posts`, with the build stamped |
| `just dev` | the same, bound to `127.0.0.1:9091` |
`just fmt` and `just fmt-check` are the formatting authority, `just vet` the
static one, and the three of them plus `build`, `test` and `race` are the
whole gate. Style rules the tools cannot see (error wrapping, `log/slog`
only, no panics outside `main`, British English names and comments) are
written in [CONTRIBUTING.md](../CONTRIBUTING.md).
## Running a single test
```sh
go test -run TestName ./internal/store
```
Narrow selections do not apply the coverage floor (that lives in the `test`
recipe), so use them freely while iterating. Or stay on the recipe:
`just unit ./internal/store 'TestName'`. Add `-v` for the sub-test names,
`-race` when the change touches concurrency, and `-count=1` when a cached
result looks stale; the gate itself runs with `-count=1`, so a cached pass
never stands in for a fresh one.
The suite mirrors the package layout (`*_test.go` next to the code), shares
no global fixtures, and uses `t.TempDir()` and `net/http/httptest` rather
than a real network:
| Package | Covers |
|---|---|
| `cmd/volumen` | CLI dispatch and every subcommand against temp directories. |
| `internal/admin` | Login, CSRF, post CRUD, settings, media, roles. |
| `internal/app` | Server assembly, the route table, `/healthz`, `robots.txt`, media serving, rate-limit headers. |
| `internal/audit` | The JSON-lines audit log, and a disabled or unwritable one. |
| `internal/backup` | Archive round-trips, the restore allowlist and the decompression budget. |
| `internal/biblio` | The `refs` frontmatter into a numbered, linked reference list. |
| `internal/config` | Loading, merging, defaults, overrides, validation, and the example file matching the embedded template. |
| `internal/diff` | The line-based LCS diff behind the revision comparison view. |
| `internal/fediverse` | The `@user@host` handle rule. |
| `internal/identifiers` | The scholarly identifier rules: DOI syntax and normalisation, ORCID shape and ISO 7064 check digit. |
| `internal/feeds` | RSS, Atom, JSON Feed and sitemap output. |
| `internal/frontmatter` | TOML frontmatter parsing, key order and round-trips. |
| `internal/httpapi` | The public API over `httptest`: ETags, CORS, pagination, token writes, and the committed contract fixtures. |
| `internal/i18n` | The admin interface catalogue: English keys, Czech translations, plural rules. |
| `internal/imagefile` | The accepted extensions, the WebP, AVIF and SVG signatures, the detected type and the header dimensions. |
| `internal/markdown` | Rendering, sanitisation, the mathematics and diagram passes, the table of contents, and the golden corpus in `testdata/`. |
| `internal/password` | scrypt hashing and verification. |
| `internal/payloads` | Payload builders, filtering and validation. |
| `internal/post` | The Post model: parsing, metadata, rendering, cloning. |
| `internal/preview` | The signed share links for unpublished posts. |
| `internal/ratelimit` | The sliding-window limiter. |
| `internal/scheduler` | Due-post selection, the publish sweep and the interval loop. |
| `internal/session` | HMAC-signed cookies and middleware. |
| `internal/store` | Loading, saving, deleting posts, revisions, tombstones, media. |
| `internal/templates` | The `templates.toml` post templates. |
| `internal/tokens` | Minting, scopes, authentication and revocation. |
| `internal/tomlfile` | Atomic `0600` TOML writes and the shape helpers. |
| `internal/updater` | Release checks, version comparison and the verified self-update. |
| `internal/users` | `users.toml`, roles and last-admin safeguards. |
| `internal/version` | The release identity from `debug.ReadBuildInfo`. |
| `internal/web` | Middleware: gzip, the cross-origin gate, security headers, the CSP nonce, the request logger and the client IP. |
| `internal/webhooks` | Signed deliveries, retries, payload shapes. |
## Coverage
```sh
just test
go tool cover -func=coverage.out
```
The `total:` line is the number that matters, and it stays at 80 percent or
more; the `test` recipe fails below the floor.
## Benchmarks
```sh
just bench
```
The recipe is `go test -run '^$' -bench=. -benchmem -count=5 ./...` over the
whole module. Two benchmarks live in the tree, `BenchmarkRender` in
`internal/markdown` and `BenchmarkServer` in `internal/app`; the binding
measurement method is in [BENCHMARKING.md](BENCHMARKING.md). Benchmark on an
idle machine, and compare only runs made in one process against each other.
## Debugging the build
```sh
go build -gcflags='-m' ./... # inlining decisions
go build -gcflags='-S' ./... # what the compiler generated
```
## Continuous integration
Workflows live in `.gitea/workflows/` and run on the project's own runners.
They are written by hand rather than through `just`, but they enforce the same
set of gates, so a green `just gates` locally is the fastest way to a green
pipeline.
## Releases
Releases are cut by merging `development` into `main` and tagging `vX.Y.Z`. The
tag drives the release workflow, which builds the assets and publishes the
notes it extracted from `CHANGELOG.md`.
Dependency changes touch one more file: `NOTICE.md` in the root
reproduces the licence of every module the binary is compiled from, and
`perl scripts/notices.pl` writes it again after a module is added, removed or
upgraded.