Files
volumen/docs/API.md
T

671 lines
31 KiB
Markdown
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
# 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"}` |