feat: site languages, setup and site URL settings
This commit is contained in:
1 parent
0806733d5c
commit
d9158025d4
106 files changed
+10184
-56
No files matched your search
+5
-5
@@ -3,7 +3,7 @@
|
||||
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).
|
||||
[api/articles.md](api/articles.md), site languages and the one-time language setup are in [api/site-languages.md](api/site-languages.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`,
|
||||
@@ -75,14 +75,14 @@ never changes the response or the change itself.
|
||||
| `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`, `user.request_email_change`, `user.resend_email_change`, `user.cancel_email_change`, `user.confirm_email_change`, `user.export_data`, `user.delete_account` (flagged `security`), `user.change_role`, `user.remove`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
|
||||
| `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` |
|
||||
| `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete`, `site.languages_edit`, `site.language_setup` |
|
||||
| `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
|
||||
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`, `public_url_pattern`, `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`, `public_url_pattern` 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`);
|
||||
@@ -145,7 +145,7 @@ session." with a key. The role is checked before the body.
|
||||
| --- | --- |
|
||||
| `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}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`, `site_name`, `title_template`, `default_description`, `public_url_pattern` (see [api/site-languages.md](api/site-languages.md)). 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. |
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# Site languages, language setup and public URL pattern
|
||||
|
||||
Contract for `features/site_languages.feature`, `features/language_setup.feature` and `features/site_public_url_pattern.feature`. The SPA mocks `/v1` from this page.
|
||||
|
||||
Errors are plain text whose body is exactly the code, with the status below. A body that cannot be decoded, or that lacks a required key, is 400 with the body-mismatch message ("The request cannot be processed due to a mismatch in the format of the body."). An anonymous caller is 401, a verified user without `settings.manage_sites` is 403 `missing_permission`. Writes need a real session; API keys are refused.
|
||||
|
||||
An owner is a verified user holding `settings.manage_sites`. A reader is any other verified user.
|
||||
|
||||
## Visibility exception (accepted)
|
||||
|
||||
`version_count` and `published_count` in `GET /v1/site/{key}/languages` are site-level aggregates of every non-deleted article (soft-deleted articles are not shown but still keep their language in use) of the language, drafts and offline ones included. They are visible to every signed-in reader, whatever that reader may open. This is a deliberate exception to the per-version visibility policy of `docs/design/localization-tables.md` ("Hidden versions"), accepted for slice 2. Later slices that expose titles or links of single versions must still filter by what the caller can read.
|
||||
|
||||
## Languages of a site
|
||||
|
||||
The supported tags are the list of `internal/domain/language` (`en`, `en-GB`, `en-US`, `fr`, `fr-CA`, `pt`, `pt-BR`, `es`, `de`, `ar`). Tags are normalized: `pt-br` is stored as `pt-BR`.
|
||||
|
||||
### `GET /v1/site/{key}/languages`
|
||||
|
||||
Any verified user (session or API key). 404 `site_not_found`.
|
||||
|
||||
```json
|
||||
{
|
||||
"revision": "9f2c...",
|
||||
"default": "en",
|
||||
"needs_setup": false,
|
||||
"languages": [
|
||||
{"lang": "en", "enabled": true, "is_default": true, "version_count": 7, "published_count": 6, "social_locale": null, "social_locale_state": "not_set"},
|
||||
{"lang": "fr", "enabled": true, "is_default": false, "version_count": 4, "published_count": 2, "social_locale": "fr_FR", "social_locale_state": "set"}
|
||||
],
|
||||
"available": [
|
||||
{"lang": "pt", "substitute": null},
|
||||
{"lang": "pt-PT", "substitute": "pt"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `revision` is an opaque digest of the rows (lang, default, enabled, social_locale). Counts do not change it. Send it back on `PUT`.
|
||||
- `languages` lists the default first, then the other enabled languages by tag, then the disabled ones by tag.
|
||||
- `social_locale_state` is `set` (an explicit `social_locale`), `derived` (no explicit value and the tag has a region: the effective value is the tag with an underscore, `en-GB` gives `en_GB`) or `not_set`. `social_locale` is only the explicit value, so the client computes the derived one from the tag.
|
||||
- `available` lists the tags an owner can pick. A tag with a non-null `substitute` is not supported itself; only the broader tag is. The server refuses it with `unsupported_language`, so the client sends the substitute once the user confirms.
|
||||
- `needs_setup` is true when the site has no confirmed default language. Then `default` is `null`, `languages` is `[]` and `PUT` answers 409 `setup_required`. Sites created after the migration have no language row and need setup too.
|
||||
|
||||
### `PUT /v1/site/{key}/languages`
|
||||
|
||||
Owner only. The whole set is replaced in one transaction under the site lock.
|
||||
|
||||
```json
|
||||
{
|
||||
"revision": "9f2c...",
|
||||
"default": "en",
|
||||
"languages": [
|
||||
{"lang": "en", "enabled": true, "social_locale": null},
|
||||
{"lang": "fr", "enabled": true, "social_locale": "fr_CA"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`revision`, `default`, `languages` and each `enabled` are required (400 when absent). `social_locale` is optional and `null` means none. A language left out of the list is removed. The answer is 200 with the new read shape. Sending the state that is already stored changes nothing and keeps the revision.
|
||||
|
||||
Checks run in this order:
|
||||
|
||||
| Order | Status | Body | When |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 404 | `site_not_found` | unknown key |
|
||||
| 2 | 409 | `setup_required` | the site has no confirmed default (any body) |
|
||||
| 3 | 409 | `languages_changed` | `revision` is not the current one; nothing is applied |
|
||||
| 4 | 422 | `invalid_language` | a tag is not a language tag (`english`, empty) |
|
||||
| 4 | 422 | `unsupported_language` | a well formed tag that is not supported (`xx`, `pt-PT`) |
|
||||
| 4 | 422 | `duplicate_language` | the same tag twice, case ignored |
|
||||
| 4 | 422 | `invalid_social_locale` | not `ll_RR` or `lll_RR` (letters, one underscore), empty string, or the language part differs from the tag's language |
|
||||
| 5 | 422 | `no_enabled_language` | no language is enabled, `languages: []` included |
|
||||
| 6 | 422 | `default_not_enabled` | `default` is not in the list, not enabled, or not a supported tag |
|
||||
| 7 | 409 | `language_in_use` | a removed language still has an article, draft, offline, online or soft-deleted (the display count `version_count` stays non-deleted only) |
|
||||
| any | 409 | `languages_changed` | the database could not lock the site in time because another change is running (lock contention); nothing is applied, reload and retry |
|
||||
|
||||
Entries are checked in list order for step 4. A rejected request changes nothing. Disabling a language never touches its articles, and no article changes `lang`, status or publication. A `social_locale` is stored as `language_REGION` (`fr_fr` becomes `fr_FR`). The language part rule only compares with the tag; matching `Site.Locale` is deferred to slice 6.
|
||||
|
||||
## First-time language setup
|
||||
|
||||
One global setup for every site with no confirmed default language. Owner only (`GET` as well: a reader is 403).
|
||||
|
||||
### `GET /v1/language/setup`
|
||||
|
||||
```json
|
||||
{
|
||||
"sites": [
|
||||
{"key": "journal", "name": "Journal", "locale": "en", "article_count": 8, "suggestion": {"lang": "en", "substituted": false}},
|
||||
{"key": "notes", "name": "Notes", "locale": null, "article_count": 3, "suggestion": null}
|
||||
],
|
||||
"available": [{"lang": "en", "substitute": null}]
|
||||
}
|
||||
```
|
||||
|
||||
`sites` lists the pending sites by id. `suggestion` comes from `Site.Locale` and keeps the region (`pt-BR` stays `pt-BR`); `substituted` is true when only the broader tag is supported (`fr-BE` gives `fr`). `suggestion` is `null` without a locale or without a supported language. An empty `sites` list means nothing is pending.
|
||||
|
||||
### `POST /v1/language/setup`
|
||||
|
||||
```json
|
||||
{"sites": [{"key": "journal", "lang": "en"}, {"key": "notes", "lang": "en"}]}
|
||||
```
|
||||
|
||||
200 with `{"assigned": 16, "sites": [{"key": "journal", "lang": "en", "assigned": 8}]}`. `assigned` counts the articles that got a language (soft-deleted ones included). `sites` lists the sites confirmed by this call. The default language is created enabled and confirmed, the seeded unconfirmed row is replaced, and every article without a language takes the tag. Nothing is translated, linked or moved. Sites that already have a confirmed default are skipped, whatever tag is asked for, so a retry answers 200 with `assigned: 0`.
|
||||
|
||||
| Status | Body | When |
|
||||
| --- | --- | --- |
|
||||
| 400 | body-mismatch message | no `sites` key or malformed JSON |
|
||||
| 404 | `site_not_found` | a key does not exist |
|
||||
| 409 | `setup_in_progress` | another setup holds the site locks (nothing applied) |
|
||||
| 422 | `invalid_language` / `unsupported_language` | a bad `lang`, empty included |
|
||||
| 422 | `duplicate_site` | the same key twice |
|
||||
| 422 | `setup_incomplete` | a pending site is missing from the request |
|
||||
| 500 | `Internal server error` | a write failed; everything is rolled back |
|
||||
|
||||
All sites are applied in one transaction. Site rows are locked in ascending id order without waiting; a busy lock is `setup_in_progress`. The pending list is read again after the locks and any site that became pending in between is locked too, until the list is stable, so a site created while a setup starts is never left half handled.
|
||||
|
||||
The 200 body also has `skipped`: the keys, sorted, of requested sites that already had a confirmed default and were left untouched (always present, `[]` when none). Their requested `lang` is ignored, never applied.
|
||||
|
||||
```json
|
||||
{"assigned": 3, "sites": [{"key": "notes", "lang": "fr", "assigned": 3}], "skipped": ["journal"]}
|
||||
```
|
||||
|
||||
## Lock contention
|
||||
|
||||
Every locking path of `PUT /v1/site/{key}/languages` and `POST /v1/language/setup` runs inside the transactor. A lock that cannot be taken in time (SQLite `SQLITE_BUSY`/`SQLITE_LOCKED`, MySQL 3572 and 1205, PostgreSQL `55P03`) is recognised once, by the database driver, and reaches the use case as `transaction.ErrLockContention`. The PUT answers 409 `languages_changed` and the setup answers 409 `setup_in_progress`. Nothing is applied and a retry is safe. It is never a 500.
|
||||
|
||||
## SQLite does not lock rows
|
||||
|
||||
The pinned SQLite driver (`github.com/glebarez/sqlite`) drops the `FOR UPDATE` clause: the `SELECT ... FOR UPDATE [NOWAIT]` that locks a site row on MySQL and PostgreSQL is a plain read on SQLite. SQLite instead serializes writers on the whole database: a second writer waits up to the 5 s busy timeout and a transaction that read before another one committed fails with `SQLITE_BUSY` when it tries to write. Both outcomes keep the data consistent and the busy failure is answered as lock contention above. `test/localization_concurrency_test.go` pins the statuses (200 or 409, never 500) for a PUT racing a site creation and for two simultaneous setups.
|
||||
|
||||
## Activity log
|
||||
|
||||
Both writes are recorded in the `sites` category, once after the commit and never for a read or a no-op:
|
||||
|
||||
| Action | Target | Changes and params |
|
||||
| --- | --- | --- |
|
||||
| `site.languages_edit` | the site | `default_language` (before, after) when the default changed, `languages` (before, after: tags sorted, `(off)` for disabled, `[social_locale]`). A PUT that changes nothing records nothing. |
|
||||
| `site.language_setup` | one event per site confirmed by the POST | `default_language` ("" to the tag), param `assigned` (articles that got the language) |
|
||||
|
||||
A refused call is recorded as a failed event with a plain reason (for example "The languages changed since they were read."). An unknown site key is not recorded.
|
||||
|
||||
## Public URL pattern
|
||||
|
||||
`public_url_pattern` is a string or `null` on every site JSON (`GET /v1/site/`, `POST`, `PATCH`). It is read with the existing list and written with the existing `PATCH /v1/site/{id}` (owner). No new route. A `PATCH` without the key leaves it unchanged; `null` or `""` unsets it; surrounding spaces are trimmed.
|
||||
|
||||
Validation, in this order, answers 422 with the code as plain text: `invalid_pattern` (more than 512 characters), `invalid_scheme` (not `https://` or `http://`), `invalid_host` (no host, for example `https:///{slug}`), `unknown_placeholder` (anything between braces other than `{lang}` and `{slug}`, or a stray brace), `not_encoded` (a space, a non-ASCII character or other character that needs URL encoding; `%XX` sequences are fine), `missing_slug` (no `{slug}`). Repeated placeholders are allowed. Saving the pattern changes no article, canonical URL or public response, and is recorded in the activity log as the `public_url_pattern` field.
|
||||
Reference in new issue
Block a user