358 lines
33 KiB
Markdown
358 lines
33 KiB
Markdown
# API reference
|
|
|
|
The full, searchable reference is the Integration page of the running app (`/integration`). It
|
|
renders `web/src/data/apiCatalogue.ts`, which is the single source for this page. This file is for
|
|
readers without the UI. Reading articles (pagination, `format`, body schemas) has its own page,
|
|
[api/articles.md](api/articles.md), and API keys are covered in [api/api-keys.md](api/api-keys.md).
|
|
|
|
## Base URL
|
|
The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`,
|
|
for example `https://cms.example/v1/article/`.
|
|
|
|
## Authentication
|
|
- Public routes need nothing: `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo`
|
|
and `GET /v1/site/{key}/sitemap`. They return online articles only; the list and the resolved SEO
|
|
object also answer drafts to a verified session or API key.
|
|
- Everything else needs a signed-in browser session (the `jwt` cookie) or an API key:
|
|
`Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its
|
|
creator, with that user's role, and shows in full once.
|
|
- A key cannot call `POST /v1/auth/verify`, `DELETE /v1/auth/register`, `/v1/api-key/*` or
|
|
`/v1/system/*`. Those answer 403 "This action needs a real session." An invalid, unknown, expired
|
|
or revoked key answers 401, even on public routes. The site writes (`POST`, `PATCH` and `DELETE`
|
|
under `/v1/site`) and the author writes (`POST`, `PATCH` and `DELETE` under `/v1/author`, `POST /v1/author/me` included) are session only too.
|
|
- Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header.
|
|
|
|
## Routes a key can call (30)
|
|
| Group | Routes |
|
|
| --- | --- |
|
|
| Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
|
|
| Media | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) |
|
|
| Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me (with the linked `author`) |
|
|
| Settings | GET /v1/settings |
|
|
| Sites | GET /v1/site/, GET /v1/site/{key}/sitemap |
|
|
| Authors | GET /v1/author/ |
|
|
|
|
## Activity log
|
|
The activity routes are session only: a key answers 403 "This action needs a real session." and a
|
|
key never reads the audit trail. Events are append-only; there is no `POST`, `PUT`, `PATCH` or
|
|
`DELETE` under `/v1/activity`.
|
|
|
|
| Route | Who | Purpose |
|
|
| --- | --- | --- |
|
|
| `GET /v1/activity` | Any verified user | Filtered, paged events. Owner and admin see every event; any other role is scoped to its own events, and `actor` set to someone else answers 403 "Only owners and admins can do this." |
|
|
| `GET /v1/activity/summary` | Owner, admin | Counts for the range, the previous period, active people and seven-bucket series. |
|
|
| `GET /v1/activity/export` | Owner, admin | The same filters as the list without `page` or `size`, as a `text/csv; charset=utf-8` attachment (`when,actor,action,target,outcome,source,ip`). Cells starting with `=`, `+`, `-` or `@` get a leading quote. |
|
|
|
|
Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`,
|
|
`api_keys`, `auth`, `system`, `sites`, `authors`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range`
|
|
(`today`, `7d`, `30d`, `all`, `custom`; default `7d`), `from` and `to` (dates, with `custom`), `page` (from
|
|
1) and `size` (25, 50 or 100). Any invalid value answers 400 with the body-mismatch message. Dates
|
|
are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`).
|
|
|
|
`GET /v1/settings` also returns `activity_retention_days` (30, 90, 365 or 0 for forever, default
|
|
90), `activity_retention_max_gb` (0 for no size limit), `activity_log_size_bytes`,
|
|
`activity_last_cleanup_at` and `activity_last_cleanup_removed`. `PATCH /v1/settings` with
|
|
`{"activity_retention_days": 30}`, `{"activity_retention_max_gb": 5}` or both is session only and
|
|
owner only (403 "Only the owner can do this."); any other value answers 400. The daily cleanup
|
|
removes events past the age limit, then the oldest events until the log fits the size limit.
|
|
Whichever limit is reached first wins.
|
|
|
|
### What is recorded
|
|
Every real action is recorded by its use case, once, after the change was made (outcome `success`)
|
|
or after the use case refused it (outcome `failed`, with a plain `failure_reason`). Nothing is
|
|
recorded for a read (no `GET`, no list, no public article read, no status poll), for a request that
|
|
fails validation, or for a target that does not exist (404). A failed audit write is logged and
|
|
never changes the response or the change itself.
|
|
|
|
| Category | Actions |
|
|
| --- | --- |
|
|
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
|
|
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` |
|
|
| `users` | `user.register`, `user.verify`, `user.update_profile`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
|
|
| `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` |
|
|
| `authors` | `author.create`, `author.edit`, `author.delete` |
|
|
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
|
|
|
|
Target types: `article`, `site`, `author`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`,
|
|
`api_key` and `release` (id and name are the tag).
|
|
Only the fields `title`, `online`, `body`, `site`, `name`, `base_url`, `default_robots`, `locale`, `default_social_image`, `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image`, `excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`, `site_name`, `title_template`, `default_description`, `bio`, `website`, `social_links`, `avatar`, `user`, `authors`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names; changing the credited authors records `authors` with the names in order; the `user` change of an author holds usernames, never an email), with the
|
|
kinds `text` and `body`; a body edit carries a signed character count delta and never the text; the free-text SEO fields (`excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`) do the same under their own field name, while `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` (image file names) and the site fields `site_name`, `title_template`, `default_description` carry `before` and `after`. `seo_dismissed` is not an edit and records nothing. A
|
|
change that leaves a value as it was (same body, same publish state, same file name, same display
|
|
name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry
|
|
`merged` and `articles_updated`, plus `failed_groups` when a group failed (still outcome `success`);
|
|
`media.variant_generate_all` carries `generated`, `failed` and `causes` (a list of `too_large`,
|
|
`unsupported_format` or `failed`) and is `failed` only when nothing was generated and something failed;
|
|
`media.variant_purge_image` carries `purged_files` and `purged_bytes`; both have an `image` target
|
|
and are recorded only when files were generated or removed;
|
|
`media.variant_purge_all` and `media.variant_purge_unused` (no target) carry `purged_files` and
|
|
`purged_bytes`, the latter also `older_than_days`; a partial purge adds `failed_files` and stays
|
|
`success`, and a purge that removed nothing while some files failed is `failed`; nothing is recorded
|
|
when no file was removed or failed;
|
|
`system.update_checked` carries `update_available`; `system.restart_requested` carries `kind` and,
|
|
for an update, `tag` (it has no target). `system.update_downloaded` is recorded when the job reaches
|
|
`ready` or `failed`, with the origin captured at request time; a refusal at the start is recorded
|
|
immediately. A failed registration or invite acceptance has an `anonymous` actor named by the
|
|
attempted username.
|
|
|
|
`failure_reason` is one fixed sentence per domain error, matched with `errors.Is`, at most 255
|
|
characters, for example "The title must be between 3 and 200 characters.", "That file name is not
|
|
valid.", "The merge request is not valid.", "A purge is already running.", "Registration is closed
|
|
on this site.", "That invitation has expired.", "The release feed could not be reached." or "The
|
|
update could not be downloaded.". Any other error becomes "Something went wrong on our side. Please
|
|
try again."; the text of the error never reaches the event, the API or the CSV. Passwords, hashes,
|
|
codes, article bodies, file bytes, storage paths and asset urls are never recorded.
|
|
|
|
Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts
|
|
`q`, `hash`, `filter` (`all`, `unused` or `duplicates`), `sort` (`newest`, `oldest`, `name`,
|
|
`largest` or `total_size`, the original plus its generated variants), `variants` (`with` or
|
|
`without` generated variant files), `page` (a whole number from 1, default 1) and `size` (5, 25, 50
|
|
or 100, default 25). The body is `{ "items": [...], "total", "page", "size", "counts" }`: `total` is
|
|
the number of images matching `q`, `hash`, `filter` and `variants` before paging, `page` and `size`
|
|
echo the values applied, and `counts` (`all`, `unused`, `duplicates`) stays library-wide, ignoring
|
|
every filter and the paging. Ordering is applied to the whole matching list before slicing, with the
|
|
image id as the final tie-break, so pages never overlap or skip. A page past the last answers 200
|
|
with `items: []` and the real `total`. Any invalid `page`, `size`, `filter`, `sort`, `variants` or
|
|
`hash` is 400 "The media query is invalid.". Every item carries `variant_count` and `variant_size`
|
|
(the index rows of the image and their bytes, 0 when none).
|
|
|
|
`GET /v1/media/{id}` reads one image with the same auth as the list (a verified session or an API key; 403 for an
|
|
unverified account, 401 without credentials) and writes nothing, so it records no activity event. It answers 200 with
|
|
the object of one `items` element of the list, a missing image is 404 "Image not found" and a malformed id is 400 with
|
|
the id-format message.
|
|
|
|
Breaking change: `next_cursor` is gone and no `X-Total-Count` header is sent. `cursor` and `limit`
|
|
are no longer parameters; they are ignored, never an error, and have no effect on paging.
|
|
|
|
## Sites
|
|
Every article belongs to exactly one site, and exactly one site is the default. A site is `{ id,
|
|
name, key, base_url, default_social_image_id, default_robots, locale, is_default, article_count }`:
|
|
`name` is 1 to 100 characters, `key` is 2 to 32 characters of lowercase letters, digits and single
|
|
dashes (`^[a-z0-9]+(-[a-z0-9]+)*$`) and never changes, `base_url` is `https` (or `http` for
|
|
`localhost`, `127.0.0.1` and `[::1]`) with no path, query or user info (a trailing slash is
|
|
stripped, uniqueness is not enforced), `default_robots` is `index` (default) or `noindex`, `locale`
|
|
is `null` or a tag such as `en` or `fr-CA` (an empty string means none) and `default_social_image_id`
|
|
is `null` or the id of an image. Read access needs a verified session or an API key; every write is
|
|
session only and owner only (403 "Only the owner can do this.", or "This action needs a real
|
|
session." with a key). The role is checked before the body.
|
|
|
|
| Route | Purpose |
|
|
| --- | --- |
|
|
| `GET /v1/site/` | The sites, default first and then by name, each with `article_count`. Query `q` (up to 200 characters, matched in `name`, `key` or `base_url`, ignoring case and accents, `%` and `_` literal), `page` (a whole number from 1) and `size` (5, 25, 50 or 100). Without `page` and `size` every matching site is returned; with only `page`, `size` is 25; with only `size`, `page` is 1. The body stays a JSON array (`[]` when nothing matches, never `null`) and `X-Total-Count` holds the number of matching sites before paging. A page past the end is `200 []` with the total. An invalid `page`, `size` or `q` is 400 with the body-mismatch message and no `X-Total-Count`. |
|
|
| `POST /v1/site/` | Create. 201 with the site. `is_default: true` makes it the default. `site_name`, `title_template` and `default_description` are optional and validated like on `PATCH`. |
|
|
| `PATCH /v1/site/{id}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`, `site_name`, `title_template`, `default_description`. Absent fields are untouched. `key` may only be repeated unchanged; `is_default` is refused. |
|
|
| `PATCH /v1/site/{id}/default` | Make the site the default, atomically. Setting the current default is a no-op. |
|
|
| `DELETE /v1/site/{id}?reassign_to={id}` | 204. A site with articles needs `reassign_to`; the articles move and the site is deleted in one transaction. `reassign_to` is ignored when the site has no article. |
|
|
|
|
Errors are plain text whose body is exactly the token: 409 `site_key_taken`, 409 `site_has_articles`,
|
|
403 `default_site` (checked before the articles), 400 `key_immutable`, 400 `invalid_site_url`, 400
|
|
`invalid_locale`, 400 `invalid_title_template`, 400 `image_not_found`, 400 `use_set_default`, 400 `invalid_reassign` (missing
|
|
number, the site itself or not a whole number) and 404 `site_not_found`. Refused writes except `site_not_found` and `image_not_found` are recorded as failed events. A body that cannot be
|
|
decoded is 400 with the body-mismatch message, a malformed id is 400 with the uint32 message, and a
|
|
field that fails validation (`name`, `key` format, `default_robots`, `site_name` over 100 characters, `title_template` over 200, `default_description` over 320) is 400 with the validator text.
|
|
|
|
A site also carries `site_name` (at most 100 characters, `""` by default), `title_template` (at most 200 characters, `%title% | %site%` by default; it must contain `%title%`, case sensitive, otherwise 400 `invalid_title_template`, an empty template included) and `default_description` (at most 320 characters, `""` by default). They appear on every site response, `POST` accepts them and `PATCH` edits them like the other site fields. Existing sites get the defaults when the server starts after the upgrade.
|
|
|
|
Articles carry `site_id`. A new article starts on the default site. `GET /v1/article/?site=<key>`
|
|
keeps only the articles of that site and composes with `status`, `q`, `sort`, `size` and `page`;
|
|
`counts` stay global, an unknown key answers 200 with no item and an empty `site=` is 400 with the
|
|
body-mismatch message. `PATCH /v1/article/{id}` takes `body`, `site_id`, `author_ids` and the SEO
|
|
fields below, and nothing else, such as `title`: absent fields are untouched, an empty object, an object without
|
|
those fields, a `null` value (except on `cover_image_id` and `social_image_id`) or a malformed value is 400 with the body-mismatch message, `site_id: 0` is
|
|
400 with the validator text and an unknown site is 404 `site_not_found`. Moving an article never
|
|
changes `is_online`. `{"body": ""}` still sets an empty body, but `{}` and `{"body": null}` no longer do: they are 400.
|
|
|
|
### Resolved SEO, check, list status and sitemap
|
|
|
|
RenewCMS renders no page and injects no meta tag: it returns the data a frontend needs.
|
|
|
|
| Route | Who | Purpose |
|
|
| --- | --- | --- |
|
|
| `GET /v1/article/{id}/seo` | public for online articles; a verified session or API key also reads drafts | The resolved object `{ title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks, json_ld }`. It never carries the body, the id or `seo_dismissed`, ignores `format`, and writes nothing. An offline article for any other caller, and a missing one, is 404 `Article not found`. |
|
|
| `POST /v1/article/{id}/seo/check` | verified session or API key (401 without credentials, 403 unverified) | Evaluates the article without saving. The body is `{}` or any of the twelve SEO fields of `PATCH` plus `body`; each replaces the saved value for this check only. `site_id`, `author_ids`, unknown fields and `null` (except on `cover_image_id` and `social_image_id`) are 400 with the body-mismatch message. The other validation is the one of `PATCH` (`invalid_slug`, `invalid_url` as 422, `image_not_found`, validator text). Answers `{ status, suggestions, slug }` where `suggestions` is `[{ code, field, params }]` and `slug` is `{ available, suggestion }`. A taken slug is a 200 with `available: false` and the first free `<slug>-N` in `suggestion`, never a 409. |
|
|
| `GET /v1/site/{key}/sitemap` | public | A JSON array `[{ url, modified_at, noindex }]` of the online articles of the site with that key, by id ascending. `url` is `base_url/slug` (never the canonical override). `page` (default 1) and `size` (25, 50, 100 or 500, default 100); an invalid value is 400 with the body-mismatch message. The total is in the `X-Total-Count` header. An unknown key, an id included, is 404 `site_not_found`. |
|
|
|
|
`GET /v1/article/` also takes `seo=ready|suggestions|hidden` (any other value, an empty one included, is 400 with the body-mismatch message, after the `format` check). It is applied before paging and composes with `q`, `status`, `site`, `sort`, `page` and `size`; `total` follows it and `counts` ignore it. Every item carries `seo_status` (`ready`, `suggestions` or `hidden`) and `seo_suggestions` (the number of suggestions that are not dismissed). The filter is evaluated in memory over every matching article, so its cost grows with the number of articles on the site; without it only the returned page is assessed.
|
|
|
|
`X-Total-Count` is added to `Access-Control-Expose-Headers` next to `Link`, so a browser on an allowed origin can read it.
|
|
|
|
### Article SEO fields
|
|
|
|
An article carries twelve search and sharing fields, all edited through `PATCH /v1/article/{id}` and returned by
|
|
every single-article read (`GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`):
|
|
`slug`, `excerpt`, `seo_title`, `seo_description`, `cover_image_id`, `cover_image_alt`, `social_title`,
|
|
`social_description`, `social_image_id`, `canonical_url`, `noindex` and `seo_dismissed`. Text fields are `""`
|
|
when empty, the two image ids are `null` when unset, `noindex` is `false` and `seo_dismissed` is `[]` by
|
|
default. Each item of `GET /v1/article/` carries `excerpt` and `slug` and none of the other ten.
|
|
The limits are counted in characters: `excerpt` 300, `seo_title` 120, `seo_description` 320,
|
|
`cover_image_alt` 200, `social_title` 120, `social_description` 320, `canonical_url` 2048; going over is
|
|
400 with the validator text. `cover_image_id` and `social_image_id` accept `null` (and only those two do);
|
|
`0` is 400 with the validator text and an unknown image is 400 `image_not_found`. `canonical_url` is `""` or an absolute
|
|
`https` URL with a host, otherwise 422 `invalid_url`. `seo_dismissed` is a list of the codes `title_long`,
|
|
`title_empty`, `desc_short`, `desc_long`, `cover_alt`, `cover_none`, `slug_long`, `no_au`, `no_h`, `body_alt`
|
|
and `short`: an unknown code is 400 with the validator text and duplicates are collapsed with the order
|
|
kept. `noindex` is only a flag, it does not take the article offline.
|
|
|
|
The `slug` matches `^[a-z0-9]+(-[a-z0-9]+)*$` with 1 to 100 characters (400 `invalid_slug`) and is unique per
|
|
site, never globally. It is generated from the title when the article is created: lowercase, accents folded,
|
|
every run of other characters becomes one dash, dashes trimmed, cut to 100 characters, and `article` when no
|
|
letter or digit is left; a slug already used on the site gets `-2`, `-3`... silently. The same rule
|
|
backfills the existing articles when the server starts after the upgrade (in id order within each site, so
|
|
the oldest article keeps the plain slug). Sending `slug: ""` regenerates the slug from the title. A slug
|
|
used by another article on the target site (the new site when `site_id` is sent too, and a move re-checks
|
|
the current slug) is 409 with the plain-text body `slug_taken`; the response then carries the header
|
|
`X-Slug-Suggestion` holding the first free `<slug>-N` from 2 on that site, fitting 100 characters. The header
|
|
is set on this 409 only, never on another response. A regeneration that collides is refused the same way, never suffixed
|
|
silently. Saving an article with its own slug is not a collision. Publishing never changes a slug, a deleted article
|
|
frees its slug.
|
|
|
|
New error tokens are plain text from the handler: 409 `slug_taken`, 400 `invalid_slug`, 422 `invalid_url`, 400
|
|
`image_not_found` and 400 `invalid_title_template` (sites). Edits that change a SEO field are recorded as
|
|
`article.edit` events (`slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` with before and after;
|
|
the free-text fields as a size delta only); a refused slug is recorded as failed.
|
|
|
|
A site's `default_social_image_id` counts as a use of that image: it raises `used_in` and keeps the
|
|
image out of the `unused` filter. Deleting the image sets the field to `null` and succeeds. `POST /v1/media/merge` and `POST
|
|
/v1/media/purge` repoint the sites of the merged copies to the kept image and report it as
|
|
`sites_updated` next to `articles_updated`.
|
|
|
|
An article's `cover_image_id` and `social_image_id` count the same way. `used_in` counts every article that uses the
|
|
image in its body, as cover or as social image once, plus the authors using it as an avatar and the sites using it as default
|
|
social image. Deleting an image never fails because of its uses: the covers and social images pointing at it are set to `null`
|
|
(the `cover_image_alt` and the bodies are untouched), and a failed delete leaves every reference in place. A merge or a purge repoints
|
|
the covers and social images of the copies to the kept image, and an article rewritten in its body and its cover counts once in
|
|
`articles_updated`.
|
|
|
|
`GET /v1/media/{id}/usage` stays an array. Every item keeps the keys it had and gains `type` (`article`,
|
|
`author` or `site`) and `fields`. An article item is `{ "type": "article", "id", "title", "published", "fields" }` with
|
|
`fields` from `body`, `cover` and `social` (one item per article, in that order); an author item is
|
|
`{ "type": "author", "id", "title": <name>, "fields": ["avatar"] }` and a site item is `{ "type": "site", "id", "title": <name>,
|
|
"fields": ["default_social_image"] }`; author and site items have no `published` key. The array is `[]` for an unused image.
|
|
|
|
## Authors
|
|
|
|
An author is a public byline, with or without an account. It is `{ id, name, slug, bio, avatar_id,
|
|
website, social_links, user_id, is_linked, article_count }`, plus `user_email` for owners and admins
|
|
only (absent for every other caller). `name` is 1 to 100 characters after trimming, `slug` matches
|
|
`^[a-z0-9]+(-[a-z0-9]+)*$` with 1 to 100 characters and is unique (generated from the name when
|
|
omitted: accents folded, punctuation runs become one dash, `author` when nothing is left), `bio` has
|
|
at most 280 characters (counted in runes), `avatar_id` is `null` or an image id, `website` is `null`
|
|
or an `https` URL with a host (an empty string means none, at most 2048 characters),
|
|
`social_links` holds at most 5 `{ "type", "url" }` with `type` one of `x`, `linkedin`, `github`,
|
|
`mastodon`, `custom` and an `https` URL. `user_id` is the unique link to a user. Zero authors on an
|
|
article is allowed and never blocks publishing.
|
|
|
|
| Route | Who | Purpose |
|
|
| --- | --- | --- |
|
|
| `GET /v1/author/` | verified session or API key | The authors newest first. Query `q` (name or slug, accent and case insensitive), `page` (default 1), `size` (5, 25, 50 or 100, default 25). Body `{ items, total, page, size }`, with `total` repeated in the `X-Total-Count` header on every 200; a bad query is 400 with the body-mismatch message and no header. |
|
|
| `POST /v1/author/` | owner or admin, session only | Create. 201 with the author. `user_id` links an account that has no author. |
|
|
| `POST /v1/author/me` | any verified session | Create the caller's own author from the display name (or the username). A taken slug gets `-2`, `-3`... silently. 201 with the author plus `slug_adjusted`: `true` when the stored slug differs from the plain slug of the name (a collision suffix was added), `false` otherwise. No other author response carries this field. |
|
|
| `PATCH /v1/author/{id}` | owner or admin on any author; any verified session on the author linked to it; session only | Edit `name`, `slug`, `bio`, `avatar_id`, `website`, `social_links`, `user_id`. Absent fields are untouched, `avatar_id`, `website` and `user_id` accept `null`, `social_links` is replaced as a whole. `user_id` links or unlinks the account and is owner or admin only. |
|
|
| `DELETE /v1/author/{id}?reassign_to={id\|none}` | owner or admin, session only | 204. An author credited on articles needs `reassign_to`: an author id (that author takes the deleted one's position and is never duplicated on an article) or `none` (the credit is removed). The move and the delete are one transaction. `reassign_to` is ignored when the author has no article. The linked account is untouched. |
|
|
|
|
The role checks use the existing bodies: 403 "Only owners and admins can do this." for `POST /` and
|
|
`DELETE`, 403 `author_rights` for a `PATCH` by an account that is not owner or admin on an author
|
|
that is not its own (or that sends `user_id`), and 403 "This action needs a real session." for a key.
|
|
Errors are plain text whose body is exactly the token: 404 `author_not_found`, 404 `user_not_found`,
|
|
409 `author_slug_taken`, 409 `author_has_articles`, 409 `user_already_linked`, 409
|
|
`author_already_linked`, 400 `invalid_slug`, 400 `invalid_reassign` (own id, not a whole number,
|
|
negative or empty), 400 `image_not_found`, 422 `bio_too_long`, 422 `invalid_url` and 422
|
|
`too_many_links`. A body that cannot be decoded, an empty `PATCH` or one that nulls a required field
|
|
is 400 with the body-mismatch message, a malformed id is 400 with the uint32 message and other
|
|
validation failures (`name`, link `type`) are 400 with the validator text. Writes refused for a
|
|
reason other than a missing target or an invalid value are recorded as failed events.
|
|
|
|
Articles carry `authors`, the ordered credits (the first is the primary byline), each with only `id`,
|
|
`name`, `slug`, `bio`, `avatar_id`, `website` and `social_links`; it is `[]` when there is none and it
|
|
is visible on online articles to anonymous readers. `PATCH /v1/article/{id}` also takes
|
|
`author_ids`, the whole ordered list: it replaces the credits, `[]` empties them, an unknown id is 404
|
|
`author_not_found`, a duplicate, `null` or a malformed list is 400 with the body-mismatch message and
|
|
`0` is 400 with the validator text. Absent means unchanged. A new article is credited to the
|
|
creator's linked author when there is one. `GET /v1/auth/me` returns the existing user fields plus
|
|
`author`: the caller's linked author (`id`, `name`, `slug`, `bio`, `avatar_id`, `website`,
|
|
`social_links`, `article_count`) or `null`.
|
|
|
|
An author's `avatar_id` counts as a use of that image like a site's social image: it raises `used_in`
|
|
and keeps the image out of the `unused` filter, and `GET /v1/media/{id}/usage` lists the author with the field `avatar`.
|
|
Deleting the image sets `avatar_id` to `null`. `POST /v1/media/merge` and `POST /v1/media/purge`
|
|
repoint the avatars of the merged copies and report it as `authors_updated`.
|
|
|
|
## Image variants
|
|
Owners and admins manage named variants of the stored images. These routes are session only (no API
|
|
key) and sit behind the verified-user check; any other role gets 403 "Only owners and admins can do
|
|
this." Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
|
|
|
|
| Route | Purpose |
|
|
| --- | --- |
|
|
| `GET /v1/image-variants` | `{ "items": [...] }`, ordered by name. |
|
|
| `POST /v1/image-variants` | Create. 201 with the variant. 409 when the name exists. |
|
|
| `PUT /v1/image-variants/{name}` | Replace the definition (the name in the body is ignored). 200 with the variant and `purged_files`. |
|
|
| `PATCH /v1/image-variants/{name}` | `{ "enabled": true }` or `{ "enabled": false }`. Keeps the generated files. |
|
|
| `DELETE /v1/image-variants/{name}` | 204. Removes the definition and its generated files. |
|
|
| `POST /v1/image-variants/{name}/purge` | `{ "purged_files", "purged_bytes" }`. Keeps the definition. |
|
|
| `GET /v1/image-variants/cache` | Cache statistics, see below. |
|
|
| `POST /v1/image-variants/cache/purge` | Purge generated files by scope, see below. |
|
|
|
|
A variant is `{ name, width, height, fit, format, quality, generate_on_upload, enabled,
|
|
generated_files, cache_size, created_at, updated_at }`. `width` and `height` are 1 to 8000 or `null`
|
|
(at least one is required), `fit` is `cover`, `contain` or `inside` (default `cover`), `format` is
|
|
`original`, `jpeg`, `png` or `webp` (default `original`), `quality` is 1 to 100 (default 82, `null`
|
|
for `png`), `generate_on_upload` defaults to `false`, `enabled` to `true`, and `cache_size` is in
|
|
bytes. A `PUT` that omits `generate_on_upload` or `enabled` keeps the stored value.
|
|
|
|
Errors are plain text: 400 for a body that is not valid JSON, 404 "Variant not found", 409 "A variant
|
|
named “thumb” already exists. Choose another name." and 422 with one sentence for the first invalid
|
|
field (name, width and height, fit, format, quality).
|
|
|
|
### Cache statistics and purge by scope
|
|
|
|
`GET /v1/image-variants/cache` answers `{ generated_files, disk_used, variants_defined,
|
|
variants_enabled, variants_disabled, rejected_requests, rejected_window_days, unused: { files, bytes,
|
|
days }, by_variant: [{ name, enabled, files, bytes }], top_images: [{ id, filename, url, files, bytes }] }`.
|
|
`by_variant` lists every defined variant by bytes descending then name; `top_images` lists the ten
|
|
images using the most variant bytes (ties by id, images that no longer exist are skipped). `unused`
|
|
counts the files of enabled variants whose reference time (the last public request, or the creation
|
|
time when never requested) is older than 30 days. `rejected_requests` counts the public requests
|
|
refused with 400 over today and the six previous UTC days. Pending request tracking is flushed before
|
|
the read. Arrays are never `null`.
|
|
|
|
`POST /v1/image-variants/cache/purge` takes `{ "scope": "all" | "unused" | "variant" | "image",
|
|
"variant": "<name>", "image_id": <id> }` (`variant` for the scope `variant`, `image_id` for `image`; a
|
|
target given for another scope is ignored) and answers `{ scope, purged_files, purged_bytes,
|
|
failed_files }`. A purge that fails on some files still answers 200 with `failed_files` above 0; the
|
|
failed rows are kept so the same request can be sent again. Errors are plain text: 400 for a body that
|
|
is not valid JSON, 422 "Scope must be all, unused, variant or image." or "A variant name or an image
|
|
id is required for this scope.", 404 "Variant not found" or "Image not found", 500 when the rows
|
|
cannot be listed.
|
|
|
|
### Variants of one image
|
|
Owners and admins only, session only: a request with an API key is 401. All ids are image ids.
|
|
|
|
| Route | Purpose |
|
|
| --- | --- |
|
|
| `GET /v1/media/{id}/variants` | `{ "unsupported": null, "items": [...] }` for the enabled variants in name order. `unsupported` is `"svg"` (with `items: []`) for an SVG. |
|
|
| `POST /v1/media/{id}/variants` | Generates every enabled variant that is missing. `{ "generated", "failed", "failures": [{ "name", "cause" }], "items": [...] }`, 200 even when some fail. |
|
|
| `POST /v1/media/{id}/variants/{name}` | Generates one enabled variant. 200 with the item. |
|
|
| `DELETE /v1/media/{id}/variants/{name}` | Removes the generated file. `{ "purged_files", "purged_bytes" }`, also for a disabled variant. |
|
|
|
|
An item is `{ name, width, height, fit, format, output_width, output_height, output_format,
|
|
generated, size, url }`: `width` and `height` are the settings (`null` when auto), `output_*` the
|
|
size and format the file has or will have (never above the source), `generated` is true only when
|
|
the file exists with the variant's current settings, `size` is its bytes or `null`, and `url` is
|
|
`/static/uploadedImages/<stored name>?v=<name>`. A `cause` is `too_large`, `unsupported_format` or
|
|
`failed`. A file generated here is the same file the public url serves, and the other way round.
|
|
Uploads generate the variants flagged `generate_on_upload` (enabled ones, raster sources only)
|
|
before answering; a failure there is logged and never fails the upload.
|
|
|
|
Errors are plain text: 400 for a malformed id, 404 "Image not found" or "Variant not found" (also for a
|
|
disabled variant on `POST`), 413 "The source image is too large to generate this variant.", 415 "The
|
|
source image can't be read, so this variant can't be generated.", 422 "Variants aren't generated for
|
|
this format." and 500 "Internal server error".
|
|
|
|
## Errors
|
|
Every error is a plain-text body with one sentence; the status is in the HTTP status line. The
|
|
statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page.
|
|
|
|
## Notes
|
|
- Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL.
|
|
- Browsers can call the API only from origins in the server's CORS setting.
|