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

31 KiB

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). 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.

curl -s 'https://lab.example.com/api/volumen/site'
{
  "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.

curl -s 'https://lab.example.com/api/volumen/posts?limit=20'
{
  "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.

{
  "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.

curl -s 'https://lab.example.com/api/volumen/posts/batch?slugs=alpha,beta'

The entry below is one detail response, printed in full:

{
  "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/, 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:

"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.

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

{
  "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 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

{
  "series": [{ "name": "Series", "count": 1 }]
}

Ordered by count, most posts first, then by name.

GET /api/volumen/series/{name}

{
  "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:

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/ 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.

{ "error": "not_found" }
{ "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:

{ "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"}