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
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:
+670
@@ -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"}` |
|
||||
Reference in New Issue
Block a user