Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6823dcc049 | ||
|
|
b8d98cef2b | ||
|
|
cf13c606f8 | ||
|
|
a41d537e52 | ||
|
|
0a62090435 | ||
|
|
7662d54ab3 | ||
|
|
5f683cba31 | ||
|
|
0e739c882e | ||
|
|
45d66f6071 | ||
|
|
311c28af3a | ||
|
|
e7832a8d26 | ||
|
|
fc3c25530c | ||
|
|
4f5bd190e5 | ||
|
|
ee3cb263dc | ||
|
|
0edf6e1b67 | ||
|
|
4b09bd9312 | ||
|
|
391435b4bf | ||
|
|
805bfdb837 | ||
|
|
e8e77a154c | ||
|
|
af25eaa677 | ||
|
|
5210b64e0f | ||
|
|
faaa9b0f55 | ||
|
|
305f94afd9 | ||
|
|
f7bfb3313b | ||
|
|
08e3d82ecd | ||
|
|
5b38a3b20d | ||
| a6fc7cd954 | |||
| 7a03640b88 | |||
| 62c75b1fc2 | |||
| f5a0394d69 | |||
| fea88f8ad7 | |||
| c5b71472ac | |||
| a598a7c5e7 | |||
| e17cebd761 | |||
| 4b3a8bb049 | |||
| 08ab15338c | |||
| 7fc07d8822 | |||
| 521df9a29d | |||
| 2aeeccc15f | |||
|
|
b1299021cd | ||
|
|
a9a9b8098d | ||
|
|
4ab94da66c | ||
|
|
ffa2c8ffb1 | ||
|
|
48d6593692 | ||
|
|
fe1bbc2ba6 | ||
|
|
05e38aee5d | ||
|
|
8de4928b80 | ||
|
|
4f633f0b04 | ||
|
|
a99e9a167e | ||
|
|
ebfb710197 | ||
|
|
d7943fb3f1 | ||
| 31d901cf25 | |||
| 3b252f2cfa | |||
| 4e2aab6fe0 | |||
| 06d0c9bc2c | |||
| c49d216f0f | |||
| fd1572a3a2 | |||
| 58851dd101 | |||
| 7bbafcbf28 | |||
| 09308d9552 | |||
| f2026c5c9b | |||
| 4ec64a3b35 | |||
| 5eea57fde1 | |||
| 4405feeaa7 | |||
| 09e56437fd | |||
| 2a3afb5107 | |||
| f3b223f131 | |||
| 0efaa04be5 | |||
| b997a416a7 | |||
| f1e6c11107 | |||
| 2c961b8408 | |||
| 5af746f31f | |||
| 159323197d | |||
| 85963b19ef | |||
| a578ddabda | |||
| da7a0732c3 | |||
| d5a5413525 | |||
| 2c78211cb9 | |||
| c1f6a83c2f | |||
| df67939251 | |||
| 9d5bfc4a35 | |||
| 2384a61e38 | |||
| cb1c688213 | |||
| 15fcad7a93 | |||
| fbd1c85168 | |||
| cbf4891703 | |||
| 086e485215 | |||
| 47f3032dcf | |||
| 234e6169cc | |||
| c60abc6d62 | |||
| 5f47083450 | |||
| 0d00ec8e0d | |||
| a3551f2bdf | |||
| 03907ee387 | |||
| a675f2f197 | |||
| 528d2d8451 | |||
| 3d533d49f0 | |||
| f22d08902e | |||
| 740dcd086d | |||
| 57a347c97b | |||
| 908ade9c78 | |||
|
|
f5cd5b16fe |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
462
README.md
462
README.md
@@ -18,14 +18,20 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName. YAML admin uses `mltext`/`mlmarkdown`. `classes.FormatHTML` regenerates `content_html` on admin save.
|
- **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName. YAML admin uses `mltext`, `mltextarea` and the plugin's own `mlblogeditor` field type for content (see [Admin: Blog Editor field types](#admin-blog-editor-field-types)); an admin save stores the sanitized HTML in both `content` and `content_html`. `classes.FormatHTML` regenerates `content_html` on the other write paths (`journal:import-posts`, Journal API writes). It renders markdown and passes raw HTML in `content` through the renderer (Winter parity), then sanitizes the result with a `bluemonday` allow-list: bluemonday's UGC policy plus `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` styles, goldmark's footnote roles, `dir="auto"`, two pasted-transcript `data-message-*` attributes, and exactly five rules for the Blog Editor's own output (Phase 16.2, Phase 33): `data-embed` matching `^youtube$` and `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` (the embed placeholder), `alt` with any text on `img`, `start` matching `^[0-9]+$` on `ol`, and `rel` on `a` holding only the tokens `nofollow`, `sponsored` and `ugc` (`^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$`; any other `rel` value is removed). No iframe, other data attribute, style property or URL scheme is allowed for the editor; `TestEditorOutputPassesPolicy` checks every entry of the editor corpus against the policy. Like Winter's `Html::clean`, it strips what the policy does not allow instead of refusing the save: script, style, iframe, object, embed, form, base and meta elements, comments, event handler attributes, and URLs other than http, https, mailto and relative ones. HTML the policy keeps whole is stored byte for byte, so imported bodies survive a save. Admin saves strip the same way and name what they removed (`classes.SanitizeHTMLReport`). A tag that repeats an attribute is never kept whole: the sanitized output, which keeps the first value, is stored. On the API and import paths `content` itself is stored as submitted. Translated `content_html` goes through the same policy on the API and `journal:import-posts` write paths (`classes.FormatTranslation`): when the translation carries a non-blank `content`, `content_html` is rendered from it and a supplied `content_html` is replaced, as the host column is always derived; a translation with only `content_html` is sanitized with `classes.SanitizeHTML`. Winter stored a supplied translated `content_html` verbatim. The API response shapes and status codes are unchanged.
|
||||||
- **Categories and tags.** `models.Category` is translatable (name, description, slug). Fillable is name, slug, code, description, parent_id; nest_* columns exist in DDL and are not fillable. `models.Tag` is not translatable. Fillable is name, slug, description. Pivots are `golem15_journal_posts_categories` and `golem15_journal_posts_tags`.
|
- **Categories and tags.** `models.Category` is translatable (name, description, slug). It also stores `content`, an HTML host column that is not translatable, edited in the admin with the `blogeditor` field type. Fillable is name, slug, code, description, content and parent_id; nest_* columns exist in DDL and are not fillable. `models.Tag` is not translatable. Fillable is name, slug, description. Pivots are `golem15_journal_posts_categories` and `golem15_journal_posts_tags`.
|
||||||
- **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense.
|
- **Menu and sitemap item types.** The plugin registers its five PHP menu item types (`journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`), `journal-tag` and `journal-author` with the framework's menu item type registry, so menus and sitemaps can point at posts, categories, tags and author pages. Posts, categories, tags and authors carry content descriptors, so plugins that work on every content type (sitemaps, SEO, search) describe, page and match Journal records without the Journal importing them. See [menu item types](#menu-item-types) and [content descriptors](#content-descriptors).
|
||||||
- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, and `/rss`. Writes under the same prefix require a cabana backend JWT (`aud=backend`).
|
- **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense. The Headings and links tab holds the H1 setting `content_h1_to_h2` (on by default) and the generic link texts `generic_anchors`; see [headings and links](#headings-and-links).
|
||||||
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`.
|
- **Image variants.** After a featured image upload commits, the job `golem15.journal.image_variants` writes a thumbnail at each width of `golem15.journal.image_widths` (480, 800 and 1200 by default, never wider than the original, and never taller than 4096 px: such a width of a tall original is skipped with a warning and the job goes on) and a WebP copy of each next to it, recorded in `golem15_journal_image_variants`. The API lists them as `featured_images[].variants`; the upload request never encodes. `journal:image-variants` backfills. See [image variants](#image-variants).
|
||||||
|
- **Headings and links.** While the H1 setting is on, every H1 saved in Journal rich text (post content and category pillar text) becomes an H2 on every write path, with a notice; `journal:fix-headings` and the MCP tool `journal_fix_headings` convert stored content on request. The Blog Editor follows the setting and adds a heading outline, link `rel` options for external links and image file naming. See [headings and links](#headings-and-links).
|
||||||
|
- **Search.** With a configured search engine and `search_use_typesense` on, published posts are kept in the Typesense collection `golem15_journal_posts` after every committed save, and `GET /posts?search=` answers in relevance order. Otherwise search is a SQL `ILIKE`. `?fields=suggest` serves short rows for a search box, and `journal:reindex` fills the index. See [search](#search).
|
||||||
|
- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. `/posts/{slug}` finds the post by its default-locale slug, then by its slug in the request locale (the router's locale, from `Accept-Language` or the translate plugin's resolver), then by id, so a translated slug resolves. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal.
|
||||||
|
- **Record events.** Post writes through the admin fire the framework's record events (`pact.RecordSaved`, `pact.RecordDeleted`) after the commit, like every admin controller. The `/_journal/api/v1` post routes, which MCP tools call, fire the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`: `POST /posts` a RecordSaved with `Created`, `PUT /posts/{id}` a RecordSaved whose changes hold the post's changed columns and each submitted translation that changed (a translated slug per locale), `DELETE /posts/{id}` a RecordDeleted. A tag `POST` or `PUT` creates from a name in `tags` fires its own RecordSaved with `Created`. `POST` and `PUT` write the row, its categories, tags and translations in one transaction and fire the event after it commits, so a write that fails part-way changes nothing and fires nothing. Their `Controller` is empty, as they do not go through an admin form. Another plugin (an SEO plugin creating redirects, a search indexer) therefore hears about a slug changed over MCP without importing the Journal.
|
||||||
|
- **MCP tools.** `journal.Plugin.MCP` declares eleven tools and one prompt for the framework's MCP endpoint (pier, a binary built with the `summercms_mcp` tag): `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags`, `journal_create_post`, `journal_update_post`, `journal_delete_post`, `journal_get_settings`, `journal_update_settings`, `journal_fix_headings`, `journal_set_image_alt` and the prompt `journal_draft_post`. Each runs the matching `/_journal/api/v1` route as the backend account linked to the MCP user (guard `backend`) and is listed only when that account has `golem15.journal.access_posts` (`golem15.journal.manage_settings` for the settings and `fix_headings` tools); the route checks its own permissions on every call. See [MCP tools](#mcp-tools).
|
||||||
|
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve.
|
||||||
- **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`.
|
- **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`.
|
||||||
- **Phrasebooks.** Embedded `lang/en/lang.yaml` and `lang/pl/lang.yaml` hold plugin, journal, post, category, and tag UI strings. They are independent of model translation JSON.
|
- **Phrasebooks.** Embedded `lang/en/lang.yaml` and `lang/pl/lang.yaml` hold plugin, journal, post, category, and tag UI strings. They are independent of model translation JSON.
|
||||||
- **Schema.** Seven gormigrate steps create the six journal tables and the author-slug column. AutoMigrate is not used. Table names are `golem15_journal_*` only.
|
- **Schema.** Ten gormigrate steps create the six journal tables, the author-slug column, the category `content` column, the settings columns `content_h1_to_h2` and `generic_anchors`, and `golem15_journal_image_variants`. AutoMigrate is not used. Table names are `golem15_journal_*` only.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -76,18 +82,162 @@ GET /_journal/api/v1/posts/hello-world
|
|||||||
GET /_journal/api/v1/categories
|
GET /_journal/api/v1/categories
|
||||||
GET /_journal/api/v1/tags
|
GET /_journal/api/v1/tags
|
||||||
GET /_journal/api/v1/rss
|
GET /_journal/api/v1/rss
|
||||||
|
GET /_journal/api/v1/authors/jane-doe
|
||||||
|
GET /_journal/api/v1/authors/jane-doe/posts?page=2
|
||||||
```
|
```
|
||||||
|
|
||||||
Editors create a post with a cabana backend JWT (audience `backend`):
|
### Public categories API
|
||||||
|
|
||||||
|
`GET /_journal/api/v1/categories` returns `{"data":[...]}`, a tree of category rows. Each row has the keys `id`, `name`, `slug`, `description`, `content`, `parent_id`, `nest_depth` and `post_count`, plus `children` only when the category has listed children. A category with no published posts and no listed children is omitted unless the request adds `include_empty=1`.
|
||||||
|
|
||||||
|
`content` is the category's HTML body for a category landing page. It is stored and returned as is: it is not markdown and the API does not render, trim or sanitize it. It is `null` when unset, and it is the same in every locale, because it is a host column and not translatable. A consumer that renders it as HTML must apply its own gate or sanitizer first.
|
||||||
|
|
||||||
|
The admin Categories form edits content with the `blogeditor` field type: a save strips what the HTML policy does not allow, names what it removed in the response's `meta.notices`, and is never refused with a `422` because of markup. Blank content is stored as `null`. Rows written by SQL or by import commands are not sanitized by the form, so consumers keep their own gate.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"data":[{"id":4,"name":"Acme news","slug":"news","description":null,"content":"<h2>Acme news</h2><p>Pillar copy</p>","parent_id":null,"nest_depth":0,"post_count":3}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Public author API
|
||||||
|
|
||||||
|
`GET /_journal/api/v1/authors/{author}` returns one author's public profile without a token. `{author}` resolves in this order:
|
||||||
|
|
||||||
|
1. `backend_users.golem15_bloghub_author_slug`, the public author slug. A user with a slug resolves even before their first post.
|
||||||
|
2. `backend_users.login`, but only for a user with at least one published post. That login is already public through the post `author` object, so other backend accounts cannot be enumerated.
|
||||||
|
|
||||||
|
Soft-deleted backend users never resolve. Unlike the WinterCMS author component, a login also resolves for a user who has a slug, because the post `author` object carries the login. Since v0.3.1 that object also carries `slug` (the same canonical identifier as below), and consumers link by it.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe","subtitle":"Editor","description":"Writes about releases.","avatar":"https://example.com/storage/app/uploads/public/012/345/678/0123456789abcdef01234567.webp"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
The author object has exactly these seven keys. `slug` is the canonical identifier: the author slug when set, otherwise the login, so a consumer that links by login sets its canonical URL from `slug`. `subtitle` and `description` are the `subtitle` and `description` keys of `backend_users.metadata` (the subtitle the admin's My account form edits, read as `cabana.BackendUser.Subtitle` reads it); `avatar` is the public URL of the admin avatar (`cabana.BackendUserAvatar`), or `null` when there is none. Missing names, subtitles and descriptions are empty strings. The object carries no contact, credential, permission or role data and has no `login` key; `slug` falls back to the login only for an author whose login is already public through a published post.
|
||||||
|
|
||||||
|
An unknown author, a login without published posts, a soft-deleted user and a malformed value (empty, over 255 bytes, invalid UTF-8 or containing a control character) all answer `404`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"error":"Author not found"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /_journal/api/v1/authors/{author}/posts` lists that author's published posts (`published` true and `published_at` in the past), resolving `{author}` the same way. Rows have the same shape as `GET /_journal/api/v1/posts` rows. Bearer tokens are ignored, so an editor also sees published posts only. Query keys:
|
||||||
|
|
||||||
|
| Key | Default | Notes |
|
||||||
|
|-----|---------|-------|
|
||||||
|
| `page` | `1` | Values below 1 or non-numeric fall back to page 1. |
|
||||||
|
| `per_page` | `9` | Clamped to 1..30. |
|
||||||
|
| `sort` | `published_at desc` | Same columns as `/posts`: `published_at`, `created_at`, `updated_at`, `title`, `id`, each `asc` or `desc`. |
|
||||||
|
|
||||||
|
The response uses the `/posts` pagination envelope (rows trimmed here):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"data":[{"id":12,"slug":"hello-world","title":"Hello world","author":{"id":7,"first_name":"Jane","last_name":"Doe","login":"jdoe","slug":"jane-doe"}}],"meta":{"current_page":2,"last_page":2,"per_page":9,"total":10}}
|
||||||
|
```
|
||||||
|
|
||||||
|
An author that does not resolve answers the same `404 {"error":"Author not found"}` as the profile route.
|
||||||
|
|
||||||
|
Editors create a post with a cabana backend JWT (audience `backend`) or a personal admin API token:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /_journal/api/v1/posts
|
POST /_journal/api/v1/posts
|
||||||
Authorization: Bearer <backend-access-token>
|
Authorization: Bearer <backend-access-token-or-g15-token>
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{"title":"Hello","content":"A blog post"}
|
{"title":"Hello","content":"A blog post"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
`GET /_journal/api/v1/posts?search=<term>` filters the list by a term of 3 or more characters; a shorter term is ignored and the list comes back unfiltered.
|
||||||
|
|
||||||
|
- **SQL.** By default, and whenever the engine cannot be used, the term is matched with `ILIKE` against title, slug, excerpt and content, and the rows keep the list order (`sort`, default newest first).
|
||||||
|
- **Typesense.** When `search.driver` is `typesense` with an API key and the Journal setting `search_use_typesense` is on, the engine is asked for the first 250 candidates (`typesense.MaxPerPage`), ranked by text match with the setting weights for title, excerpt and content (default 5, 3, 1), then by date. The candidates are re-gated in SQL with the same filters as any list (published, `category`, `tag`, `author`), so a stale index entry never shows. `meta.total` counts the gated rows, the rows keep the engine's rank, with pinned posts first when `pinned_first` is set, and `page` and `per_page` page over them as over any list. An explicit `sort` overrides the rank. A search that matches more than 250 posts keeps the first 250. When the engine fails, the request is answered from SQL and the failure is logged at Warn.
|
||||||
|
|
||||||
|
The setting row is read on every engine search, so turning `search_use_typesense` off takes effect on the next request, without a restart.
|
||||||
|
|
||||||
|
`?fields=suggest` replaces each row with the short shape a search box needs, under the same pagination envelope, filters and limits:
|
||||||
|
|
||||||
|
| Key | Value |
|
||||||
|
|-----|-------|
|
||||||
|
| `id` | Post id. |
|
||||||
|
| `title` | Default-locale title, as in the full row. |
|
||||||
|
| `slug` | Default-locale slug, as in the full row. |
|
||||||
|
| `excerpt` | The excerpt as plain text, cut to about 160 characters on a word boundary with `…`; the content as plain text when the post has no excerpt. |
|
||||||
|
| `featured_image_url` | The first featured image URL, or `null`. |
|
||||||
|
| `published_at` | As in the full row. |
|
||||||
|
|
||||||
|
Any other `fields` value, or none, serves the full rows.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /_journal/api/v1/posts?search=borowik&fields=suggest&per_page=5
|
||||||
|
```
|
||||||
|
|
||||||
|
The search document holds the default-locale title, excerpt and content; content is indexed as plain text, from `content_html` (or `content` when nothing was rendered) with tags removed and entities decoded. The collection is created from `models.Post.SearchIndexSchema` when it does not exist. A collection created before the schema existed keeps its auto-detected fields until `journal:reindex --fresh` recreates it.
|
||||||
|
|
||||||
|
### Headings and links
|
||||||
|
|
||||||
|
Journal decides per site whether its rich text may hold an H1 (2026-10-28, D-50): a page template that shows the post title or the category name as the H1 needs the body to start at H2, and Journal cannot know which template a site uses.
|
||||||
|
|
||||||
|
- **The setting.** Settings › Journal, tab Headings and links, switch `content_h1_to_h2` (Convert H1 to H2 in post and category content), on by default. The settings row decides; the config key `golem15.journal.content_h1_to_h2` is used only when no row exists (`classes.EffectiveH1ToH2`). Switching it never rewrites stored content.
|
||||||
|
- **On.** Every H1 saved in Journal rich text becomes an H2 (`classes.ConvertH1`, on the `golang.org/x/net/html` tokenizer: only the tag name changes, attributes and every other byte are kept), on every write path: an admin save of a post or a category (notice `editor.h1_converted` or `editor.h1_converted_category` on the editor field), `POST` and `PUT /_journal/api/v1/posts` and so MCP (default and translated `content` and `content_html`; the answer gains `notices: [{message, count, locale}]`, `locale` only on a translation's notice), markdown content (its source is converted outside code only; `classes.ConvertContentH1` reads `content` as markdown, as the renderer does: an `<h1>` in a code block or code span keeps its tag, also when the body starts with an HTML block, and a raw `<h1>` elsewhere becomes `<h2>`, as in the rendered HTML) and `journal:import-posts` (it prints `Converted N H1 headings to H2.`). The bytes of an unfinished tag at the end of a text are kept. The editor offers H2 to H4.
|
||||||
|
- **Off.** H1 is stored as written, no notice appears, the editor offers H1 to H4 and `journal:fix-headings` refuses.
|
||||||
|
- **Stored content.** `journal:fix-headings` (a dry run unless `--apply`) and the MCP tool `journal_fix_headings` (`dry_run` default `true`) convert H1 already stored in post content (default and translated; in a markdown source outside code only) and in category pillar text, in one transaction, firing a `RecordSaved` per changed post and category; they answer the post, category and heading counts and refuse while the setting is off (exit code 1; `409` from the API).
|
||||||
|
- **The API flag.** `GET /_journal/api/v1/posts/{slug}` and every category row of `GET /_journal/api/v1/categories` carry `content_h1_to_h2` (boolean, an added key), so a frontend reads the setting from the Journal and not from its own option.
|
||||||
|
- **Generic link texts.** The `generic_anchors` setting (one per line; the English and Polish seed lists when empty, `classes.DefaultGenericAnchors`) is the list the editor's outline and link form warn about.
|
||||||
|
- **Settings API.** `GET` and `PUT /_journal/api/v1/settings` read and write every settings column for `golem15.journal.manage_settings` (`403` without it); `PUT` checks the submitted columns against `models.Settings.Rules` and ignores other keys.
|
||||||
|
- **Image alt.** `POST /_journal/api/v1/images/alt` `{dry_run, changes: [{post_id, locale, src, alt}]}` sets the alt of the image whose `src` equals `src` in the post's `content_html` and in `content`, in the default locale or in `locale`. `content` is read as markdown, as the renderer reads it (an HTML body included, whose `<img>` is a raw image outside code), and gets the alt in its inline image `` (with or without a title, outside code; `classes.SetMarkdownImageAlt`) or a raw `<img>` outside code (`classes.SetImageAltOutsideCode`), also when the markdown starts with an HTML tag, so the next content save keeps the alt; a locale whose `content` is blank (a translation that carries only `content_html`) gets the alt in `content_html` alone. An image the markdown does not hold that way (a reference-style image) refuses the change with `422` `changes.N.src` ("The image is not an inline image of the post's markdown; edit the post."); `alt: ""` marks it decorative. It needs `golem15.journal.access_posts` and edit rights on each post, refuses the whole batch with `422` when one image is not in its post, answers `{changes: [{post_id, locale, src, old_alt, alt, changed}], dry_run}` and fires a `RecordSaved` per changed post. Alt text is post content, so the bulk alt tool is Journal's (`journal_set_image_alt`), not the SEO plugin's.
|
||||||
|
|
||||||
|
### Related posts
|
||||||
|
|
||||||
|
`GET /_journal/api/v1/posts/{slug}` answers `related_posts` with the post's related posts and `previous_post` and `next_post` with its site-wide neighbours by publish time. The ranking is opt-in (D-82; `classes.ReadRelatedSettings`):
|
||||||
|
|
||||||
|
- **`golem15.journal.related_mode: category`** (the default): as before, up to three posts sharing a category with the post, newest first; none for a post without a category. The answer is unchanged.
|
||||||
|
- **`scored`**: posts sharing a tag or a category, ranked by 2 points per shared tag plus 1 point per shared category, ties by the newest publish time, `golem15.journal.related_count` of them (3 to 6, clamped; 3 when unset). A post that shares nothing is never related. One query computes the scores (`classes.ScoredRelatedPosts`). The answer adds `previous_in_category` and `next_in_category` (`{id, title, slug}` or `null`): the neighbours by publish time within the post's first category by name (`classes.PrimaryCategoryID`, `classes.CategoryNeighbour`), in the direction of `previous_post` and `next_post` (previous is the nearest post published after it, next the nearest one published before it).
|
||||||
|
|
||||||
|
Visitors only ever see published posts with a past publish time; an editor who may see drafts also gets drafts among the scored related posts, as the default mode does. The neighbours are published posts only.
|
||||||
|
|
||||||
|
### Image variants
|
||||||
|
|
||||||
|
Every entry of `featured_images` (on `GET /posts`, `GET /posts/{slug}` and the author lists) carries `variants`, the image's sizes by width, `[]` until they are made (additive key; the other keys are unchanged):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id": 12, "url": "/storage/uploads/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.jpg", "file_name": "cover.jpg", "file_size": 183402,
|
||||||
|
"variants": [
|
||||||
|
{"width": 480, "height": 288, "url": "/storage/uploads/5f1/d0c/2e9/thumb_12_480_288_0_0_exact.jpg", "webp_url": "/storage/uploads/5f1/d0c/2e9/thumb_12_480_288_0_0_exact.webp", "source_width": 1000, "source_height": 600},
|
||||||
|
{"width": 800, "height": 480, "url": "…_800_480_0_0_exact.jpg", "webp_url": "…_800_480_0_0_exact.webp", "source_width": 1000, "source_height": 600}
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`url` is the thumbnail in the original's format (a `.webp` original's thumbnail holds JPEG bytes), `webp_url` its WebP copy at quality 80, and `source_width` and `source_height` the original's size, so a page can list the original in a `srcset` too. One query loads the variants of a post's images.
|
||||||
|
|
||||||
|
- **When they are made.** `POST /_journal/api/v1/posts/{id}/featured-images` stores the original and queues the conga job `golem15.journal.image_variants` (`classes.ImageVariantsArgs`, three attempts) after the row is written; the request itself never resizes or encodes, and a queue that cannot take the job is logged without failing the upload. An admin save of a post queues the job, after the save commits, for its featured images that lack a width.
|
||||||
|
- **What is made.** For each width of `golem15.journal.image_widths` no larger than the original, `classes.GenerateImageVariants` writes the thumbnail (`attach.File.ThumbKey`, mode `exact`, the original's aspect ratio) and its WebP copy (`webp.Variant` of the framework's `modules/lagoon/attach/webp`). A width that has its row is skipped, so the job and the command can run any number of times. A missing or undecodable original, or one over the thumbnailer's 4096 by 4096 pixel limit, is skipped and logged.
|
||||||
|
- **Backfill.** `journal:image-variants` takes the images that lack a width no larger than their original; `--all` checks every featured image. It runs from the application's CLI too: without a running server it opens `database.dsn` and `storage.uploads.bucket_url` itself.
|
||||||
|
- **Not made.** AVIF (deferred). Images inserted into a post body keep their original file.
|
||||||
|
- **Server.** WebP is encoded in Go. With the system libwebp installed (`libwebp.so` and `libwebpdemux.so`, Debian `libwebp-dev`) it is about five times faster. A binary that mounts this plugin is dynamically linked against glibc, unless it is built with `-tags nodynamic` (see the framework's attachments documentation).
|
||||||
|
|
||||||
|
### Personal API tokens
|
||||||
|
|
||||||
|
The plugin registers two scopes through `journal.Plugin.TokenScopes` (`pact.HasTokenScopes`):
|
||||||
|
|
||||||
|
| Scope | Methods | Cover |
|
||||||
|
|-------|---------|-------|
|
||||||
|
| `journal` | GET, HEAD, POST, PUT, PATCH, DELETE | `golem15.journal.*` |
|
||||||
|
| `journal:read` | GET, HEAD | `golem15.journal.*` |
|
||||||
|
|
||||||
|
Mint a token from My account (or `admin:token:create`) with scope `journal`, then call the Journal API as that administrator:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /_journal/api/v1/posts
|
||||||
|
Authorization: Bearer g15_<40-hex>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"title":"Agent draft","content":"Written with a journal token"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional-auth reads (`GET /posts` and `GET /posts/{slug}`) unlock drafts the token owner may see when the Bearer token is a valid `journal` or `journal:read` token, or when the MCP endpoint dispatches the request in-process as a backend principal (no `Authorization` header then; `cabana.ResolveBackend` reads the dispatch marker). A missing, invalid, expired, revoked or out-of-cover token on those GETs is treated as anonymous (no 401). Write routes stay Bearer-only: missing or bad credentials answer `401 {"error":"Authentication required"}`; a `journal:read` token on a write, or a token whose scopes do not cover `golem15.journal.*`, answers `403 {"error":"Insufficient permissions"}`.
|
||||||
|
|
||||||
|
This reverses Phase 15 D-15 (JWT-only Journal writes): principals resolve through `cabana.ResolveBackend` with cover `golem15.journal.*`, so the admin cookie never authenticates a Journal request. Featured-image upload and delete still authorize only through `Post.CanEdit` (PHP parity — no separate `access_posts` check on those routes). Because the resolver uses the admin guard's compiled registry, the Journal API now honors code-declared role grants (`pact.Permission.Roles`, for example the developer role on `golem15.journal.access_posts`); previously a nil registry meant only row grants applied. The `journal-api` throttle bucket still keys by client IP (it runs before the handler resolves the principal).
|
||||||
|
|
||||||
A blog post model already declares translatable fields:
|
A blog post model already declares translatable fields:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -98,6 +248,192 @@ func (Post) TranslatableIndexes() []string { return []string{"slug"} }
|
|||||||
func (Post) MorphName() string { return `Golem15\Journal\Models\Post` }
|
func (Post) MorphName() string { return `Golem15\Journal\Models\Post` }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### MCP tools
|
||||||
|
|
||||||
|
With the framework's MCP endpoint mounted (`summer.yaml` `build.tags: [summercms_mcp]`, `mcp.enabled: true`), an MCP client connected through the application's OAuth server with the `backend` scope and a verified link to an administrator sees these tools when that administrator has `golem15.journal.access_posts`:
|
||||||
|
|
||||||
|
| Tool | Route | Input |
|
||||||
|
|------|-------|-------|
|
||||||
|
| `journal_list_posts` | `GET /_journal/api/v1/posts` | query `page`, `per_page`, `search`, `category`, `tag`, `author`, `published`, `sort` |
|
||||||
|
| `journal_show_post` | `GET /_journal/api/v1/posts/{slug}` | `slug` |
|
||||||
|
| `journal_list_categories` | `GET /_journal/api/v1/categories` | none |
|
||||||
|
| `journal_list_tags` | `GET /_journal/api/v1/tags` | none |
|
||||||
|
| `journal_create_post` | `POST /_journal/api/v1/posts` | `title`, `content`, optional `slug`, `excerpt`, `published`, `published_at`, `is_pinned`, `categories` (ids), `tags` (names) |
|
||||||
|
| `journal_update_post` | `PUT /_journal/api/v1/posts/{id}` | `id` and any of the create fields; only the fields sent change |
|
||||||
|
| `journal_delete_post` | `DELETE /_journal/api/v1/posts/{id}` | `id` (destructive) |
|
||||||
|
| `journal_get_settings` | `GET /_journal/api/v1/settings` | none (`golem15.journal.manage_settings`) |
|
||||||
|
| `journal_update_settings` | `PUT /_journal/api/v1/settings` | any settings column, for example `content_h1_to_h2` or `generic_anchors`; only the fields sent change (`golem15.journal.manage_settings`) |
|
||||||
|
| `journal_fix_headings` | `POST /_journal/api/v1/fix-headings` | `dry_run` (default `true`) (`golem15.journal.manage_settings`) |
|
||||||
|
| `journal_set_image_alt` | `POST /_journal/api/v1/images/alt` | `dry_run`, `changes` (`post_id`, `locale`, `src`, `alt`) (destructive) |
|
||||||
|
|
||||||
|
The prompt `journal_draft_post` (arguments `topic`, required, and `tone`) asks the agent to draft a post, reuse existing categories and tags and save it with `journal_create_post` as a draft; it is marked `Writes`, so it is offered only to grants that may write. A grant without the `write` scope lists only the read tools and no prompt. Publishing still needs `golem15.journal.access_publish` and editing another author's post `golem15.journal.access_other_posts`; the route answers its usual error body, which the agent receives as the tool error. The media and featured-image uploads (multipart) are not tools.
|
||||||
|
|
||||||
|
### Blog Editor
|
||||||
|
|
||||||
|
`assets/src/blogeditor` is the Blog Editor admin element, the custom element `golem15-journal-blogeditor` (`BlogEditorElement`). It is a WYSIWYG HTML editor on TipTap (ProseMirror, MIT) with a Source tab that shows and edits the exact HTML string. The toolbar offers paragraphs and headings 2 to 4 (1 to 4 while the H1 setting is off), bold, italic, bullet and ordered lists, links, blockquotes, tables, images, YouTube embeds, a raw HTML block, undo and redo, plus a Preview toggle. Labels follow the `ui-locale` attribute (`en`, `pl`).
|
||||||
|
|
||||||
|
The element follows the admin field type element contract: the `value` property holds the HTML string; the attributes are `field-name`, `record-id`, `locale`, `ui-locale`, `options`, `disabled` and `invalid`; a bubbling `summer-change` event with `detail.value` fires on user edits only. It styles itself only through the admin `--c-*` variables, in `blogeditor.css` under its tag (TipTap's injected stylesheet is off), and never receives or sends credentials.
|
||||||
|
|
||||||
|
- **Untouched values.** The element returns the exact string it was given until the user edits; an untouched imported post is saved byte for byte. Setting `value` to the string it currently returns is a no-op. After an edit, top-level blocks that are still equal to the loaded ones keep their original bytes, so editing one paragraph changes only that paragraph. Markup the schema does not model (`footer`, `section`, `aside`, `header`, `nav`, `details`, `style`, `div` and similar blocks, and a `figure` that is not an image with an optional caption) becomes a raw HTML card that shows its source as text and is written back verbatim. On paragraphs, headings, lists, blockquotes, tables, cells, images and links, and on `span` elements, the view-safe unmodelled attributes survive an edit of their block: `class`, `title`, `alt`, `width`, `height`, `colspan`, `rowspan`, `start`, `rel`, `target` and `data-*` (`href` and `src` are kept by the link and image nodes, which check them). Every other attribute, such as `id`, `name`, `style`, `srcset`, `form`, `contenteditable`, `popover`, `itemprop` or an event handler (`on*`), is never rendered into the admin page and is dropped from a block once that block is edited; a `span` left with none of the kept attributes is dropped and its text kept. Untouched documents and untouched blocks keep every stored attribute.
|
||||||
|
- **Embeds.** The embed button takes a YouTube URL (`youtube.com/watch?v=`, `youtu.be/`, `youtube.com/shorts/`, `youtube.com/embed/`) and stores exactly `<figure data-embed="youtube" data-id="ID"></figure>`, where `ID` matches `^[A-Za-z0-9_-]{11}$`. The admin shows a card and never an iframe; the site layer expands the placeholder. Another provider is one entry in `EMBED_PROVIDERS` (`embed.ts`) and one in the layer.
|
||||||
|
- **Preview.** The Preview button (`Podgląd` for `ui-locale` `pl`) is off by default, belongs to each element on its own and shows its state through `aria-pressed`. It splits the editor 50/50; when the element is narrower than about 720px, the pane wraps below the editor. The pane renders the current value from the WYSIWYG or Source tab 300 ms (`PREVIEW_DEBOUNCE_MS` in `preview.ts`) after the last change, and each update reloads the frame. The frame is an `<iframe srcdoc>` with an empty `sandbox` attribute, so no scripts run and its origin is opaque. It has `referrerpolicy="no-referrer"` and a Content-Security-Policy meta (`PREVIEW_CSP`) that allows only inline styles and images. The preview copy leaves out `script`, `iframe`, `frame`, `frameset`, `object`, `embed`, `meta`, `base` and `link` elements and event handler attributes. YouTube placeholders show as thumbnail cards loaded from img.youtube.com, never as a player; these and the post's own images are the only network traffic the pane makes. The styling is post-body typography, with the `--c-*` values read from the element at render time or neutral defaults. Previewing never fires `summer-change` and never changes the value, so an untouched post is still saved byte for byte.
|
||||||
|
- **Headings (J1).** The element reads `headingLevels` from its `options` (the field types serve them per form request, see [headings and links](#headings-and-links)): `[2, 3, 4]` while the H1 setting is on, when the toolbar offers H2 to H4 and a pasted or loaded `<h1>` is parsed as an H2, so the editor never holds an H1; `[1, 2, 3, 4]` while it is off, with an H1 button first. Stored H5 and H6 still parse. The Source tab accepts any HTML; the server decides what is stored.
|
||||||
|
- **Outline (J2).** A full-width bar between the editor and the status line shows the heading count and, when there are any, the problem count; it starts collapsed and never takes focus. Opened, it lists the headings in document order with their level, indented 12px per level, and flags a skipped level (the page title is the H1), an empty heading, a heading that is only a link and, while the H1 setting is on, an H1 in the Source tab; then the links whose whole text is on the generic list (`genericAnchors` option). It recomputes 300 ms (`OUTLINE_DEBOUNCE_MS`) after a change, from the Source text in the Source tab. Activating a heading puts the caret in it. It never edits the content.
|
||||||
|
- **Links.** The editor creates and keeps links only to `http`, `https`, `mailto` and relative URLs (`isPolicyUrl` in `extensions.ts`), the schemes both sanitizer policies keep; the scheme is read after removing control characters and spaces. A created link has `href` only, or `href` and `rel` (J3): for an absolute `http(s)` URL the link form offers `nofollow`, `sponsored (paid)` and `ugc (user content)` and Apply writes the checked ones; links within the site never get `rel`. When the link text is on the generic list the form warns (“click here” does not say where the link leads) without blocking Apply. Stored links keep their own `target` and `rel`; the server keeps only the three `rel` tokens.
|
||||||
|
- **Images.** An image chosen with the file picker (JPEG, PNG, GIF, WebP) or dropped on the editor opens the image form, one file at a time (J4): the file name, prefilled with the slug of the original name (lowercase, Polish letters transliterated, hyphens, at most 80 characters, the extension shown fixed after it), the alt text (required unless the image is marked decorative), the Decorative image checkbox (it clears and disables the alt and inserts `alt=""`) and an optional caption. Insert hands the renamed file to the admin page as a bubbling `summer-upload` event with `detail {id, file}`. The admin uploads it with its own session and answers on the element with `summer-upload-result` `{id, url}` or `{id, error}`. On the URL the editor inserts `<img src alt>`, or `<figure>` with `<img>` and `<figcaption>` when a caption is given. The status line shows the upload until the answer arrives; an error, or no answer within 60 seconds, is shown there and nothing is inserted.
|
||||||
|
- **Sanitizing.** The element never sanitizes. Every admin save goes through `classes.SanitizeHTMLReport`, which strips what the policy does not allow and reports it; the policy keeps all of the editor's own output.
|
||||||
|
|
||||||
|
The element is built by the plugin's own JS build into `assets/dist/blogeditor.js` and `assets/dist/blogeditor.css`. Both files are committed and embedded through `admin.go`, so a host application needs no Node:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci # exact versions from package-lock.json
|
||||||
|
npm run build # typecheck, then vite build into assets/dist
|
||||||
|
npm test # vitest on happy-dom
|
||||||
|
bash scripts/check-dist.sh # fails when assets/dist differs from a fresh build
|
||||||
|
```
|
||||||
|
|
||||||
|
After changing `assets/src`, run `npm run build` and commit `assets/dist`.
|
||||||
|
|
||||||
|
`tests/blogeditor/fixtures/editor-corpus.json` holds the editor's own output, built through editor commands: an embed, images with and without a caption, a decorative image, a table with a header row, links, external links with `rel`, headings, lists (one starting at 3), a blockquote, inline marks, a code block, a horizontal rule, a hard break, and one document combining them. `tests/blogeditor/corpus.test.ts` rebuilds it and fails when the committed file differs; `UPDATE_CORPUS=1 npm test` rewrites it. The Go policy test and the site layer check the same entries against both sanitizer policies, so entry names stay stable.
|
||||||
|
|
||||||
|
### Admin: Blog Editor field types
|
||||||
|
|
||||||
|
The plugin registers two admin form field types (`journal.Plugin.FormFieldTypes`, built by `classes.BlogEditorTypes`) that render the Blog Editor element. Any controller's `fields.yaml` may use them:
|
||||||
|
|
||||||
|
| Type | Value | Options |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `blogeditor` | One HTML string in a host column (category content). | `uploadFolder` |
|
||||||
|
| `mlblogeditor` | One HTML string per content locale, stored like `mlmarkdown`: the default locale in the host column, the others as translations (post content). | `htmlColumn`, `uploadFolder` |
|
||||||
|
|
||||||
|
Both types also serve two options per form request (`pact.FormFieldType.RequestOptions`): `headingLevels` (`[2, 3, 4]` while the H1 setting is on, `[1, 2, 3, 4]` while it is off) and `genericAnchors` (the settings' generic link texts, `classes.GenericAnchors`). A `fields.yaml` option of the same name wins.
|
||||||
|
|
||||||
|
Both types show the element's Preview pane, and previewing never changes what a save stores.
|
||||||
|
|
||||||
|
`htmlColumn` names the translatable attribute that holds the rendered HTML of a markdown value (`content_html` for posts). `uploadFolder` is the folder under `/journal/` that editor images are stored in (`posts`, `categories`; `editor` when unset); it follows the public upload route's folder rule (at most 100 letters, digits, `_`, `-` or `/`, no `..`) and is checked at boot.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
content:
|
||||||
|
label: golem15.journal::lang.post.content
|
||||||
|
type: mlblogeditor
|
||||||
|
htmlColumn: content_html
|
||||||
|
uploadFolder: posts
|
||||||
|
```
|
||||||
|
|
||||||
|
What an admin save stores:
|
||||||
|
|
||||||
|
- Each submitted locale's value goes through `classes.SanitizeHTMLReport`. While the H1 setting is on, every H1 left is then stored as an H2 (`classes.ConvertH1`) with the notice `1 H1 heading was converted to H2, because the page shows the post title as the H1.` (the category name, on a category). A value the policy keeps whole is stored byte for byte. Anything the policy does not allow is removed, the save goes through, and the response's `meta.notices` names what was removed per field and locale (for example `2 elements were removed: script, iframe`), in English or Polish. A save is never refused because of markup; a value that is not text is a `422` on the field.
|
||||||
|
- For posts, the stored HTML goes to both `content` and `content_html` of that locale: the host columns through the `FormBeforeCreate`/`FormBeforeUpdate` hooks, the other locales through `FormAfterCreate`/`FormAfterUpdate` and the translation writer. The admin path does not run goldmark.
|
||||||
|
- A locale whose content the administrator did not change is not written, so an imported post keeps both columns byte for byte until its body is edited.
|
||||||
|
- A post whose `content` does not start with `<` (`classes.LooksLikeHTML`) is a markdown post: the editor opens it with that locale's `content_html`, or with `FormatHTML(content)` when that is empty. Its first edited save stores HTML in both columns.
|
||||||
|
- Category content follows the same rule and is no longer refused with a `422`.
|
||||||
|
|
||||||
|
The public Journal API is unchanged: `POST` and `PUT /_journal/api/v1/posts` still take markdown `content` and store `FormatHTML` output in `content_html`. While the H1 setting is on they store every H1 of `content`, `content_html` and the submitted translations as an H2, and the answer gains a `notices` key (see [headings and links](#headings-and-links)).
|
||||||
|
|
||||||
|
Editor images are uploaded by the admin through the field's upload route and stored by `classes.StoreMedia`, the same storage as `POST /_journal/api/v1/media/upload`: the key is `/journal/<uploadFolder>/<name>` with the same numbered suffix when the name is taken and the same public URL. An editor upload must be a JPEG, PNG, GIF or WebP file whose first bytes sniff as the type its extension names (no SVG), at most 10240 KiB. Both uploads store the name `classes.MediaFileName` makes (D-85): the stem lowercased, Polish letters transliterated, every other run of characters outside `a-z` and `0-9` one hyphen, at most 80 characters (`image` when nothing is left), and the extension lowercased, so `Lwia Grzywa – w lesie!.JPG` is stored as `lwia-grzywa-w-lesie.jpg` and the inserted `src` needs no escaping. The public upload route keeps its own request fields, response and messages.
|
||||||
|
|
||||||
|
### Menu item types
|
||||||
|
|
||||||
|
`journal.Plugin.MenuItemTypes` (built by `classes.MenuItemTypes`) registers the Journal types under the PHP keys of the WinterCMS plugin's `pages.menuitem` handlers. A menu or sitemap plugin offers them as item types and resolves its items through the registry (`signpost` in the framework), which hands each type the URL pattern it layered: the item's own pattern, else the host's override of the type, else, for a list type, the host's override of the record type it names in `PatternFrom`, else the type's default.
|
||||||
|
|
||||||
|
| Key | Picks | Default pattern | `PatternFrom` | Resolves to |
|
||||||
|
|-----|-------|-----------------|---------------|-------------|
|
||||||
|
| `journal-category` | A category from the category tree | `/blog/category/:slug` | | The category's URL; with nesting, its subcategories as sub-items, each once with its URL. |
|
||||||
|
| `all-journal-categories` | Nothing | `/blog/category/:slug` | `journal-category` | No URL of its own; every category ordered by name as items. |
|
||||||
|
| `journal-post` | A post (listed by title, drafts included) | `/blog/:slug` | | The post's URL while it is published with a past publish date; nothing for a draft or a future post, so the menu entry or sitemap row appears once the post goes live. PHP resolved any post. |
|
||||||
|
| `all-journal-posts` | Nothing | `/blog/:slug` | `journal-post` | No URL of its own; the published posts ordered by title as items. |
|
||||||
|
| `category-journal-posts` | A category from the category tree | `/blog/:slug` | `journal-post` | No URL of its own; the published posts of the category and its descendants ordered by title as items. |
|
||||||
|
| `journal-tag` | A tag (listed by name) | `/blog/tag/:slug` | | The tag's URL; nothing when the host has no tag pages (`golem15.journal.tag_pages: false`). It has no PHP counterpart. |
|
||||||
|
| `journal-author` | An author: a backend user with at least one published post (listed by name) | `/blog/author/:author` | | The author page's URL, `:author` being the author slug, else the login (the public author API's lookup order); nothing when the host has no author pages (`golem15.journal.author_pages: false`). It has no PHP counterpart. |
|
||||||
|
|
||||||
|
Published means published with a publish date in the past, the public API's rule. Ties in every order break by id.
|
||||||
|
|
||||||
|
Placeholders are replaced literally, in order, like the PHP `buildUrlFromPattern`. A category and a tag pattern take `:id` and `:slug`, an author pattern `:author` and `:id`. A post pattern takes `:id`, `:slug`, `:category` (the slug of the post's first category by name) and, for a published post with a publish date, `:year`, `:month` and `:day` (UTC).
|
||||||
|
|
||||||
|
Every resolved record carries alternates, one per enabled translate locale in `sort_order` (ties by id). The unprefixed locale (the caller's, for example a sitemap's frontend default locale, else translate's default locale) gets no prefix; every other locale gets `/<code>`. Slugs and titles are translated per locale and fall back to the default value. Without an enabled locale the only alternate is `x-default`. A record's URL is its first alternate's URL, as in PHP.
|
||||||
|
|
||||||
|
The content descriptors (posts, categories, tags and authors, which feed the SEO plugin's hreflang and the sitemaps) keep one locale entry per enabled locale but give a locale an address only when the record has content in it: the default locale (translate's default, else the unprefixed locale, else the first enabled one) always; a post or a category when its own translation in that locale holds text (a post's title, content, content_html or excerpt, a category's name or description; a translated slug alone is no content); a tag or an author when at least one of its published posts has such a translation. An entry without content has the URL `""`, pact's "no public page", and the default locale's title, description and word count, so an untranslated locale is never advertised as an alternate. The menu item types' alternates above are unchanged.
|
||||||
|
|
||||||
|
A host changes the URLs in its config file `config/menu_items.yaml` (YAML only, because the keys contain dashes). Overriding the two record types is enough; the list types follow:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
patterns:
|
||||||
|
journal-post: /blog/:slug
|
||||||
|
journal-category: /:slug
|
||||||
|
```
|
||||||
|
|
||||||
|
A host may also override a list key itself (for example `all-journal-posts: /articles/:slug`) to give its items a different pattern than the single-record type, and a menu or sitemap item can carry its own pattern.
|
||||||
|
|
||||||
|
Category content stays on the Blog Editor (`blogeditor`) field type; it does not move to the framework's `richeditor`.
|
||||||
|
|
||||||
|
### Content descriptors
|
||||||
|
|
||||||
|
`journal-post`, `journal-category`, `journal-tag` and `journal-author` set `pact.MenuItemType.Content` (built in `classes/content_descriptors.go` and `classes/menu_item_types.go`): their admin controllers (`golem15.journal.posts`, `golem15.journal.categories`, `golem15.journal.tags`; none for authors, who are backend users with the morph name `Backend\Models\User`), their morph names (the `Model` record events carry) and three functions the framework's registry calls with the layered pattern. The functions read through the admin save transaction when one is in the context, so a form extension's `Save` sees a slug the save has just changed.
|
||||||
|
|
||||||
|
- **Describe.** One record per enabled locale, with the URL the menu item type resolves to in that locale, the translated title, a plain-text description (a post's excerpt, a category's or tag's description), a post's first featured image and the word count of a post's or category's body. Posts are described with drafts included; `Published` says whether visitors see the post (published, with a past publish date). Categories and tags are always published. On a host with `golem15.journal.tag_pages: false` tag URLs are empty.
|
||||||
|
- **Facts (Phase 33).** The descriptors also fill the optional content facts the SEO, sitemap and audit plugins read: `Kind` (`article` for posts, `collection` for categories and tags, `profile` for authors) and `Tools`, the MCP tools that fix a record (posts: `edit` `journal_update_post`, `headings` `journal_fix_headings`, `image_alt` `journal_set_image_alt`; categories: `headings`). A post record carries `PublishedAt`, its categories by name as `Parents`, its author in `Authors` (with a `journal-author` reference while author pages are on) and `TopHeading` (2 while the H1 setting is on, 0 while it is off); each locale lists `Images` (the featured image first, its alt the file's title or else the post title, then the body's images with their alt, `Decorative` for `alt=""` and the `width` and `height` attributes), `Videos` (YouTube embeds) and `Headings` (the body's outline). A child category's `Parents` is its parent, and a category carries its pillar text's `Headings` and the same `TopHeading`. The body facts are read once per locale with `golang.org/x/net/html`; the queries are batched per page. An author record has the author's name as every locale's title, the author URL per locale (empty without author pages) and the newest update of their published posts as `LastMod`.
|
||||||
|
- **Records.** Every record in id order, paged by the last id (at most 500 per page), drafts included; for `journal-author`, the authors with a published post.
|
||||||
|
- **Match.** The record a public path belongs to. The path's locale prefix is read first (an enabled locale other than the unprefixed one), then the pattern's placeholders: a post or category is found by its slug in that locale (translate's slug index), else its default slug, else a slug of another locale, else by `:id`; a tag by its slug or id. The match is exact only when the path is the record's own URL in that locale, so every segment (category, date, id, locale prefix, slug) must fit; a path that names the record with a wrong segment, a stale prefix or another locale's slug matches inexactly, for a redirect to the record's URL. An author is found by `:author` (the author slug, then the login) or `:id` among the authors with a published post. Drafts and future posts never match, tags never match without tag pages and authors never match without author pages. Path values reach the database only as query parameters.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Site cache purge
|
||||||
|
|
||||||
|
A host site that caches its pages (for example a Nuxt app with `swr` route rules) would keep serving a page rendered before an admin edit until its cache entry expires. An admin save of a Journal post or category, and every post write through the token API (create, update, delete, featured image upload and removal), asks the host site to drop its page caches, so the edit shows on the next page view. The purge itself is the framework's site-cache purger (`cooler.SiteCachePurger`), shared with the menu and FAQ plugins, so one setting serves every plugin:
|
||||||
|
|
||||||
|
| Key | Environment variable | Value |
|
||||||
|
|-----|----------------------|-------|
|
||||||
|
| `site_cache.purge_url` | `SUMMER_SITE_CACHE__PURGE_URL` | The host site's purge route, for example `http://127.0.0.1:3000/api/__site__/cache/purge`. |
|
||||||
|
| `site_cache.purge_token` | `SUMMER_SITE_CACHE__PURGE_TOKEN` | The bearer token the route expects, at least 32 characters. Keep it in the server's environment file, never in a committed config file. |
|
||||||
|
|
||||||
|
With neither key set, nothing is purged and no request is sent. `serve` fails at boot, naming the key, when only one of them is set, when the URL is not http or https with a host, when it carries user info or a fragment, when an `http` URL points at a host other than loopback (`127.0.0.0/8`, `::1`, `localhost`; the token would cross the network in clear, so use `https` for any other host), or when the token is shorter than 32 characters. When the purge is enabled, start-up logs the URL's host and path once.
|
||||||
|
|
||||||
|
**Deprecated Journal keys (one release).** The Journal's own keys still work when the framework keys are unset:
|
||||||
|
|
||||||
|
| Deprecated key | Environment variable |
|
||||||
|
|----------------|----------------------|
|
||||||
|
| `golem15.journal.site_cache.purge_url` | `SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL` |
|
||||||
|
| `golem15.journal.site_cache.purge_token` | `SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN` |
|
||||||
|
|
||||||
|
With only these set, Boot validates them with the same rules (errors name the Journal keys), publishes the purger they describe, so menu and FAQ saves purge through it as well, and logs one deprecation warning naming `site_cache.purge_url` and `site_cache.purge_token`. With the framework keys set, the Journal keys are ignored with a warning. Move the values to the framework keys before the next release removes them.
|
||||||
|
|
||||||
|
The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Content-Type: application/json` and the body `{"kind":"post","slug":"<slug>"}` or `{"kind":"category","slug":"<slug>"}`. The receiver answers `200` with `{"purged":<n>}`, the number of cache entries it removed.
|
||||||
|
|
||||||
|
- **Timing.** The purge is registered with `lagoon.AfterCommit` on the admin save transaction: it runs only after the save commits, and never for a save that fails, so a concurrent page view cannot re-cache the old content. It runs before the save response, so the cache is clear when the administrator sees the save confirmed. The token API writes purge outside their transaction: the purge is queued once the write committed and sent in the background, so the response does not wait for the site.
|
||||||
|
- **Never fails a save.** The request has a 2 s timeout and ignores the cancellation of the admin request. A refused, failing, slow or unreachable purge is logged as a warning (`cooler: site cache purge failed`) and the save response is unchanged; a successful one logs `cooler: site cache purged` with the kind, slug, status and purged count. The token and the Authorization header are never logged.
|
||||||
|
- **What purges.** Admin creates, updates and deletes of posts and categories, including a save that changes nothing, and the Journal API post writes (create, update, delete, featured image upload and removal). Tags and the posts import action do not purge.
|
||||||
|
- **Receiver.** The host's Nuxt app built on vue-journal-layer 0.4.0 or later provides the shared route `POST /api/__site__/cache/purge` with its token in `NUXT_SITE_CACHE_PURGE_TOKEN`; the old route `POST /api/__journal__/cache/purge` with `NUXT_JOURNAL_CACHE_PURGE_TOKEN` stays for one release. Generate one token on the server and put the same value in both environments.
|
||||||
|
|
||||||
|
### Headings and links
|
||||||
|
|
||||||
|
| Key | Environment variable | Value |
|
||||||
|
|-----|----------------------|-------|
|
||||||
|
| `golem15.journal.content_h1_to_h2` | `SUMMER_GOLEM15__JOURNAL__CONTENT_H1_TO_H2` | The H1 setting used only when the Journal settings row does not exist, default `true` (`config/config.yaml`). The row's `content_h1_to_h2` (Settings › Journal, Headings and links) decides otherwise; `classes.EffectiveH1ToH2` is the one reader. |
|
||||||
|
|
||||||
|
### Related posts
|
||||||
|
|
||||||
|
| Key | Environment variable | Value |
|
||||||
|
|-----|----------------------|-------|
|
||||||
|
| `golem15.journal.related_mode` | `SUMMER_GOLEM15__JOURNAL__RELATED_MODE` | `category` (default, `config/config.yaml`): related posts as before. `scored`: ranked by shared tags and categories, plus `previous_in_category` and `next_in_category` on the post answer. See [related posts](#related-posts). |
|
||||||
|
| `golem15.journal.related_count` | `SUMMER_GOLEM15__JOURNAL__RELATED_COUNT` | The number of scored related posts, default `3`, clamped to 3 to 6. Read in `scored` mode only. |
|
||||||
|
|
||||||
|
### Image variants
|
||||||
|
|
||||||
|
| Key | Environment variable | Value |
|
||||||
|
|-----|----------------------|-------|
|
||||||
|
| `golem15.journal.image_widths` | `SUMMER_GOLEM15__JOURNAL__IMAGE_WIDTHS` | The widths in pixels of the featured image variants, default `[480, 800, 1200]` (`config/config.yaml`); the environment takes a comma-separated list such as `480,960`. Each is 16 to 4096; an invalid list falls back to the default. See [image variants](#image-variants). |
|
||||||
|
|
||||||
|
### Author pages
|
||||||
|
|
||||||
|
| Key | Environment variable | Value |
|
||||||
|
|-----|----------------------|-------|
|
||||||
|
| `golem15.journal.author_pages` | `SUMMER_GOLEM15__JOURNAL__AUTHOR_PAGES` | Whether the host site has author pages, default `true`. With `false`, `journal-author` items resolve to nothing, author descriptions carry empty URLs, author paths never match and post descriptors name their author without a `journal-author` reference. |
|
||||||
|
|
||||||
|
### Tag pages
|
||||||
|
|
||||||
|
| Key | Environment variable | Value |
|
||||||
|
|-----|----------------------|-------|
|
||||||
|
| `golem15.journal.tag_pages` | `SUMMER_GOLEM15__JOURNAL__TAG_PAGES` | Whether the host site has tag pages, default `true`. With `false`, `journal-tag` items resolve to nothing, tag descriptions carry empty URLs and tag paths never match, for sites whose frontend turns tag routes off. |
|
||||||
|
|
||||||
## API reference
|
## API reference
|
||||||
|
|
||||||
| Identifier | Description |
|
| Identifier | Description |
|
||||||
@@ -105,10 +441,26 @@ func (Post) MorphName() string { return `Golem15\Journal\Models\Post
|
|||||||
| `journal.Plugin` | Compiled plugin; `ID` is `golem15.journal`. |
|
| `journal.Plugin` | Compiled plugin; `ID` is `golem15.journal`. |
|
||||||
| `journal.Plugin.Requires` | Returns `golem15.translate` only. |
|
| `journal.Plugin.Requires` | Returns `golem15.translate` only. |
|
||||||
| `journal.Plugin.LangFS` | Embedded English and Polish UI phrasebooks. |
|
| `journal.Plugin.LangFS` | Embedded English and Polish UI phrasebooks. |
|
||||||
|
| `journal.Plugin.ConfigFS` | Ships `config/config.yaml` (`golem15.journal.content_h1_to_h2`, `golem15.journal.related_mode`, `golem15.journal.related_count`, `golem15.journal.image_widths`). |
|
||||||
| `journal.Plugin.Migrations` | gormigrate steps registered by this plugin. |
|
| `journal.Plugin.Migrations` | gormigrate steps registered by this plugin. |
|
||||||
| `journal.Plugin.Models` | Models registered by this plugin. |
|
| `journal.Plugin.Models` | Models registered by this plugin. |
|
||||||
| `journal.Plugin.Commands` | Registers `journal:export-posts` and `journal:import-posts`. |
|
| `journal.Plugin.Commands` | Registers `journal:export-posts`, `journal:import-posts`, `journal:reindex`, `journal:fix-headings` and `journal:image-variants`. |
|
||||||
| `journal.Plugin.Routes` | Public GETs and backend-Bearer writes under `/_journal/api/v1`. |
|
| `journal.Plugin.Jobs` | Registers the image variants job `golem15.journal.image_variants` (`pact.HasJobs`). |
|
||||||
|
| `journal.Plugin.Routes` | Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`. |
|
||||||
|
| `journal.Plugin.MCP` | The MCP tools and the `draft_post` prompt (`pact.HasMCP`), guard `backend`, permission `golem15.journal.access_posts` (`golem15.journal.manage_settings` for the settings and `fix_headings` tools). |
|
||||||
|
| `journal.Plugin.TokenScopes` | Personal API token scopes `journal` and `journal:read` (`pact.HasTokenScopes`). |
|
||||||
|
| `journal.Plugin.FormFieldTypes` | Registers the `blogeditor` and `mlblogeditor` admin field types. |
|
||||||
|
| `journal.Plugin.MenuItemTypes` | Registers the Journal menu item types (`pact.HasMenuItemTypes`). |
|
||||||
|
| `console.FixHeadings` | `journal:fix-headings`. |
|
||||||
|
| `console.ImageVariants` | `journal:image-variants`. |
|
||||||
|
| `classes.ImageVariantsArgs`, `classes.ImageVariantsKind` | The image variants job's arguments (`FileIDs`) and kind `golem15.journal.image_variants`. |
|
||||||
|
| `classes.GenerateImageVariants`, `classes.VariantsResult` | Writes the thumbnails and WebP copies of files at the widths no larger than their originals and records them; what one run did. |
|
||||||
|
| `classes.ImageWidths`, `classes.ImageWidthsKey`, `classes.DefaultImageWidths`, `classes.MinImageWidth`, `classes.MaxImageWidth` | `golem15.journal.image_widths` read from the config (a list or a comma-separated text, 16 to 4096, sorted), its default 480, 800 and 1200. |
|
||||||
|
| `classes.ImageVariantsMode`, `classes.FeaturedImagesField` | The variants' thumbnail mode `exact` and the `featured_images` field. |
|
||||||
|
| `classes.FeaturedImageIDs`, `classes.DispatchImageVariants`, `classes.DispatchPostImageVariantsAfterCommit` | The featured images to backfill, and queueing the job for files or, after a commit, for a post's images that lack a width. |
|
||||||
|
| `models.ImageVariant` | A row of `golem15_journal_image_variants`: file, width, height, thumbnail and WebP keys, the original's size. |
|
||||||
|
| `console.ImportFromDirConfig`, `console.ImportStats.H1Converted` | The import with the config the H1 setting falls back to, and the number of H1 headings it stored as H2. |
|
||||||
|
| `controllers.PermissionManageSettings` | `golem15.journal.manage_settings`. |
|
||||||
| `journal.Plugin.Buckets` | Limiter buckets `journal-public-api` and `journal-api` (Max 120). |
|
| `journal.Plugin.Buckets` | Limiter buckets `journal-public-api` and `journal-api` (Max 120). |
|
||||||
| `models.Post` | Post row; `TableName` is `golem15_journal_posts`. |
|
| `models.Post` | Post row; `TableName` is `golem15_journal_posts`. |
|
||||||
| `models.Post.MorphName` | PHP class string `Golem15\Journal\Models\Post`. |
|
| `models.Post.MorphName` | PHP class string `Golem15\Journal\Models\Post`. |
|
||||||
@@ -116,37 +468,88 @@ func (Post) MorphName() string { return `Golem15\Journal\Models\Post
|
|||||||
| `models.Post.AttachRelations` | `featured_images` and `content_images` attachMany. |
|
| `models.Post.AttachRelations` | `featured_images` and `content_images` attachMany. |
|
||||||
| `models.Post.SearchableAs` | Index name `golem15_journal_posts`. |
|
| `models.Post.SearchableAs` | Index name `golem15_journal_posts`. |
|
||||||
| `models.Post.ShouldBeSearchable` | False when unpublished or `search_use_typesense` is off. |
|
| `models.Post.ShouldBeSearchable` | False when unpublished or `search_use_typesense` is off. |
|
||||||
| `models.Post.ToSearchableArray` | Typesense document built after a committed save. |
|
| `models.Post.ToSearchableArray` | Typesense document built after a committed save; content is plain text. |
|
||||||
|
| `models.Post.SearchIndexSchema` | The Typesense collection schema a missing collection is created with (`beachcomber.IndexSchemaProvider`). |
|
||||||
|
| `models.PlainText` | HTML to plain text: tags dropped, entities decoded, whitespace collapsed. |
|
||||||
| `models.Post.CanEdit` | Owner or `golem15.journal.access_other_posts`. |
|
| `models.Post.CanEdit` | Owner or `golem15.journal.access_other_posts`. |
|
||||||
| `models.Category` | Category row; `TableName` is `golem15_journal_categories`. |
|
| `models.Category` | Category row; `TableName` is `golem15_journal_categories`; `Content` holds the HTML body served as `content`. |
|
||||||
| `models.Category.MorphName` | PHP class string `Golem15\Journal\Models\Category`. |
|
| `models.Category.MorphName` | PHP class string `Golem15\Journal\Models\Category`. |
|
||||||
| `models.Category.Fillable` | `name`, `slug`, `code`, `description`, `parent_id`. |
|
| `models.Category.Fillable` | `name`, `slug`, `code`, `description`, `content`, `parent_id`. |
|
||||||
| `models.Tag` | Tag row; `TableName` is `golem15_journal_tags`. |
|
| `models.Tag` | Tag row; `TableName` is `golem15_journal_tags`. |
|
||||||
| `models.Tag.Fillable` | `name`, `slug`, `description`. |
|
| `models.Tag.Fillable` | `name`, `slug`, `description`. |
|
||||||
| `models.Settings` | Singleton settings row; `TableName` is `golem15_journal_settings`. |
|
| `models.Settings` | Singleton settings row; `TableName` is `golem15_journal_settings`; `ContentH1ToH2` and `GenericAnchors` hold the Headings and links tab. |
|
||||||
| `models.All` | Models registered by this plugin. |
|
| `models.All` | Models registered by this plugin. |
|
||||||
| `updates.All` | gormigrate steps registered by this plugin. |
|
| `updates.All` | gormigrate steps registered by this plugin. |
|
||||||
|
| `classes.BlogEditorTypes` | The `blogeditor` and `mlblogeditor` field types: Decode through `SanitizeHTMLReport` with removal notices and, while the H1 setting is on, `ConvertH1` with the conversion notice; Load with the markdown fallback, list text, the image upload and the per-request `headingLevels` and `genericAnchors` options. |
|
||||||
|
| `classes.EditorH1Converted`, `classes.EditorH1ConvertedCategory` | The phrase keys of the H1 conversion notice for a post and for a category. |
|
||||||
|
| `classes.H1ToH2Key` | `golem15.journal.content_h1_to_h2`, the config key of the H1 setting when no settings row exists. |
|
||||||
|
| `classes.EffectiveH1ToH2` | The one reader of the H1 setting: the settings row, else the config key, else true. |
|
||||||
|
| `classes.RelatedModeKey`, `classes.RelatedCountKey` | `golem15.journal.related_mode` and `golem15.journal.related_count`. |
|
||||||
|
| `classes.RelatedModeCategory`, `classes.RelatedModeScored`, `classes.RelatedCountDefault`, `classes.RelatedCountMin`, `classes.RelatedCountMax` | The related-posts modes and the count's default and bounds. |
|
||||||
|
| `classes.RelatedSettings`, `classes.ReadRelatedSettings` | The related-posts mode and count read from the config (`Scored` reports the scored mode). |
|
||||||
|
| `classes.ScoredRelatedPosts` | The scored mode's related posts: 2 points per shared tag plus 1 per shared category, ties by newest, at most the count, published only unless drafts are allowed. |
|
||||||
|
| `classes.PrimaryCategoryID`, `classes.CategoryNeighbour` | The post's first category by name, and its previous or next published post by publish time within that category. |
|
||||||
|
| `classes.GenericAnchors`, `classes.DefaultGenericAnchors` | The generic link texts: the settings row's list, one per line, lowercased, else the default English and Polish lists. |
|
||||||
|
| `classes.ConvertH1` | Turns every h1 of HTML into an h2 on the tokenizer, keeping every other byte; returns the converted count. |
|
||||||
|
| `classes.CountH1` | The number of h1 start tags `ConvertH1` would convert. |
|
||||||
|
| `classes.ConvertContentH1`, `classes.ConvertTranslationH1` | `ConvertH1` over a post's `content` (outside markdown code) and `content_html`, and over one locale's translated attributes. |
|
||||||
|
| `classes.FixStoredHeadings`, `classes.HeadingFix` | The stored-H1 conversion behind `journal:fix-headings` and `POST /fix-headings`: posts in every locale and category pillar text, counted or applied in one transaction with record events. |
|
||||||
|
| `classes.H1ConversionOffText`, `classes.ErrH1ConversionOff` | The refusal while the H1 setting is off. |
|
||||||
|
| `classes.SetImageAlt` | Sets the alt of the matching img through the tokenizer; returns the HTML, the match count and the old alt. |
|
||||||
|
| `classes.MediaFileName`, `classes.MaxMediaStem` | The D-85 slug rule for uploaded image names, at most 80 characters before the extension. |
|
||||||
|
| `classes.BlogEditorElement` | The editor's custom element tag, `golem15-journal-blogeditor`. |
|
||||||
|
| `classes.MenuItemTypes` | The Journal menu item types with their references and resolvers (see [menu item types](#menu-item-types)). |
|
||||||
|
| `classes.MenuTypeCategory`, `classes.MenuTypeAllCategories`, `classes.MenuTypePost`, `classes.MenuTypeAllPosts`, `classes.MenuTypeCategoryPosts`, `classes.MenuTypeTag`, `classes.MenuTypeAuthor` | The type keys `journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`, `journal-tag` and `journal-author`. |
|
||||||
|
| `classes.DefaultPostPattern`, `classes.DefaultCategoryPattern`, `classes.DefaultTagPattern`, `classes.DefaultAuthorPattern` | The default patterns `/blog/:slug`, `/blog/category/:slug`, `/blog/tag/:slug` and `/blog/author/:author`. |
|
||||||
|
| `classes.TagPagesKey` | `golem15.journal.tag_pages`, the host key that says whether the site has tag pages. |
|
||||||
|
| `classes.AuthorPagesKey` | `golem15.journal.author_pages`, the host key that says whether the site has author pages. |
|
||||||
|
| `classes.LooksLikeHTML` | Whether stored content is HTML: its first non-space character is `<`. |
|
||||||
|
| `classes.StoreMedia` | Stores an uploaded image under `/journal/<folder>/<MediaFileName>` and returns its `StoredMedia`; shared by the public upload route and the editor upload. |
|
||||||
|
| `classes.StoredMedia` | A stored media object's public `URL` and `Path`. |
|
||||||
|
| `classes.MediaError` | An upload `StoreMedia` refuses, with the request `Field` and the public route's `Message`. |
|
||||||
|
| `classes.MediaMaxBytes` | The upload size limit, 10240 KiB. |
|
||||||
|
| `classes.MediaFolderPattern` | The characters an upload folder may hold. |
|
||||||
|
| `classes.SanitizeHTML` | Strips from HTML (not markdown) what the `FormatHTML` allow-list does not allow; HTML the policy keeps whole is returned byte for byte. The string half of `SanitizeHTMLReport`. |
|
||||||
|
| `classes.SanitizeHTMLReport` | `SanitizeHTML` plus an `HTMLRemovals` report of what the policy removed; the report is empty exactly when the input comes back unchanged. |
|
||||||
|
| `classes.HTMLRemovals` | Removed element and attribute names (distinct, first seen, at most ten each) with exact `ElementCount` and `AttributeCount`, removed `Comments`, and `Other` for any other change; `Empty` reports no removal. |
|
||||||
|
| `classes.FormatTranslation` | Sets `content_html` in one locale's translated attributes: rendered from a non-blank `content` with `FormatHTML`, otherwise a supplied `content_html` sanitized with `SanitizeHTML`. |
|
||||||
|
| `classes.CheckHTML` | Predicate: an error when the `FormatHTML` allow-list would strip or change the HTML, including any tag that repeats an attribute. No write path refuses with it any more (admin saves strip and report); it is the counterpart of the site layer's gate and is used by the policy tests. |
|
||||||
|
| `classes.SiteCachePurger` | Deprecated alias of `cooler.SiteCachePurger`, the framework's purger; a nil purger does nothing. |
|
||||||
|
| `classes.NewSiteCachePurger` | Deprecated: builds the framework purger from values of the Journal's keys (`cooler.NewSiteCachePurgerFor`), errors naming those keys; both empty disables the purge (nil, nil). |
|
||||||
|
| `classes.SiteCachePurger.Purge` | One purge request for a kind and slug, with a 2 s timeout, logged and never returned as an error. |
|
||||||
|
| `classes.SiteCachePurger.Endpoint` | The purge URL's host and path, for log lines. |
|
||||||
|
| `classes.PurgeSiteCacheAfterCommit` | Deprecated alias of `cooler.PurgeSiteCacheAfterCommit`: registers a purge to run after the surrounding save transaction commits. |
|
||||||
|
| `classes.SiteCachePurgeURLKey`, `classes.SiteCachePurgeTokenKey` | The deprecated config keys `golem15.journal.site_cache.purge_url` and `purge_token` (framework keys: `cooler.SiteCachePurgeURLKey`, `cooler.SiteCachePurgeTokenKey`). |
|
||||||
|
| `classes.SiteCachePurgeMinTokenLength`, `classes.SiteCachePurgeTimeout` | Deprecated aliases of cooler's shortest accepted token (32) and request timeout (2 s). |
|
||||||
|
|
||||||
## CLI commands
|
## CLI commands
|
||||||
|
|
||||||
Both commands write JSON files (one per post slug), matching the PHP `journal:export-posts` / `journal:import-posts` tools. They are not a CSV ImportExport framework. A blog application binary registers them through `Plugin.Commands`.
|
The export and import commands write JSON files (one per post slug), matching the PHP `journal:export-posts` / `journal:import-posts` tools. They are not a CSV ImportExport framework. A blog application binary registers them through `Plugin.Commands`.
|
||||||
|
|
||||||
| Command | Arguments and flags | Effect |
|
| Command | Arguments and flags | Effect |
|
||||||
|---------|---------------------|--------|
|
|---------|---------------------|--------|
|
||||||
| `journal:export-posts` | `--path` (default `journal-export`), `--dry-run` | Writes one JSON file per post, including non-default-locale translations. Attachments are omitted. |
|
| `journal:export-posts` | `--path` (default `journal-export`), `--dry-run` | Writes one JSON file per post, including non-default-locale translations, with a progress bar over the posts. Attachments are omitted. Ends with `Exported N posts to: <path>`. |
|
||||||
| `journal:import-posts` | `--path` (default `journal-export`), `--force`, `--dry-run` | Creates posts from those files. Existing slugs are skipped unless `--force` updates them. |
|
| `journal:import-posts` | `--path` (default `journal-export`), `--force`, `--dry-run` | Creates posts from those files, one line per post; while the H1 setting is on, H1 headings are stored as H2 and the count is printed. Existing slugs are skipped unless `--force` updates them. Ends with `Results: N created, N updated, N skipped, N failed`. |
|
||||||
|
| `journal:image-variants` | `--all` | Writes the thumbnails and WebP copies of featured images at `golem15.journal.image_widths` that are missing (with `--all`, checks every featured image) and prints `Checked N featured images, wrote N variants, skipped N.`, or `Every featured image has its variants.` |
|
||||||
|
| `journal:fix-headings` | `--apply`, `--dry-run` (the default) | Converts H1 to H2 in stored post content (every locale) and category pillar text while the H1 setting is on, firing a record event per changed post and category. Prints `Would convert N H1 headings in N posts and N categories. Run with --apply to save.`, `Converted N H1 headings in N posts and N categories.` or `No H1 headings found in post or category content.`; refuses with exit code 1 while the setting is off. Runs from the application CLI like `journal:image-variants`: without a published database it opens `database.dsn` (`SUMMER_DATABASE__DSN`). |
|
||||||
|
| `journal:reindex` | `--fresh` | Syncs every post into the search index in batches of 200: published posts are upserted, the others removed. `--fresh` first drops the collection and recreates it from the schema. Refuses, sending nothing, when the engine is not configured or `search_use_typesense` is off. Ends with `Reindexed: N synced, N removed, N failed` and exits non-zero on any failure. |
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./bin/acme journal:export-posts --path journal-export
|
./bin/acme journal:export-posts --path journal-export
|
||||||
./bin/acme journal:import-posts --path journal-export --dry-run
|
./bin/acme journal:import-posts --path journal-export --dry-run
|
||||||
./bin/acme journal:import-posts --path journal-export --force
|
./bin/acme journal:import-posts --path journal-export --force
|
||||||
|
./bin/acme journal:reindex --fresh
|
||||||
|
./bin/acme journal:fix-headings --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`./bin/acme help journal:import-posts` shows each command's long help and examples. The plugin logs through the component `journal` (`log.components.journal` in `config/log.yaml`).
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- SummerCMS modules: backpack, beachcomber, bonfire, cabana, lagoon/attach, pact, party, surf.
|
- SummerCMS v0.6.1 modules (the content facts and `pact.FormFieldType.RequestOptions` are new in v0.6.1): backpack, beachcomber, bonfire, cabana, compass, conga (the image variants job), cooler (site-cache purge), diary, lagoon/attach, lagoon/attach/webp (WebP variants, through `github.com/gen2brain/webp` v0.6.4), pact, party, phrasebook, signpost (content descriptors), surf.
|
||||||
- Compiled plugin: `golem15.translate`.
|
- Compiled plugin: `golem15.translate`.
|
||||||
- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `github.com/yuin/goldmark`, `gocloud.dev`. Test-only: `github.com/testcontainers/testcontainers-go`.
|
- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `github.com/yuin/goldmark`, `github.com/microcosm-cc/bluemonday`, `golang.org/x/net/html`, `gocloud.dev`. Test-only: `github.com/testcontainers/testcontainers-go`.
|
||||||
|
- Blog Editor JS build (not needed by a host application): `@tiptap/core`, `@tiptap/pm`, `@tiptap/starter-kit`, `@tiptap/extension-table` and `@tiptap/extension-image` 3.31.4, bundled into `assets/dist`; dev-only `vite`, `vitest`, `happy-dom` and `typescript`, pinned exactly in `package.json`.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -155,3 +558,24 @@ go test ./...
|
|||||||
```
|
```
|
||||||
|
|
||||||
Postgres integration smokes start Docker unless `-short`. Missing Docker is a failure, not a skip, unless `-short`.
|
Postgres integration smokes start Docker unless `-short`. Missing Docker is a failure, not a skip, unless `-short`.
|
||||||
|
|
||||||
|
`TestCommandsDocumented` (root package, Docker-free) logs, without failing, each command whose help lacks a description, long help, examples or an option description (`bonfire.CheckDocs`).
|
||||||
|
|
||||||
|
Personal API token coverage: `TestResolveErrorMapping` (Docker-free, `controllers/api`) maps resolver errors to the PHP bodies; `TestJournalTokenTracer` and `TestJournalTokenExpansion` (root package, Postgres) mint tokens through the admin account API and exercise write, read-only, unrelated, revoked, featured-image and code-declared-role paths.
|
||||||
|
|
||||||
|
Headings, links and media (Phase 33): `TestHeadingConversionTracer`, `TestHeadingConversion`, `TestFixHeadingsCommand`, `TestJournalSettingsTools`, `TestSetImageAlt` and `TestBlogEditorRequestOptions` (`headings_test.go`), `TestJournalDescriptorFields` and `TestJournalAuthorType` (`journal_descriptor_fields_test.go`) run on Postgres; `TestConvertH1`, `TestSetImageAlt` and `TestMediaFileName` (`classes`) are Docker-free; `TestSettingsHeadingsMigration` (`updates`) checks the migration and its rollback. The editor's J1 to J4 parts are covered by `tests/blogeditor/additions.test.ts`.
|
||||||
|
|
||||||
|
`TestJournalMCPSmoke` (root package, Docker-free) pins the eleven tools and the prompt (guard, permission, destructive delete, the prompt's arguments and text) and checks that each tool's method and path is a route the plugin registers; `TestOptionalBackendPrincipalDispatch` (`controllers/api`) checks that the list and show handlers see a dispatched backend principal and stay anonymous without one. `TestJournalPromptWrites` (root package, Docker-free) checks that `draft_post` declares `Writes` and, through a served pier endpoint, that a read-only grant's `prompts/list` omits `journal_draft_post` while a read and write grant lists it.
|
||||||
|
|
||||||
|
Related posts: `TestRelatedDefaultUnchanged` (the default and the explicit category mode answer as before, without the scored keys), `TestRelatedScored` (ranking, ties, a draft and a future post sharing every tag and category never shown, the category neighbours) and `TestRelatedScoredCountClamped` (`related_media_test.go`) run on Postgres. `TestImageVariants` (same file, Postgres) checks that an upload answers before any encoding and queues the job, that the job writes the default widths no wider than the original with a WebP copy of each, the API's `variants`, that a second run writes nothing, and that an admin save queues the job only for an image that lacks a width; `TestImageVariantsTallOriginal` that a 1000×9000 original followed by a normal one fails neither the job nor `journal:image-variants --all`.
|
||||||
|
|
||||||
|
`TestMenuItemTypesSmoke` (root package, Postgres) checks the five registered types and resolves each through the registry with host and item pattern overrides, ordered alternates, nesting, the published filter and the `x-default` rule.
|
||||||
|
|
||||||
|
The site cache purge has unit tests of the deprecated keys in `classes` (`TestSiteCachePurgeConfig`, `TestSiteCachePurgeRequest`; the request rules are tested in the framework's cooler) and Postgres tests through the admin API (`TestCategoriesAdminPurgesSiteCache`, `TestPostsAdminPurgesSiteCache` on the deprecated keys, `TestSiteCacheFrameworkKeysWin` with both sets) that point the plugin at a local receiver and check one authorized request after each commit.
|
||||||
|
|
||||||
|
The Blog Editor has its own suite and drift gate:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test
|
||||||
|
bash scripts/check-dist.sh
|
||||||
|
```
|
||||||
|
|||||||
6
admin.go
6
admin.go
@@ -10,14 +10,16 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminFS holds the admin YAML and the nav SVG file by file: the controllers
|
// adminFS holds the admin YAML, the nav SVG and the committed Blog Editor
|
||||||
// directory also holds Go sources that are not admin assets.
|
// bundle file by file: the controllers directory also holds Go sources that
|
||||||
|
// are not admin assets, and assets/src is the bundle's source.
|
||||||
//
|
//
|
||||||
//go:embed controllers/posts/config_list.yaml controllers/posts/config_form.yaml controllers/posts/config_filter.yaml models/post/fields.yaml models/post/columns.yaml
|
//go:embed controllers/posts/config_list.yaml controllers/posts/config_form.yaml controllers/posts/config_filter.yaml models/post/fields.yaml models/post/columns.yaml
|
||||||
//go:embed controllers/categories/config_list.yaml controllers/categories/config_form.yaml models/category/fields.yaml models/category/columns.yaml
|
//go:embed controllers/categories/config_list.yaml controllers/categories/config_form.yaml models/category/fields.yaml models/category/columns.yaml
|
||||||
//go:embed controllers/tags/config_list.yaml controllers/tags/config_form.yaml models/tag/fields.yaml models/tag/columns.yaml
|
//go:embed controllers/tags/config_list.yaml controllers/tags/config_form.yaml models/tag/fields.yaml models/tag/columns.yaml
|
||||||
//go:embed models/settings/fields.yaml
|
//go:embed models/settings/fields.yaml
|
||||||
//go:embed assets/images/journal-icon.svg
|
//go:embed assets/images/journal-icon.svg
|
||||||
|
//go:embed assets/dist/blogeditor.js assets/dist/blogeditor.css
|
||||||
var adminFS embed.FS
|
var adminFS embed.FS
|
||||||
|
|
||||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -67,13 +68,11 @@ func TestMain(m *testing.M) {
|
|||||||
os.Exit(code)
|
os.Exit(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testShort reports -short. go test passes -test.short=true, so the flags
|
||||||
|
// are parsed and read through testing.Short, never by scanning os.Args.
|
||||||
func testShort() bool {
|
func testShort() bool {
|
||||||
for _, a := range os.Args {
|
flag.Parse()
|
||||||
if a == "-test.short" {
|
return testing.Short()
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startAdminPostgres(ctx context.Context) error {
|
func startAdminPostgres(ctx context.Context) error {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Navigation is the plugin's Winter registerNavigation() entry: the main
|
// Navigation is the plugin's Winter registerNavigation() entry: the main
|
||||||
// item journal (order 300) with its side menu.
|
// item journal (order 300) with its side menu. Winter's new_post side item is
|
||||||
|
// left out: a side item can only point at a controller's list, and the admin
|
||||||
|
// names each list page after the first side item of its controller.
|
||||||
func (*Plugin) Navigation() []pact.NavigationItem {
|
func (*Plugin) Navigation() []pact.NavigationItem {
|
||||||
return []pact.NavigationItem{
|
return []pact.NavigationItem{
|
||||||
{
|
{
|
||||||
@@ -17,13 +19,6 @@ func (*Plugin) Navigation() []pact.NavigationItem {
|
|||||||
Order: 300,
|
Order: 300,
|
||||||
Controller: controllers.PostsControllerID,
|
Controller: controllers.PostsControllerID,
|
||||||
SideMenu: []pact.NavigationItem{
|
SideMenu: []pact.NavigationItem{
|
||||||
{
|
|
||||||
Code: "new_post",
|
|
||||||
Label: "golem15.journal::lang.posts.new_post",
|
|
||||||
Icon: "plus",
|
|
||||||
Permissions: []string{controllers.PermissionAccessPosts},
|
|
||||||
Controller: controllers.PostsControllerID,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Code: "posts",
|
Code: "posts",
|
||||||
Label: "golem15.journal::lang.journal.posts",
|
Label: "golem15.journal::lang.journal.posts",
|
||||||
|
|||||||
1
assets/dist/blogeditor.css
vendored
Normal file
1
assets/dist/blogeditor.css
vendored
Normal file
File diff suppressed because one or more lines are too long
155
assets/dist/blogeditor.js
vendored
Normal file
155
assets/dist/blogeditor.js
vendored
Normal file
File diff suppressed because one or more lines are too long
596
assets/src/blogeditor/blogeditor.css
Normal file
596
assets/src/blogeditor/blogeditor.css
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
/*
|
||||||
|
* Blog Editor styles (D-13). Every selector sits under the element tag and
|
||||||
|
* every colour comes from the admin's public --c-* variables; TipTap's own
|
||||||
|
* injected stylesheet is off (injectCSS false), so its ProseMirror base
|
||||||
|
* rules are repeated here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
golem15-journal-blogeditor {
|
||||||
|
display: block;
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--c-surface);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor[invalid] {
|
||||||
|
border-color: var(--c-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
background: var(--c-subtle);
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__tools {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__view {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
background: var(--c-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__form label {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 220px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--c-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__form input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid var(--c-border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--c-bg);
|
||||||
|
color: var(--c-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__form input::placeholder {
|
||||||
|
color: var(--c-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__form input:focus {
|
||||||
|
outline: 2px solid var(--c-ring);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__form button[data-action="apply"],
|
||||||
|
golem15-journal-blogeditor .blogeditor__form button[data-action="insert"] {
|
||||||
|
background: var(--c-primary);
|
||||||
|
color: var(--c-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__error {
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__error:empty,
|
||||||
|
golem15-journal-blogeditor .blogeditor__status:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__status {
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--c-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--c-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor button:hover {
|
||||||
|
background: var(--c-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor button:focus-visible {
|
||||||
|
outline: 2px solid var(--c-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor button[aria-selected="true"],
|
||||||
|
golem15-journal-blogeditor button[aria-pressed="true"] {
|
||||||
|
background: var(--c-sel);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor button:disabled {
|
||||||
|
color: var(--c-muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__content {
|
||||||
|
min-height: 240px;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__source {
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 320px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
background: var(--c-bg);
|
||||||
|
color: var(--c-text);
|
||||||
|
font-family: "DM Mono", ui-monospace, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__source:focus {
|
||||||
|
outline: 2px solid var(--c-ring);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Preview split: two equal columns when the element is at least about 720px
|
||||||
|
* wide; below that auto-fit collapses to one column and the preview wraps
|
||||||
|
* below the editor. The element's width drives it, not the viewport's.
|
||||||
|
*/
|
||||||
|
golem15-journal-blogeditor .blogeditor__panes--split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__preview {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 360px;
|
||||||
|
background: var(--c-surface);
|
||||||
|
outline: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__preview iframe {
|
||||||
|
display: block;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 360px;
|
||||||
|
border: 0;
|
||||||
|
background: var(--c-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor [hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ProseMirror base rules (TipTap's injected style, repeated here). */
|
||||||
|
golem15-journal-blogeditor .ProseMirror {
|
||||||
|
position: relative;
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
white-space: break-spaces;
|
||||||
|
-webkit-font-variant-ligatures: none;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
font-feature-settings: "liga" 0;
|
||||||
|
min-height: 240px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
outline: none;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror[contenteditable="false"] {
|
||||||
|
color: var(--c-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror [contenteditable="false"] {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror [contenteditable="false"] [contenteditable="true"] {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor img.ProseMirror-separator {
|
||||||
|
display: inline !important;
|
||||||
|
border: none !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror-gapcursor {
|
||||||
|
display: none;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror-gapcursor::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
width: 20px;
|
||||||
|
border-top: 1px solid var(--c-text);
|
||||||
|
animation: blogeditor-cursor-blink 1.1s steps(2, start) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blogeditor-cursor-blink {
|
||||||
|
to {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror-hideselection *::selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror-hideselection * {
|
||||||
|
caret-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror-focused .ProseMirror-gapcursor {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content. */
|
||||||
|
golem15-journal-blogeditor .ProseMirror > * + * {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror :where(p, ul, ol, blockquote, table, figure, pre) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror h2 { font-size: 1.5em; font-weight: 600; margin: 1em 0 0.5em; }
|
||||||
|
golem15-journal-blogeditor .ProseMirror h3 { font-size: 1.25em; font-weight: 600; margin: 1em 0 0.5em; }
|
||||||
|
golem15-journal-blogeditor .ProseMirror h4 { font-size: 1.1em; font-weight: 600; margin: 1em 0 0.5em; }
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror ul { list-style: disc; padding-left: 1.5em; }
|
||||||
|
golem15-journal-blogeditor .ProseMirror ol { list-style: decimal; padding-left: 1.5em; }
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror a {
|
||||||
|
color: var(--c-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror blockquote {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: 3px solid var(--c-border-strong);
|
||||||
|
color: var(--c-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror code {
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--c-subtle);
|
||||||
|
font-family: "DM Mono", ui-monospace, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror pre {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--c-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror pre code {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--c-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror :where(td, th) {
|
||||||
|
border: 1px solid var(--c-border-strong);
|
||||||
|
padding: 4px 8px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror th {
|
||||||
|
background: var(--c-subtle);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror :where(td, th) > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror .selectedCell {
|
||||||
|
background: var(--c-sel);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror figure {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror figcaption {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Raw HTML and embed cards: never live markup. */
|
||||||
|
golem15-journal-blogeditor .blogeditor__raw,
|
||||||
|
golem15-journal-blogeditor .blogeditor__embed {
|
||||||
|
border: 1px dashed var(--c-border-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--c-subtle);
|
||||||
|
color: var(--c-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__embed {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 4px 4px 10px;
|
||||||
|
border-bottom: 1px dashed var(--c-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__raw-source {
|
||||||
|
max-height: 160px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
overflow: auto;
|
||||||
|
background: transparent;
|
||||||
|
font-family: "DM Mono", ui-monospace, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .ProseMirror-selectednode.blogeditor__raw,
|
||||||
|
golem15-journal-blogeditor .ProseMirror-selectednode.blogeditor__embed,
|
||||||
|
golem15-journal-blogeditor .ProseMirror img.ProseMirror-selectednode {
|
||||||
|
outline: 2px solid var(--c-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* J2 outline (Phase 33, UI-SPEC C28): a full-width bar between the panes
|
||||||
|
* and the status line, and the panel it opens. Metrics copy the editor's
|
||||||
|
* own: 6x8px bar padding, 8px form padding, 12px per heading level.
|
||||||
|
*/
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline {
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-toggle {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--c-subtle);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-toggle:hover {
|
||||||
|
background: var(--c-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-problems,
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-flag,
|
||||||
|
golem15-journal-blogeditor .blogeditor__warning {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__alert {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-panel {
|
||||||
|
padding: 8px;
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-item {
|
||||||
|
padding-inline-start: calc(var(--blogeditor-outline-depth, 0) * 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A long heading wraps under its text, the level tag hanging beside it. */
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-target {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-level {
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--c-subtle);
|
||||||
|
font-family: "DM Mono", ui-monospace, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-text--empty,
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-flag,
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-empty,
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-heading {
|
||||||
|
color: var(--c-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-flags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px 8px;
|
||||||
|
padding: 0 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-empty,
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-heading {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-heading {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* J3 link form: the rel checkboxes, their hint and the generic-text warning. */
|
||||||
|
golem15-journal-blogeditor .blogeditor__rel {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__rel legend {
|
||||||
|
float: left;
|
||||||
|
margin-right: 8px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--c-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__form label.blogeditor__check {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__form input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__form input:disabled {
|
||||||
|
background: var(--c-subtle);
|
||||||
|
color: var(--c-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__hint {
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--c-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__warning {
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* J4 image form: the file name with its fixed extension. */
|
||||||
|
golem15-journal-blogeditor .blogeditor__filename-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__ext {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-muted);
|
||||||
|
}
|
||||||
753
assets/src/blogeditor/element.ts
Normal file
753
assets/src/blogeditor/element.ts
Normal file
@@ -0,0 +1,753 @@
|
|||||||
|
import { Editor } from '@tiptap/core'
|
||||||
|
import { parseEmbedUrl } from './embed'
|
||||||
|
import { DEFAULT_HEADING_LEVELS, EDIT_HTML_EVENT, isPolicyUrl, preservationExtensions } from './extensions'
|
||||||
|
import { buildImageForm, type ImageChoice, type ImageForm } from './imageform'
|
||||||
|
import { format, labelsFor, type Labels } from './labels'
|
||||||
|
import { buildLinkExtras, type LinkExtras } from './linkform'
|
||||||
|
import { buildOutline, normalizeAnchor, type Outline } from './outline'
|
||||||
|
import { buildPreviewDocument, PREVIEW_DEBOUNCE_MS, PREVIEW_SANDBOX, resolvePreviewColors } from './preview'
|
||||||
|
import { captureBlocks, serializeDocument, type Blocks } from './serializer'
|
||||||
|
import { buildToolbar, type Toolbar } from './toolbar'
|
||||||
|
|
||||||
|
export const TAG = 'golem15-journal-blogeditor'
|
||||||
|
export const CHANGE_EVENT = 'summer-change'
|
||||||
|
export const UPLOAD_EVENT = 'summer-upload'
|
||||||
|
export const UPLOAD_RESULT_EVENT = 'summer-upload-result'
|
||||||
|
|
||||||
|
/** The image types the picker offers and the editor uploads (D-18). */
|
||||||
|
export const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||||
|
/** How long the element waits for summer-upload-result. */
|
||||||
|
export const UPLOAD_TIMEOUT_MS = 60_000
|
||||||
|
|
||||||
|
type Mode = 'clean' | 'wysiwyg' | 'source'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The generic link texts when the options carry none: the seed lists of the
|
||||||
|
* Journal settings (classes.DefaultGenericAnchors).
|
||||||
|
*/
|
||||||
|
export const DEFAULT_GENERIC_ANCHORS: readonly string[] = [
|
||||||
|
'click here', 'here', 'more', 'read more', 'learn more', 'this link', 'link',
|
||||||
|
'kliknij tutaj', 'tutaj', 'tu', 'więcej', 'czytaj więcej', 'czytaj dalej', 'ten link',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** The element options the editor reads (the field's typeOptions). */
|
||||||
|
export interface EditorOptions {
|
||||||
|
/** The offered heading levels (J1): [2, 3, 4], or [1, 2, 3, 4] while the H1 setting is off. */
|
||||||
|
headingLevels: number[]
|
||||||
|
/** The generic link texts, normalized (normalizeAnchor). */
|
||||||
|
genericAnchors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* readOptions reads the options attribute (JSON): headingLevels (integers 1
|
||||||
|
* to 6, default [2, 3, 4]) and genericAnchors (strings, default
|
||||||
|
* DEFAULT_GENERIC_ANCHORS). Anything else, or invalid JSON, is the default.
|
||||||
|
*/
|
||||||
|
export function readOptions(raw: string | null): EditorOptions {
|
||||||
|
let parsed: Record<string, unknown> = {}
|
||||||
|
try {
|
||||||
|
const value: unknown = raw ? JSON.parse(raw) : {}
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) parsed = value as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON: the defaults.
|
||||||
|
}
|
||||||
|
const levels = Array.isArray(parsed.headingLevels)
|
||||||
|
? [...new Set(parsed.headingLevels.filter((n): n is number => Number.isInteger(n) && n >= 1 && n <= 6))].sort()
|
||||||
|
: []
|
||||||
|
const anchors = Array.isArray(parsed.genericAnchors)
|
||||||
|
? parsed.genericAnchors.filter((t): t is string => typeof t === 'string')
|
||||||
|
: [...DEFAULT_GENERIC_ANCHORS]
|
||||||
|
return {
|
||||||
|
headingLevels: levels.length > 0 ? levels : [...DEFAULT_HEADING_LEVELS],
|
||||||
|
genericAnchors: [...new Set(anchors.map(normalizeAnchor).filter((t) => t !== ''))],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InlineForm {
|
||||||
|
root: HTMLDivElement
|
||||||
|
error: HTMLParagraphElement
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlogEditorElement is the Journal Blog Editor (D-01, D-03, D-13): a light-DOM
|
||||||
|
* custom element with a WYSIWYG tab on TipTap and a Source tab that edits the
|
||||||
|
* exact HTML string.
|
||||||
|
*
|
||||||
|
* Contract: the value property holds the HTML string; the attributes are
|
||||||
|
* field-name, record-id, locale, ui-locale, options (JSON), disabled and
|
||||||
|
* invalid; a bubbling summer-change event with detail.value is dispatched on
|
||||||
|
* user edits only. The element returns the exact string it was given until
|
||||||
|
* the user edits, and setting value to the string it currently returns is a
|
||||||
|
* no-op. Images are handed to the host page as summer-upload {id, file} and
|
||||||
|
* inserted when it answers summer-upload-result {id, url} (or shows
|
||||||
|
* {id, error}). It never receives or sends credentials and never sanitizes:
|
||||||
|
* the server does that on save.
|
||||||
|
*/
|
||||||
|
export class BlogEditorElement extends HTMLElement {
|
||||||
|
static observedAttributes = ['disabled', 'invalid', 'locale']
|
||||||
|
|
||||||
|
/** The exact string the element was given (D-03). */
|
||||||
|
#value = ''
|
||||||
|
/** The text of the Source tab while it defines the value. */
|
||||||
|
#sourceText = ''
|
||||||
|
/** The string the editor content was last parsed from. */
|
||||||
|
#parsed = ''
|
||||||
|
/** That string split into top-level blocks, for block reuse. */
|
||||||
|
#blocks: Blocks | null = null
|
||||||
|
#mode: Mode = 'clean'
|
||||||
|
#editor?: Editor
|
||||||
|
#labels: Labels = labelsFor('en')
|
||||||
|
#toolbar?: Toolbar
|
||||||
|
#mount?: HTMLDivElement
|
||||||
|
#textarea?: HTMLTextAreaElement
|
||||||
|
#status?: HTMLParagraphElement
|
||||||
|
#fileInput?: HTMLInputElement
|
||||||
|
#linkForm?: InlineForm
|
||||||
|
#linkExtras?: LinkExtras
|
||||||
|
#embedForm?: InlineForm
|
||||||
|
#imageForm?: ImageForm
|
||||||
|
#outline?: Outline
|
||||||
|
#options: EditorOptions = readOptions(null)
|
||||||
|
#showingSource = false
|
||||||
|
#pending = new Map<string, { name: string; choice: ImageChoice; timer: ReturnType<typeof setTimeout> }>()
|
||||||
|
/** The picked files waiting for their image form (J4). */
|
||||||
|
#queue: File[] = []
|
||||||
|
/** Whether this element shows the preview pane (per instance, off by default). */
|
||||||
|
#previewOn = false
|
||||||
|
#panes?: HTMLDivElement
|
||||||
|
#previewPane?: HTMLDivElement
|
||||||
|
#previewFrame?: HTMLIFrameElement
|
||||||
|
#previewTimer?: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
// A value set on the element before the tag was defined is an own
|
||||||
|
// property that shadows the accessor; move it through the setter.
|
||||||
|
if (Object.prototype.hasOwnProperty.call(this, 'value')) {
|
||||||
|
const early = (this as unknown as { value: unknown }).value
|
||||||
|
delete (this as unknown as { value?: unknown }).value
|
||||||
|
this.value = early as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): string {
|
||||||
|
switch (this.#mode) {
|
||||||
|
case 'source':
|
||||||
|
return this.#sourceText
|
||||||
|
case 'wysiwyg':
|
||||||
|
return this.#editor ? serializeDocument(this.#editor, this.#blocks) : this.#value
|
||||||
|
default:
|
||||||
|
return this.#value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set value(next: string) {
|
||||||
|
const v = typeof next === 'string' ? next : next == null ? '' : String(next)
|
||||||
|
if (v === this.value) return // echo of our own summer-change
|
||||||
|
this.#value = v
|
||||||
|
this.#mode = 'clean'
|
||||||
|
if (this.#textarea) this.#textarea.value = v
|
||||||
|
this.#load(v)
|
||||||
|
this.#schedulePreview()
|
||||||
|
this.#outline?.schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The TipTap editor while the element is connected (toolbar and tests). */
|
||||||
|
get editor(): Editor | undefined {
|
||||||
|
return this.#editor
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
if (this.#editor) return
|
||||||
|
this.classList.add('blogeditor')
|
||||||
|
this.replaceChildren()
|
||||||
|
this.#labels = labelsFor(this.getAttribute('ui-locale'))
|
||||||
|
const labels = this.#labels
|
||||||
|
this.#options = readOptions(this.getAttribute('options'))
|
||||||
|
const options = this.#options
|
||||||
|
|
||||||
|
this.#toolbar = buildToolbar({
|
||||||
|
editor: () => this.#editor,
|
||||||
|
labels,
|
||||||
|
openLink: () => this.#openLink(),
|
||||||
|
openEmbed: () => this.#openEmbed(),
|
||||||
|
pickImage: () => this.#fileInput?.click(),
|
||||||
|
insertRawHtml: () => this.#insertRawHtml(),
|
||||||
|
showSource: (on) => this.showSource(on),
|
||||||
|
togglePreview: () => this.showPreview(!this.#previewOn),
|
||||||
|
headingLevels: options.headingLevels,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.#linkForm = this.#form('link', labels.linkUrl, [
|
||||||
|
['apply', labels.linkApply, () => this.#applyLink()],
|
||||||
|
['remove', labels.linkRemove, () => this.#removeLink()],
|
||||||
|
['cancel', labels.cancel, () => this.#close(this.#linkForm)],
|
||||||
|
])
|
||||||
|
// J3: the rel checkboxes after the URL input, the generic-anchor
|
||||||
|
// warning above the buttons.
|
||||||
|
const extras = buildLinkExtras(labels, options.genericAnchors)
|
||||||
|
this.#linkExtras = extras
|
||||||
|
const urlField = this.#linkForm.root.querySelector('label')!
|
||||||
|
urlField.after(extras.fieldset, extras.warning)
|
||||||
|
urlField.querySelector('input')!.addEventListener('input', (event) => extras.setUrl((event.target as HTMLInputElement).value))
|
||||||
|
this.#embedForm = this.#form('embed', labels.embedUrl, [
|
||||||
|
['insert', labels.embedInsert, () => this.#insertEmbed()],
|
||||||
|
['cancel', labels.cancel, () => this.#close(this.#embedForm)],
|
||||||
|
])
|
||||||
|
this.#imageForm = buildImageForm(labels, () => this.#insertImage(), () => this.#nextImage())
|
||||||
|
|
||||||
|
const mount = document.createElement('div')
|
||||||
|
mount.className = 'blogeditor__content'
|
||||||
|
this.#mount = mount
|
||||||
|
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.className = 'blogeditor__source'
|
||||||
|
textarea.spellcheck = false
|
||||||
|
textarea.hidden = true
|
||||||
|
textarea.setAttribute('aria-label', labels.tabSource)
|
||||||
|
textarea.value = this.value
|
||||||
|
textarea.addEventListener('input', () => this.#sourceInput())
|
||||||
|
this.#textarea = textarea
|
||||||
|
|
||||||
|
const status = document.createElement('p')
|
||||||
|
status.className = 'blogeditor__status'
|
||||||
|
status.setAttribute('role', 'status')
|
||||||
|
status.setAttribute('aria-live', 'polite')
|
||||||
|
this.#status = status
|
||||||
|
|
||||||
|
const fileInput = document.createElement('input')
|
||||||
|
fileInput.type = 'file'
|
||||||
|
fileInput.accept = IMAGE_TYPES.join(',')
|
||||||
|
fileInput.multiple = true
|
||||||
|
fileInput.hidden = true
|
||||||
|
fileInput.addEventListener('change', () => this.#picked())
|
||||||
|
this.#fileInput = fileInput
|
||||||
|
|
||||||
|
// The editor and the Source tab sit in the main pane; the preview pane
|
||||||
|
// beside it is hidden until the toggle is pressed.
|
||||||
|
const main = document.createElement('div')
|
||||||
|
main.className = 'blogeditor__main'
|
||||||
|
main.append(mount, textarea)
|
||||||
|
|
||||||
|
// The frame itself is created on the first switch-on (#previewFrameOf),
|
||||||
|
// so an element that never previews holds no iframe.
|
||||||
|
const previewPane = document.createElement('div')
|
||||||
|
previewPane.className = 'blogeditor__preview'
|
||||||
|
previewPane.hidden = true
|
||||||
|
this.#previewPane = previewPane
|
||||||
|
this.#previewFrame = undefined
|
||||||
|
|
||||||
|
const panes = document.createElement('div')
|
||||||
|
panes.className = 'blogeditor__panes'
|
||||||
|
panes.append(main, previewPane)
|
||||||
|
this.#panes = panes
|
||||||
|
|
||||||
|
// J2: the outline bar between the panes and the status line.
|
||||||
|
this.#outline = buildOutline({
|
||||||
|
labels,
|
||||||
|
editor: () => this.#editor,
|
||||||
|
sourceText: () => (this.#showingSource && this.#textarea ? this.#textarea.value : null),
|
||||||
|
allowsH1: options.headingLevels.includes(1),
|
||||||
|
genericAnchors: options.genericAnchors,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.append(
|
||||||
|
this.#toolbar.element,
|
||||||
|
this.#linkForm.root,
|
||||||
|
this.#embedForm.root,
|
||||||
|
this.#imageForm.root,
|
||||||
|
panes,
|
||||||
|
this.#outline.element,
|
||||||
|
status,
|
||||||
|
fileInput,
|
||||||
|
)
|
||||||
|
|
||||||
|
this.addEventListener(UPLOAD_RESULT_EVENT, this.#onUploadResult)
|
||||||
|
this.addEventListener(EDIT_HTML_EVENT, this.#onEditHtml)
|
||||||
|
|
||||||
|
const initial = this.value
|
||||||
|
this.#parsed = initial
|
||||||
|
this.#editor = new Editor({
|
||||||
|
element: mount,
|
||||||
|
injectCSS: false,
|
||||||
|
editable: !this.hasAttribute('disabled'),
|
||||||
|
extensions: preservationExtensions(labels, options.headingLevels),
|
||||||
|
content: initial,
|
||||||
|
editorProps: {
|
||||||
|
handleDrop: (_view, event, _slice, moved) => {
|
||||||
|
if (moved) return false
|
||||||
|
const files = Array.from((event as DragEvent).dataTransfer?.files ?? []).filter(isImage)
|
||||||
|
if (files.length === 0) return false
|
||||||
|
event.preventDefault()
|
||||||
|
this.#enqueue(files)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate: ({ transaction, appendedTransactions }) => {
|
||||||
|
if (!transaction.docChanged && !appendedTransactions.some((tr) => tr.docChanged)) return
|
||||||
|
this.#mode = 'wysiwyg'
|
||||||
|
this.#emitChange()
|
||||||
|
this.#schedulePreview()
|
||||||
|
this.#outline?.schedule()
|
||||||
|
},
|
||||||
|
onTransaction: () => this.#toolbar?.refresh(),
|
||||||
|
})
|
||||||
|
this.#blocks = captureBlocks(initial, this.#editor.schema)
|
||||||
|
this.#syncAttributes()
|
||||||
|
this.showSource(false)
|
||||||
|
if (this.#previewOn) this.showPreview(true)
|
||||||
|
this.#outline.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
this.removeEventListener(UPLOAD_RESULT_EVENT, this.#onUploadResult)
|
||||||
|
this.removeEventListener(EDIT_HTML_EVENT, this.#onEditHtml)
|
||||||
|
for (const { timer } of this.#pending.values()) clearTimeout(timer)
|
||||||
|
this.#pending.clear()
|
||||||
|
clearTimeout(this.#previewTimer)
|
||||||
|
this.#previewTimer = undefined
|
||||||
|
this.#outline?.stop()
|
||||||
|
this.#queue = []
|
||||||
|
// Keep an edited value readable while the element is detached.
|
||||||
|
if (this.#mode === 'wysiwyg' && this.#editor) {
|
||||||
|
this.#value = this.value
|
||||||
|
this.#mode = 'clean'
|
||||||
|
}
|
||||||
|
this.#editor?.destroy()
|
||||||
|
this.#editor = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(): void {
|
||||||
|
this.#syncAttributes()
|
||||||
|
this.#schedulePreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switches between the WYSIWYG and the Source tab. */
|
||||||
|
showSource(on: boolean): void {
|
||||||
|
if (!this.#textarea || !this.#mount) return
|
||||||
|
if (on) {
|
||||||
|
this.#textarea.value = this.value
|
||||||
|
} else if (this.#showingSource && this.#mode === 'source' && this.#sourceText !== this.#parsed) {
|
||||||
|
// Re-parse only when the source text changed; the value stays the
|
||||||
|
// exact text until the next WYSIWYG edit.
|
||||||
|
this.#load(this.#sourceText)
|
||||||
|
}
|
||||||
|
this.#showingSource = on
|
||||||
|
this.#textarea.hidden = !on
|
||||||
|
this.#mount.hidden = on
|
||||||
|
if (on) {
|
||||||
|
this.#close(this.#linkForm)
|
||||||
|
this.#close(this.#embedForm)
|
||||||
|
}
|
||||||
|
this.#toolbar?.selectTab(on)
|
||||||
|
this.#outline?.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows or hides the preview pane. The preview is read-only: it reads the
|
||||||
|
* value getter and never edits, emits summer-change or changes the value.
|
||||||
|
*/
|
||||||
|
showPreview(on: boolean): void {
|
||||||
|
this.#previewOn = on
|
||||||
|
if (!this.#panes || !this.#previewPane) return
|
||||||
|
this.#previewPane.hidden = !on
|
||||||
|
this.#panes.classList.toggle('blogeditor__panes--split', on)
|
||||||
|
this.#toolbar?.setPreview(on)
|
||||||
|
if (on) {
|
||||||
|
this.#renderPreview()
|
||||||
|
} else {
|
||||||
|
clearTimeout(this.#previewTimer)
|
||||||
|
this.#previewTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the current value into the preview frame. It reads only the
|
||||||
|
* value getter: an untouched document returns the exact string without
|
||||||
|
* the serializer, and nothing in the stored-value path is written.
|
||||||
|
*/
|
||||||
|
#renderPreview(): void {
|
||||||
|
clearTimeout(this.#previewTimer)
|
||||||
|
this.#previewTimer = undefined
|
||||||
|
if (!this.#previewOn) return
|
||||||
|
const frame = this.#previewFrameOf()
|
||||||
|
if (!frame) return
|
||||||
|
const html = this.value
|
||||||
|
const doc = buildPreviewDocument(html, {
|
||||||
|
labels: this.#labels,
|
||||||
|
lang: this.getAttribute('locale'),
|
||||||
|
...resolvePreviewColors(this),
|
||||||
|
})
|
||||||
|
// An identical document would only reload the frame.
|
||||||
|
if (frame.getAttribute('srcdoc') !== doc) frame.srcdoc = doc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-renders the preview PREVIEW_DEBOUNCE_MS after the last change, in
|
||||||
|
* both tabs; nothing is scheduled while the preview is off.
|
||||||
|
*/
|
||||||
|
#schedulePreview(): void {
|
||||||
|
if (!this.#previewOn || !this.#previewFrame) return
|
||||||
|
clearTimeout(this.#previewTimer)
|
||||||
|
this.#previewTimer = setTimeout(() => this.#renderPreview(), PREVIEW_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the preview frame, creating it in the pane on first use. */
|
||||||
|
#previewFrameOf(): HTMLIFrameElement | undefined {
|
||||||
|
if (this.#previewFrame) return this.#previewFrame
|
||||||
|
if (!this.#previewPane) return undefined
|
||||||
|
const frame = document.createElement('iframe')
|
||||||
|
// An empty sandbox: no scripts, an opaque origin. Content enters only
|
||||||
|
// through srcdoc; there is never a src, an allow or another token.
|
||||||
|
frame.setAttribute('sandbox', PREVIEW_SANDBOX)
|
||||||
|
frame.setAttribute('referrerpolicy', 'no-referrer')
|
||||||
|
frame.title = this.#labels.previewFrame
|
||||||
|
this.#previewPane.append(frame)
|
||||||
|
this.#previewFrame = frame
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses html into the editor without an update event (Pitfall 4) and
|
||||||
|
* outside the undo history, so undo never brings back a replaced value.
|
||||||
|
*/
|
||||||
|
#load(html: string): void {
|
||||||
|
this.#parsed = html
|
||||||
|
if (!this.#editor) return
|
||||||
|
this.#editor.chain().setMeta('addToHistory', false).setContent(html, { emitUpdate: false }).run()
|
||||||
|
this.#blocks = captureBlocks(html, this.#editor.schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
#sourceInput(): void {
|
||||||
|
if (!this.#textarea) return
|
||||||
|
this.#sourceText = this.#textarea.value
|
||||||
|
this.#mode = 'source'
|
||||||
|
this.#emitChange()
|
||||||
|
this.#schedulePreview()
|
||||||
|
this.#outline?.schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
#emitChange(): void {
|
||||||
|
this.dispatchEvent(new CustomEvent(CHANGE_EVENT, { bubbles: true, composed: true, detail: { value: this.value } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#setStatus(text: string): void {
|
||||||
|
if (this.#status) this.#status.textContent = text
|
||||||
|
}
|
||||||
|
|
||||||
|
#syncAttributes(): void {
|
||||||
|
const disabled = this.hasAttribute('disabled')
|
||||||
|
const invalid = this.hasAttribute('invalid')
|
||||||
|
if (this.#editor && this.#editor.isEditable === disabled) this.#editor.setEditable(!disabled, false)
|
||||||
|
this.#toolbar?.setDisabled(disabled)
|
||||||
|
if (this.#textarea) {
|
||||||
|
this.#textarea.readOnly = disabled
|
||||||
|
if (invalid) this.#textarea.setAttribute('aria-invalid', 'true')
|
||||||
|
else this.#textarea.removeAttribute('aria-invalid')
|
||||||
|
}
|
||||||
|
const view = this.#editor?.view.dom
|
||||||
|
if (view) {
|
||||||
|
if (invalid) view.setAttribute('aria-invalid', 'true')
|
||||||
|
else view.removeAttribute('aria-invalid')
|
||||||
|
const locale = this.getAttribute('locale')
|
||||||
|
if (locale) view.setAttribute('lang', locale)
|
||||||
|
else view.removeAttribute('lang')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline forms -----------------------------------------------------------
|
||||||
|
|
||||||
|
#form(kind: string, label: string, actions: [string, string, () => void][]): InlineForm {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
root.className = 'blogeditor__form'
|
||||||
|
root.dataset.form = kind
|
||||||
|
root.hidden = true
|
||||||
|
const field = document.createElement('label')
|
||||||
|
const caption = document.createElement('span')
|
||||||
|
caption.textContent = label
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'text'
|
||||||
|
input.name = 'url'
|
||||||
|
input.autocomplete = 'off'
|
||||||
|
input.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
actions[0]![2]()
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
this.#close(form)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
field.append(caption, input)
|
||||||
|
root.append(field)
|
||||||
|
for (const [action, text, run] of actions) root.append(this.#action(action, text, run))
|
||||||
|
const error = document.createElement('p')
|
||||||
|
error.className = 'blogeditor__error'
|
||||||
|
error.dataset.error = ''
|
||||||
|
error.setAttribute('aria-live', 'polite')
|
||||||
|
root.append(error)
|
||||||
|
const form = { root, error }
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
#action(action: string, text: string, run: () => void): HTMLButtonElement {
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.type = 'button'
|
||||||
|
button.dataset.action = action
|
||||||
|
button.textContent = text
|
||||||
|
button.addEventListener('click', run)
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
#open(form: InlineForm | undefined, value = ''): void {
|
||||||
|
if (!form) return
|
||||||
|
for (const other of [this.#linkForm, this.#embedForm]) if (other && other !== form) this.#close(other)
|
||||||
|
form.error.textContent = ''
|
||||||
|
form.root.hidden = false
|
||||||
|
const input = form.root.querySelector('input')
|
||||||
|
if (input) {
|
||||||
|
input.value = value
|
||||||
|
input.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#close(form: InlineForm | undefined): void {
|
||||||
|
if (!form) return
|
||||||
|
form.root.hidden = true
|
||||||
|
form.error.textContent = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
#inputOf(form: InlineForm | undefined, name = 'url'): string {
|
||||||
|
return (form?.root.querySelector<HTMLInputElement>(`input[name="${name}"]`)?.value ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#openLink(): void {
|
||||||
|
const editor = this.#editor
|
||||||
|
if (!editor?.isEditable) return
|
||||||
|
const attrs = editor.getAttributes('link')
|
||||||
|
const href = typeof attrs.href === 'string' ? attrs.href : ''
|
||||||
|
this.#open(this.#linkForm, href)
|
||||||
|
this.#linkExtras?.setUrl(href)
|
||||||
|
this.#linkExtras?.setRel(attrs.rel)
|
||||||
|
this.#linkExtras?.setText(this.#linkText())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text the link form links: the existing link's text, else the selection. */
|
||||||
|
#linkText(): string {
|
||||||
|
const editor = this.#editor
|
||||||
|
if (!editor) return ''
|
||||||
|
const { state } = editor
|
||||||
|
let { from, to } = state.selection
|
||||||
|
if (editor.isActive('link')) {
|
||||||
|
const type = state.schema.marks.link
|
||||||
|
const $from = state.doc.resolve(from)
|
||||||
|
const start = $from.parent.childAfter($from.parentOffset)
|
||||||
|
if (type && start.node && type.isInSet(start.node.marks)) {
|
||||||
|
// The whole run of text carrying the same link mark.
|
||||||
|
const parentStart = $from.start()
|
||||||
|
let s0 = parentStart + start.offset
|
||||||
|
let e0 = s0 + start.node.nodeSize
|
||||||
|
const href = editor.getAttributes('link').href
|
||||||
|
$from.parent.forEach((child, offset) => {
|
||||||
|
const mark = child.marks.find((m) => m.type === type)
|
||||||
|
if (!mark || mark.attrs.href !== href) return
|
||||||
|
const a = parentStart + offset
|
||||||
|
const b = a + child.nodeSize
|
||||||
|
if (b === s0) s0 = a
|
||||||
|
if (a === e0) e0 = b
|
||||||
|
})
|
||||||
|
from = s0
|
||||||
|
to = e0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state.doc.textBetween(from, to, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
#applyLink(): void {
|
||||||
|
const editor = this.#editor
|
||||||
|
if (!editor || !this.#linkForm) return
|
||||||
|
const url = this.#inputOf(this.#linkForm)
|
||||||
|
if (url === '') {
|
||||||
|
this.#removeLink()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isPolicyUrl(url)) {
|
||||||
|
this.#linkForm.error.textContent = this.#labels.linkInvalid
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// J3: rel only on an external link, with the checked tokens (D-80, D-83).
|
||||||
|
const rel = this.#linkExtras?.rel(url) ?? null
|
||||||
|
const { empty } = editor.state.selection
|
||||||
|
if (empty && !editor.isActive('link')) {
|
||||||
|
// Nothing selected: insert the URL as the link text.
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent({ type: 'text', text: url, marks: [{ type: 'link', attrs: { href: url, rel } }] })
|
||||||
|
.unsetMark('link')
|
||||||
|
.run()
|
||||||
|
} else {
|
||||||
|
editor.chain().focus().extendMarkRange('link').setLink({ href: url, rel }).run()
|
||||||
|
}
|
||||||
|
this.#close(this.#linkForm)
|
||||||
|
}
|
||||||
|
|
||||||
|
#removeLink(): void {
|
||||||
|
this.#editor?.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||||
|
this.#close(this.#linkForm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embeds ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
#openEmbed(): void {
|
||||||
|
if (!this.#editor?.isEditable) return
|
||||||
|
this.#open(this.#embedForm)
|
||||||
|
}
|
||||||
|
|
||||||
|
#insertEmbed(): void {
|
||||||
|
if (!this.#editor || !this.#embedForm) return
|
||||||
|
const embed = parseEmbedUrl(this.#inputOf(this.#embedForm))
|
||||||
|
if (!embed) {
|
||||||
|
this.#embedForm.error.textContent = this.#labels.embedInvalid
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.#editor.chain().focus().setEmbed(embed).run()
|
||||||
|
this.#close(this.#embedForm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw HTML -------------------------------------------------------------------
|
||||||
|
|
||||||
|
#insertRawHtml(): void {
|
||||||
|
const editor = this.#editor
|
||||||
|
if (!editor?.isEditable) return
|
||||||
|
const before = this.value
|
||||||
|
const html = '<div></div>'
|
||||||
|
if (!editor.chain().focus().insertRawHtml(html).run()) return
|
||||||
|
const after = this.value
|
||||||
|
let at = 0
|
||||||
|
while (at < before.length && at < after.length && before[at] === after[at]) at++
|
||||||
|
this.showSource(true)
|
||||||
|
const caret = after.startsWith(html, at) ? at + '<div>'.length : at
|
||||||
|
this.#textarea?.focus()
|
||||||
|
this.#textarea?.setSelectionRange(caret, caret)
|
||||||
|
}
|
||||||
|
|
||||||
|
#onEditHtml = (event: Event): void => {
|
||||||
|
const html = (event as CustomEvent<{ html?: string }>).detail?.html ?? ''
|
||||||
|
this.showSource(true)
|
||||||
|
const textarea = this.#textarea
|
||||||
|
if (!textarea) return
|
||||||
|
textarea.focus()
|
||||||
|
const at = html ? textarea.value.indexOf(html) : -1
|
||||||
|
if (at >= 0) textarea.setSelectionRange(at, at + html.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uploads (D-18, J4) -----------------------------------------------------------
|
||||||
|
|
||||||
|
#picked(): void {
|
||||||
|
const input = this.#fileInput
|
||||||
|
if (!input) return
|
||||||
|
const files = Array.from(input.files ?? [])
|
||||||
|
const images = files.filter(isImage)
|
||||||
|
if (images.length < files.length) this.#setStatus(this.#labels.notAnImage)
|
||||||
|
this.#enqueue(images)
|
||||||
|
try {
|
||||||
|
input.value = ''
|
||||||
|
} catch {
|
||||||
|
// A test double may not allow resetting the value.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queues picked or dropped images; each gets its image form in turn (J4). */
|
||||||
|
#enqueue(files: File[]): void {
|
||||||
|
this.#queue.push(...files)
|
||||||
|
this.#showImageForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
#upload(file: File, choice: ImageChoice): void {
|
||||||
|
const id = uploadRequestId()
|
||||||
|
const name = file.name
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!this.#pending.delete(id)) return
|
||||||
|
this.#setStatus(format(this.#labels.uploadTimeout, { name }))
|
||||||
|
}, UPLOAD_TIMEOUT_MS)
|
||||||
|
this.#pending.set(id, { name, choice, timer })
|
||||||
|
this.#setStatus(format(this.#labels.uploading, { name }))
|
||||||
|
this.dispatchEvent(new CustomEvent(UPLOAD_EVENT, { bubbles: true, composed: true, detail: { id, file } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#onUploadResult = (event: Event): void => {
|
||||||
|
const detail = (event as CustomEvent<{ id?: unknown; url?: unknown; error?: unknown }>).detail ?? {}
|
||||||
|
const id = typeof detail.id === 'string' ? detail.id : ''
|
||||||
|
const pending = this.#pending.get(id)
|
||||||
|
if (!pending) return
|
||||||
|
clearTimeout(pending.timer)
|
||||||
|
this.#pending.delete(id)
|
||||||
|
const url = typeof detail.url === 'string' ? detail.url : ''
|
||||||
|
if (typeof detail.error === 'string' || !url || !isPolicyUrl(url)) {
|
||||||
|
const error = typeof detail.error === 'string' && detail.error ? detail.error : pending.name
|
||||||
|
this.#setStatus(format(this.#labels.uploadFailed, { error }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.#setStatus('')
|
||||||
|
const editor = this.#editor
|
||||||
|
if (!editor) return
|
||||||
|
const { alt, caption } = pending.choice
|
||||||
|
if (caption) editor.chain().focus().setFigure({ src: url, alt, caption }).run()
|
||||||
|
else editor.chain().focus().setImage({ src: url, alt }).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
#showImageForm(): void {
|
||||||
|
const form = this.#imageForm
|
||||||
|
const file = this.#queue[0]
|
||||||
|
if (!form || !form.root.hidden || !file) return
|
||||||
|
form.open(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert: uploads the file under the chosen name; the image is inserted on the result. */
|
||||||
|
#insertImage(): void {
|
||||||
|
const form = this.#imageForm
|
||||||
|
const file = this.#queue[0]
|
||||||
|
if (!this.#editor || !form || !file) return
|
||||||
|
const choice = form.choice()
|
||||||
|
if (!choice) return
|
||||||
|
const renamed = choice.fileName === file.name ? file : new File([file], choice.fileName, { type: file.type, lastModified: file.lastModified })
|
||||||
|
this.#upload(renamed, choice)
|
||||||
|
this.#nextImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
#nextImage(): void {
|
||||||
|
this.#queue.shift()
|
||||||
|
if (this.#imageForm) this.#imageForm.root.hidden = true
|
||||||
|
this.#showImageForm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(file: File): boolean {
|
||||||
|
return IMAGE_TYPES.includes(file.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
let uploadCounter = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* uploadRequestId returns a new id for a summer-upload request. It is
|
||||||
|
* crypto.randomUUID where the admin runs in a secure context. Over plain
|
||||||
|
* HTTP from a host other than localhost randomUUID does not exist, so it is
|
||||||
|
* a version 4 UUID built from crypto.getRandomValues (available in every
|
||||||
|
* context), or a time and counter id when no crypto object exists at all.
|
||||||
|
*/
|
||||||
|
export function uploadRequestId(): string {
|
||||||
|
const c: Partial<Crypto> | undefined = globalThis.crypto
|
||||||
|
if (c && typeof c.randomUUID === 'function') return c.randomUUID()
|
||||||
|
if (c && typeof c.getRandomValues === 'function') {
|
||||||
|
const bytes = c.getRandomValues(new Uint8Array(16))
|
||||||
|
bytes[6] = (bytes[6]! & 0x0f) | 0x40
|
||||||
|
bytes[8] = (bytes[8]! & 0x3f) | 0x80
|
||||||
|
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||||
|
}
|
||||||
|
uploadCounter += 1
|
||||||
|
return `upload-${Date.now().toString(36)}-${uploadCounter}`
|
||||||
|
}
|
||||||
59
assets/src/blogeditor/embed.ts
Normal file
59
assets/src/blogeditor/embed.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Embed providers (D-05). Stored posts hold only the placeholder
|
||||||
|
* <figure data-embed="PROVIDER" data-id="ID"></figure>; the layer turns it
|
||||||
|
* into the real iframe. Adding a provider is one entry here and one in the
|
||||||
|
* layer's table.
|
||||||
|
*/
|
||||||
|
export interface EmbedProvider {
|
||||||
|
/** The name shown on the editor card. */
|
||||||
|
name: string
|
||||||
|
/** The id the placeholder may hold. */
|
||||||
|
idPattern: RegExp
|
||||||
|
/** Returns the id from a provider URL, or null. */
|
||||||
|
idFromUrl: (url: URL) => string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const YOUTUBE_HOSTS = new Set(['youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com', 'youtube-nocookie.com', 'www.youtube-nocookie.com'])
|
||||||
|
|
||||||
|
export const EMBED_PROVIDERS = {
|
||||||
|
youtube: {
|
||||||
|
name: 'YouTube',
|
||||||
|
idPattern: /^[A-Za-z0-9_-]{11}$/,
|
||||||
|
idFromUrl(url: URL): string | null {
|
||||||
|
const host = url.hostname.toLowerCase()
|
||||||
|
if (host === 'youtu.be') {
|
||||||
|
return url.pathname.split('/')[1] ?? null
|
||||||
|
}
|
||||||
|
if (!YOUTUBE_HOSTS.has(host)) return null
|
||||||
|
if (url.pathname === '/watch') return url.searchParams.get('v')
|
||||||
|
const match = /^\/(?:shorts|embed)\/([^/]+)\/?$/.exec(url.pathname)
|
||||||
|
return match ? match[1]! : null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Record<string, EmbedProvider>
|
||||||
|
|
||||||
|
export type EmbedProviderName = keyof typeof EMBED_PROVIDERS
|
||||||
|
|
||||||
|
export function isEmbedProvider(name: string): name is EmbedProviderName {
|
||||||
|
return Object.prototype.hasOwnProperty.call(EMBED_PROVIDERS, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parseEmbedUrl returns the provider and id for a supported video URL
|
||||||
|
* (youtube.com/watch?v=, youtu.be/, youtube.com/shorts/ and
|
||||||
|
* youtube.com/embed/ for YouTube), or null for anything else.
|
||||||
|
*/
|
||||||
|
export function parseEmbedUrl(input: string): { provider: EmbedProviderName; id: string } | null {
|
||||||
|
let url: URL
|
||||||
|
try {
|
||||||
|
url = new URL(input.trim())
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
|
||||||
|
for (const provider of Object.keys(EMBED_PROVIDERS) as EmbedProviderName[]) {
|
||||||
|
const id = EMBED_PROVIDERS[provider].idFromUrl(url)
|
||||||
|
if (id && EMBED_PROVIDERS[provider].idPattern.test(id)) return { provider, id }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
601
assets/src/blogeditor/extensions.ts
Normal file
601
assets/src/blogeditor/extensions.ts
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
import { Extension, Mark, Node, mergeAttributes, type AnyExtension } from '@tiptap/core'
|
||||||
|
import type { DOMOutputSpec } from '@tiptap/pm/model'
|
||||||
|
import Image from '@tiptap/extension-image'
|
||||||
|
import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import { EMBED_PROVIDERS, isEmbedProvider } from './embed'
|
||||||
|
import { labelsFor, type Labels } from './labels'
|
||||||
|
|
||||||
|
/** The event a raw HTML card dispatches to open its source in the Source tab. */
|
||||||
|
export const EDIT_HTML_EVENT = 'blogeditor-edit-html'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block elements the schema does not model. Each becomes one atomic rawHtml
|
||||||
|
* node holding its outer HTML, shown in the editor as text (D-04).
|
||||||
|
*/
|
||||||
|
export const RAW_HTML_TAGS = [
|
||||||
|
'footer',
|
||||||
|
'section',
|
||||||
|
'aside',
|
||||||
|
'header',
|
||||||
|
'nav',
|
||||||
|
'details',
|
||||||
|
'style',
|
||||||
|
'div',
|
||||||
|
'article',
|
||||||
|
'main',
|
||||||
|
'address',
|
||||||
|
'dl',
|
||||||
|
'iframe',
|
||||||
|
'video',
|
||||||
|
'audio',
|
||||||
|
'picture',
|
||||||
|
'object',
|
||||||
|
'embed',
|
||||||
|
'form',
|
||||||
|
'svg',
|
||||||
|
'math',
|
||||||
|
'script',
|
||||||
|
'noscript',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const URL_IGNORED = /[\x00-\x20\x7f]+/g
|
||||||
|
const URL_SCHEME = /^([a-z][a-z0-9+.-]*):/i
|
||||||
|
const POLICY_SCHEMES = ['http', 'https', 'mailto']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* isPolicyUrl reports whether url is one the server and layer sanitizer
|
||||||
|
* policies keep (D-05, D-10 amended): a relative URL (including # and ?) or
|
||||||
|
* an http, https or mailto URL. The scheme is read after removing ASCII
|
||||||
|
* control characters, spaces and DEL, as browsers do, so "java\tscript:" is
|
||||||
|
* refused. It mirrors allowedUrl in the layer's htmlGate.ts.
|
||||||
|
*/
|
||||||
|
export function isPolicyUrl(url: string | null | undefined): boolean {
|
||||||
|
if (url == null) return false
|
||||||
|
const scheme = URL_SCHEME.exec(url.replace(URL_IGNORED, ''))
|
||||||
|
if (scheme && !POLICY_SCHEMES.includes(scheme[1]!.toLowerCase())) return false
|
||||||
|
try {
|
||||||
|
new URL(url, 'https://journal.invalid/')
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes each node or mark models itself; htmlAttrs captures the
|
||||||
|
* view-safe others (KEPT_ATTRIBUTES).
|
||||||
|
*/
|
||||||
|
const MODELLED: Record<string, readonly string[]> = {
|
||||||
|
orderedList: ['start', 'type'],
|
||||||
|
link: ['href', 'target', 'rel', 'class', 'title'],
|
||||||
|
image: ['src', 'alt', 'title', 'width', 'height'],
|
||||||
|
figureImage: ['src', 'alt', 'title', 'width', 'height'],
|
||||||
|
tableCell: ['colspan', 'rowspan', 'colwidth'],
|
||||||
|
tableHeader: ['colspan', 'rowspan', 'colwidth'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const HTML_ATTRIBUTE_TYPES = [
|
||||||
|
'paragraph',
|
||||||
|
'heading',
|
||||||
|
'blockquote',
|
||||||
|
'bulletList',
|
||||||
|
'orderedList',
|
||||||
|
'listItem',
|
||||||
|
'codeBlock',
|
||||||
|
'horizontalRule',
|
||||||
|
'hardBreak',
|
||||||
|
'table',
|
||||||
|
'tableRow',
|
||||||
|
'tableCell',
|
||||||
|
'tableHeader',
|
||||||
|
'image',
|
||||||
|
'figure',
|
||||||
|
'figcaption',
|
||||||
|
'link',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KEPT_ATTRIBUTES are the unmodelled attributes htmlAttrs keeps (besides
|
||||||
|
* data-*): ones that are safe to render in the live admin DOM, where
|
||||||
|
* ProseMirror draws the document (WR-05). Every other attribute (id, name,
|
||||||
|
* style, srcset, form, contenteditable, popover, event handlers and the
|
||||||
|
* like) is dropped from a block once the block is edited; an untouched
|
||||||
|
* document and every untouched block keep their stored bytes. href and src
|
||||||
|
* are kept through the link and image nodes, which check them.
|
||||||
|
*/
|
||||||
|
export const KEPT_ATTRIBUTES: readonly string[] = [
|
||||||
|
'class',
|
||||||
|
'title',
|
||||||
|
'alt',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'colspan',
|
||||||
|
'rowspan',
|
||||||
|
'start',
|
||||||
|
'rel',
|
||||||
|
'target',
|
||||||
|
]
|
||||||
|
|
||||||
|
const DATA_ATTRIBUTE = /^data-[a-z0-9][a-z0-9._-]*$/
|
||||||
|
|
||||||
|
/** isKeptAttribute reports whether htmlAttrs keeps an attribute (lower-case name). */
|
||||||
|
export function isKeptAttribute(name: string): boolean {
|
||||||
|
return KEPT_ATTRIBUTES.includes(name) || DATA_ATTRIBUTE.test(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** captureAttributes returns the element's kept attributes outside skip, in order. */
|
||||||
|
function captureAttributes(element: HTMLElement, skip: readonly string[]): Record<string, string> | null {
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
let any = false
|
||||||
|
for (const attr of Array.from(element.attributes)) {
|
||||||
|
const name = attr.name.toLowerCase()
|
||||||
|
if (skip.includes(name) || !isKeptAttribute(name)) continue
|
||||||
|
out[name] = attr.value
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
return any ? out : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCaptured(captured: unknown): Record<string, string> {
|
||||||
|
if (!captured || typeof captured !== 'object') return {}
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
for (const [name, value] of Object.entries(captured as Record<string, unknown>)) {
|
||||||
|
if (!isKeptAttribute(name.toLowerCase()) || typeof value !== 'string') continue
|
||||||
|
out[name] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HtmlAttributes keeps the view-safe attributes the schema does not model
|
||||||
|
* (KEPT_ATTRIBUTES and data-*) on paragraphs, headings, lists, list items,
|
||||||
|
* blockquotes, tables, cells, images and links, so they survive an edit
|
||||||
|
* elsewhere in the block (D-03).
|
||||||
|
*/
|
||||||
|
export const HtmlAttributes = Extension.create({
|
||||||
|
name: 'htmlAttributes',
|
||||||
|
|
||||||
|
addGlobalAttributes() {
|
||||||
|
return HTML_ATTRIBUTE_TYPES.map((type) => ({
|
||||||
|
types: [type],
|
||||||
|
attributes: {
|
||||||
|
htmlAttrs: {
|
||||||
|
default: null,
|
||||||
|
keepOnSplit: false,
|
||||||
|
parseHTML: (element: HTMLElement) => captureAttributes(element, MODELLED[type] ?? []),
|
||||||
|
renderHTML: (attributes: Record<string, unknown>) => renderCaptured(attributes.htmlAttrs),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Span keeps inline spans with kept attributes (class, data-*, title) as a
|
||||||
|
* mark; a span with none of them is dropped and its text kept.
|
||||||
|
*/
|
||||||
|
export const Span = Mark.create({
|
||||||
|
name: 'span',
|
||||||
|
priority: 40,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
htmlAttrs: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element: HTMLElement) => captureAttributes(element, []),
|
||||||
|
renderHTML: (attributes: Record<string, unknown>) => renderCaptured(attributes.htmlAttrs),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'span', getAttrs: (element) => (captureAttributes(element as HTMLElement, []) ? null : false) }]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['span', HTMLAttributes, 0]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let inertDocument: Document | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* inert returns a document without a browsing context: markup parsed into
|
||||||
|
* it loads no image and runs no handler, so stored HTML never reaches the
|
||||||
|
* live admin DOM.
|
||||||
|
*/
|
||||||
|
export function inert(): Document {
|
||||||
|
inertDocument ??= document.implementation.createHTMLDocument('')
|
||||||
|
return inertDocument
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
rawHtml: {
|
||||||
|
/** Inserts an atomic raw HTML block. */
|
||||||
|
insertRawHtml: (html: string) => ReturnType
|
||||||
|
}
|
||||||
|
embed: {
|
||||||
|
/** Inserts an embed placeholder for an allowlisted provider. */
|
||||||
|
setEmbed: (attributes: { provider: string; id: string }) => ReturnType
|
||||||
|
}
|
||||||
|
figure: {
|
||||||
|
/** Inserts an image with a caption as figure, img and figcaption. */
|
||||||
|
setFigure: (attributes: { src: string; alt: string; caption: string }) => ReturnType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawHtmlOptions {
|
||||||
|
labels: Labels
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RawHtml is an atomic block holding the outer HTML of markup the schema
|
||||||
|
* does not model (D-04). Its card renders the source with textContent,
|
||||||
|
* never as live DOM.
|
||||||
|
*/
|
||||||
|
export const RawHtml = Node.create<RawHtmlOptions>({
|
||||||
|
name: 'rawHtml',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
selectable: true,
|
||||||
|
draggable: true,
|
||||||
|
priority: 10,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return { labels: labelsFor('en') }
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
html: {
|
||||||
|
default: '',
|
||||||
|
parseHTML: (element: HTMLElement) => element.outerHTML,
|
||||||
|
renderHTML: () => ({}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
// The last rule takes a figure that is not exactly img plus an optional
|
||||||
|
// figcaption.
|
||||||
|
return [...RAW_HTML_TAGS, 'figure'].map((tag) => ({ tag, priority: 10 }))
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ node }) {
|
||||||
|
// Serializers only (the editor shows the card): parsed in the inert
|
||||||
|
// document and returned whole.
|
||||||
|
const template = inert().createElement('template')
|
||||||
|
template.innerHTML = String(node.attrs.html ?? '')
|
||||||
|
// ProseMirror accepts any DOM node here; the typing names only elements.
|
||||||
|
return template.content as unknown as DOMOutputSpec
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
insertRawHtml:
|
||||||
|
(html) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.insertContent({ type: this.name, attrs: { html } }),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
const labels = this.options.labels
|
||||||
|
return ({ node }) => {
|
||||||
|
const dom = document.createElement('div')
|
||||||
|
dom.className = 'blogeditor__raw'
|
||||||
|
dom.contentEditable = 'false'
|
||||||
|
const head = document.createElement('div')
|
||||||
|
head.className = 'blogeditor__card-head'
|
||||||
|
const title = document.createElement('span')
|
||||||
|
title.textContent = labels.rawHtmlCard
|
||||||
|
const edit = document.createElement('button')
|
||||||
|
edit.type = 'button'
|
||||||
|
edit.textContent = labels.editAsHtml
|
||||||
|
head.append(title, edit)
|
||||||
|
const source = document.createElement('pre')
|
||||||
|
source.className = 'blogeditor__raw-source'
|
||||||
|
source.textContent = String(node.attrs.html ?? '')
|
||||||
|
dom.append(head, source)
|
||||||
|
let current = node
|
||||||
|
edit.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
dom.dispatchEvent(
|
||||||
|
new CustomEvent(EDIT_HTML_EVENT, { bubbles: true, detail: { html: String(current.attrs.html ?? '') } }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
dom,
|
||||||
|
update: (next) => {
|
||||||
|
if (next.type !== current.type) return false
|
||||||
|
current = next
|
||||||
|
source.textContent = String(next.attrs.html ?? '')
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
stopEvent: (event) => event.target === edit,
|
||||||
|
ignoreMutation: () => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface EmbedOptions {
|
||||||
|
labels: Labels
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embed is the allowlisted video placeholder (D-05). It renders exactly
|
||||||
|
* <figure data-embed="PROVIDER" data-id="ID"></figure>; the editor shows a
|
||||||
|
* card and never an iframe. The layer expands the placeholder on the site.
|
||||||
|
*/
|
||||||
|
export const Embed = Node.create<EmbedOptions>({
|
||||||
|
name: 'embed',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
selectable: true,
|
||||||
|
draggable: true,
|
||||||
|
priority: 70,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return { labels: labelsFor('en') }
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
provider: { default: null, parseHTML: (element: HTMLElement) => element.getAttribute('data-embed'), renderHTML: () => ({}) },
|
||||||
|
id: { default: null, parseHTML: (element: HTMLElement) => element.getAttribute('data-id'), renderHTML: () => ({}) },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'figure[data-embed]',
|
||||||
|
priority: 70,
|
||||||
|
getAttrs: (element) => {
|
||||||
|
const el = element as HTMLElement
|
||||||
|
const provider = el.getAttribute('data-embed') ?? ''
|
||||||
|
const id = el.getAttribute('data-id') ?? ''
|
||||||
|
// Exactly the placeholder: two attributes and no content.
|
||||||
|
if (el.attributes.length !== 2 || el.childNodes.length !== 0) return false
|
||||||
|
if (!isEmbedProvider(provider) || !EMBED_PROVIDERS[provider].idPattern.test(id)) return false
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ node }) {
|
||||||
|
return ['figure', { 'data-embed': node.attrs.provider, 'data-id': node.attrs.id }]
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setEmbed:
|
||||||
|
({ provider, id }) =>
|
||||||
|
({ commands }) => {
|
||||||
|
if (!isEmbedProvider(provider) || !EMBED_PROVIDERS[provider].idPattern.test(id)) return false
|
||||||
|
return commands.insertContent({ type: this.name, attrs: { provider, id } })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
const labels = this.options.labels
|
||||||
|
return ({ node }) => {
|
||||||
|
const dom = document.createElement('div')
|
||||||
|
dom.className = 'blogeditor__embed'
|
||||||
|
dom.contentEditable = 'false'
|
||||||
|
const render = (n: typeof node) => {
|
||||||
|
const provider = String(n.attrs.provider ?? '')
|
||||||
|
const name = isEmbedProvider(provider) ? EMBED_PROVIDERS[provider].name : provider
|
||||||
|
dom.textContent = `${labels.embedCard}: ${name} ${String(n.attrs.id ?? '')}`
|
||||||
|
}
|
||||||
|
render(node)
|
||||||
|
let current = node
|
||||||
|
return {
|
||||||
|
dom,
|
||||||
|
update: (next) => {
|
||||||
|
if (next.type !== current.type) return false
|
||||||
|
current = next
|
||||||
|
render(next)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
ignoreMutation: () => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function isFigureShape(element: HTMLElement): boolean {
|
||||||
|
if (element.hasAttribute('data-embed')) return false
|
||||||
|
const children: Element[] = []
|
||||||
|
for (const child of Array.from(element.childNodes)) {
|
||||||
|
if (child.nodeType === 1) children.push(child as Element)
|
||||||
|
else if (child.nodeType === 3 && /[^ \t\r\n\f]/.test(child.nodeValue ?? '')) return false
|
||||||
|
else if (child.nodeType !== 3) return false
|
||||||
|
}
|
||||||
|
if (children.length < 1 || children.length > 2) return false
|
||||||
|
const img = children[0]!
|
||||||
|
if (img.nodeName !== 'IMG') return false
|
||||||
|
const src = img.getAttribute('src')
|
||||||
|
if (!src || /^\s*data:/i.test(src)) return false
|
||||||
|
return children.length === 1 || children[1]!.nodeName === 'FIGCAPTION'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Figure holds an image and an optional caption (D-18). */
|
||||||
|
export const Figure = Node.create({
|
||||||
|
name: 'figure',
|
||||||
|
group: 'block',
|
||||||
|
content: 'figureImage figcaption?',
|
||||||
|
draggable: true,
|
||||||
|
isolating: true,
|
||||||
|
priority: 60,
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'figure',
|
||||||
|
priority: 60,
|
||||||
|
getAttrs: (element) => (isFigureShape(element as HTMLElement) ? null : false),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['figure', HTMLAttributes, 0]
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setFigure:
|
||||||
|
({ src, alt, caption }) =>
|
||||||
|
({ commands }) => {
|
||||||
|
const content: Record<string, unknown>[] = [{ type: 'figureImage', attrs: { src, alt } }]
|
||||||
|
if (caption) content.push({ type: 'figcaption', content: [{ type: 'text', text: caption }] })
|
||||||
|
return commands.insertContent({ type: this.name, content })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** FigureImage is the img inside a figure. */
|
||||||
|
export const FigureImage = Node.create({
|
||||||
|
name: 'figureImage',
|
||||||
|
atom: true,
|
||||||
|
selectable: false,
|
||||||
|
priority: 60,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: { default: null },
|
||||||
|
alt: { default: null },
|
||||||
|
title: { default: null },
|
||||||
|
width: { default: null },
|
||||||
|
height: { default: null },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'img[src]:not([src^="data:"])', context: 'figure/', priority: 60 }]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['img', HTMLAttributes]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Figcaption is the editable caption of a figure. */
|
||||||
|
export const Figcaption = Node.create({
|
||||||
|
name: 'figcaption',
|
||||||
|
content: 'inline*',
|
||||||
|
priority: 60,
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'figcaption', context: 'figure/' }]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['figcaption', HTMLAttributes, 0]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Tables render without TipTap's colgroup and min-width style. */
|
||||||
|
export const PlainTable = Table.extend({
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['table', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), ['tbody', 0]]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cell alignment is kept through htmlAttrs (style or align, as stored)
|
||||||
|
// instead of TipTap's align attribute, which rewrites it as a style.
|
||||||
|
function withoutAlign<T extends Record<string, unknown>>(attributes: T | undefined): Omit<T, 'align'> {
|
||||||
|
const { align: _align, ...rest } = attributes ?? ({} as T)
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlainTableCell = TableCell.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return withoutAlign(this.parent?.())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const PlainTableHeader = TableHeader.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return withoutAlign(this.parent?.())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The heading levels the editor offers when no option says otherwise (J1). */
|
||||||
|
export const DEFAULT_HEADING_LEVELS: readonly number[] = [2, 3, 4]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* schemaHeadingLevels are the heading levels the schema holds for the
|
||||||
|
* offered ones: the offered levels and every lower level (5 and 6), so
|
||||||
|
* stored H5 and H6 survive an edit, and H1 only when it is offered.
|
||||||
|
*/
|
||||||
|
export function schemaHeadingLevels(offered: readonly number[]): (1 | 2 | 3 | 4 | 5 | 6)[] {
|
||||||
|
const allowsH1 = offered.includes(1)
|
||||||
|
return ([1, 2, 3, 4, 5, 6] as const).filter((level) => level > 1 || allowsH1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HeadingOneAsTwo parses an h1 (pasted or loaded) as a level 2 heading while
|
||||||
|
* the schema has no level 1 (the H1 setting is on, J1), so the editor never
|
||||||
|
* holds an H1. It is a parse rule for the heading node carried by a node
|
||||||
|
* type the parser never creates.
|
||||||
|
*/
|
||||||
|
export const HeadingOneAsTwo = Node.create({
|
||||||
|
name: 'headingOneAsTwo',
|
||||||
|
content: 'inline*',
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'h1',
|
||||||
|
node: 'heading',
|
||||||
|
getAttrs: (element) => ({ level: 2, htmlAttrs: captureAttributes(element as HTMLElement, []) }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
renderHTML() {
|
||||||
|
return ['h2', 0]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* preservationExtensions is the editor schema (D-03, D-04): StarterKit
|
||||||
|
* without the trailing node and with links that gain no target, rel or class
|
||||||
|
* and accept only policy URLs; inline images without base64; tables; and
|
||||||
|
* the preservation nodes and marks. headingLevels are the offered heading
|
||||||
|
* levels (J1): without 1, an h1 is parsed as a level 2 heading.
|
||||||
|
*/
|
||||||
|
export function preservationExtensions(labels: Labels = labelsFor('en'), headingLevels: readonly number[] = DEFAULT_HEADING_LEVELS): AnyExtension[] {
|
||||||
|
const levels = schemaHeadingLevels(headingLevels)
|
||||||
|
return [
|
||||||
|
StarterKit.configure({
|
||||||
|
trailingNode: false,
|
||||||
|
heading: { levels },
|
||||||
|
link: {
|
||||||
|
openOnClick: false,
|
||||||
|
HTMLAttributes: { target: null, rel: null, class: null },
|
||||||
|
isAllowedUri: (url) => isPolicyUrl(url),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(levels.includes(1) ? [] : [HeadingOneAsTwo]),
|
||||||
|
Image.configure({ inline: true, allowBase64: false }),
|
||||||
|
PlainTable,
|
||||||
|
TableRow,
|
||||||
|
PlainTableHeader,
|
||||||
|
PlainTableCell,
|
||||||
|
Figure,
|
||||||
|
FigureImage,
|
||||||
|
Figcaption,
|
||||||
|
Embed.configure({ labels }),
|
||||||
|
RawHtml.configure({ labels }),
|
||||||
|
Span,
|
||||||
|
HtmlAttributes,
|
||||||
|
]
|
||||||
|
}
|
||||||
167
assets/src/blogeditor/imageform.ts
Normal file
167
assets/src/blogeditor/imageform.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import type { Labels } from './labels'
|
||||||
|
|
||||||
|
/** The longest file name stem mediaFileName keeps (D-85). */
|
||||||
|
export const MAX_FILE_STEM = 80
|
||||||
|
|
||||||
|
const POLISH: Record<string, string> = { ą: 'a', ć: 'c', ę: 'e', ł: 'l', ń: 'n', ó: 'o', ś: 's', ź: 'z', ż: 'z' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* slugFileStem is the J4 slug rule (D-85), the same as the server's
|
||||||
|
* classes.MediaFileName: lowercase, Polish diacritics transliterated, every
|
||||||
|
* other run of characters outside a-z and 0-9 one hyphen, trimmed, at most
|
||||||
|
* 80 characters, and "image" when nothing is left.
|
||||||
|
*/
|
||||||
|
export function slugFileStem(name: string): string {
|
||||||
|
let out = ''
|
||||||
|
let dash = false
|
||||||
|
for (const ch of name.toLowerCase()) {
|
||||||
|
const c = POLISH[ch] ?? ch
|
||||||
|
if (/^[a-z0-9]$/.test(c)) {
|
||||||
|
out += c
|
||||||
|
dash = false
|
||||||
|
} else if (!dash) {
|
||||||
|
out += '-'
|
||||||
|
dash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = out.replace(/^-+|-+$/g, '').slice(0, MAX_FILE_STEM).replace(/-+$/, '')
|
||||||
|
return out === '' ? 'image' : out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** splitFileName splits a file name into its stem and its lower-case extension (with the dot, or ""). */
|
||||||
|
export function splitFileName(name: string): { stem: string; ext: string } {
|
||||||
|
const base = name.replace(/\\/g, '/').split('/').pop() ?? ''
|
||||||
|
const dot = base.lastIndexOf('.')
|
||||||
|
if (dot >= 0) {
|
||||||
|
const ext = base.slice(dot).toLowerCase()
|
||||||
|
if (/^\.[a-z0-9]+$/.test(ext)) return { stem: base.slice(0, dot), ext }
|
||||||
|
}
|
||||||
|
return { stem: base, ext: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mediaFileName applies slugFileStem to a file name and keeps its extension, lowercased. */
|
||||||
|
export function mediaFileName(name: string): string {
|
||||||
|
const { stem, ext } = splitFileName(name)
|
||||||
|
return slugFileStem(stem) + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the image form answers on Insert. */
|
||||||
|
export interface ImageChoice {
|
||||||
|
/** The file name to upload under: the slugged stem and the fixed extension. */
|
||||||
|
fileName: string
|
||||||
|
alt: string
|
||||||
|
caption: string
|
||||||
|
decorative: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The J4 image form, one picked file at a time. */
|
||||||
|
export interface ImageForm {
|
||||||
|
root: HTMLDivElement
|
||||||
|
error: HTMLParagraphElement
|
||||||
|
/** Shows the form for file: the slug of its name, its extension, empty alt and caption. */
|
||||||
|
open: (file: File) => void
|
||||||
|
/** Validates and returns the choice, or shows altRequired and returns null. */
|
||||||
|
choice: () => ImageChoice | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildImageForm builds the J4 inline image form: fileName (prefilled with
|
||||||
|
* the slug of the original name, slugged again on blur and on Insert, the
|
||||||
|
* extension shown as fixed text after it, fileNameHint under it), altText
|
||||||
|
* (required unless the image is decorative), the decorative checkbox (it
|
||||||
|
* clears and disables the alt input; the image gets alt="") and caption.
|
||||||
|
*/
|
||||||
|
export function buildImageForm(labels: Labels, onInsert: () => void, onCancel: () => void): ImageForm {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
root.className = 'blogeditor__form'
|
||||||
|
root.dataset.form = 'image'
|
||||||
|
root.hidden = true
|
||||||
|
|
||||||
|
const textField = (name: string, caption: string) => {
|
||||||
|
const label = document.createElement('label')
|
||||||
|
const text = document.createElement('span')
|
||||||
|
text.textContent = caption
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'text'
|
||||||
|
input.name = name
|
||||||
|
input.autocomplete = 'off'
|
||||||
|
label.append(text, input)
|
||||||
|
return { label, input }
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = textField('fileName', labels.fileName)
|
||||||
|
name.label.classList.add('blogeditor__filename')
|
||||||
|
const nameRow = document.createElement('span')
|
||||||
|
nameRow.className = 'blogeditor__filename-row'
|
||||||
|
const ext = document.createElement('span')
|
||||||
|
ext.className = 'blogeditor__ext'
|
||||||
|
nameRow.append(name.input, ext)
|
||||||
|
const hint = document.createElement('span')
|
||||||
|
hint.className = 'blogeditor__hint'
|
||||||
|
hint.textContent = labels.fileNameHint
|
||||||
|
name.label.append(nameRow, hint)
|
||||||
|
name.input.addEventListener('blur', () => {
|
||||||
|
name.input.value = slugFileStem(name.input.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const alt = textField('alt', labels.altText)
|
||||||
|
const decorativeLabel = document.createElement('label')
|
||||||
|
decorativeLabel.className = 'blogeditor__check'
|
||||||
|
const decorative = document.createElement('input')
|
||||||
|
decorative.type = 'checkbox'
|
||||||
|
decorative.name = 'decorative'
|
||||||
|
decorativeLabel.append(decorative, document.createTextNode(labels.decorative))
|
||||||
|
decorative.addEventListener('change', () => {
|
||||||
|
if (decorative.checked) alt.input.value = ''
|
||||||
|
alt.input.disabled = decorative.checked
|
||||||
|
if (decorative.checked) error.textContent = ''
|
||||||
|
})
|
||||||
|
const caption = textField('caption', labels.caption)
|
||||||
|
|
||||||
|
const button = (action: string, text: string, run: () => void) => {
|
||||||
|
const b = document.createElement('button')
|
||||||
|
b.type = 'button'
|
||||||
|
b.dataset.action = action
|
||||||
|
b.textContent = text
|
||||||
|
b.addEventListener('click', run)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
const error = document.createElement('p')
|
||||||
|
error.className = 'blogeditor__error'
|
||||||
|
error.dataset.error = ''
|
||||||
|
error.setAttribute('aria-live', 'polite')
|
||||||
|
|
||||||
|
root.append(name.label, alt.label, decorativeLabel, caption.label, button('insert', labels.insertImage, onInsert), button('cancel', labels.cancel, onCancel), error)
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
error,
|
||||||
|
open: (file) => {
|
||||||
|
const parts = splitFileName(file.name)
|
||||||
|
name.input.value = slugFileStem(parts.stem)
|
||||||
|
ext.textContent = parts.ext
|
||||||
|
alt.input.value = ''
|
||||||
|
alt.input.disabled = false
|
||||||
|
decorative.checked = false
|
||||||
|
caption.input.value = ''
|
||||||
|
error.textContent = ''
|
||||||
|
root.hidden = false
|
||||||
|
alt.input.focus()
|
||||||
|
},
|
||||||
|
choice: () => {
|
||||||
|
name.input.value = slugFileStem(name.input.value)
|
||||||
|
const altText = alt.input.value.trim()
|
||||||
|
if (!decorative.checked && altText === '') {
|
||||||
|
error.textContent = labels.altRequired
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
error.textContent = ''
|
||||||
|
return {
|
||||||
|
fileName: name.input.value + ext.textContent,
|
||||||
|
alt: decorative.checked ? '' : altText,
|
||||||
|
caption: caption.input.value.trim(),
|
||||||
|
decorative: decorative.checked,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
10
assets/src/blogeditor/index.ts
Normal file
10
assets/src/blogeditor/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import './blogeditor.css'
|
||||||
|
import { BlogEditorElement, TAG } from './element'
|
||||||
|
|
||||||
|
// The admin SPA loads this script once on every admin page (D-13); defining
|
||||||
|
// the tag twice would throw, so a second evaluation is a no-op.
|
||||||
|
if (!customElements.get(TAG)) {
|
||||||
|
customElements.define(TAG, BlogEditorElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BlogEditorElement, TAG }
|
||||||
253
assets/src/blogeditor/labels.ts
Normal file
253
assets/src/blogeditor/labels.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* A CLDR plural map: one/other in English, one/few/many/other in Polish
|
||||||
|
* (UI-SPEC zero-one-many); {count} is the number.
|
||||||
|
*/
|
||||||
|
export type Plural = Partial<Record<Intl.LDMLPluralRule, string>> & { other: string }
|
||||||
|
|
||||||
|
/** Every label, prompt and error the Blog Editor shows. */
|
||||||
|
export interface Labels {
|
||||||
|
/** The language the plural rules of this table follow. */
|
||||||
|
language: string
|
||||||
|
tabWysiwyg: string
|
||||||
|
tabSource: string
|
||||||
|
toolbar: string
|
||||||
|
paragraph: string
|
||||||
|
heading2: string
|
||||||
|
heading3: string
|
||||||
|
heading4: string
|
||||||
|
bold: string
|
||||||
|
italic: string
|
||||||
|
bulletList: string
|
||||||
|
orderedList: string
|
||||||
|
link: string
|
||||||
|
blockquote: string
|
||||||
|
insertTable: string
|
||||||
|
addRow: string
|
||||||
|
deleteRow: string
|
||||||
|
addColumn: string
|
||||||
|
deleteColumn: string
|
||||||
|
deleteTable: string
|
||||||
|
image: string
|
||||||
|
embed: string
|
||||||
|
rawHtml: string
|
||||||
|
undo: string
|
||||||
|
redo: string
|
||||||
|
linkUrl: string
|
||||||
|
linkApply: string
|
||||||
|
linkRemove: string
|
||||||
|
linkInvalid: string
|
||||||
|
embedUrl: string
|
||||||
|
embedInsert: string
|
||||||
|
embedInvalid: string
|
||||||
|
cancel: string
|
||||||
|
uploading: string
|
||||||
|
uploadFailed: string
|
||||||
|
uploadTimeout: string
|
||||||
|
notAnImage: string
|
||||||
|
altText: string
|
||||||
|
caption: string
|
||||||
|
altRequired: string
|
||||||
|
insertImage: string
|
||||||
|
rawHtmlCard: string
|
||||||
|
editAsHtml: string
|
||||||
|
embedCard: string
|
||||||
|
preview: string
|
||||||
|
previewFrame: string
|
||||||
|
heading1: string
|
||||||
|
outline: string
|
||||||
|
outlineShow: string
|
||||||
|
outlineHide: string
|
||||||
|
outlineCount: Plural
|
||||||
|
outlineProblems: Plural
|
||||||
|
outlineEmpty: string
|
||||||
|
flagSkipped: string
|
||||||
|
flagEmpty: string
|
||||||
|
flagLinkOnly: string
|
||||||
|
flagH1: string
|
||||||
|
outlineLinks: string
|
||||||
|
flagGeneric: string
|
||||||
|
linkRel: string
|
||||||
|
relNofollow: string
|
||||||
|
relSponsored: string
|
||||||
|
relUgc: string
|
||||||
|
relHint: string
|
||||||
|
genericWarning: string
|
||||||
|
fileName: string
|
||||||
|
fileNameHint: string
|
||||||
|
decorative: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The keys of the labels that are plain strings. */
|
||||||
|
export type TextLabel = { [K in keyof Labels]: Labels[K] extends string ? K : never }[keyof Labels]
|
||||||
|
|
||||||
|
const en: Labels = {
|
||||||
|
language: 'en',
|
||||||
|
tabWysiwyg: 'WYSIWYG',
|
||||||
|
tabSource: 'Source',
|
||||||
|
toolbar: 'Formatting',
|
||||||
|
paragraph: 'Paragraph',
|
||||||
|
heading2: 'Heading 2',
|
||||||
|
heading3: 'Heading 3',
|
||||||
|
heading4: 'Heading 4',
|
||||||
|
bold: 'Bold',
|
||||||
|
italic: 'Italic',
|
||||||
|
bulletList: 'Bullet list',
|
||||||
|
orderedList: 'Numbered list',
|
||||||
|
link: 'Link',
|
||||||
|
blockquote: 'Quote',
|
||||||
|
insertTable: 'Insert table',
|
||||||
|
addRow: 'Add row',
|
||||||
|
deleteRow: 'Delete row',
|
||||||
|
addColumn: 'Add column',
|
||||||
|
deleteColumn: 'Delete column',
|
||||||
|
deleteTable: 'Delete table',
|
||||||
|
image: 'Image',
|
||||||
|
embed: 'Embed video',
|
||||||
|
rawHtml: 'HTML block',
|
||||||
|
undo: 'Undo',
|
||||||
|
redo: 'Redo',
|
||||||
|
linkUrl: 'Link URL',
|
||||||
|
linkApply: 'Apply',
|
||||||
|
linkRemove: 'Remove link',
|
||||||
|
linkInvalid: 'Links must use http, https, mailto or a relative URL.',
|
||||||
|
embedUrl: 'YouTube URL',
|
||||||
|
embedInsert: 'Insert',
|
||||||
|
embedInvalid: 'This video URL is not supported. Paste a YouTube link.',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
uploading: 'Uploading {name}…',
|
||||||
|
uploadFailed: 'Upload failed: {error}',
|
||||||
|
uploadTimeout: 'The upload of {name} took too long.',
|
||||||
|
notAnImage: 'Only JPEG, PNG, GIF and WebP images can be uploaded.',
|
||||||
|
altText: 'Alt text (required)',
|
||||||
|
caption: 'Caption (optional)',
|
||||||
|
altRequired: 'Enter the alt text, or mark the image as decorative.',
|
||||||
|
insertImage: 'Insert image',
|
||||||
|
rawHtmlCard: 'HTML block',
|
||||||
|
editAsHtml: 'Edit as HTML',
|
||||||
|
embedCard: 'Embedded video',
|
||||||
|
preview: 'Preview',
|
||||||
|
previewFrame: 'Post preview',
|
||||||
|
heading1: 'Heading 1',
|
||||||
|
outline: 'Outline',
|
||||||
|
outlineShow: 'Show outline',
|
||||||
|
outlineHide: 'Hide outline',
|
||||||
|
outlineCount: { one: '{count} heading', other: '{count} headings' },
|
||||||
|
outlineProblems: { one: '{count} problem', other: '{count} problems' },
|
||||||
|
outlineEmpty: 'No headings yet. Headings split the text into sections for readers and search engines.',
|
||||||
|
flagSkipped: 'Skipped level: H{from} → H{to}',
|
||||||
|
flagEmpty: 'Empty heading',
|
||||||
|
flagLinkOnly: 'Heading is only a link',
|
||||||
|
flagH1: 'H1 in the text: saved as H2',
|
||||||
|
outlineLinks: 'Links',
|
||||||
|
flagGeneric: 'Link text “{text}” does not say where it leads',
|
||||||
|
linkRel: 'Link attributes',
|
||||||
|
relNofollow: 'nofollow',
|
||||||
|
relSponsored: 'sponsored (paid)',
|
||||||
|
relUgc: 'ugc (user content)',
|
||||||
|
relHint: 'Only for links to other sites. Links within the site never get these.',
|
||||||
|
genericWarning: '“{text}” does not say where the link leads. Use words that describe the target page.',
|
||||||
|
fileName: 'File name',
|
||||||
|
fileNameHint: 'Lowercase words joined with hyphens, e.g. lion-s-mane-in-the-forest.',
|
||||||
|
decorative: 'Decorative image (no alt text)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const pl: Labels = {
|
||||||
|
language: 'pl',
|
||||||
|
tabWysiwyg: 'WYSIWYG',
|
||||||
|
tabSource: 'Źródło',
|
||||||
|
toolbar: 'Formatowanie',
|
||||||
|
paragraph: 'Akapit',
|
||||||
|
heading2: 'Nagłówek 2',
|
||||||
|
heading3: 'Nagłówek 3',
|
||||||
|
heading4: 'Nagłówek 4',
|
||||||
|
bold: 'Pogrubienie',
|
||||||
|
italic: 'Kursywa',
|
||||||
|
bulletList: 'Lista punktowana',
|
||||||
|
orderedList: 'Lista numerowana',
|
||||||
|
link: 'Link',
|
||||||
|
blockquote: 'Cytat',
|
||||||
|
insertTable: 'Wstaw tabelę',
|
||||||
|
addRow: 'Dodaj wiersz',
|
||||||
|
deleteRow: 'Usuń wiersz',
|
||||||
|
addColumn: 'Dodaj kolumnę',
|
||||||
|
deleteColumn: 'Usuń kolumnę',
|
||||||
|
deleteTable: 'Usuń tabelę',
|
||||||
|
image: 'Obraz',
|
||||||
|
embed: 'Osadź wideo',
|
||||||
|
rawHtml: 'Blok HTML',
|
||||||
|
undo: 'Cofnij',
|
||||||
|
redo: 'Ponów',
|
||||||
|
linkUrl: 'Adres linku',
|
||||||
|
linkApply: 'Zastosuj',
|
||||||
|
linkRemove: 'Usuń link',
|
||||||
|
linkInvalid: 'Link musi używać http, https, mailto albo adresu względnego.',
|
||||||
|
embedUrl: 'Adres filmu YouTube',
|
||||||
|
embedInsert: 'Wstaw',
|
||||||
|
embedInvalid: 'Ten adres filmu nie jest obsługiwany. Wklej link do YouTube.',
|
||||||
|
cancel: 'Anuluj',
|
||||||
|
uploading: 'Wysyłanie {name}…',
|
||||||
|
uploadFailed: 'Wysyłanie nie powiodło się: {error}',
|
||||||
|
uploadTimeout: 'Wysyłanie {name} trwało zbyt długo.',
|
||||||
|
notAnImage: 'Można wysłać tylko obrazy JPEG, PNG, GIF i WebP.',
|
||||||
|
altText: 'Tekst alternatywny (wymagany)',
|
||||||
|
caption: 'Podpis (opcjonalny)',
|
||||||
|
altRequired: 'Wpisz tekst alternatywny lub oznacz obraz jako dekoracyjny.',
|
||||||
|
insertImage: 'Wstaw obraz',
|
||||||
|
rawHtmlCard: 'Blok HTML',
|
||||||
|
editAsHtml: 'Edytuj jako HTML',
|
||||||
|
embedCard: 'Osadzone wideo',
|
||||||
|
preview: 'Podgląd',
|
||||||
|
previewFrame: 'Podgląd wpisu',
|
||||||
|
heading1: 'Nagłówek 1',
|
||||||
|
outline: 'Konspekt',
|
||||||
|
outlineShow: 'Pokaż konspekt',
|
||||||
|
outlineHide: 'Ukryj konspekt',
|
||||||
|
outlineCount: { one: '{count} nagłówek', few: '{count} nagłówki', many: '{count} nagłówków', other: '{count} nagłówka' },
|
||||||
|
outlineProblems: { one: '{count} problem', few: '{count} problemy', many: '{count} problemów', other: '{count} problemu' },
|
||||||
|
outlineEmpty: 'Brak nagłówków. Nagłówki dzielą tekst na części dla czytelników i wyszukiwarek.',
|
||||||
|
flagSkipped: 'Pominięty poziom: H{from} → H{to}',
|
||||||
|
flagEmpty: 'Pusty nagłówek',
|
||||||
|
flagLinkOnly: 'Nagłówek jest tylko linkiem',
|
||||||
|
flagH1: 'H1 w tekście: zostanie zapisany jako H2',
|
||||||
|
outlineLinks: 'Linki',
|
||||||
|
flagGeneric: 'Tekst linku „{text}” nie mówi, dokąd prowadzi',
|
||||||
|
linkRel: 'Atrybuty linku',
|
||||||
|
relNofollow: 'nofollow',
|
||||||
|
relSponsored: 'sponsored (płatny)',
|
||||||
|
relUgc: 'ugc (od użytkowników)',
|
||||||
|
relHint: 'Tylko dla linków do innych witryn. Linki w obrębie witryny nigdy ich nie dostają.',
|
||||||
|
genericWarning: '„{text}” nie mówi, dokąd prowadzi link. Użyj słów opisujących stronę docelową.',
|
||||||
|
fileName: 'Nazwa pliku',
|
||||||
|
fileNameHint: 'Małe litery i łączniki, np. lwia-grzywa-w-lesie.',
|
||||||
|
decorative: 'Obraz dekoracyjny (bez tekstu alternatywnego)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABLES: Record<string, Labels> = { en, pl }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* labelsFor returns the labels for the admin UI locale (the element's
|
||||||
|
* ui-locale attribute): the table for its language subtag, else English.
|
||||||
|
*/
|
||||||
|
export function labelsFor(uiLocale: string | null | undefined): Labels {
|
||||||
|
const language = (uiLocale ?? '').toLowerCase().split(/[-_]/)[0] ?? ''
|
||||||
|
return TABLES[language] ?? en
|
||||||
|
}
|
||||||
|
|
||||||
|
/** format replaces {name} placeholders in a label. */
|
||||||
|
export function format(label: string, values: Record<string, string>): string {
|
||||||
|
return label.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* plural picks the CLDR form of a plural label for count in the table's
|
||||||
|
* language and fills {count} (and any other placeholder in values).
|
||||||
|
*/
|
||||||
|
export function plural(labels: Labels, forms: Plural, count: number, values: Record<string, string> = {}): string {
|
||||||
|
let rule: Intl.LDMLPluralRule = 'other'
|
||||||
|
try {
|
||||||
|
rule = new Intl.PluralRules(labels.language).select(count)
|
||||||
|
} catch {
|
||||||
|
// An engine without Intl.PluralRules falls back to the other form.
|
||||||
|
}
|
||||||
|
return format(forms[rule] ?? forms.other, { ...values, count: String(count) })
|
||||||
|
}
|
||||||
108
assets/src/blogeditor/linkform.ts
Normal file
108
assets/src/blogeditor/linkform.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { format, type Labels } from './labels'
|
||||||
|
import { alertIcon, isGenericAnchor } from './outline'
|
||||||
|
|
||||||
|
/** The rel tokens the link form writes and the server policy keeps (D-83). */
|
||||||
|
export const REL_TOKENS = ['nofollow', 'sponsored', 'ugc'] as const
|
||||||
|
|
||||||
|
export type RelToken = (typeof REL_TOKENS)[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* isExternalUrl reports whether url is an absolute http or https URL, the
|
||||||
|
* only links that may carry rel (D-80): links within the site never do.
|
||||||
|
*/
|
||||||
|
export function isExternalUrl(url: string): boolean {
|
||||||
|
const trimmed = url.trim()
|
||||||
|
if (!/^https?:\/\//i.test(trimmed)) return false
|
||||||
|
try {
|
||||||
|
return new URL(trimmed).host !== ''
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** relTokens reads the known rel tokens of a stored rel value, in REL_TOKENS order. */
|
||||||
|
export function relTokens(rel: unknown): RelToken[] {
|
||||||
|
if (typeof rel !== 'string') return []
|
||||||
|
const present = rel.toLowerCase().split(/\s+/)
|
||||||
|
return REL_TOKENS.filter((t) => present.includes(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The J3 additions to the link form. */
|
||||||
|
export interface LinkExtras {
|
||||||
|
/** The fieldset with the three rel checkboxes and the hint. */
|
||||||
|
fieldset: HTMLFieldSetElement
|
||||||
|
/** The generic-anchor warning line, placed above the buttons. */
|
||||||
|
warning: HTMLParagraphElement
|
||||||
|
/** Enables the checkboxes for an external URL, else disables them and shows the hint. */
|
||||||
|
setUrl: (url: string) => void
|
||||||
|
/** Checks the checkboxes of a stored rel value. */
|
||||||
|
setRel: (rel: unknown) => void
|
||||||
|
/** The rel value to write for url: the checked tokens of an external URL, else null. */
|
||||||
|
rel: (url: string) => string | null
|
||||||
|
/** Shows the warning when the link text is on the generic list, hides it otherwise. */
|
||||||
|
setText: (text: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildLinkExtras builds the J3 part of the link form: a fieldset labelled
|
||||||
|
* linkRel with nofollow, sponsored and ugc checkboxes, enabled only for an
|
||||||
|
* absolute http(s) URL (relHint otherwise), and the genericWarning line
|
||||||
|
* (aria-live polite) shown when the link text is on the generic list. The
|
||||||
|
* warning never blocks Apply (D-81).
|
||||||
|
*/
|
||||||
|
export function buildLinkExtras(labels: Labels, genericAnchors: readonly string[]): LinkExtras {
|
||||||
|
const fieldset = document.createElement('fieldset')
|
||||||
|
fieldset.className = 'blogeditor__rel'
|
||||||
|
const legend = document.createElement('legend')
|
||||||
|
legend.textContent = labels.linkRel
|
||||||
|
fieldset.append(legend)
|
||||||
|
const boxes = new Map<RelToken, HTMLInputElement>()
|
||||||
|
const names: Record<RelToken, string> = { nofollow: labels.relNofollow, sponsored: labels.relSponsored, ugc: labels.relUgc }
|
||||||
|
for (const token of REL_TOKENS) {
|
||||||
|
const label = document.createElement('label')
|
||||||
|
label.className = 'blogeditor__check'
|
||||||
|
const box = document.createElement('input')
|
||||||
|
box.type = 'checkbox'
|
||||||
|
box.name = `rel-${token}`
|
||||||
|
box.value = token
|
||||||
|
label.append(box, document.createTextNode(names[token]))
|
||||||
|
fieldset.append(label)
|
||||||
|
boxes.set(token, box)
|
||||||
|
}
|
||||||
|
const hint = document.createElement('p')
|
||||||
|
hint.className = 'blogeditor__hint'
|
||||||
|
hint.textContent = labels.relHint
|
||||||
|
fieldset.append(hint)
|
||||||
|
|
||||||
|
const warning = document.createElement('p')
|
||||||
|
warning.className = 'blogeditor__warning'
|
||||||
|
warning.dataset.warning = ''
|
||||||
|
warning.setAttribute('aria-live', 'polite')
|
||||||
|
warning.hidden = true
|
||||||
|
|
||||||
|
const setUrl = (url: string) => {
|
||||||
|
const external = isExternalUrl(url)
|
||||||
|
for (const box of boxes.values()) box.disabled = !external
|
||||||
|
hint.hidden = external
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fieldset,
|
||||||
|
warning,
|
||||||
|
setUrl,
|
||||||
|
setRel: (rel) => {
|
||||||
|
const tokens = relTokens(rel)
|
||||||
|
for (const [token, box] of boxes) box.checked = tokens.includes(token)
|
||||||
|
},
|
||||||
|
rel: (url) => {
|
||||||
|
if (!isExternalUrl(url)) return null
|
||||||
|
const checked = REL_TOKENS.filter((t) => boxes.get(t)!.checked)
|
||||||
|
return checked.length > 0 ? checked.join(' ') : null
|
||||||
|
},
|
||||||
|
setText: (text) => {
|
||||||
|
const generic = isGenericAnchor(text, genericAnchors)
|
||||||
|
warning.hidden = !generic
|
||||||
|
warning.replaceChildren(...(generic ? [alertIcon(), document.createTextNode(format(labels.genericWarning, { text: text.trim() }))] : []))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
302
assets/src/blogeditor/outline.ts
Normal file
302
assets/src/blogeditor/outline.ts
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import type { Editor } from '@tiptap/core'
|
||||||
|
import { format, plural, type Labels } from './labels'
|
||||||
|
|
||||||
|
/** How long after the last change the outline recomputes (J2). */
|
||||||
|
export const OUTLINE_DEBOUNCE_MS = 300
|
||||||
|
|
||||||
|
/** The text-free alert icon the outline flags carry (triangle-alert). */
|
||||||
|
const ALERT_PATHS = ['m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3', 'M12 9v4', 'M12 17h.01']
|
||||||
|
|
||||||
|
/** One heading of the outline, in document order. */
|
||||||
|
export interface OutlineHeading {
|
||||||
|
level: number
|
||||||
|
text: string
|
||||||
|
/** Every non-blank character of the heading sits inside a link. */
|
||||||
|
linkOnly: boolean
|
||||||
|
/** The heading's document position in the WYSIWYG tab, -1 in the Source tab. */
|
||||||
|
pos: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The outline facts: headings and the texts of the links. */
|
||||||
|
export interface OutlineFacts {
|
||||||
|
headings: OutlineHeading[]
|
||||||
|
links: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the outline reads from the element. */
|
||||||
|
export interface OutlineHost {
|
||||||
|
labels: Labels
|
||||||
|
editor: () => Editor | undefined
|
||||||
|
/** The Source tab's text while it defines the value, null otherwise. */
|
||||||
|
sourceText: () => string | null
|
||||||
|
/** Whether level 1 is offered (the H1 setting is off). */
|
||||||
|
allowsH1: boolean
|
||||||
|
/** The generic link texts, normalized (normalizeAnchor). */
|
||||||
|
genericAnchors: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Outline {
|
||||||
|
element: HTMLDivElement
|
||||||
|
/** Recomputes OUTLINE_DEBOUNCE_MS after the last call. */
|
||||||
|
schedule: () => void
|
||||||
|
/** Recomputes now. */
|
||||||
|
refresh: () => void
|
||||||
|
/** Stops a pending recompute. */
|
||||||
|
stop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* normalizeAnchor is a link text as the generic list compares it: trimmed,
|
||||||
|
* lower-cased, punctuation and symbols removed, white space collapsed.
|
||||||
|
*/
|
||||||
|
export function normalizeAnchor(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\p{P}\p{S}]+/gu, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** isGenericAnchor reports whether a link text is on the normalized generic list. */
|
||||||
|
export function isGenericAnchor(text: string, list: readonly string[]): boolean {
|
||||||
|
const n = normalizeAnchor(text)
|
||||||
|
return n !== '' && list.includes(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** factsFromEditor reads the headings and links of the editor document. */
|
||||||
|
export function factsFromEditor(editor: Editor): OutlineFacts {
|
||||||
|
const headings: OutlineHeading[] = []
|
||||||
|
const links: string[] = []
|
||||||
|
editor.state.doc.descendants((node, pos) => {
|
||||||
|
if (node.type.name === 'heading') {
|
||||||
|
let linked = 0
|
||||||
|
let all = 0
|
||||||
|
node.descendants((child) => {
|
||||||
|
if (!child.isText) return true
|
||||||
|
const chars = (child.text ?? '').replace(/\s+/g, '').length
|
||||||
|
all += chars
|
||||||
|
if (child.marks.some((m) => m.type.name === 'link')) linked += chars
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
headings.push({ level: Number(node.attrs.level) || 2, text: node.textContent.trim(), linkOnly: all > 0 && linked === all, pos })
|
||||||
|
}
|
||||||
|
if (node.isTextblock) {
|
||||||
|
let current = ''
|
||||||
|
let href: unknown = null
|
||||||
|
const flush = () => {
|
||||||
|
if (current.trim() !== '') links.push(current)
|
||||||
|
current = ''
|
||||||
|
href = null
|
||||||
|
}
|
||||||
|
node.forEach((child) => {
|
||||||
|
const link = child.isText ? child.marks.find((m) => m.type.name === 'link') : undefined
|
||||||
|
if (link && link.attrs.href === href) {
|
||||||
|
current += child.text ?? ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
if (link) {
|
||||||
|
current = child.text ?? ''
|
||||||
|
href = link.attrs.href
|
||||||
|
}
|
||||||
|
})
|
||||||
|
flush()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return { headings, links }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** factsFromSource reads the headings and links of HTML source text. */
|
||||||
|
export function factsFromSource(html: string): OutlineFacts {
|
||||||
|
const doc = document.implementation.createHTMLDocument('')
|
||||||
|
const host = doc.createElement('div')
|
||||||
|
host.innerHTML = html
|
||||||
|
const headings: OutlineHeading[] = []
|
||||||
|
for (const el of Array.from(host.querySelectorAll('h1, h2, h3, h4, h5, h6'))) {
|
||||||
|
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||||
|
const linkedText = Array.from(el.querySelectorAll('a'))
|
||||||
|
.map((a) => a.textContent ?? '')
|
||||||
|
.join('')
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
headings.push({ level: Number(el.tagName.slice(1)), text, linkOnly: text !== '' && linkedText.length === text.replace(/\s+/g, '').length, pos: -1 })
|
||||||
|
}
|
||||||
|
const links = Array.from(host.querySelectorAll('a'))
|
||||||
|
.map((a) => a.textContent ?? '')
|
||||||
|
.filter((t) => t.trim() !== '')
|
||||||
|
return { headings, links }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One heading's flags as label texts. */
|
||||||
|
export function headingFlags(facts: OutlineFacts, labels: Labels, opts: { allowsH1: boolean; source: boolean }): string[][] {
|
||||||
|
let previous = 1 // the page title is the H1
|
||||||
|
return facts.headings.map((h) => {
|
||||||
|
const flags: string[] = []
|
||||||
|
if (h.level > previous + 1) flags.push(format(labels.flagSkipped, { from: String(previous), to: String(h.level) }))
|
||||||
|
if (h.text === '') flags.push(labels.flagEmpty)
|
||||||
|
else if (h.linkOnly) flags.push(labels.flagLinkOnly)
|
||||||
|
if (h.level === 1 && !opts.allowsH1 && opts.source) flags.push(labels.flagH1)
|
||||||
|
previous = h.level
|
||||||
|
return flags
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alertIcon(): SVGSVGElement {
|
||||||
|
const ns = 'http://www.w3.org/2000/svg'
|
||||||
|
const svg = document.createElementNS(ns, 'svg')
|
||||||
|
svg.setAttribute('class', 'blogeditor__alert')
|
||||||
|
svg.setAttribute('viewBox', '0 0 24 24')
|
||||||
|
svg.setAttribute('width', '14')
|
||||||
|
svg.setAttribute('height', '14')
|
||||||
|
svg.setAttribute('fill', 'none')
|
||||||
|
svg.setAttribute('stroke', 'currentColor')
|
||||||
|
svg.setAttribute('stroke-width', '2')
|
||||||
|
svg.setAttribute('stroke-linecap', 'round')
|
||||||
|
svg.setAttribute('stroke-linejoin', 'round')
|
||||||
|
svg.setAttribute('aria-hidden', 'true')
|
||||||
|
for (const d of ALERT_PATHS) {
|
||||||
|
const path = document.createElementNS(ns, 'path')
|
||||||
|
path.setAttribute('d', d)
|
||||||
|
svg.append(path)
|
||||||
|
}
|
||||||
|
return svg
|
||||||
|
}
|
||||||
|
|
||||||
|
/** flagLine is one 13px muted flag with the alert icon. */
|
||||||
|
function flagLine(text: string): HTMLSpanElement {
|
||||||
|
const span = document.createElement('span')
|
||||||
|
span.className = 'blogeditor__outline-flag'
|
||||||
|
span.append(alertIcon(), document.createTextNode(text))
|
||||||
|
return span
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildOutline builds the J2 outline: a full-width toggle bar (collapsed by
|
||||||
|
* default, aria-expanded) with the heading count and, when there are any,
|
||||||
|
* the problem count, and a panel listing the headings in document order
|
||||||
|
* with their flags (skipped level, empty, only a link, an H1 in the Source
|
||||||
|
* tab while the H1 setting is on), then the links whose text is on the
|
||||||
|
* generic list. Activating a heading puts the caret in it. The outline
|
||||||
|
* reads the document and never edits it, and never takes focus by itself.
|
||||||
|
*/
|
||||||
|
export function buildOutline(host: OutlineHost): Outline {
|
||||||
|
const { labels } = host
|
||||||
|
const element = document.createElement('div')
|
||||||
|
element.className = 'blogeditor__outline'
|
||||||
|
|
||||||
|
const toggle = document.createElement('button')
|
||||||
|
toggle.type = 'button'
|
||||||
|
toggle.className = 'blogeditor__outline-toggle'
|
||||||
|
toggle.setAttribute('aria-expanded', 'false')
|
||||||
|
toggle.title = labels.outlineShow
|
||||||
|
const summary = document.createElement('span')
|
||||||
|
summary.className = 'blogeditor__outline-summary'
|
||||||
|
const problems = document.createElement('span')
|
||||||
|
problems.className = 'blogeditor__outline-problems'
|
||||||
|
problems.hidden = true
|
||||||
|
toggle.append(summary, problems)
|
||||||
|
|
||||||
|
const panel = document.createElement('div')
|
||||||
|
panel.className = 'blogeditor__outline-panel'
|
||||||
|
panel.hidden = true
|
||||||
|
panel.id = `blogeditor-outline-${Math.random().toString(36).slice(2)}`
|
||||||
|
toggle.setAttribute('aria-controls', panel.id)
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
const open = panel.hidden
|
||||||
|
panel.hidden = !open
|
||||||
|
toggle.setAttribute('aria-expanded', String(open))
|
||||||
|
toggle.title = open ? labels.outlineHide : labels.outlineShow
|
||||||
|
if (open) refresh()
|
||||||
|
})
|
||||||
|
element.append(toggle, panel)
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
const render = (facts: OutlineFacts, source: boolean) => {
|
||||||
|
const flags = headingFlags(facts, labels, { allowsH1: host.allowsH1, source })
|
||||||
|
const generic = facts.links.filter((t) => isGenericAnchor(t, host.genericAnchors))
|
||||||
|
const problemCount = flags.reduce((n, f) => n + f.length, 0) + generic.length
|
||||||
|
summary.textContent = `${labels.outline} · ${plural(labels, labels.outlineCount, facts.headings.length)}`
|
||||||
|
problems.hidden = problemCount === 0
|
||||||
|
problems.replaceChildren(...(problemCount > 0 ? [alertIcon(), document.createTextNode(plural(labels, labels.outlineProblems, problemCount))] : []))
|
||||||
|
if (panel.hidden) return
|
||||||
|
|
||||||
|
const parts: HTMLElement[] = []
|
||||||
|
if (facts.headings.length === 0) {
|
||||||
|
const empty = document.createElement('p')
|
||||||
|
empty.className = 'blogeditor__outline-empty'
|
||||||
|
empty.textContent = labels.outlineEmpty
|
||||||
|
parts.push(empty)
|
||||||
|
} else {
|
||||||
|
const base = host.allowsH1 ? 1 : 2
|
||||||
|
const list = document.createElement('ol')
|
||||||
|
list.className = 'blogeditor__outline-list'
|
||||||
|
facts.headings.forEach((h, i) => {
|
||||||
|
const item = document.createElement('li')
|
||||||
|
item.className = 'blogeditor__outline-item'
|
||||||
|
item.style.setProperty('--blogeditor-outline-depth', String(Math.max(0, h.level - base)))
|
||||||
|
const target = document.createElement('button')
|
||||||
|
target.type = 'button'
|
||||||
|
target.className = 'blogeditor__outline-target'
|
||||||
|
const tag = document.createElement('span')
|
||||||
|
tag.className = 'blogeditor__outline-level'
|
||||||
|
tag.textContent = `H${h.level}`
|
||||||
|
const text = document.createElement('span')
|
||||||
|
text.className = h.text === '' ? 'blogeditor__outline-text blogeditor__outline-text--empty' : 'blogeditor__outline-text'
|
||||||
|
text.textContent = h.text === '' ? labels.flagEmpty : h.text
|
||||||
|
target.append(tag, text)
|
||||||
|
target.addEventListener('click', () => focusHeading(h))
|
||||||
|
item.append(target)
|
||||||
|
const shown = h.text === '' ? flags[i]!.filter((f) => f !== labels.flagEmpty) : flags[i]!
|
||||||
|
if (shown.length > 0) {
|
||||||
|
const line = document.createElement('span')
|
||||||
|
line.className = 'blogeditor__outline-flags'
|
||||||
|
line.append(...shown.map(flagLine))
|
||||||
|
item.append(line)
|
||||||
|
}
|
||||||
|
list.append(item)
|
||||||
|
})
|
||||||
|
parts.push(list)
|
||||||
|
}
|
||||||
|
if (generic.length > 0) {
|
||||||
|
const links = document.createElement('div')
|
||||||
|
links.className = 'blogeditor__outline-links'
|
||||||
|
const title = document.createElement('p')
|
||||||
|
title.className = 'blogeditor__outline-heading'
|
||||||
|
title.textContent = labels.outlineLinks
|
||||||
|
links.append(title, ...generic.map((t) => flagLine(format(labels.flagGeneric, { text: t.trim() }))))
|
||||||
|
parts.push(links)
|
||||||
|
}
|
||||||
|
panel.replaceChildren(...parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusHeading = (h: OutlineHeading) => {
|
||||||
|
const editor = host.editor()
|
||||||
|
if (!editor || h.pos < 0 || host.sourceText() !== null) return
|
||||||
|
editor.chain().focus().setTextSelection(h.pos + 1).scrollIntoView().run()
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
const source = host.sourceText()
|
||||||
|
const editor = host.editor()
|
||||||
|
if (source !== null) render(factsFromSource(source), true)
|
||||||
|
else if (editor) render(factsFromEditor(editor), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
schedule: () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(refresh, OUTLINE_DEBOUNCE_MS)
|
||||||
|
},
|
||||||
|
refresh,
|
||||||
|
stop: () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
197
assets/src/blogeditor/preview.ts
Normal file
197
assets/src/blogeditor/preview.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { EMBED_PROVIDERS, isEmbedProvider } from './embed'
|
||||||
|
import { inert } from './extensions'
|
||||||
|
import type { Labels } from './labels'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preview pane's iframe sandbox attribute: an empty token list, the most
|
||||||
|
* restrictive sandbox. Pasted or imported HTML runs no script, the frame
|
||||||
|
* gets an opaque origin (it cannot read the admin's cookies, storage or DOM)
|
||||||
|
* and it cannot submit forms, open popups or navigate the top window.
|
||||||
|
*/
|
||||||
|
export const PREVIEW_SANDBOX = ''
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Content-Security-Policy the preview document declares on top of the
|
||||||
|
* admin page's policy it inherits: only inline styles and images load.
|
||||||
|
*/
|
||||||
|
export const PREVIEW_CSP = "default-src 'none'; img-src * data:; style-src 'unsafe-inline'"
|
||||||
|
|
||||||
|
/** How long the preview waits after the last change before it re-renders. */
|
||||||
|
export const PREVIEW_DEBOUNCE_MS = 300
|
||||||
|
|
||||||
|
/** The colours the preview stylesheet uses, as concrete CSS values. */
|
||||||
|
export interface PreviewColors {
|
||||||
|
surface: string
|
||||||
|
text: string
|
||||||
|
muted: string
|
||||||
|
border: string
|
||||||
|
borderStrong: string
|
||||||
|
subtle: string
|
||||||
|
primary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neutral light-theme values for anything the host does not provide. */
|
||||||
|
export const PREVIEW_DEFAULTS: { colors: PreviewColors; fontFamily: string } = {
|
||||||
|
colors: {
|
||||||
|
surface: '#ffffff',
|
||||||
|
text: '#1a1a1a',
|
||||||
|
muted: '#6b7280',
|
||||||
|
border: '#e5e7eb',
|
||||||
|
borderStrong: '#9ca3af',
|
||||||
|
subtle: '#f6f7f9',
|
||||||
|
primary: '#2563eb',
|
||||||
|
},
|
||||||
|
fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewOptions {
|
||||||
|
labels: Labels
|
||||||
|
lang?: string | null
|
||||||
|
colors?: PreviewColors
|
||||||
|
fontFamily?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The host variable each preview colour is read from. */
|
||||||
|
const COLOR_VARIABLES: Record<keyof PreviewColors, string> = {
|
||||||
|
surface: '--c-surface',
|
||||||
|
text: '--c-text',
|
||||||
|
muted: '--c-muted',
|
||||||
|
border: '--c-border',
|
||||||
|
borderStrong: '--c-border-strong',
|
||||||
|
subtle: '--c-subtle',
|
||||||
|
primary: '--c-primary',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The active elements the preview copy leaves out. */
|
||||||
|
const DROPPED_ELEMENTS = 'script, iframe, frame, frameset, object, embed, meta, base, link'
|
||||||
|
|
||||||
|
const LANG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* safeCssValue returns value trimmed, or fallback when it is empty, longer
|
||||||
|
* than 200 characters or holds a character that could close the style
|
||||||
|
* element or the declaration (angle or curly brackets, a semicolon, a
|
||||||
|
* backslash or a comment opener). A host value can then never inject a rule.
|
||||||
|
*/
|
||||||
|
export function safeCssValue(value: string | null | undefined, fallback: string): string {
|
||||||
|
const v = (value ?? '').trim()
|
||||||
|
if (v === '' || v.length > 200 || /[<>{};\\]/.test(v) || v.includes('/*')) return fallback
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolvePreviewColors reads the host's --c-* variables and font family as
|
||||||
|
* computed values. CSS variables do not cross into the srcdoc document, so
|
||||||
|
* the preview stylesheet carries the concrete values.
|
||||||
|
*/
|
||||||
|
export function resolvePreviewColors(host: Element): { colors: PreviewColors; fontFamily: string } {
|
||||||
|
const style = getComputedStyle(host)
|
||||||
|
const colors = { ...PREVIEW_DEFAULTS.colors }
|
||||||
|
for (const key of Object.keys(COLOR_VARIABLES) as (keyof PreviewColors)[]) {
|
||||||
|
colors[key] = safeCssValue(style.getPropertyValue(COLOR_VARIABLES[key]), PREVIEW_DEFAULTS.colors[key])
|
||||||
|
}
|
||||||
|
return { colors, fontFamily: safeCssValue(style.fontFamily, PREVIEW_DEFAULTS.fontFamily) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* youtubeThumbnail returns the thumbnail URL for a YouTube id. Call it only
|
||||||
|
* with an id that passed the provider's idPattern.
|
||||||
|
*/
|
||||||
|
export function youtubeThumbnail(id: string): string {
|
||||||
|
return `https://img.youtube.com/vi/${id}/hqdefault.jpg`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* previewBody returns the HTML the preview shows. It works on a copy parsed
|
||||||
|
* in the inert document: active elements and on* attributes are left out
|
||||||
|
* (defense in depth on top of the sandbox) and each embed placeholder
|
||||||
|
* becomes a thumbnail card, never a player. The stored value never changes.
|
||||||
|
*/
|
||||||
|
export function previewBody(html: string, labels: Labels): string {
|
||||||
|
const doc = inert()
|
||||||
|
const template = doc.createElement('template')
|
||||||
|
template.innerHTML = html
|
||||||
|
const root = template.content
|
||||||
|
|
||||||
|
for (const el of Array.from(root.querySelectorAll(DROPPED_ELEMENTS))) el.remove()
|
||||||
|
for (const el of Array.from(root.querySelectorAll('*'))) {
|
||||||
|
for (const attr of Array.from(el.attributes)) {
|
||||||
|
if (attr.name.toLowerCase().startsWith('on')) el.removeAttribute(attr.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const figure of Array.from(root.querySelectorAll('figure[data-embed]'))) {
|
||||||
|
const provider = figure.getAttribute('data-embed') ?? ''
|
||||||
|
const id = figure.getAttribute('data-id') ?? ''
|
||||||
|
// Exactly the editor's placeholder, the same test as the Embed node.
|
||||||
|
if (figure.attributes.length !== 2 || figure.childNodes.length !== 0) continue
|
||||||
|
if (!isEmbedProvider(provider) || !EMBED_PROVIDERS[provider].idPattern.test(id)) continue
|
||||||
|
const card = doc.createElement('figure')
|
||||||
|
card.className = 'embed-card'
|
||||||
|
const img = doc.createElement('img')
|
||||||
|
img.setAttribute('src', youtubeThumbnail(id))
|
||||||
|
img.setAttribute('alt', '')
|
||||||
|
img.setAttribute('referrerpolicy', 'no-referrer')
|
||||||
|
img.setAttribute('loading', 'lazy')
|
||||||
|
img.setAttribute('width', '480')
|
||||||
|
img.setAttribute('height', '360')
|
||||||
|
const caption = doc.createElement('figcaption')
|
||||||
|
caption.textContent = `${labels.embedCard}: ${EMBED_PROVIDERS[provider].name} ${id}`
|
||||||
|
card.append(img, caption)
|
||||||
|
figure.replaceWith(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = doc.createElement('div')
|
||||||
|
out.append(root)
|
||||||
|
return out.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
/** previewStyles returns the post-body typography with concrete values. */
|
||||||
|
function previewStyles(c: PreviewColors, font: string): string {
|
||||||
|
const mono = '"DM Mono", ui-monospace, SFMono-Regular, Menlo, monospace'
|
||||||
|
return [
|
||||||
|
`body{margin:0;padding:16px 20px;background:${c.surface};color:${c.text};font-family:${font};font-size:16px;line-height:1.7;overflow-wrap:anywhere}`,
|
||||||
|
'.post-body{max-width:72ch;margin-left:auto;margin-right:auto}',
|
||||||
|
'h1,h2,h3,h4{font-weight:600;margin:1.4em 0 0.5em}',
|
||||||
|
'h1{font-size:1.875em}h2{font-size:1.5em}h3{font-size:1.25em}h4{font-size:1.1em}',
|
||||||
|
'p,ul,ol,blockquote,table,figure,pre{margin:0 0 1em}',
|
||||||
|
'ul{list-style:disc;padding-left:1.5em}ol{list-style:decimal;padding-left:1.5em}',
|
||||||
|
`a{color:${c.primary};text-decoration:underline}`,
|
||||||
|
`blockquote{border-left:3px solid ${c.borderStrong};padding-left:1em;color:${c.muted}}`,
|
||||||
|
`code{padding:0 4px;border-radius:4px;background:${c.subtle};font-family:${mono};font-size:0.9em}`,
|
||||||
|
`pre{padding:8px 12px;border-radius:8px;background:${c.subtle};font-family:${mono};overflow:auto}`,
|
||||||
|
'pre code{padding:0;background:transparent}',
|
||||||
|
`hr{border:0;border-top:1px solid ${c.borderStrong}}`,
|
||||||
|
'img{max-width:100%;height:auto}',
|
||||||
|
'table{border-collapse:collapse;width:100%}',
|
||||||
|
`td,th{border:1px solid ${c.borderStrong};padding:4px 8px;vertical-align:top}`,
|
||||||
|
`th{background:${c.subtle};font-weight:600;text-align:left}`,
|
||||||
|
`figcaption{font-size:13px;color:${c.muted};margin-top:4px}`,
|
||||||
|
`.embed-card{max-width:480px;border:1px solid ${c.border};border-radius:8px;overflow:hidden;background:${c.subtle}}`,
|
||||||
|
'.embed-card img{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:cover}',
|
||||||
|
'.embed-card figcaption{padding:8px 12px;margin:0}',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildPreviewDocument returns the srcdoc for the preview frame: a full
|
||||||
|
* document whose head starts with the charset, the CSP and the no-referrer
|
||||||
|
* metas, then one stylesheet with the post-body typography, and whose body
|
||||||
|
* wraps previewBody(html) in article.post-body. CSS variables do not cross
|
||||||
|
* into the frame, so the stylesheet holds concrete colours.
|
||||||
|
*/
|
||||||
|
export function buildPreviewDocument(html: string, options: PreviewOptions): string {
|
||||||
|
const lang = options.lang && LANG.test(options.lang) ? ` lang="${options.lang}"` : ''
|
||||||
|
const colors = { ...PREVIEW_DEFAULTS.colors, ...options.colors }
|
||||||
|
const font = options.fontFamily || PREVIEW_DEFAULTS.fontFamily
|
||||||
|
return (
|
||||||
|
`<!doctype html><html${lang}><head>` +
|
||||||
|
'<meta charset="utf-8">' +
|
||||||
|
`<meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}">` +
|
||||||
|
'<meta name="referrer" content="no-referrer">' +
|
||||||
|
`<style>${previewStyles(colors, font)}</style>` +
|
||||||
|
'</head><body>' +
|
||||||
|
`<article class="post-body">${previewBody(html, options.labels)}</article>` +
|
||||||
|
'</body></html>'
|
||||||
|
)
|
||||||
|
}
|
||||||
305
assets/src/blogeditor/serializer.ts
Normal file
305
assets/src/blogeditor/serializer.ts
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import { elementFromString, type Editor } from '@tiptap/core'
|
||||||
|
import { DOMParser as PMDOMParser, DOMSerializer, type Node as PMNode, type Schema } from '@tiptap/pm/model'
|
||||||
|
import { inert } from './extensions'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One top-level piece of the original HTML: its exact source text and the
|
||||||
|
* nodes it parses to on its own. Whitespace and comments parse to no node.
|
||||||
|
*/
|
||||||
|
interface Segment {
|
||||||
|
html: string
|
||||||
|
nodes: PMNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks is the original HTML split into top-level blocks (RESEARCH A3).
|
||||||
|
* Each block keeps its exact source and the text between it and the
|
||||||
|
* previous block, so unedited blocks keep their bytes after an edit.
|
||||||
|
*/
|
||||||
|
export interface Blocks {
|
||||||
|
blocks: { before: string; html: string; nodes: PMNode[] }[]
|
||||||
|
trailing: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'])
|
||||||
|
const RAW_TEXT_TAGS = new Set(['script', 'style', 'textarea', 'title', 'xmp', 'iframe', 'noembed', 'noframes'])
|
||||||
|
const TAG_NAME = /^<\/?([a-zA-Z][^\s/>]*)/
|
||||||
|
|
||||||
|
/** tagEnd returns the index after the '>' closing the tag at start, or -1. */
|
||||||
|
function tagEnd(html: string, start: number): number {
|
||||||
|
let quote = ''
|
||||||
|
let prev = ''
|
||||||
|
for (let i = start + 1; i < html.length; i++) {
|
||||||
|
const c = html[i]!
|
||||||
|
if (quote) {
|
||||||
|
if (c === quote) quote = ''
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ((c === '"' || c === "'") && prev === '=') {
|
||||||
|
quote = c
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (c === '>') return i + 1
|
||||||
|
if (!/\s/.test(c)) prev = c
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** rawTextEnd returns the index after the end tag of a raw text element, or -1. */
|
||||||
|
function rawTextEnd(html: string, from: number, name: string): number {
|
||||||
|
const close = new RegExp(`</${name}(?=[\\s/>])`, 'ig')
|
||||||
|
close.lastIndex = from
|
||||||
|
const match = close.exec(html)
|
||||||
|
return match ? tagEnd(html, match.index) : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** elementEnd returns the index after the element whose start tag ends at from. */
|
||||||
|
function elementEnd(html: string, from: number, name: string): number {
|
||||||
|
const stack = [name]
|
||||||
|
let i = from
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const lt = html.indexOf('<', i)
|
||||||
|
if (lt < 0) return -1
|
||||||
|
if (html.startsWith('<!--', lt)) {
|
||||||
|
const end = html.indexOf('-->', lt + 4)
|
||||||
|
if (end < 0) return -1
|
||||||
|
i = end + 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const match = TAG_NAME.exec(html.slice(lt, lt + 64))
|
||||||
|
if (!match) {
|
||||||
|
i = lt + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const tag = match[1]!.toLowerCase()
|
||||||
|
const end = tagEnd(html, lt)
|
||||||
|
if (end < 0) return -1
|
||||||
|
i = end
|
||||||
|
if (html[lt + 1] === '/') {
|
||||||
|
const at = stack.lastIndexOf(tag)
|
||||||
|
if (at >= 0) stack.length = at
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (VOID_TAGS.has(tag) || html[end - 2] === '/') continue
|
||||||
|
if (RAW_TEXT_TAGS.has(tag)) {
|
||||||
|
i = rawTextEnd(html, end, tag)
|
||||||
|
if (i < 0) return -1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stack.push(tag)
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* splitTopLevel splits html into its top-level pieces (elements, text runs,
|
||||||
|
* comments) by source position, or returns null when it cannot.
|
||||||
|
*/
|
||||||
|
function splitTopLevel(html: string): string[] | null {
|
||||||
|
const pieces: string[] = []
|
||||||
|
let i = 0
|
||||||
|
while (i < html.length) {
|
||||||
|
const start = i
|
||||||
|
if (html[i] !== '<') {
|
||||||
|
const lt = html.indexOf('<', i)
|
||||||
|
i = lt < 0 ? html.length : lt
|
||||||
|
} else if (html.startsWith('<!--', i)) {
|
||||||
|
const end = html.indexOf('-->', i + 4)
|
||||||
|
if (end < 0) return null
|
||||||
|
i = end + 3
|
||||||
|
} else if (html.startsWith('<!', i) || html.startsWith('<?', i) || html.startsWith('</', i)) {
|
||||||
|
const end = tagEnd(html, i)
|
||||||
|
if (end < 0) return null
|
||||||
|
i = end
|
||||||
|
} else {
|
||||||
|
const match = TAG_NAME.exec(html.slice(i, i + 64))
|
||||||
|
if (!match) {
|
||||||
|
const lt = html.indexOf('<', i + 1)
|
||||||
|
i = lt < 0 ? html.length : lt
|
||||||
|
} else {
|
||||||
|
const tag = match[1]!.toLowerCase()
|
||||||
|
const end = tagEnd(html, i)
|
||||||
|
if (end < 0) return null
|
||||||
|
if (VOID_TAGS.has(tag) || html[end - 2] === '/') i = end
|
||||||
|
else if (RAW_TEXT_TAGS.has(tag)) i = rawTextEnd(html, end, tag)
|
||||||
|
else i = elementEnd(html, end, tag)
|
||||||
|
if (i < 0) return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pieces.push(html.slice(start, i))
|
||||||
|
}
|
||||||
|
return pieces
|
||||||
|
}
|
||||||
|
|
||||||
|
/** isGap reports whether a piece parses to no block: whitespace or a comment. */
|
||||||
|
function isGap(piece: string): boolean {
|
||||||
|
return /^\s*$/.test(piece) || (piece.startsWith('<!--') && piece.endsWith('-->'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* captureBlocks splits the original HTML into top-level blocks and parses
|
||||||
|
* each alone, recording its nodes, its exact outer HTML and the text
|
||||||
|
* between blocks. It returns null when the pieces do not parse to the same
|
||||||
|
* DOM as the whole (then every edited document is serialized whole).
|
||||||
|
*/
|
||||||
|
export function captureBlocks(html: string, schema: Schema): Blocks | null {
|
||||||
|
const pieces = splitTopLevel(html)
|
||||||
|
if (!pieces) return null
|
||||||
|
const whole = elementFromString(html).innerHTML
|
||||||
|
const joined = pieces.map((piece) => elementFromString(piece).innerHTML).join('')
|
||||||
|
if (whole !== joined) return null
|
||||||
|
|
||||||
|
const parser = PMDOMParser.fromSchema(schema)
|
||||||
|
const segments: Segment[] = pieces.map((piece) => {
|
||||||
|
if (isGap(piece)) return { html: piece, nodes: [] }
|
||||||
|
const nodes: PMNode[] = []
|
||||||
|
parser.parseSlice(elementFromString(piece)).content.forEach((node) => nodes.push(node))
|
||||||
|
return { html: piece, nodes }
|
||||||
|
})
|
||||||
|
|
||||||
|
const blocks: Blocks['blocks'] = []
|
||||||
|
let pending = ''
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (segment.nodes.length === 0) {
|
||||||
|
pending += segment.html
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
blocks.push({ before: pending, html: segment.html, nodes: segment.nodes })
|
||||||
|
pending = ''
|
||||||
|
}
|
||||||
|
return { blocks, trailing: pending }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tidyTables applies the stored table form to serialized tables. */
|
||||||
|
function tidyTables(root: Element): void {
|
||||||
|
for (const table of Array.from(root.querySelectorAll('table'))) {
|
||||||
|
const tbody = Array.from(table.children).find((child) => child.nodeName === 'TBODY')
|
||||||
|
const first = tbody?.firstElementChild
|
||||||
|
// A leading row of header cells goes into thead.
|
||||||
|
if (tbody && first && first.children.length > 0 && Array.from(first.children).every((cell) => cell.nodeName === 'TH')) {
|
||||||
|
const thead = table.ownerDocument.createElement('thead')
|
||||||
|
thead.append(first)
|
||||||
|
table.insertBefore(thead, tbody)
|
||||||
|
if (tbody.children.length === 0) tbody.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A cell holding one plain paragraph holds its text directly.
|
||||||
|
for (const cell of Array.from(root.querySelectorAll('td, th'))) {
|
||||||
|
const only = cell.childNodes.length === 1 ? cell.firstChild : null
|
||||||
|
if (only && only.nodeName === 'P' && (only as Element).attributes.length === 0) {
|
||||||
|
cell.replaceChildren(...Array.from(only.childNodes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** serializeNode returns the HTML of one top-level node. */
|
||||||
|
function serializeNode(node: PMNode, serializer: DOMSerializer): string {
|
||||||
|
if (node.type.name === 'rawHtml') return String(node.attrs.html ?? '')
|
||||||
|
const doc = inert()
|
||||||
|
const host = doc.createElement('div')
|
||||||
|
host.appendChild(serializer.serializeNode(node, { document: doc }))
|
||||||
|
tidyTables(host)
|
||||||
|
return host.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameNodes(a: readonly PMNode[], b: readonly PMNode[], from: number): boolean {
|
||||||
|
if (from + a.length > b.length) return false
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (!a[i]!.eq(b[from + i]!)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* align matches captured blocks to the document's top-level nodes in order
|
||||||
|
* (longest common subsequence by node count) and returns the pairs.
|
||||||
|
*/
|
||||||
|
function align(blocks: Blocks['blocks'], nodes: readonly PMNode[]): { block: number; node: number }[] {
|
||||||
|
const pairs: { block: number; node: number }[] = []
|
||||||
|
// Unchanged prefix and suffix first, so a single edit costs no table.
|
||||||
|
let lo = 0
|
||||||
|
let at = 0
|
||||||
|
while (lo < blocks.length && sameNodes(blocks[lo]!.nodes, nodes, at)) {
|
||||||
|
pairs.push({ block: lo, node: at })
|
||||||
|
at += blocks[lo]!.nodes.length
|
||||||
|
lo++
|
||||||
|
}
|
||||||
|
const tail: { block: number; node: number }[] = []
|
||||||
|
let hi = blocks.length
|
||||||
|
let end = nodes.length
|
||||||
|
while (hi > lo && end - blocks[hi - 1]!.nodes.length >= at && sameNodes(blocks[hi - 1]!.nodes, nodes, end - blocks[hi - 1]!.nodes.length)) {
|
||||||
|
hi--
|
||||||
|
end -= blocks[hi]!.nodes.length
|
||||||
|
tail.unshift({ block: hi, node: end })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = hi - lo
|
||||||
|
const cols = end - at
|
||||||
|
if (rows > 0 && cols > 0) {
|
||||||
|
const width = cols + 1
|
||||||
|
const score = new Int32Array((rows + 1) * width)
|
||||||
|
const matches = (b: number, n: number) => sameNodes(blocks[lo + b]!.nodes, nodes, at + n) && at + n + blocks[lo + b]!.nodes.length <= end
|
||||||
|
for (let b = rows - 1; b >= 0; b--) {
|
||||||
|
for (let n = cols - 1; n >= 0; n--) {
|
||||||
|
let best = Math.max(score[(b + 1) * width + n]!, score[b * width + n + 1]!)
|
||||||
|
if (matches(b, n)) {
|
||||||
|
const size = blocks[lo + b]!.nodes.length
|
||||||
|
best = Math.max(best, size + score[(b + 1) * width + n + size]!)
|
||||||
|
}
|
||||||
|
score[b * width + n] = best
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let b = 0
|
||||||
|
let n = 0
|
||||||
|
while (b < rows && n < cols) {
|
||||||
|
const size = blocks[lo + b]!.nodes.length
|
||||||
|
if (matches(b, n) && score[b * width + n] === size + score[(b + 1) * width + n + size]) {
|
||||||
|
pairs.push({ block: lo + b, node: at + n })
|
||||||
|
b++
|
||||||
|
n += size
|
||||||
|
} else if (score[(b + 1) * width + n]! >= score[b * width + n + 1]!) {
|
||||||
|
b++
|
||||||
|
} else {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pairs.concat(tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* serializeDocument returns the HTML of the edited document (D-03). Runs of
|
||||||
|
* top-level nodes still equal (Node.eq) to the captured blocks are emitted
|
||||||
|
* as their original source; raw HTML blocks are emitted verbatim; the rest
|
||||||
|
* is serialized with the schema, tables in their stored form. It is the
|
||||||
|
* element's value after a user edit; editor.getHTML() never is.
|
||||||
|
*/
|
||||||
|
export function serializeDocument(editor: Editor, blocks: Blocks | null): string {
|
||||||
|
const serializer = DOMSerializer.fromSchema(editor.schema)
|
||||||
|
const nodes: PMNode[] = []
|
||||||
|
editor.state.doc.forEach((node) => nodes.push(node))
|
||||||
|
const captured = blocks?.blocks ?? []
|
||||||
|
const pairs = align(captured, nodes)
|
||||||
|
|
||||||
|
let out = ''
|
||||||
|
let nextBlock = 0
|
||||||
|
let nextNode = 0
|
||||||
|
const emitGap = (block: number, node: number) => {
|
||||||
|
if (node > nextNode) {
|
||||||
|
// Replaced or inserted nodes keep the separator of the first block
|
||||||
|
// they replace.
|
||||||
|
if (block > nextBlock) out += captured[nextBlock]!.before
|
||||||
|
for (let i = nextNode; i < node; i++) out += serializeNode(nodes[i]!, serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const pair of pairs) {
|
||||||
|
emitGap(pair.block, pair.node)
|
||||||
|
const block = captured[pair.block]!
|
||||||
|
out += block.before + block.html
|
||||||
|
nextBlock = pair.block + 1
|
||||||
|
nextNode = pair.node + block.nodes.length
|
||||||
|
}
|
||||||
|
emitGap(captured.length, nodes.length)
|
||||||
|
if (blocks && (pairs.length > 0 || nodes.length === 0)) out += blocks.trailing
|
||||||
|
return out
|
||||||
|
}
|
||||||
209
assets/src/blogeditor/toolbar.ts
Normal file
209
assets/src/blogeditor/toolbar.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import type { Editor } from '@tiptap/core'
|
||||||
|
import type { Labels, TextLabel } from './labels'
|
||||||
|
|
||||||
|
/** What the toolbar needs from the element. */
|
||||||
|
export interface ToolbarHost {
|
||||||
|
editor: () => Editor | undefined
|
||||||
|
labels: Labels
|
||||||
|
openLink: () => void
|
||||||
|
openEmbed: () => void
|
||||||
|
pickImage: () => void
|
||||||
|
insertRawHtml: () => void
|
||||||
|
showSource: (on: boolean) => void
|
||||||
|
/** Switches the preview pane on or off. */
|
||||||
|
togglePreview: () => void
|
||||||
|
/**
|
||||||
|
* The heading levels the toolbar offers (the element's headingLevels
|
||||||
|
* option): 2 to 4, with 1 first when the H1 setting is off (J1).
|
||||||
|
*/
|
||||||
|
headingLevels: readonly number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Toolbar {
|
||||||
|
element: HTMLDivElement
|
||||||
|
/** Updates pressed and enabled states from the editor. */
|
||||||
|
refresh: () => void
|
||||||
|
/** Marks the selected tab. */
|
||||||
|
selectTab: (source: boolean) => void
|
||||||
|
/** Disables every control (the element's disabled attribute). */
|
||||||
|
setDisabled: (disabled: boolean) => void
|
||||||
|
/** Marks the preview toggle as pressed or not. */
|
||||||
|
setPreview: (on: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ButtonSpec {
|
||||||
|
command: string
|
||||||
|
glyph: string
|
||||||
|
label: TextLabel
|
||||||
|
run: (editor: Editor, host: ToolbarHost) => void
|
||||||
|
active?: (editor: Editor) => boolean
|
||||||
|
enabled?: (editor: Editor) => boolean
|
||||||
|
/** Runs without the editor (opens a form or the file picker). */
|
||||||
|
host?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const inTable = (editor: Editor) => editor.isActive('table')
|
||||||
|
|
||||||
|
type Level = 1 | 2 | 3 | 4
|
||||||
|
|
||||||
|
/** headingButton is the toolbar button of one heading level. */
|
||||||
|
function headingButton(level: Level): ButtonSpec {
|
||||||
|
return {
|
||||||
|
command: `heading${level}`,
|
||||||
|
glyph: `H${level}`,
|
||||||
|
label: `heading${level}` as TextLabel,
|
||||||
|
run: (e) => e.chain().focus().toggleHeading({ level }).run(),
|
||||||
|
active: (e) => e.isActive('heading', { level }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** headingLevelOf is the level of a heading button's command, 0 otherwise. */
|
||||||
|
function headingLevelOf(command: string): number {
|
||||||
|
const m = /^heading([1-6])$/.exec(command)
|
||||||
|
return m ? Number(m[1]) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUPS: ButtonSpec[][] = [
|
||||||
|
[
|
||||||
|
{ command: 'paragraph', glyph: 'P', label: 'paragraph', run: (e) => e.chain().focus().setParagraph().run(), active: (e) => e.isActive('paragraph') },
|
||||||
|
headingButton(1),
|
||||||
|
headingButton(2),
|
||||||
|
headingButton(3),
|
||||||
|
headingButton(4),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ command: 'bold', glyph: 'B', label: 'bold', run: (e) => e.chain().focus().toggleBold().run(), active: (e) => e.isActive('bold') },
|
||||||
|
{ command: 'italic', glyph: 'I', label: 'italic', run: (e) => e.chain().focus().toggleItalic().run(), active: (e) => e.isActive('italic') },
|
||||||
|
{ command: 'link', glyph: 'Link', label: 'link', run: (_e, h) => h.openLink(), active: (e) => e.isActive('link'), host: true },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ command: 'bulletList', glyph: '•', label: 'bulletList', run: (e) => e.chain().focus().toggleBulletList().run(), active: (e) => e.isActive('bulletList') },
|
||||||
|
{ command: 'orderedList', glyph: '1.', label: 'orderedList', run: (e) => e.chain().focus().toggleOrderedList().run(), active: (e) => e.isActive('orderedList') },
|
||||||
|
{ command: 'blockquote', glyph: '“”', label: 'blockquote', run: (e) => e.chain().focus().toggleBlockquote().run(), active: (e) => e.isActive('blockquote') },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ command: 'insertTable', glyph: 'Table', label: 'insertTable', run: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||||
|
{ command: 'addRow', glyph: '+Row', label: 'addRow', run: (e) => e.chain().focus().addRowAfter().run(), enabled: inTable },
|
||||||
|
{ command: 'deleteRow', glyph: '−Row', label: 'deleteRow', run: (e) => e.chain().focus().deleteRow().run(), enabled: inTable },
|
||||||
|
{ command: 'addColumn', glyph: '+Col', label: 'addColumn', run: (e) => e.chain().focus().addColumnAfter().run(), enabled: inTable },
|
||||||
|
{ command: 'deleteColumn', glyph: '−Col', label: 'deleteColumn', run: (e) => e.chain().focus().deleteColumn().run(), enabled: inTable },
|
||||||
|
{ command: 'deleteTable', glyph: '×Table', label: 'deleteTable', run: (e) => e.chain().focus().deleteTable().run(), enabled: inTable },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ command: 'image', glyph: 'Image', label: 'image', run: (_e, h) => h.pickImage(), host: true },
|
||||||
|
{ command: 'embed', glyph: 'Video', label: 'embed', run: (_e, h) => h.openEmbed(), host: true },
|
||||||
|
{ command: 'rawHtml', glyph: 'HTML', label: 'rawHtml', run: (_e, h) => h.insertRawHtml(), host: true },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ command: 'undo', glyph: 'Undo', label: 'undo', run: (e) => e.chain().focus().undo().run(), enabled: (e) => e.can().undo() },
|
||||||
|
{ command: 'redo', glyph: 'Redo', label: 'redo', run: (e) => e.chain().focus().redo().run(), enabled: (e) => e.can().redo() },
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildToolbar builds the formatting toolbar and the WYSIWYG/Source tabs
|
||||||
|
* (D-04): paragraph and the offered heading levels (2 to 4, with 1 first
|
||||||
|
* when the H1 setting is off, J1), bold, italic, link, lists,
|
||||||
|
* blockquote, tables, image, embed, raw HTML block, undo and redo, plus the
|
||||||
|
* preview toggle.
|
||||||
|
*/
|
||||||
|
export function buildToolbar(host: ToolbarHost): Toolbar {
|
||||||
|
const { labels } = host
|
||||||
|
const element = document.createElement('div')
|
||||||
|
element.className = 'blogeditor__bar'
|
||||||
|
|
||||||
|
const tools = document.createElement('div')
|
||||||
|
tools.className = 'blogeditor__tools'
|
||||||
|
tools.setAttribute('role', 'toolbar')
|
||||||
|
tools.setAttribute('aria-label', labels.toolbar)
|
||||||
|
|
||||||
|
const buttons: { spec: ButtonSpec; button: HTMLButtonElement }[] = []
|
||||||
|
for (const group of GROUPS) {
|
||||||
|
const wrap = document.createElement('div')
|
||||||
|
wrap.className = 'blogeditor__group'
|
||||||
|
for (const spec of group) {
|
||||||
|
const level = headingLevelOf(spec.command)
|
||||||
|
if (level > 0 && !host.headingLevels.includes(level)) continue
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.type = 'button'
|
||||||
|
button.dataset.command = spec.command
|
||||||
|
button.textContent = spec.glyph
|
||||||
|
button.title = labels[spec.label]
|
||||||
|
button.setAttribute('aria-label', labels[spec.label])
|
||||||
|
// Keep the editor selection when a button is pressed.
|
||||||
|
button.addEventListener('mousedown', (event) => event.preventDefault())
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const editor = host.editor()
|
||||||
|
if (!editor) return
|
||||||
|
if (!spec.host && !editor.isEditable) return
|
||||||
|
spec.run(editor, host)
|
||||||
|
})
|
||||||
|
wrap.append(button)
|
||||||
|
buttons.push({ spec, button })
|
||||||
|
}
|
||||||
|
tools.append(wrap)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = document.createElement('div')
|
||||||
|
tabs.className = 'blogeditor__tabs'
|
||||||
|
tabs.setAttribute('role', 'tablist')
|
||||||
|
const tab = (name: 'wysiwyg' | 'source', label: string) => {
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.type = 'button'
|
||||||
|
button.dataset.tab = name
|
||||||
|
button.setAttribute('role', 'tab')
|
||||||
|
button.textContent = label
|
||||||
|
button.addEventListener('click', () => host.showSource(name === 'source'))
|
||||||
|
tabs.append(button)
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
const wysiwygTab = tab('wysiwyg', labels.tabWysiwyg)
|
||||||
|
const sourceTab = tab('source', labels.tabSource)
|
||||||
|
|
||||||
|
// The preview is a toggle, not a tab: it sits outside the tablist and
|
||||||
|
// outside the buttons refresh() disables, because a read-only preview works
|
||||||
|
// in the Source tab, when disabled and before an editor exists.
|
||||||
|
const view = document.createElement('div')
|
||||||
|
view.className = 'blogeditor__view'
|
||||||
|
const preview = document.createElement('button')
|
||||||
|
preview.type = 'button'
|
||||||
|
preview.dataset.command = 'preview'
|
||||||
|
preview.textContent = labels.preview
|
||||||
|
preview.title = labels.preview
|
||||||
|
preview.setAttribute('aria-label', labels.preview)
|
||||||
|
preview.setAttribute('aria-pressed', 'false')
|
||||||
|
preview.addEventListener('mousedown', (event) => event.preventDefault())
|
||||||
|
preview.addEventListener('click', () => host.togglePreview())
|
||||||
|
view.append(preview)
|
||||||
|
|
||||||
|
element.append(tools, tabs, view)
|
||||||
|
|
||||||
|
let disabled = false
|
||||||
|
let source = false
|
||||||
|
const refresh = () => {
|
||||||
|
const editor = host.editor()
|
||||||
|
for (const { spec, button } of buttons) {
|
||||||
|
if (spec.active) button.setAttribute('aria-pressed', String(!!editor && spec.active(editor)))
|
||||||
|
const enabled = !!editor && !disabled && !source && (spec.enabled ? spec.enabled(editor) : true)
|
||||||
|
button.disabled = !enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
refresh,
|
||||||
|
selectTab: (on) => {
|
||||||
|
source = on
|
||||||
|
wysiwygTab.setAttribute('aria-selected', String(!on))
|
||||||
|
sourceTab.setAttribute('aria-selected', String(on))
|
||||||
|
refresh()
|
||||||
|
},
|
||||||
|
setDisabled: (on) => {
|
||||||
|
disabled = on
|
||||||
|
refresh()
|
||||||
|
},
|
||||||
|
setPreview: (on) => {
|
||||||
|
preview.setAttribute('aria-pressed', String(on))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
263
categories_admin_editor_test.go
Normal file
263
categories_admin_editor_test.go
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
)
|
||||||
|
|
||||||
|
// editorPNG is a file head http.DetectContentType recognizes as PNG.
|
||||||
|
var editorPNG = append([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"), make([]byte, 64)...)
|
||||||
|
|
||||||
|
// uploadEditorFile posts one file to a Blog Editor field's upload route.
|
||||||
|
func uploadEditorFile(t *testing.T, env *adminEnv, token, path, filename string, content []byte) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
part, err := mw.CreateFormFile("file", filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(content); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, &buf)
|
||||||
|
req.Host = "example.com"
|
||||||
|
req.RemoteAddr = "198.51.100.9:4000"
|
||||||
|
req.Header.Set("Accept-Language", "en")
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
env.h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCategoriesAdminBlogEditor covers category content on the blogeditor
|
||||||
|
// field type through the real admin API (Phase 16.2 D-03, D-10 amended,
|
||||||
|
// D-18, D-19): the field-types route lists the editor, an untouched save
|
||||||
|
// keeps stored content byte for byte (imported markup and NULL included)
|
||||||
|
// and reports nothing, a clean edit is stored as sent, an unsafe edit is
|
||||||
|
// stripped and reported (never a 422), blank content becomes NULL, and the
|
||||||
|
// editor's image upload stores under journal/categories.
|
||||||
|
func TestCategoriesAdminBlogEditor(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
token := env.admin(t, controllers.PermissionAccessCategories)
|
||||||
|
|
||||||
|
t.Run("field-types lists the Blog Editor with versioned assets", func(t *testing.T) {
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/field-types"), token, nil)
|
||||||
|
var types []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Element string `json:"element"`
|
||||||
|
Translatable bool `json:"translatable"`
|
||||||
|
Scripts []string `json:"scripts"`
|
||||||
|
Styles []string `json:"styles"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(adminDecode(t, rec).Data, &types); err != nil {
|
||||||
|
t.Fatalf("field types %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
found := map[string]bool{}
|
||||||
|
for _, ft := range types {
|
||||||
|
if ft.Name != "blogeditor" && ft.Name != "mlblogeditor" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found[ft.Name] = true
|
||||||
|
if ft.Element != classes.BlogEditorElement || ft.Translatable != (ft.Name == "mlblogeditor") {
|
||||||
|
t.Fatalf("%s = %+v", ft.Name, ft)
|
||||||
|
}
|
||||||
|
if len(ft.Scripts) != 1 || !strings.Contains(ft.Scripts[0], "/assets/golem15/journal/dist/blogeditor.js?v=") ||
|
||||||
|
len(ft.Styles) != 1 || !strings.Contains(ft.Styles[0], "/assets/golem15/journal/dist/blogeditor.css?v=") {
|
||||||
|
t.Fatalf("%s assets = %v %v", ft.Name, ft.Scripts, ft.Styles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found["blogeditor"] || !found["mlblogeditor"] {
|
||||||
|
t.Fatalf("field types = %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
safe := "<h2>Acme</h2>\n<p>Pillar <a href=\"https://example.test/a\">copy</a></p>\n"
|
||||||
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{
|
||||||
|
"name": map[string]any{"en": "Editor news", "pl": "Wiadomości edytora"},
|
||||||
|
"slug": map[string]any{"en": "editor-news", "pl": "wiadomosci-edytora"},
|
||||||
|
"content": safe,
|
||||||
|
})
|
||||||
|
if notices := adminNotices(t, rec); notices != nil {
|
||||||
|
t.Fatalf("safe create carries meta.notices: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
id := adminCreatedID(t, rec)
|
||||||
|
itemPath := adminAPI(fmt.Sprintf("%s/%d", adminCategoriesPath, id))
|
||||||
|
stored := func(t *testing.T) *string {
|
||||||
|
t.Helper()
|
||||||
|
var cat models.Category
|
||||||
|
if err := env.gdb.First(&cat, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return cat.Content
|
||||||
|
}
|
||||||
|
if got := stored(t); got == nil || *got != safe {
|
||||||
|
t.Fatalf("created content = %#v, want %q", got, safe)
|
||||||
|
}
|
||||||
|
served := func(t *testing.T) (map[string]json.RawMessage, string) {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, itemPath, token, nil)
|
||||||
|
var data map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(adminDecode(t, rec).Data, &data); err != nil {
|
||||||
|
t.Fatalf("show %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
var content *string
|
||||||
|
if raw, ok := data["content"]; ok {
|
||||||
|
if err := json.Unmarshal(raw, &content); err != nil {
|
||||||
|
t.Fatalf("served content %s: %v", raw, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if content == nil {
|
||||||
|
return data, ""
|
||||||
|
}
|
||||||
|
return data, *content
|
||||||
|
}
|
||||||
|
resave := func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
data, _ := served(t)
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodPut, itemPath, token, servedBody(data, "name", "slug", "content"))
|
||||||
|
if _, ok := adminMeta(t, rec)["notices"]; ok {
|
||||||
|
t.Fatalf("untouched save carries meta.notices: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("an untouched save keeps imported markup byte for byte", func(t *testing.T) {
|
||||||
|
// Imported content the policy would strip, written by SQL.
|
||||||
|
const imported = "\n<!-- imported -->\n<style>.x{color:red}</style>\n<p onclick=\"x()\">Kept as stored</p>\n"
|
||||||
|
if err := env.gdb.Model(&models.Category{}).Where("id = ?", id).Update("content", imported).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, content := served(t); content != imported {
|
||||||
|
t.Fatalf("served content = %q, want the stored bytes", content)
|
||||||
|
}
|
||||||
|
resave(t)
|
||||||
|
if got := stored(t); got == nil || *got != imported {
|
||||||
|
t.Fatalf("content after an untouched save = %#v, want %q", got, imported)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an untouched save keeps NULL content NULL", func(t *testing.T) {
|
||||||
|
if err := env.gdb.Model(&models.Category{}).Where("id = ?", id).Update("content", nil).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, content := served(t); content != "" {
|
||||||
|
t.Fatalf("served NULL content = %q, want empty", content)
|
||||||
|
}
|
||||||
|
resave(t)
|
||||||
|
if got := stored(t); got != nil {
|
||||||
|
t.Fatalf("content after an untouched save = %q, want NULL", *got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
put := func(t *testing.T, content string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
data, _ := served(t)
|
||||||
|
body := servedBody(data, "name", "slug")
|
||||||
|
body["content"] = content
|
||||||
|
return env.call(t, http.MethodPut, itemPath, token, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("a clean edit is stored as sent without notices", func(t *testing.T) {
|
||||||
|
edited := "<p>Edited <strong>copy</strong></p>\n<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure>\n"
|
||||||
|
rec := put(t, edited)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := adminMeta(t, rec)["notices"]; ok {
|
||||||
|
t.Fatalf("clean edit carries meta.notices: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := stored(t); got == nil || *got != edited {
|
||||||
|
t.Fatalf("stored = %#v, want %q", got, edited)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an unsafe edit is stripped and reported, not refused", func(t *testing.T) {
|
||||||
|
const unsafe = "<p>a</p><style>p{}</style><!-- c --><p onclick=\"x()\">b</p>"
|
||||||
|
rec := put(t, unsafe)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
want := classes.SanitizeHTML(unsafe)
|
||||||
|
if want == unsafe {
|
||||||
|
t.Fatal("the unsafe input passes the policy; pick another")
|
||||||
|
}
|
||||||
|
if got := stored(t); got == nil || *got != want {
|
||||||
|
t.Fatalf("stored = %#v, want %q", got, want)
|
||||||
|
}
|
||||||
|
notices := adminNotices(t, rec)
|
||||||
|
var messages []string
|
||||||
|
for _, n := range notices {
|
||||||
|
if n.Field != "content" || n.Locale != "" {
|
||||||
|
t.Fatalf("notice %+v, want field content without a locale", n)
|
||||||
|
}
|
||||||
|
messages = append(messages, n.Message)
|
||||||
|
}
|
||||||
|
wantMessages := []string{"1 element was removed: style", "1 attribute was removed: onclick", "1 HTML comment was removed"}
|
||||||
|
if strings.Join(messages, "|") != strings.Join(wantMessages, "|") {
|
||||||
|
t.Fatalf("notices = %q, want %q", messages, wantMessages)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("blank content becomes NULL", func(t *testing.T) {
|
||||||
|
rec := put(t, " \n\t")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := stored(t); got != nil {
|
||||||
|
t.Fatalf("stored = %q, want NULL", *got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the editor's image upload stores under journal/categories", func(t *testing.T) {
|
||||||
|
path := adminAPI(fmt.Sprintf("%s/%d/fields/content/upload", adminCategoriesPath, id))
|
||||||
|
rec := uploadEditorFile(t, env, token, path, "Okładka.PNG", editorPNG)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("upload = %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(adminDecode(t, rec).Data, &result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.URL != attach.PublicURL("journal/categories/okladka.png") {
|
||||||
|
t.Fatalf("url = %q", result.URL)
|
||||||
|
}
|
||||||
|
bucket, ok := env.app.Lookup[*blob.Bucket]()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("no media bucket")
|
||||||
|
}
|
||||||
|
if data, err := bucket.ReadAll(t.Context(), "journal/categories/okladka.png"); err != nil || !bytes.Equal(data, editorPNG) {
|
||||||
|
t.Fatalf("stored upload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
disguised := uploadEditorFile(t, env, token, path, "page.png", []byte("<!DOCTYPE html><script>alert(1)</script>"))
|
||||||
|
if disguised.Code != http.StatusUnprocessableEntity || !strings.Contains(disguised.Body.String(), "The image must be a JPEG, PNG, GIF or WebP file.") {
|
||||||
|
t.Fatalf("disguised upload = %d: %s", disguised.Code, disguised.Body.String())
|
||||||
|
}
|
||||||
|
create := uploadEditorFile(t, env, token, adminAPI(adminCategoriesPath+"/0/fields/content/upload"), "new.png", editorPNG)
|
||||||
|
if create.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create-form upload = %d: %s", create.Code, create.Body.String())
|
||||||
|
}
|
||||||
|
other := env.admin(t, controllers.PermissionAccessPosts)
|
||||||
|
if denied := uploadEditorFile(t, env, other, path, "a.png", editorPNG); denied.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("upload without access_categories = %d: %s", denied.Code, denied.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
481
classes/blog_editor.go
Normal file
481
classes/blog_editor.go
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
xhtml "golang.org/x/net/html"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BlogEditorElement is the custom element of the blogeditor and mlblogeditor
|
||||||
|
// field types, defined by assets/dist/blogeditor.js.
|
||||||
|
const BlogEditorElement = "golem15-journal-blogeditor"
|
||||||
|
|
||||||
|
// Phrase keys of the Blog Editor field types.
|
||||||
|
const (
|
||||||
|
editorContentInvalid = "golem15.journal::lang.editor.content_invalid"
|
||||||
|
editorImageInvalid = "golem15.journal::lang.editor.image_invalid"
|
||||||
|
editorRemovedElements = "golem15.journal::lang.editor.removed_elements"
|
||||||
|
editorRemovedAttributes = "golem15.journal::lang.editor.removed_attributes"
|
||||||
|
editorRemovedComments = "golem15.journal::lang.editor.removed_comments"
|
||||||
|
editorMarkupChanged = "golem15.journal::lang.editor.markup_changed"
|
||||||
|
// EditorH1Converted is the notice of a save that turned H1 headings
|
||||||
|
// into H2 (Count is the number of headings).
|
||||||
|
EditorH1Converted = "golem15.journal::lang.editor.h1_converted"
|
||||||
|
// EditorH1ConvertedCategory is EditorH1Converted for a category's
|
||||||
|
// pillar text, which names the category name instead of the post title.
|
||||||
|
EditorH1ConvertedCategory = "golem15.journal::lang.editor.h1_converted_category"
|
||||||
|
)
|
||||||
|
|
||||||
|
// blogListValueRunes is how many runes of text a list column shows.
|
||||||
|
const blogListValueRunes = 120
|
||||||
|
|
||||||
|
// blogUploadFolder is the upload folder of a field without uploadFolder.
|
||||||
|
const blogUploadFolder = "editor"
|
||||||
|
|
||||||
|
var (
|
||||||
|
blogHTMLColumnPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
blogUploadExtPattern = regexp.MustCompile(`^\.[a-z0-9]+$`)
|
||||||
|
// blogImageTypes maps an upload extension to the content type its first
|
||||||
|
// bytes must sniff as.
|
||||||
|
blogImageTypes = map[string]string{
|
||||||
|
"jpg": "image/jpeg",
|
||||||
|
"jpeg": "image/jpeg",
|
||||||
|
"png": "image/png",
|
||||||
|
"gif": "image/gif",
|
||||||
|
"webp": "image/webp",
|
||||||
|
}
|
||||||
|
// blogImageExtensions is the extension given to a name without one.
|
||||||
|
blogImageExtensions = map[string]string{
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/gif": "gif",
|
||||||
|
"image/webp": "webp",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// BlogEditorTypes returns the Blog Editor admin field types: blogeditor for
|
||||||
|
// a column that holds one HTML string, and mlblogeditor for a translatable
|
||||||
|
// one. Both render BlogEditorElement. Decode stores what the HTML policy
|
||||||
|
// keeps (SanitizeHTMLReport) and reports what it removed as notices; it
|
||||||
|
// never refuses a string because of its markup. Load serves HTML content as
|
||||||
|
// it is and a markdown value as the rendered HTML of the htmlColumn option,
|
||||||
|
// and a blank value as the htmlColumn attribute when that holds a body; a
|
||||||
|
// value that starts with '<' counts as HTML only when the htmlColumn
|
||||||
|
// attribute is empty or holds that same HTML (loadBlogHTML). Upload stores an image the editor
|
||||||
|
// inserts through StoreMedia, in the media bucket the app publishes. app is
|
||||||
|
// asked for the booted application at request time; it may be nil in tests,
|
||||||
|
// where Upload then fails.
|
||||||
|
//
|
||||||
|
// Options: htmlColumn (mlblogeditor only) names the translatable attribute
|
||||||
|
// that holds the rendered HTML of a markdown value; uploadFolder is the
|
||||||
|
// folder under /journal/ that editor image uploads are stored in ("editor"
|
||||||
|
// when unset).
|
||||||
|
func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
|
||||||
|
upload := uploadBlogImage(app)
|
||||||
|
decode := decodeBlogHTMLFor(app)
|
||||||
|
options := blogRequestOptions(app)
|
||||||
|
return []pact.FormFieldType{
|
||||||
|
{
|
||||||
|
Name: "blogeditor",
|
||||||
|
Element: BlogEditorElement,
|
||||||
|
Scripts: []string{"assets/dist/blogeditor.js"},
|
||||||
|
Styles: []string{"assets/dist/blogeditor.css"},
|
||||||
|
Options: []string{"uploadFolder"},
|
||||||
|
CheckOptions: checkBlogOptions,
|
||||||
|
Decode: decode,
|
||||||
|
Load: loadBlogHTML,
|
||||||
|
ListValue: blogListValue,
|
||||||
|
Upload: upload,
|
||||||
|
RequestOptions: options,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "mlblogeditor",
|
||||||
|
Element: BlogEditorElement,
|
||||||
|
Translatable: true,
|
||||||
|
Scripts: []string{"assets/dist/blogeditor.js"},
|
||||||
|
Styles: []string{"assets/dist/blogeditor.css"},
|
||||||
|
Options: []string{"htmlColumn", "uploadFolder"},
|
||||||
|
CheckOptions: checkBlogOptions,
|
||||||
|
Decode: decode,
|
||||||
|
Load: loadBlogHTML,
|
||||||
|
ListValue: blogListValue,
|
||||||
|
Upload: upload,
|
||||||
|
RequestOptions: options,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LooksLikeHTML reports whether s may be stored HTML rather than markdown:
|
||||||
|
// its first character that is not white space is '<' (an HTML comment
|
||||||
|
// included). Phase 16.2 assumption A1. Markdown can start with a tag too,
|
||||||
|
// so Load also compares the value with its htmlColumn attribute before it
|
||||||
|
// serves the value as HTML.
|
||||||
|
func LooksLikeHTML(s string) bool {
|
||||||
|
t := strings.TrimLeftFunc(s, unicode.IsSpace)
|
||||||
|
return strings.HasPrefix(t, "<")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkBlogOptions checks a Blog Editor field's options at boot.
|
||||||
|
func checkBlogOptions(options map[string]any) error {
|
||||||
|
if raw, ok := options["htmlColumn"]; ok {
|
||||||
|
column, isString := raw.(string)
|
||||||
|
if !isString || !blogHTMLColumnPattern.MatchString(column) {
|
||||||
|
return fmt.Errorf("htmlColumn must be an attribute name, got %v", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw, ok := options["uploadFolder"]; ok {
|
||||||
|
folder, isString := raw.(string)
|
||||||
|
if !isString {
|
||||||
|
return fmt.Errorf("uploadFolder must be a string, got %v", raw)
|
||||||
|
}
|
||||||
|
if err := checkUploadFolder(folder); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkUploadFolder applies the public media upload route's folder rule.
|
||||||
|
func checkUploadFolder(folder string) error {
|
||||||
|
if !validMediaFolder(folder) {
|
||||||
|
return errors.New("uploadFolder must be at most 100 letters, digits, '_', '-' or '/' without '..'")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeBlogHTMLFor is the Blog Editor types' Decode: decodeBlogHTML, then,
|
||||||
|
// while the H1 setting is on (EffectiveH1ToH2 on the save transaction and
|
||||||
|
// the booted application's config), ConvertH1 on the stored HTML with a
|
||||||
|
// notice counting the converted headings: EditorH1ConvertedCategory on a
|
||||||
|
// category's pillar text, EditorH1Converted otherwise. app may be nil
|
||||||
|
// or answer nil; the setting is then read from the database alone.
|
||||||
|
func decodeBlogHTMLFor(app func() *backpack.App) func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||||
|
return func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||||
|
out, err := decodeBlogHTML(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
text, isString := out.Value.(string)
|
||||||
|
if !isString || text == "" {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if !EffectiveH1ToH2(ctx, in.DB, appConfig(app)) {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
converted, n := ConvertH1(text)
|
||||||
|
if n == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
out.Value = converted
|
||||||
|
out.Notices = append(out.Notices, pact.FormFieldNotice{Message: h1ConvertedNotice(in.Record), Count: n})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// h1ConvertedNotice is the conversion notice for the saved record: the
|
||||||
|
// category phrase for a category, the post phrase otherwise.
|
||||||
|
func h1ConvertedNotice(record any) string {
|
||||||
|
switch record.(type) {
|
||||||
|
case *models.Category, models.Category:
|
||||||
|
return EditorH1ConvertedCategory
|
||||||
|
}
|
||||||
|
return EditorH1Converted
|
||||||
|
}
|
||||||
|
|
||||||
|
// blogRequestOptions is the Blog Editor types' RequestOptions (33-01
|
||||||
|
// P33-02, UI-SPEC C28): headingLevels, [2, 3, 4] while the H1 setting is on
|
||||||
|
// and [1, 2, 3, 4] while it is off (J1), and genericAnchors, the settings'
|
||||||
|
// generic link texts (J2, J3). Both are read per schema request, so a
|
||||||
|
// changed setting reaches the next form that opens. db may be nil; the
|
||||||
|
// defaults then apply. fields.yaml options with the same names win.
|
||||||
|
func blogRequestOptions(app func() *backpack.App) func(context.Context, *gorm.DB) (map[string]any, error) {
|
||||||
|
return func(ctx context.Context, db *gorm.DB) (map[string]any, error) {
|
||||||
|
levels := []int{2, 3, 4}
|
||||||
|
if !EffectiveH1ToH2(ctx, db, appConfig(app)) {
|
||||||
|
levels = []int{1, 2, 3, 4}
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"headingLevels": levels,
|
||||||
|
"genericAnchors": GenericAnchors(ctx, db),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// appConfig is the config of the application app answers, nil when there
|
||||||
|
// is none.
|
||||||
|
func appConfig(app func() *backpack.App) *compass.Config {
|
||||||
|
if app == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
a := app()
|
||||||
|
if a == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeBlogHTML stores the HTML policy's output for a submitted value: the
|
||||||
|
// exact submitted bytes with no notice when the policy keeps everything,
|
||||||
|
// otherwise the stripped HTML with notices naming what was removed. nil
|
||||||
|
// decodes to the empty string; any other value that is not a string is
|
||||||
|
// refused.
|
||||||
|
func decodeBlogHTML(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||||
|
var text string
|
||||||
|
switch v := in.Value.(type) {
|
||||||
|
case nil:
|
||||||
|
return pact.FormFieldDecoded{Value: ""}, nil
|
||||||
|
case string:
|
||||||
|
text = v
|
||||||
|
default:
|
||||||
|
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{editorContentInvalid}}
|
||||||
|
}
|
||||||
|
clean, report := SanitizeHTMLReport(text)
|
||||||
|
return pact.FormFieldDecoded{Value: clean, Notices: removalNotices(report)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removalNotices turns a removal report into admin notices: removed
|
||||||
|
// elements, removed attributes, removed comments and other changes, in that
|
||||||
|
// order and only when non-zero.
|
||||||
|
func removalNotices(r HTMLRemovals) []pact.FormFieldNotice {
|
||||||
|
var out []pact.FormFieldNotice
|
||||||
|
if r.ElementCount > 0 {
|
||||||
|
out = append(out, pact.FormFieldNotice{
|
||||||
|
Message: editorRemovedElements,
|
||||||
|
Params: map[string]string{"names": removalNames(r.Elements, r.moreElements)},
|
||||||
|
Count: r.ElementCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if r.AttributeCount > 0 {
|
||||||
|
out = append(out, pact.FormFieldNotice{
|
||||||
|
Message: editorRemovedAttributes,
|
||||||
|
Params: map[string]string{"names": removalNames(r.Attributes, r.moreAttributes)},
|
||||||
|
Count: r.AttributeCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if r.Comments > 0 {
|
||||||
|
out = append(out, pact.FormFieldNotice{Message: editorRemovedComments, Count: r.Comments})
|
||||||
|
}
|
||||||
|
if r.Other {
|
||||||
|
out = append(out, pact.FormFieldNotice{Message: editorMarkupChanged})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func removalNames(names []string, cut bool) string {
|
||||||
|
joined := strings.Join(names, ", ")
|
||||||
|
if cut {
|
||||||
|
joined += ", …"
|
||||||
|
}
|
||||||
|
return joined
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadBlogHTML serves a stored value to the editor (D-07). Without the
|
||||||
|
// htmlColumn option every value is served as stored. With it:
|
||||||
|
//
|
||||||
|
// - A blank value is served as the htmlColumn attribute in the same locale
|
||||||
|
// when that is not empty, so a locale that carries only content_html (an
|
||||||
|
// API-written translation, FormatTranslation) opens with the body the
|
||||||
|
// site renders instead of an empty editor whose first edit would replace
|
||||||
|
// it; it is served as stored otherwise.
|
||||||
|
// - A value that looks like HTML (LooksLikeHTML) is served as it is when
|
||||||
|
// the htmlColumn attribute is empty or missing, or holds the same HTML:
|
||||||
|
// the value itself or the policy's output for it (SanitizeHTML), white
|
||||||
|
// space at either end ignored. Otherwise the value is markdown that
|
||||||
|
// merely starts with a tag (a hero <img>, a leading <div> or comment),
|
||||||
|
// and it is served as the htmlColumn attribute, so the first edit does
|
||||||
|
// not store raw markdown as HTML.
|
||||||
|
// - Any other value is markdown, served as the htmlColumn attribute, or as
|
||||||
|
// FormatHTML of the value when that is empty or cannot be read (the
|
||||||
|
// value itself when rendering fails).
|
||||||
|
func loadBlogHTML(_ context.Context, in pact.FormFieldValue) (any, error) {
|
||||||
|
text := storedText(in.Value)
|
||||||
|
column, _ := in.Options["htmlColumn"].(string)
|
||||||
|
if column == "" {
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
html := ""
|
||||||
|
if in.Translated != nil {
|
||||||
|
value, found, err := in.Translated(column)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
html = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(text) == "":
|
||||||
|
if html != "" {
|
||||||
|
return html, nil
|
||||||
|
}
|
||||||
|
return text, nil
|
||||||
|
case LooksLikeHTML(text):
|
||||||
|
if html == "" || sameStoredHTML(text, html) {
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
return html, nil
|
||||||
|
case html != "":
|
||||||
|
return html, nil
|
||||||
|
}
|
||||||
|
rendered, err := FormatHTML(text)
|
||||||
|
if err != nil {
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
return rendered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameStoredHTML reports whether rendered, a stored htmlColumn value, is the
|
||||||
|
// HTML text itself: equal to text or to the policy's output for it, with
|
||||||
|
// white space at either end ignored. It is false when rendered came from
|
||||||
|
// rendering text as markdown in a way that changed it.
|
||||||
|
func sameStoredHTML(text, rendered string) bool {
|
||||||
|
rendered = strings.TrimSpace(rendered)
|
||||||
|
return rendered == strings.TrimSpace(text) || rendered == strings.TrimSpace(SanitizeHTML(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// storedText is a stored column value as text: a string, a *string (nil is
|
||||||
|
// empty) or nil.
|
||||||
|
func storedText(v any) string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
case string:
|
||||||
|
return t
|
||||||
|
case *string:
|
||||||
|
if t == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *t
|
||||||
|
default:
|
||||||
|
return fmt.Sprint(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inlineListTags are the elements that do not separate words in a list
|
||||||
|
// value's text.
|
||||||
|
var inlineListTags = map[string]bool{
|
||||||
|
"a": true, "abbr": true, "b": true, "cite": true, "code": true, "del": true,
|
||||||
|
"em": true, "i": true, "ins": true, "kbd": true, "mark": true, "q": true,
|
||||||
|
"s": true, "small": true, "span": true, "strong": true, "sub": true,
|
||||||
|
"sup": true, "u": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// blogListValue is the text a list column of a Blog Editor type shows: the
|
||||||
|
// value without tags, white space collapsed, cut to 120 runes with an
|
||||||
|
// ellipsis.
|
||||||
|
func blogListValue(_ context.Context, value any) string {
|
||||||
|
var b strings.Builder
|
||||||
|
z := xhtml.NewTokenizer(strings.NewReader(storedText(value)))
|
||||||
|
skip := 0 // inside script or style
|
||||||
|
for {
|
||||||
|
tt := z.Next()
|
||||||
|
if tt == xhtml.ErrorToken {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
switch tt {
|
||||||
|
case xhtml.TextToken:
|
||||||
|
if skip == 0 {
|
||||||
|
b.WriteString(z.Token().Data)
|
||||||
|
}
|
||||||
|
case xhtml.StartTagToken, xhtml.EndTagToken, xhtml.SelfClosingTagToken:
|
||||||
|
name, _ := z.TagName()
|
||||||
|
switch string(name) {
|
||||||
|
case "script", "style":
|
||||||
|
if tt == xhtml.StartTagToken {
|
||||||
|
skip++
|
||||||
|
} else if tt == xhtml.EndTagToken && skip > 0 {
|
||||||
|
skip--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !inlineListTags[string(name)] {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text := strings.Join(strings.Fields(b.String()), " ")
|
||||||
|
if utf8.RuneCountInString(text) <= blogListValueRunes {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
runes := []rune(text)
|
||||||
|
return strings.TrimRightFunc(string(runes[:blogListValueRunes]), unicode.IsSpace) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadBlogImage is the Blog Editor's Upload: it stores an image the
|
||||||
|
// editor inserts through StoreMedia, in the field's uploadFolder, and
|
||||||
|
// returns its URL. The file's first bytes must sniff as a JPEG, PNG, GIF or
|
||||||
|
// WebP image of the type its extension names (a name without an extension
|
||||||
|
// gets the sniffed one); anything else is refused with
|
||||||
|
// golem15.journal::lang.editor.image_invalid. The stored name is
|
||||||
|
// safeUploadName's. A MediaError is refused with its message; a missing
|
||||||
|
// application or bucket is an opaque error.
|
||||||
|
func uploadBlogImage(app func() *backpack.App) func(context.Context, pact.FormFieldUpload) (pact.FormFieldUploadResult, error) {
|
||||||
|
return func(ctx context.Context, in pact.FormFieldUpload) (pact.FormFieldUploadResult, error) {
|
||||||
|
var a *backpack.App
|
||||||
|
if app != nil {
|
||||||
|
a = app()
|
||||||
|
}
|
||||||
|
if a == nil {
|
||||||
|
return pact.FormFieldUploadResult{}, errors.New("journal: editor upload: application is not configured")
|
||||||
|
}
|
||||||
|
bucket, ok := a.Lookup[*blob.Bucket]()
|
||||||
|
if !ok || bucket == nil {
|
||||||
|
return pact.FormFieldUploadResult{}, errors.New("journal: editor upload: media bucket is not configured")
|
||||||
|
}
|
||||||
|
if in.Body == nil {
|
||||||
|
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{editorImageInvalid}}
|
||||||
|
}
|
||||||
|
head := make([]byte, 512)
|
||||||
|
n, err := io.ReadFull(in.Body, head)
|
||||||
|
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
return pact.FormFieldUploadResult{}, fmt.Errorf("journal: editor upload: read: %w", err)
|
||||||
|
}
|
||||||
|
head = head[:n]
|
||||||
|
sniffed := http.DetectContentType(head)
|
||||||
|
ext, known := blogImageExtensions[sniffed]
|
||||||
|
if !known {
|
||||||
|
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{editorImageInvalid}}
|
||||||
|
}
|
||||||
|
name := safeUploadName(in.Filename)
|
||||||
|
if named := strings.TrimPrefix(path.Ext(name), "."); named == "" {
|
||||||
|
name += "." + ext
|
||||||
|
} else if want, allowed := blogImageTypes[named]; allowed && want != sniffed {
|
||||||
|
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{editorImageInvalid}}
|
||||||
|
}
|
||||||
|
folder, _ := in.Options["uploadFolder"].(string)
|
||||||
|
if folder == "" {
|
||||||
|
folder = blogUploadFolder
|
||||||
|
}
|
||||||
|
stored, err := StoreMedia(ctx, bucket, folder, name, io.MultiReader(bytes.NewReader(head), in.Body))
|
||||||
|
if err != nil {
|
||||||
|
var refused *MediaError
|
||||||
|
if errors.As(err, &refused) && refused != nil {
|
||||||
|
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{refused.Message}}
|
||||||
|
}
|
||||||
|
return pact.FormFieldUploadResult{}, err
|
||||||
|
}
|
||||||
|
return pact.FormFieldUploadResult{URL: stored.URL}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeUploadName is the stored name of an editor upload: MediaFileName of
|
||||||
|
// its base name (D-85), so it needs no escaping in an src attribute. An
|
||||||
|
// empty name becomes "image".
|
||||||
|
func safeUploadName(filename string) string {
|
||||||
|
return MediaFileName(filename)
|
||||||
|
}
|
||||||
732
classes/blog_editor_test.go
Normal file
732
classes/blog_editor_test.go
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"gocloud.dev/blob/memblob"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Unit tests of the Blog Editor field types (Phase 16.2 plan 06).
|
||||||
|
|
||||||
|
// blogOptions are the post content field's options.
|
||||||
|
var blogOptions = map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts"}
|
||||||
|
|
||||||
|
// noTranslated is a Translated reader that fails the test when called.
|
||||||
|
func noTranslated(t *testing.T) func(string) (string, bool, error) {
|
||||||
|
return func(attribute string) (string, bool, error) {
|
||||||
|
t.Errorf("Translated(%q) called for HTML content", attribute)
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// translatedReader serves one attribute and records what was asked.
|
||||||
|
func translatedReader(asked *[]string, value string, found bool, err error) func(string) (string, bool, error) {
|
||||||
|
return func(attribute string) (string, bool, error) {
|
||||||
|
*asked = append(*asked, attribute)
|
||||||
|
return value, found, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlogEditorLoad(t *testing.T) {
|
||||||
|
const markdown = "## Nagłówek\n\nTekst z *kursywą*."
|
||||||
|
rendered, err := FormatHTML(markdown)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rendered == markdown || rendered == "" {
|
||||||
|
t.Fatalf("FormatHTML(%q) = %q", markdown, rendered)
|
||||||
|
}
|
||||||
|
html := "<p>Grzyby</p>"
|
||||||
|
empty := ""
|
||||||
|
|
||||||
|
t.Run("HTML content is served verbatim", func(t *testing.T) {
|
||||||
|
for _, stored := range []string{
|
||||||
|
"<p>Grzyby rosną.</p>\n<footer class=\"author-footer\"><p>Autor</p></footer>\n",
|
||||||
|
"\n \t<p>leading white space</p>",
|
||||||
|
"<!-- imported --><p>leading comment</p>",
|
||||||
|
"\n<!-- imported -->\n<p>both</p>\n",
|
||||||
|
"<style>.x{}</style><p>unsafe markup is served too; Decode strips it on save</p>",
|
||||||
|
} {
|
||||||
|
// content_html missing, empty, the stored HTML itself (an admin
|
||||||
|
// save), the policy's output for it, or either with other white
|
||||||
|
// space at the ends (FormatHTML trims and goldmark appends "\n").
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
html string
|
||||||
|
found bool
|
||||||
|
}{
|
||||||
|
{"missing", "", false},
|
||||||
|
{"empty", "", true},
|
||||||
|
{"equal", stored, true},
|
||||||
|
{"sanitized", SanitizeHTML(stored), true},
|
||||||
|
{"trimmed", strings.TrimSpace(stored) + "\n", true},
|
||||||
|
{"sanitized and trimmed", "\n" + strings.TrimSpace(SanitizeHTML(stored)), true},
|
||||||
|
} {
|
||||||
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
||||||
|
Options: blogOptions, Locale: "pl", Default: true, Value: stored,
|
||||||
|
Translated: translatedReader(new([]string), tc.html, tc.found, nil),
|
||||||
|
})
|
||||||
|
if err != nil || got != stored {
|
||||||
|
t.Fatalf("Load(%q) with content_html %s = %q, %v; want the stored bytes", stored, tc.name, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
||||||
|
Options: blogOptions, Locale: "pl", Default: true, Value: stored,
|
||||||
|
})
|
||||||
|
if err != nil || got != stored {
|
||||||
|
t.Fatalf("Load(%q) without a reader = %q, %v; want the stored bytes", stored, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// WR-04: markdown whose first character is '<' is not HTML when its
|
||||||
|
// content_html is a different rendering of it; serving the raw markdown
|
||||||
|
// would make the first edit store "##" and "**" as HTML.
|
||||||
|
t.Run("markdown that starts with a tag is served as content_html", func(t *testing.T) {
|
||||||
|
for _, source := range []string{
|
||||||
|
"<img src=\"/storage/hero.png\" alt=\"Hero\">\n\n## Nagłówek\n\nTekst z **pogrubieniem**.",
|
||||||
|
"<div class=\"lead\">Wstęp</div>\n\n## Nagłówek\n\n*kursywa*",
|
||||||
|
"<!-- imported -->\n\n## Heading\n\n**bold**",
|
||||||
|
} {
|
||||||
|
stored, err := FormatHTML(source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !LooksLikeHTML(source) || sameStoredHTML(source, stored) {
|
||||||
|
t.Fatalf("fixture %q does not exercise WR-04 (content_html %q)", source, stored)
|
||||||
|
}
|
||||||
|
var asked []string
|
||||||
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
||||||
|
Options: blogOptions, Locale: "en", Value: source,
|
||||||
|
Translated: translatedReader(&asked, stored, true, nil),
|
||||||
|
})
|
||||||
|
if err != nil || got != stored {
|
||||||
|
t.Fatalf("Load(%q) = %q, %v; want content_html %q", source, got, err, stored)
|
||||||
|
}
|
||||||
|
if strings.Contains(got.(string), "**") || strings.Contains(got.(string), "## ") {
|
||||||
|
t.Fatalf("Load(%q) served markdown markers: %q", source, got)
|
||||||
|
}
|
||||||
|
if len(asked) != 1 || asked[0] != "content_html" {
|
||||||
|
t.Fatalf("asked %v, want [content_html]", asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an HTML value with a failing content_html read is the Load error", func(t *testing.T) {
|
||||||
|
boom := errors.New("read failed")
|
||||||
|
_, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
||||||
|
Options: blogOptions, Value: "<p>x</p>", Translated: translatedReader(new([]string), "", false, boom),
|
||||||
|
})
|
||||||
|
if !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("err = %v, want %v", err, boom)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("markdown content is served as that locale's content_html", func(t *testing.T) {
|
||||||
|
var asked []string
|
||||||
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
||||||
|
Options: blogOptions, Locale: "en", Value: markdown,
|
||||||
|
Translated: translatedReader(&asked, "<h2>Imported</h2>\n", true, nil),
|
||||||
|
})
|
||||||
|
if err != nil || got != "<h2>Imported</h2>\n" {
|
||||||
|
t.Fatalf("Load = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
if len(asked) != 1 || asked[0] != "content_html" {
|
||||||
|
t.Fatalf("asked %v, want [content_html]", asked)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an empty or missing content_html falls back to FormatHTML", func(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
translated func(string) (string, bool, error)
|
||||||
|
}{
|
||||||
|
{"empty", translatedReader(new([]string), "", true, nil)},
|
||||||
|
{"missing", translatedReader(new([]string), "", false, nil)},
|
||||||
|
{"no writer", nil},
|
||||||
|
} {
|
||||||
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
||||||
|
Options: blogOptions, Locale: "pl", Default: true, Value: markdown, Translated: tc.translated,
|
||||||
|
})
|
||||||
|
if err != nil || got != rendered {
|
||||||
|
t.Fatalf("%s: Load = %q, %v; want %q", tc.name, got, err, rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a Translated error is the Load error", func(t *testing.T) {
|
||||||
|
boom := errors.New("read failed")
|
||||||
|
_, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
||||||
|
Options: blogOptions, Value: markdown, Translated: translatedReader(new([]string), "", false, boom),
|
||||||
|
})
|
||||||
|
if !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("err = %v, want %v", err, boom)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil and empty values give the empty string", func(t *testing.T) {
|
||||||
|
for _, value := range []any{nil, (*string)(nil), &empty, "", " \n"} {
|
||||||
|
for _, translated := range []func(string) (string, bool, error){
|
||||||
|
nil,
|
||||||
|
translatedReader(new([]string), "", false, nil),
|
||||||
|
translatedReader(new([]string), "", true, nil),
|
||||||
|
} {
|
||||||
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{Options: blogOptions, Value: value, Translated: translated})
|
||||||
|
want := storedText(value)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Fatalf("Load(%#v) = %q, %v; want %q", value, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// CR-01: a locale whose content is blank but whose content_html holds a
|
||||||
|
// body (an API-written translation, FormatTranslation) opens with that
|
||||||
|
// body, not an empty editor whose first edit would replace it.
|
||||||
|
t.Run("blank content is served as that locale's content_html", func(t *testing.T) {
|
||||||
|
const translatedBody = "<h2>Tłumaczenie</h2>\n<p>Treść z API.</p>\n"
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
value any
|
||||||
|
locale string
|
||||||
|
isDefault bool
|
||||||
|
}{
|
||||||
|
{"translation with empty content", "", "en", false},
|
||||||
|
{"translation with blank content", " \n\t", "en", false},
|
||||||
|
{"host row with nil content", nil, "pl", true},
|
||||||
|
{"host row with a nil *string", (*string)(nil), "pl", true},
|
||||||
|
} {
|
||||||
|
var asked []string
|
||||||
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
||||||
|
Options: blogOptions, Locale: tc.locale, Default: tc.isDefault, Value: tc.value,
|
||||||
|
Translated: translatedReader(&asked, translatedBody, true, nil),
|
||||||
|
})
|
||||||
|
if err != nil || got != translatedBody {
|
||||||
|
t.Fatalf("%s: Load = %q, %v; want the stored content_html", tc.name, got, err)
|
||||||
|
}
|
||||||
|
if len(asked) != 1 || asked[0] != "content_html" {
|
||||||
|
t.Fatalf("%s: asked %v, want [content_html]", tc.name, asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("blank content with a failing content_html read is the Load error", func(t *testing.T) {
|
||||||
|
boom := errors.New("read failed")
|
||||||
|
_, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
||||||
|
Options: blogOptions, Locale: "en", Value: "", Translated: translatedReader(new([]string), "", false, boom),
|
||||||
|
})
|
||||||
|
if !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("err = %v, want %v", err, boom)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("blank content without htmlColumn never reads another attribute", func(t *testing.T) {
|
||||||
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
||||||
|
Options: map[string]any{"uploadFolder": "categories"}, Value: "", Translated: noTranslated(t),
|
||||||
|
})
|
||||||
|
if err != nil || got != "" {
|
||||||
|
t.Fatalf("Load = %q, %v; want the empty string", got, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a *string is dereferenced", func(t *testing.T) {
|
||||||
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{Value: &html})
|
||||||
|
if err != nil || got != html {
|
||||||
|
t.Fatalf("Load = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("without htmlColumn every value is served as stored", func(t *testing.T) {
|
||||||
|
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{Options: map[string]any{"uploadFolder": "categories"}, Value: markdown, Translated: noTranslated(t)})
|
||||||
|
if err != nil || got != markdown {
|
||||||
|
t.Fatalf("Load = %q, %v; want the stored markdown", got, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the registered types use this Load", func(t *testing.T) {
|
||||||
|
for _, typ := range BlogEditorTypes(nil) {
|
||||||
|
if typ.Load == nil {
|
||||||
|
t.Fatalf("%s has no Load", typ.Name)
|
||||||
|
}
|
||||||
|
got, err := typ.Load(context.Background(), pact.FormFieldValue{Options: blogOptions, Value: "\n<!-- c --><p>x</p>"})
|
||||||
|
if err != nil || got != "\n<!-- c --><p>x</p>" {
|
||||||
|
t.Fatalf("%s: Load = %q, %v", typ.Name, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeBoth runs both Blog Editor types' Decode on value.
|
||||||
|
func decodeBoth(t *testing.T, value any) []pact.FormFieldDecoded {
|
||||||
|
t.Helper()
|
||||||
|
var out []pact.FormFieldDecoded
|
||||||
|
for _, typ := range BlogEditorTypes(nil) {
|
||||||
|
got, err := typ.Decode(context.Background(), pact.FormFieldValue{Field: "content", Locale: "pl", Default: true, Value: value})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: Decode(%#v) error %v", typ.Name, value, err)
|
||||||
|
}
|
||||||
|
out = append(out, got)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlogEditorDecode(t *testing.T) {
|
||||||
|
t.Run("clean values are stored byte for byte with no notice", func(t *testing.T) {
|
||||||
|
clean := []string{
|
||||||
|
"",
|
||||||
|
"plain text, no markup",
|
||||||
|
"## Markdown heading\n\nText with *emphasis*.\n",
|
||||||
|
"<h2>Grzyby</h2>\n<p>Tekst z <a href=\"https://example.test/a\">linkiem</a> & <strong>pogrubieniem</strong>.</p>\n",
|
||||||
|
"\n <p>leading and trailing white space</p>\n\n",
|
||||||
|
editorPlaceholder,
|
||||||
|
}
|
||||||
|
for _, e := range editorCorpus(t) {
|
||||||
|
clean = append(clean, e.HTML)
|
||||||
|
}
|
||||||
|
for _, in := range clean {
|
||||||
|
for _, got := range decodeBoth(t, in) {
|
||||||
|
if got.Value != in || len(got.Notices) != 0 {
|
||||||
|
t.Fatalf("Decode(%q) = %#v, want it unchanged without notices", in, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil decodes to the empty string", func(t *testing.T) {
|
||||||
|
for _, got := range decodeBoth(t, nil) {
|
||||||
|
if got.Value != "" || got.Notices != nil {
|
||||||
|
t.Fatalf("Decode(nil) = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a value that is not a string is a FormFieldError", func(t *testing.T) {
|
||||||
|
for _, value := range []any{42.0, true, map[string]any{"html": "<p>x</p>"}, []any{"<p>x</p>"}} {
|
||||||
|
for _, typ := range BlogEditorTypes(nil) {
|
||||||
|
_, err := typ.Decode(context.Background(), pact.FormFieldValue{Value: value})
|
||||||
|
var refused *pact.FormFieldError
|
||||||
|
if !errors.As(err, &refused) || len(refused.Messages) != 1 || refused.Messages[0] != editorContentInvalid {
|
||||||
|
t.Fatalf("%s: Decode(%#v) error = %v", typ.Name, value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("every unsafe payload is stripped with a notice, never refused", func(t *testing.T) {
|
||||||
|
stripped := 0
|
||||||
|
for _, tc := range unsafePayloads {
|
||||||
|
for _, got := range decodeBoth(t, tc.src) {
|
||||||
|
text, _ := got.Value.(string)
|
||||||
|
if text != SanitizeHTML(tc.src) {
|
||||||
|
t.Fatalf("%s: Decode = %q, want SanitizeHTML's %q", tc.name, text, SanitizeHTML(tc.src))
|
||||||
|
}
|
||||||
|
// Markdown-only payloads ("[x](javascript:...)") are inert text
|
||||||
|
// to the HTML policy: kept, and then without a notice.
|
||||||
|
if (text == tc.src) != (len(got.Notices) == 0) {
|
||||||
|
t.Fatalf("%s: Decode = %q with %d notices, want notices exactly when stripped", tc.name, text, len(got.Notices))
|
||||||
|
}
|
||||||
|
if text != tc.src {
|
||||||
|
stripped++
|
||||||
|
}
|
||||||
|
assertNoActiveContent(t, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every payload with HTML markup is stripped; only the markdown
|
||||||
|
// syntax ones (attribute block, javascript, vbscript and data links,
|
||||||
|
// data image) are plain text here.
|
||||||
|
if want := 2 * (len(unsafePayloads) - 5); stripped != want {
|
||||||
|
t.Fatalf("stripped %d decodes, want %d", stripped, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// langPluralForms returns the plural form names of editor.<key> in
|
||||||
|
// lang/<locale>/lang.yaml, or nil when the key is a single string.
|
||||||
|
func langPluralForms(t *testing.T, locale, key string) (forms []string, found bool) {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile("../lang/" + locale + "/lang.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inEditor, inKey := false, false
|
||||||
|
sc := bufio.NewScanner(bytes.NewReader(raw))
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
indent := len(line) - len(strings.TrimLeft(line, " "))
|
||||||
|
switch {
|
||||||
|
case trimmed == "" || strings.HasPrefix(trimmed, "#"):
|
||||||
|
case indent == 0:
|
||||||
|
inEditor, inKey = trimmed == "editor:", false
|
||||||
|
case inEditor && indent == 2:
|
||||||
|
name, rest, _ := strings.Cut(trimmed, ":")
|
||||||
|
inKey = name == key
|
||||||
|
if inKey {
|
||||||
|
found = true
|
||||||
|
if strings.TrimSpace(rest) != "" {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case inEditor && inKey && indent == 4:
|
||||||
|
name, _, _ := strings.Cut(trimmed, ":")
|
||||||
|
forms = append(forms, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return forms, found
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlogEditorNotices(t *testing.T) {
|
||||||
|
t.Run("notices come in the order elements, attributes, comments, other", func(t *testing.T) {
|
||||||
|
_, report := SanitizeHTMLReport(`<p onclick="x" onload="y">a</p><script>s</script><style>p{}</style><!-- c -->`)
|
||||||
|
if report.Other {
|
||||||
|
t.Fatalf("report = %+v, want no unexplained change", report)
|
||||||
|
}
|
||||||
|
report.Other = true
|
||||||
|
got := removalNotices(report)
|
||||||
|
if len(got) != 4 {
|
||||||
|
t.Fatalf("notices = %#v, report %+v", got, report)
|
||||||
|
}
|
||||||
|
want := []struct {
|
||||||
|
message string
|
||||||
|
count int
|
||||||
|
names string
|
||||||
|
}{
|
||||||
|
{editorRemovedElements, 2, "script, style"},
|
||||||
|
{editorRemovedAttributes, 2, "onclick, onload"},
|
||||||
|
{editorRemovedComments, 1, ""},
|
||||||
|
{editorMarkupChanged, 0, ""},
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
if got[i].Message != w.message || got[i].Count != w.count || got[i].Params["names"] != w.names {
|
||||||
|
t.Fatalf("notice %d = %#v, want %+v", i, got[i], w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an empty report has no notice", func(t *testing.T) {
|
||||||
|
if got := removalNotices(HTMLRemovals{}); got != nil {
|
||||||
|
t.Fatalf("notices = %#v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("names are cut after ten with an ellipsis; the count stays exact", func(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, tag := range []string{"script", "iframe", "object", "embed", "form", "input", "button", "select", "textarea", "base", "meta", "link"} {
|
||||||
|
b.WriteString("<" + tag + "></" + tag + ">")
|
||||||
|
}
|
||||||
|
_, report := SanitizeHTMLReport(b.String())
|
||||||
|
got := removalNotices(report)
|
||||||
|
if len(got) != 1 || got[0].Count != 12 {
|
||||||
|
t.Fatalf("notices = %#v", got)
|
||||||
|
}
|
||||||
|
names := strings.Split(got[0].Params["names"], ", ")
|
||||||
|
if len(names) != 11 || names[10] != "…" || names[0] != "script" || names[9] != "base" {
|
||||||
|
t.Fatalf("names = %q", got[0].Params["names"])
|
||||||
|
}
|
||||||
|
if removalNames([]string{"a", "b"}, false) != "a, b" || removalNames([]string{"a"}, true) != "a, …" {
|
||||||
|
t.Fatal("removalNames")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("every notice key is in lang/en and lang/pl with its plural forms", func(t *testing.T) {
|
||||||
|
for _, key := range []string{"removed_elements", "removed_attributes", "removed_comments"} {
|
||||||
|
en, ok := langPluralForms(t, "en", key)
|
||||||
|
if !ok || strings.Join(en, ",") != "one,other" {
|
||||||
|
t.Fatalf("en %s forms = %v (found %v), want one, other", key, en, ok)
|
||||||
|
}
|
||||||
|
pl, ok := langPluralForms(t, "pl", key)
|
||||||
|
if !ok || strings.Join(pl, ",") != "one,few,many,other" {
|
||||||
|
t.Fatalf("pl %s forms = %v (found %v), want one, few, many, other", key, pl, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{"content_invalid", "image_invalid", "upload_failed", "markup_changed"} {
|
||||||
|
for _, locale := range []string{"en", "pl"} {
|
||||||
|
if forms, ok := langPluralForms(t, locale, key); !ok || forms != nil {
|
||||||
|
t.Fatalf("%s %s: forms %v found %v, want a single phrase", locale, key, forms, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, constant := range map[string]string{
|
||||||
|
"content_invalid": editorContentInvalid, "image_invalid": editorImageInvalid,
|
||||||
|
"removed_elements": editorRemovedElements, "removed_attributes": editorRemovedAttributes,
|
||||||
|
"removed_comments": editorRemovedComments, "markup_changed": editorMarkupChanged,
|
||||||
|
} {
|
||||||
|
if constant != "golem15.journal::lang.editor."+key {
|
||||||
|
t.Fatalf("%s constant = %q", key, constant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLooksLikeHTML(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
in string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"<p>a</p>", true},
|
||||||
|
{" \n\t<p>a</p>", true},
|
||||||
|
{"<!-- imported --><p>a</p>", true},
|
||||||
|
{"<!DOCTYPE html>", true},
|
||||||
|
{"\u00a0<p>a</p>", true},
|
||||||
|
{"", false},
|
||||||
|
{" ", false},
|
||||||
|
{"# Heading", false},
|
||||||
|
{"Text with <b>inline</b> markup", false},
|
||||||
|
{"> quote", false},
|
||||||
|
{"<p>", false},
|
||||||
|
} {
|
||||||
|
if got := LooksLikeHTML(tc.in); got != tc.want {
|
||||||
|
t.Fatalf("LooksLikeHTML(%q) = %v, want %v", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlogEditorOptions(t *testing.T) {
|
||||||
|
types := BlogEditorTypes(nil)
|
||||||
|
if len(types) != 2 || types[0].Name != "blogeditor" || types[1].Name != "mlblogeditor" {
|
||||||
|
t.Fatalf("types = %+v", types)
|
||||||
|
}
|
||||||
|
for _, typ := range types {
|
||||||
|
if typ.Element != BlogEditorElement || typ.CheckOptions == nil || typ.Decode == nil || typ.Load == nil || typ.ListValue == nil || typ.Upload == nil {
|
||||||
|
t.Fatalf("%s = %+v", typ.Name, typ)
|
||||||
|
}
|
||||||
|
if strings.Join(typ.Scripts, ",") != "assets/dist/blogeditor.js" || strings.Join(typ.Styles, ",") != "assets/dist/blogeditor.css" {
|
||||||
|
t.Fatalf("%s assets = %v %v", typ.Name, typ.Scripts, typ.Styles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if types[0].Translatable || strings.Join(types[0].Options, ",") != "uploadFolder" {
|
||||||
|
t.Fatalf("blogeditor = %+v", types[0])
|
||||||
|
}
|
||||||
|
if !types[1].Translatable || strings.Join(types[1].Options, ",") != "htmlColumn,uploadFolder" {
|
||||||
|
t.Fatalf("mlblogeditor = %+v", types[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ok := range []map[string]any{
|
||||||
|
{},
|
||||||
|
{"htmlColumn": "content_html"},
|
||||||
|
{"htmlColumn": "_x9"},
|
||||||
|
{"uploadFolder": "posts"},
|
||||||
|
{"uploadFolder": "posts/2026_10-a"},
|
||||||
|
{"uploadFolder": ""},
|
||||||
|
{"uploadFolder": strings.Repeat("a", 100)},
|
||||||
|
} {
|
||||||
|
if err := checkBlogOptions(ok); err != nil {
|
||||||
|
t.Fatalf("checkBlogOptions(%v) = %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range []map[string]any{
|
||||||
|
{"htmlColumn": ""},
|
||||||
|
{"htmlColumn": "content html"},
|
||||||
|
{"htmlColumn": "1column"},
|
||||||
|
{"htmlColumn": "content-html"},
|
||||||
|
{"htmlColumn": 3},
|
||||||
|
{"htmlColumn": nil},
|
||||||
|
{"uploadFolder": "../up"},
|
||||||
|
{"uploadFolder": "a..b"},
|
||||||
|
{"uploadFolder": "a b"},
|
||||||
|
{"uploadFolder": "a\\b"},
|
||||||
|
{"uploadFolder": strings.Repeat("a", 101)},
|
||||||
|
{"uploadFolder": 5},
|
||||||
|
} {
|
||||||
|
if err := checkBlogOptions(bad); err == nil {
|
||||||
|
t.Fatalf("checkBlogOptions(%v) = nil, want an error", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlogEditorListValue(t *testing.T) {
|
||||||
|
html := "<p>Grzyby</p>"
|
||||||
|
for _, tc := range []struct {
|
||||||
|
in any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"<h2>Title</h2><p>Some <strong>bold</strong>\n text</p>", "Title Some bold text"},
|
||||||
|
{"<p>Grz<strong>yby</strong> <em>leśne</em></p>", "Grzyby leśne"},
|
||||||
|
{"<p>a</p><p>b</p><ul><li>c</li><li>d</li></ul>", "a b c d"},
|
||||||
|
{"<p>x</p><script>alert(1)</script><style>p{}</style><p>y</p>", "x y"},
|
||||||
|
{"<p>Fish & chips <3</p>", "Fish & chips <3"},
|
||||||
|
{"<!-- hidden --><p>shown</p>", "shown"},
|
||||||
|
{editorPlaceholder + "<p>after</p>", "after"},
|
||||||
|
{"## Markdown *stays* text", "## Markdown *stays* text"},
|
||||||
|
{" \n ", ""},
|
||||||
|
{nil, ""},
|
||||||
|
{(*string)(nil), ""},
|
||||||
|
{&html, "Grzyby"},
|
||||||
|
{42, "42"},
|
||||||
|
} {
|
||||||
|
if got := blogListValue(context.Background(), tc.in); got != tc.want {
|
||||||
|
t.Fatalf("ListValue(%#v) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exact := strings.Repeat("ż", 120)
|
||||||
|
if got := blogListValue(context.Background(), "<p>"+exact+"</p>"); got != exact {
|
||||||
|
t.Fatalf("120 runes were cut: %q", got)
|
||||||
|
}
|
||||||
|
long := blogListValue(context.Background(), "<p>"+strings.Repeat("ż", 121)+"</p>")
|
||||||
|
if utf8.RuneCountInString(long) != 121 || !strings.HasSuffix(long, "…") {
|
||||||
|
t.Fatalf("121 runes = %q", long)
|
||||||
|
}
|
||||||
|
// White space before the cut is trimmed before the ellipsis.
|
||||||
|
spaced := blogListValue(context.Background(), "<p>"+strings.Repeat("a", 119)+" bcd</p>")
|
||||||
|
if spaced != strings.Repeat("a", 119)+"…" {
|
||||||
|
t.Fatalf("cut at a space = %q", spaced)
|
||||||
|
}
|
||||||
|
for _, typ := range BlogEditorTypes(nil) {
|
||||||
|
if got := typ.ListValue(context.Background(), "<p>a</p>"); got != "a" {
|
||||||
|
t.Fatalf("%s ListValue = %q", typ.Name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// File heads http.DetectContentType recognizes.
|
||||||
|
var (
|
||||||
|
testPNG = append([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"), make([]byte, 64)...)
|
||||||
|
testJPEG = append([]byte("\xff\xd8\xff\xe0\x00\x10JFIF\x00"), make([]byte, 64)...)
|
||||||
|
testGIF = append([]byte("GIF89a"), make([]byte, 64)...)
|
||||||
|
testWebP = append([]byte("RIFF\x24\x00\x00\x00WEBPVP8 "), make([]byte, 64)...)
|
||||||
|
testHTML = []byte("<!DOCTYPE html><html><body><script>alert(1)</script></body></html>")
|
||||||
|
testSVG = []byte(`<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>`)
|
||||||
|
testText = []byte("just some text that is not an image at all")
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBlogEditorUpload(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
bucket := memblob.OpenBucket(nil)
|
||||||
|
t.Cleanup(func() { _ = bucket.Close() })
|
||||||
|
app := backpack.New(nil)
|
||||||
|
if err := attach.Publish(app, bucket); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
types := BlogEditorTypes(func() *backpack.App { return app })
|
||||||
|
upload := func(typ pact.FormFieldType, options map[string]any, name string, body []byte) (pact.FormFieldUploadResult, error) {
|
||||||
|
return typ.Upload(ctx, pact.FormFieldUpload{Field: "content", Options: options, Filename: name, Body: bytes.NewReader(body)})
|
||||||
|
}
|
||||||
|
refused := func(t *testing.T, err error, message string) {
|
||||||
|
t.Helper()
|
||||||
|
var fe *pact.FormFieldError
|
||||||
|
if !errors.As(err, &fe) || len(fe.Messages) != 1 || fe.Messages[0] != message {
|
||||||
|
t.Fatalf("error = %v, want FormFieldError %q", err, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stored := func(t *testing.T, key string, want []byte) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := bucket.ReadAll(ctx, key)
|
||||||
|
if err != nil || !bytes.Equal(data, want) {
|
||||||
|
t.Fatalf("read %s: %v (bytes equal %v)", key, err, bytes.Equal(data, want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("each image type is stored when its bytes match its name", func(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
body []byte
|
||||||
|
key string
|
||||||
|
}{
|
||||||
|
{"a.png", testPNG, "journal/posts/a.png"},
|
||||||
|
{"b.jpg", testJPEG, "journal/posts/b.jpg"},
|
||||||
|
{"c.JPEG", testJPEG, "journal/posts/c.jpeg"},
|
||||||
|
{"d.gif", testGIF, "journal/posts/d.gif"},
|
||||||
|
{"e.webp", testWebP, "journal/posts/e.webp"},
|
||||||
|
{"noext", testPNG, "journal/posts/noext.png"},
|
||||||
|
{"photo", testJPEG, "journal/posts/photo.jpg"},
|
||||||
|
} {
|
||||||
|
got, err := upload(types[1], map[string]any{"uploadFolder": "posts"}, tc.name, tc.body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
if got.URL != attach.PublicURL(tc.key) {
|
||||||
|
t.Fatalf("%s: URL = %q, want %q", tc.name, got.URL, attach.PublicURL(tc.key))
|
||||||
|
}
|
||||||
|
stored(t, tc.key, tc.body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a file that does not sniff as its image type is refused", func(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
body []byte
|
||||||
|
}{
|
||||||
|
{"page.png", testHTML},
|
||||||
|
{"logo.svg", testSVG},
|
||||||
|
{"vector.png", testSVG},
|
||||||
|
{"notes.txt", testText},
|
||||||
|
{"noext", testText},
|
||||||
|
{"photo.jpg", testPNG},
|
||||||
|
{"photo.png", testGIF},
|
||||||
|
{"empty.png", nil},
|
||||||
|
} {
|
||||||
|
_, err := upload(types[0], nil, tc.name, tc.body)
|
||||||
|
refused(t, err, editorImageInvalid)
|
||||||
|
}
|
||||||
|
if _, err := types[0].Upload(ctx, pact.FormFieldUpload{Filename: "a.png"}); err == nil {
|
||||||
|
t.Fatal("an upload without a body was accepted")
|
||||||
|
} else {
|
||||||
|
refused(t, err, editorImageInvalid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an image under a name StoreMedia refuses gets its message", func(t *testing.T) {
|
||||||
|
_, err := upload(types[0], nil, "photo.bmp", testPNG)
|
||||||
|
refused(t, err, "The file must be an image.")
|
||||||
|
big := append(append([]byte{}, testPNG...), make([]byte, MediaMaxBytes)...)
|
||||||
|
_, err = upload(types[0], nil, "big.png", big)
|
||||||
|
refused(t, err, "The file may not be greater than 10240 kilobytes.")
|
||||||
|
_, err = upload(types[0], map[string]any{"uploadFolder": "../x"}, "a.png", testPNG)
|
||||||
|
refused(t, err, "The folder format is invalid.")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the folder is the field's uploadFolder, editor by default", func(t *testing.T) {
|
||||||
|
got, err := upload(types[0], map[string]any{"uploadFolder": "categories"}, "cat.png", testPNG)
|
||||||
|
if err != nil || got.URL != attach.PublicURL("journal/categories/cat.png") {
|
||||||
|
t.Fatalf("URL = %q, %v", got.URL, err)
|
||||||
|
}
|
||||||
|
got, err = upload(types[0], nil, "default.png", testPNG)
|
||||||
|
if err != nil || got.URL != attach.PublicURL("journal/editor/default.png") {
|
||||||
|
t.Fatalf("URL = %q, %v", got.URL, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("names are made safe", func(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"Zdjęcie 1.PNG": "zdjecie-1.png",
|
||||||
|
`C:\\Users\\x\\My Photo.jpg`: "my-photo.jpg",
|
||||||
|
"../../etc/passwd.png": "passwd.png",
|
||||||
|
"...": "image",
|
||||||
|
"": "image",
|
||||||
|
"-.-a-.png": "a.png",
|
||||||
|
"a--b__c.webp": "a-b-c.webp",
|
||||||
|
"<script>.gif": "script.gif",
|
||||||
|
`x" onerror="alert(1).png`: "x-onerror-alert-1.png",
|
||||||
|
"name.with.dots.jpeg": "name-with-dots.jpeg",
|
||||||
|
"weird.ext$": "weird-ext",
|
||||||
|
} {
|
||||||
|
if got := safeUploadName(in); got != want {
|
||||||
|
t.Fatalf("safeUploadName(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a missing application or bucket is an opaque error", func(t *testing.T) {
|
||||||
|
for name, typ := range map[string]pact.FormFieldType{
|
||||||
|
"nil app": BlogEditorTypes(nil)[0],
|
||||||
|
"nil value": BlogEditorTypes(func() *backpack.App { return nil })[0],
|
||||||
|
"no bucket": BlogEditorTypes(func() *backpack.App { return backpack.New(nil) })[0],
|
||||||
|
} {
|
||||||
|
_, err := upload(typ, nil, "a.png", testPNG)
|
||||||
|
if err == nil || errors.As(err, new(*pact.FormFieldError)) {
|
||||||
|
t.Fatalf("%s: error = %v, want an opaque error", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
66
classes/check_html_test.go
Normal file
66
classes/check_html_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCheckHTML(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"empty", "", true},
|
||||||
|
{"heading and link", `<h2>Cordyceps</h2><p>Plain <a href="https://example.test/a">link</a></p>`, true},
|
||||||
|
{"table", `<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>`, true},
|
||||||
|
{"polish date label", `<p>Data: 12 maja 2024</p>`, true},
|
||||||
|
{"metadata text", `<p>Some metadata: here</p>`, true},
|
||||||
|
{"quotes, apostrophes and nbsp", `<h2>Co to jest Lion's Mane? </h2><p>"superior herb" & inne</p>`, true},
|
||||||
|
{"pasted chat transcript", `<div data-message-author-role="assistant" data-message-id="cf51192a-db36-4816-8d29-b0606b53d289" dir="auto"><p>x</p></div>`, true},
|
||||||
|
{"script", `<script>alert(1)</script>`, false},
|
||||||
|
{"iframe", `<iframe src="https://example.test/"></iframe>`, false},
|
||||||
|
{"object", `<object data=x>`, false},
|
||||||
|
{"embed", `<embed src=x>`, false},
|
||||||
|
{"event handler", `<img src=x onerror="alert(1)">`, false},
|
||||||
|
{"event handler without a space", `<p><img src="x"onerror="alert(1)"></p>`, false},
|
||||||
|
{"slash-separated svg onload", "<div>\n<svg/onload=alert(1)>\n</div>", false},
|
||||||
|
{"javascript url", `<a href="javascript:alert(1)">`, false},
|
||||||
|
{"numeric entity colon", `<a href="javascript:alert(1)">x</a>`, false},
|
||||||
|
{"named entity colon", `<a href="javascript:alert(1)">x</a>`, false},
|
||||||
|
{"entity inside the scheme", `<a href="javascript:alert(1)">x</a>`, false},
|
||||||
|
{"tab inside the scheme", "<a href=\"java\tscript:alert(1)\">x</a>", false},
|
||||||
|
{"vbscript url mixed case", `<a href="VBScript:x">`, false},
|
||||||
|
{"data url", `<img src="data:image/png;base64,AAAA">`, false},
|
||||||
|
{"base", `<base href="https://evil.test/">`, false},
|
||||||
|
{"meta refresh", `<meta http-equiv="refresh" content="0;url=https://evil.test">`, false},
|
||||||
|
{"form", `<form action="https://evil.test"><input type=password></form>`, false},
|
||||||
|
{"style element", `<style>p { color: red }</style>`, false},
|
||||||
|
{"comment", `<p>a</p><!-- hidden -->`, false},
|
||||||
|
{"duplicate href", `<p><a href="https://x" href="javascript:alert(1)">x</a></p>`, false},
|
||||||
|
{"duplicate src", `<img src=ok src="javascript:alert(1)">`, false},
|
||||||
|
{"duplicate style", `<p style="color: red" style="position: fixed">x</p>`, false},
|
||||||
|
{"duplicate of an allowed attribute", `<p class="a" class="a">x</p>`, false},
|
||||||
|
{"attribute names inside a value", `<p><a href="https://example.test/?src=a&href=b">x</a></p>`, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := CheckHTML(tc.in)
|
||||||
|
if tc.ok && err != nil {
|
||||||
|
t.Fatalf("CheckHTML(%q) = %v, want nil", tc.in, err)
|
||||||
|
}
|
||||||
|
if !tc.ok && err == nil {
|
||||||
|
t.Fatalf("CheckHTML(%q) = nil, want an error", tc.in)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every stored-XSS payload FormatHTML strips is refused by CheckHTML.
|
||||||
|
for _, tc := range unsafePayloads {
|
||||||
|
if tc.src[0] != '<' {
|
||||||
|
continue // markdown, not HTML
|
||||||
|
}
|
||||||
|
t.Run("payload "+tc.name, func(t *testing.T) {
|
||||||
|
if err := CheckHTML(tc.src); err == nil {
|
||||||
|
t.Fatalf("CheckHTML(%q) = nil, want an error", tc.src)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
860
classes/content_descriptors.go
Normal file
860
classes/content_descriptors.go
Normal file
@@ -0,0 +1,860 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The admin controller IDs whose forms edit the described records. They
|
||||||
|
// repeat the controllers package constants, which this package cannot
|
||||||
|
// import (controllers imports classes).
|
||||||
|
const (
|
||||||
|
postsControllerID = "golem15.journal.posts"
|
||||||
|
categoriesControllerID = "golem15.journal.categories"
|
||||||
|
tagsControllerID = "golem15.journal.tags"
|
||||||
|
)
|
||||||
|
|
||||||
|
// contentDB is the database a content descriptor reads: the write
|
||||||
|
// transaction in ctx when there is one (cabana.TxFromContext), so code
|
||||||
|
// running inside a save sees what the save wrote, else the application's.
|
||||||
|
func (m menuTypes) contentDB(ctx context.Context) (*gorm.DB, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if tx, ok := cabana.TxFromContext(ctx); ok {
|
||||||
|
return tx.Session(&gorm.Session{NewDB: true, Context: ctx}), nil
|
||||||
|
}
|
||||||
|
return m.db(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// describePost describes one post, drafts included: Published tells
|
||||||
|
// whether visitors see it.
|
||||||
|
func (m menuTypes) describePost(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
||||||
|
id, ok := menuReferenceID(in.ID)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
db, err := m.contentDB(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var posts []models.Post
|
||||||
|
if err := db.Where("id = ?", id).Limit(1).Find(&posts).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(posts) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
records, err := describePosts(ctx, db, posts, in.Pattern, in.UnprefixedLocale, m.facts(ctx, db))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &records[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// descriptorFacts are the site settings the descriptors' facts follow.
|
||||||
|
type descriptorFacts struct {
|
||||||
|
h1ToH2 bool // EffectiveH1ToH2: TopHeading is 2, else 0
|
||||||
|
authorPages bool // AuthorPagesKey: authors carry a journal-author reference
|
||||||
|
}
|
||||||
|
|
||||||
|
// facts reads the settings the descriptors follow, on db.
|
||||||
|
func (m menuTypes) facts(ctx context.Context, db *gorm.DB) descriptorFacts {
|
||||||
|
var cfg *compass.Config
|
||||||
|
if m.app != nil {
|
||||||
|
if app := m.app(); app != nil {
|
||||||
|
cfg = app.Config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return descriptorFacts{h1ToH2: EffectiveH1ToH2(ctx, db, cfg), authorPages: m.authorPages()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// describePosts describes posts in the given order. Each locale's URL is
|
||||||
|
// the alternate resolvePost builds; Title is the translated title,
|
||||||
|
// Description the excerpt as plain text, Image the first featured image and
|
||||||
|
// Words the body's word count, each translated with the default value as
|
||||||
|
// fallback. The record carries its first publication, its categories by
|
||||||
|
// name (Parents), its author (with a journal-author reference while author
|
||||||
|
// pages are on) and TopHeading (2 while the H1 setting is on, else 0); each
|
||||||
|
// locale lists the featured image (alt: the file's title, else the
|
||||||
|
// translated post title, P33-06) and the body's images, YouTube embeds and
|
||||||
|
// headings (parseContentFacts). A locale in which the post has no content
|
||||||
|
// (no translated title, content, content_html or excerpt, P33-64) keeps its
|
||||||
|
// entry with URL "" (no public page, D-57). The queries are batched per
|
||||||
|
// page.
|
||||||
|
func describePosts(ctx context.Context, db *gorm.DB, posts []models.Post, pattern, unprefixed string, facts descriptorFacts) ([]pact.ContentRecord, error) {
|
||||||
|
in := pact.MenuItemResolve{Pattern: pattern, UnprefixedLocale: unprefixed}
|
||||||
|
items, err := resolvePostItems(ctx, db, posts, in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
locales, err := loadMenuLocales(ctx, db, unprefixed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids := make([]uint, 0, len(posts))
|
||||||
|
for _, p := range posts {
|
||||||
|
ids = append(ids, p.ID)
|
||||||
|
}
|
||||||
|
tr, err := loadMenuTranslations(ctx, db, models.Post{}.MorphName(), ids, locales)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
featured, err := firstFiles(ctx, db, models.Post{}.MorphName(), "featured_images", ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parents, err := postCategoryRefs(ctx, db, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
userIDs := []uint{}
|
||||||
|
for _, p := range posts {
|
||||||
|
if p.UserID != nil {
|
||||||
|
userIDs = append(userIDs, *p.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
authors, err := loadAuthors(ctx, db, userIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]pact.ContentRecord, 0, len(posts))
|
||||||
|
for i := range posts {
|
||||||
|
p := &posts[i]
|
||||||
|
rec := pact.ContentRecord{
|
||||||
|
ID: strconv.FormatUint(uint64(p.ID), 10),
|
||||||
|
Published: postPublished(p),
|
||||||
|
LastMod: copyTime(p.UpdatedAt),
|
||||||
|
PublishedAt: copyTime(p.PublishedAt),
|
||||||
|
Parents: parents[p.ID],
|
||||||
|
TopHeading: topHeading(facts.h1ToH2),
|
||||||
|
}
|
||||||
|
if p.UserID != nil {
|
||||||
|
if a, ok := authors[*p.UserID]; ok {
|
||||||
|
person := pact.ContentPerson{Name: a.name()}
|
||||||
|
if facts.authorPages {
|
||||||
|
person.Ref = pact.ContentRef{Type: MenuTypeAuthor, ID: strconv.FormatUint(uint64(a.ID), 10)}
|
||||||
|
}
|
||||||
|
rec.Authors = []pact.ContentPerson{person}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file, hasFile := featured[p.ID]
|
||||||
|
for _, alt := range items[i].Alternates {
|
||||||
|
locale := alt.Locale
|
||||||
|
if locale == xDefaultLocale {
|
||||||
|
locale = ""
|
||||||
|
}
|
||||||
|
body := tr.value(locale, p.ID, "content_html", p.ContentHTML)
|
||||||
|
if strings.TrimSpace(body) == "" {
|
||||||
|
body = tr.value(locale, p.ID, "content", p.Content)
|
||||||
|
}
|
||||||
|
words := wordCount(plainText(body))
|
||||||
|
found := parseContentFacts(body)
|
||||||
|
var images []pact.ContentImage
|
||||||
|
image := ""
|
||||||
|
if hasFile {
|
||||||
|
image = file.URL()
|
||||||
|
altText := alt.Title
|
||||||
|
if file.Title != nil && strings.TrimSpace(*file.Title) != "" {
|
||||||
|
altText = strings.TrimSpace(*file.Title)
|
||||||
|
}
|
||||||
|
images = append(images, pact.ContentImage{URL: image, Alt: altText})
|
||||||
|
}
|
||||||
|
images = append(images, found.images...)
|
||||||
|
url := alt.URL
|
||||||
|
if !tr.hasContent(locales, alt.Locale, p.ID, postContentFields) {
|
||||||
|
// No translation in this locale (P33-64, D-57): no public
|
||||||
|
// page for it, so no hreflang or sitemap address.
|
||||||
|
url = ""
|
||||||
|
}
|
||||||
|
rec.Locales = append(rec.Locales, pact.ContentLocale{
|
||||||
|
Locale: alt.Locale,
|
||||||
|
URL: url,
|
||||||
|
Title: alt.Title,
|
||||||
|
Description: strings.TrimSpace(plainText(tr.value(locale, p.ID, "excerpt", p.Excerpt))),
|
||||||
|
Image: image,
|
||||||
|
Words: &words,
|
||||||
|
Images: images,
|
||||||
|
Videos: found.videos,
|
||||||
|
Headings: found.headings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// postPublished is publishedPosts for one loaded post.
|
||||||
|
func postPublished(p *models.Post) bool {
|
||||||
|
return p.Published && p.PublishedAt != nil && p.PublishedAt.Before(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstImages maps each record id to the public URL of its first file in
|
||||||
|
// field (sort order, then id).
|
||||||
|
func firstImages(ctx context.Context, db *gorm.DB, morph, field string, ids []uint) (map[uint]string, error) {
|
||||||
|
files, err := firstFiles(ctx, db, morph, field, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[uint]string, len(files))
|
||||||
|
for id, f := range files {
|
||||||
|
out[id] = f.URL()
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstFiles maps each record id to its first file in field (sort order,
|
||||||
|
// then id).
|
||||||
|
func firstFiles(ctx context.Context, db *gorm.DB, morph, field string, ids []uint) (map[uint]attach.File, error) {
|
||||||
|
out := map[uint]attach.File{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
keys = append(keys, strconv.FormatUint(uint64(id), 10))
|
||||||
|
}
|
||||||
|
var files []attach.File
|
||||||
|
err := db.WithContext(ctx).
|
||||||
|
Where("attachment_type = ? AND field = ? AND attachment_id IN ?", morph, field, keys).
|
||||||
|
Order("sort_order ASC, id ASC").
|
||||||
|
Find(&files).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range files {
|
||||||
|
id, err := strconv.ParseUint(files[i].AttachmentID, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, seen := out[uint(id)]; !seen {
|
||||||
|
out[uint(id)] = files[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyTime(t *time.Time) *time.Time {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := *t
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainText drops HTML tags and unescapes entities, with whitespace runs
|
||||||
|
// collapsed to one space.
|
||||||
|
func plainText(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
inTag := false
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r == '<':
|
||||||
|
inTag = true
|
||||||
|
b.WriteRune(' ')
|
||||||
|
case r == '>':
|
||||||
|
inTag = false
|
||||||
|
case !inTag:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(strings.Fields(html.UnescapeString(b.String())), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordCount counts the whitespace-separated words of s.
|
||||||
|
func wordCount(s string) int {
|
||||||
|
n := 0
|
||||||
|
in := false
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.IsSpace(r) {
|
||||||
|
in = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !in {
|
||||||
|
n++
|
||||||
|
in = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagPagesKey is the host configuration key that says whether the site has
|
||||||
|
// tag pages (default true). With false, journal-tag items resolve to
|
||||||
|
// nothing, tag descriptions carry empty URLs and tag paths never match.
|
||||||
|
const TagPagesKey = "golem15.journal.tag_pages"
|
||||||
|
|
||||||
|
// maxContentPage caps the page size of a descriptor's Records; a limit of 0
|
||||||
|
// or below asks for the cap.
|
||||||
|
const maxContentPage = 500
|
||||||
|
|
||||||
|
func pageLimit(n int) int {
|
||||||
|
if n <= 0 || n > maxContentPage {
|
||||||
|
return maxContentPage
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// afterID parses a Records After cursor: "" or an invalid value is the
|
||||||
|
// start.
|
||||||
|
func afterID(after string) uint {
|
||||||
|
id, _ := menuReferenceID(after)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorPagesKey is the host configuration key that says whether the site
|
||||||
|
// has author pages (default true). With false, journal-author items resolve
|
||||||
|
// to nothing, author descriptions carry empty URLs, author paths never
|
||||||
|
// match and post authors carry no journal-author reference.
|
||||||
|
const AuthorPagesKey = "golem15.journal.author_pages"
|
||||||
|
|
||||||
|
// authorPages reports AuthorPagesKey, true when unset.
|
||||||
|
func (m menuTypes) authorPages() bool {
|
||||||
|
if m.app == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
app := m.app()
|
||||||
|
if app == nil || app.Config == nil || !app.Config.Has(AuthorPagesKey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return app.Config.Bool(AuthorPagesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagPages reports TagPagesKey, true when unset.
|
||||||
|
func (m menuTypes) tagPages() bool {
|
||||||
|
if m.app == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
app := m.app()
|
||||||
|
if app == nil || app.Config == nil || !app.Config.Has(TagPagesKey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return app.Config.Bool(TagPagesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listPosts is the Records of journal-post: every post, drafts included, in
|
||||||
|
// id order.
|
||||||
|
func (m menuTypes) listPosts(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
||||||
|
db, err := m.contentDB(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return pact.ContentPage{}, err
|
||||||
|
}
|
||||||
|
limit := pageLimit(in.Limit)
|
||||||
|
var posts []models.Post
|
||||||
|
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&posts).Error; err != nil {
|
||||||
|
return pact.ContentPage{}, err
|
||||||
|
}
|
||||||
|
more := len(posts) > limit
|
||||||
|
if more {
|
||||||
|
posts = posts[:limit]
|
||||||
|
}
|
||||||
|
records, err := describePosts(ctx, db, posts, in.Pattern, in.UnprefixedLocale, m.facts(ctx, db))
|
||||||
|
if err != nil {
|
||||||
|
return pact.ContentPage{}, err
|
||||||
|
}
|
||||||
|
return contentPage(records, more), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentPage(records []pact.ContentRecord, more bool) pact.ContentPage {
|
||||||
|
page := pact.ContentPage{Records: records}
|
||||||
|
if page.Records == nil {
|
||||||
|
page.Records = []pact.ContentRecord{}
|
||||||
|
}
|
||||||
|
if more && len(records) > 0 {
|
||||||
|
page.Next = records[len(records)-1].ID
|
||||||
|
}
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchPost is the Match of journal-post. The post is found by its slug in
|
||||||
|
// the path's locale, else its default slug, else a slug of another locale,
|
||||||
|
// else by :id; drafts never match. The match is exact only when the path is
|
||||||
|
// the post's own URL in that locale, so every segment (category, date, id,
|
||||||
|
// locale prefix, slug) must fit the post.
|
||||||
|
func (m menuTypes) matchPost(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
||||||
|
db, err := m.contentDB(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
morph := models.Post{}.MorphName()
|
||||||
|
find := func(values map[string]string, locale string, locales menuLocales) (uint, error) {
|
||||||
|
return findMatchRecord(ctx, db, values, locale, locales, morph, func(q *gorm.DB) *gorm.DB {
|
||||||
|
return publishedPosts(q.Model(&models.Post{}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
|
||||||
|
var posts []models.Post
|
||||||
|
if err := db.Where("id = ?", id).Limit(1).Find(&posts).Error; err != nil || len(posts) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items, err := resolvePostItems(ctx, db, posts, pact.MenuItemResolve{Pattern: in.Pattern, UnprefixedLocale: in.UnprefixedLocale})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items[0].Alternates, nil
|
||||||
|
}
|
||||||
|
return matchContent(ctx, db, in, find, urls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m menuTypes) describeCategory(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
||||||
|
id, ok := menuReferenceID(in.ID)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
db, err := m.contentDB(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cats []models.Category
|
||||||
|
if err := db.Where("id = ?", id).Limit(1).Find(&cats).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(cats) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
records, err := describeCategories(ctx, db, cats, in.Pattern, in.UnprefixedLocale, m.facts(ctx, db))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &records[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m menuTypes) listCategories(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
||||||
|
db, err := m.contentDB(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return pact.ContentPage{}, err
|
||||||
|
}
|
||||||
|
limit := pageLimit(in.Limit)
|
||||||
|
var cats []models.Category
|
||||||
|
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&cats).Error; err != nil {
|
||||||
|
return pact.ContentPage{}, err
|
||||||
|
}
|
||||||
|
more := len(cats) > limit
|
||||||
|
if more {
|
||||||
|
cats = cats[:limit]
|
||||||
|
}
|
||||||
|
records, err := describeCategories(ctx, db, cats, in.Pattern, in.UnprefixedLocale, m.facts(ctx, db))
|
||||||
|
if err != nil {
|
||||||
|
return pact.ContentPage{}, err
|
||||||
|
}
|
||||||
|
return contentPage(records, more), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeCategories describes categories, always published: each locale's
|
||||||
|
// URL and title as resolveCategory builds them, the translated description
|
||||||
|
// as plain text and the word count of the category's content. A child
|
||||||
|
// category's Parents is its parent; every locale carries the pillar text's
|
||||||
|
// heading outline (content is not translatable) and TopHeading follows the
|
||||||
|
// H1 setting (2 on, 0 off), so the pillar text is checked as post content
|
||||||
|
// is (CONTEXT 2026-10-28 H1 conversion scope).
|
||||||
|
// A locale in which the category has no translated name or description
|
||||||
|
// (P33-64) gets URL "".
|
||||||
|
func describeCategories(ctx context.Context, db *gorm.DB, cats []models.Category, pattern, unprefixed string, facts descriptorFacts) ([]pact.ContentRecord, error) {
|
||||||
|
ids := make([]uint, 0, len(cats))
|
||||||
|
for _, c := range cats {
|
||||||
|
ids = append(ids, c.ID)
|
||||||
|
}
|
||||||
|
ci, err := newCategoryItems(ctx, db, pact.MenuItemResolve{Pattern: pattern, UnprefixedLocale: unprefixed}, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]pact.ContentRecord, 0, len(cats))
|
||||||
|
for _, c := range cats {
|
||||||
|
item := ci.item(c)
|
||||||
|
rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(c.ID), 10), Published: true, LastMod: copyTime(c.UpdatedAt),
|
||||||
|
TopHeading: topHeading(facts.h1ToH2)}
|
||||||
|
if c.ParentID != nil && *c.ParentID != 0 && *c.ParentID != c.ID {
|
||||||
|
rec.Parents = []pact.ContentRef{{Type: MenuTypeCategory, ID: strconv.FormatUint(uint64(*c.ParentID), 10)}}
|
||||||
|
}
|
||||||
|
var words *int
|
||||||
|
var headings []pact.ContentHeading
|
||||||
|
if c.Content != nil && strings.TrimSpace(*c.Content) != "" {
|
||||||
|
n := wordCount(plainText(*c.Content))
|
||||||
|
words = &n
|
||||||
|
headings = parseContentFacts(*c.Content).headings
|
||||||
|
}
|
||||||
|
host := ""
|
||||||
|
if c.Description != nil {
|
||||||
|
host = *c.Description
|
||||||
|
}
|
||||||
|
for _, alt := range item.Alternates {
|
||||||
|
locale := alt.Locale
|
||||||
|
if locale == xDefaultLocale {
|
||||||
|
locale = ""
|
||||||
|
}
|
||||||
|
url := alt.URL
|
||||||
|
if !ci.tr.hasContent(ci.locales, alt.Locale, c.ID, categoryContentFields) {
|
||||||
|
url = ""
|
||||||
|
}
|
||||||
|
rec.Locales = append(rec.Locales, pact.ContentLocale{
|
||||||
|
Locale: alt.Locale,
|
||||||
|
URL: url,
|
||||||
|
Title: alt.Title,
|
||||||
|
Description: plainText(ci.tr.value(locale, c.ID, "description", host)),
|
||||||
|
Words: words,
|
||||||
|
Headings: headings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchCategory is the Match of journal-category, by translated or default
|
||||||
|
// slug or :id, exact only on the category's own URL in the path's locale.
|
||||||
|
func (m menuTypes) matchCategory(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
||||||
|
db, err := m.contentDB(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
morph := models.Category{}.MorphName()
|
||||||
|
find := func(values map[string]string, locale string, locales menuLocales) (uint, error) {
|
||||||
|
return findMatchRecord(ctx, db, values, locale, locales, morph, func(q *gorm.DB) *gorm.DB {
|
||||||
|
return q.Model(&models.Category{})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
|
||||||
|
var cats []models.Category
|
||||||
|
if err := db.Where("id = ?", id).Limit(1).Find(&cats).Error; err != nil || len(cats) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ci, err := newCategoryItems(ctx, db, pact.MenuItemResolve{Pattern: in.Pattern, UnprefixedLocale: in.UnprefixedLocale}, []uint{id})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ci.item(cats[0]).Alternates, nil
|
||||||
|
}
|
||||||
|
return matchContent(ctx, db, in, find, urls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagReferences lists every tag by name, ties by id.
|
||||||
|
func (m menuTypes) tagReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
|
||||||
|
db, err := m.db(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var tags []models.Tag
|
||||||
|
if err := db.Order("name ASC, id ASC").Find(&tags).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
refs := make([]pact.MenuItemReference, 0, len(tags))
|
||||||
|
for _, tag := range tags {
|
||||||
|
refs = append(refs, pact.MenuItemReference{Value: strconv.FormatUint(uint64(tag.ID), 10), Label: tag.Name})
|
||||||
|
}
|
||||||
|
return refs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTag resolves one journal-tag item; an unknown tag, and any tag on a
|
||||||
|
// host without tag pages, resolve to nothing.
|
||||||
|
func (m menuTypes) resolveTag(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||||
|
id, ok := menuReferenceID(in.Reference)
|
||||||
|
if !ok || !m.tagPages() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
db, err := m.db(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var tags []models.Tag
|
||||||
|
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(tags) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tag := tags[0]
|
||||||
|
item := resolvedMenuItem(in.Pattern, locales, tag.Name, tag.UpdatedAt, tagParams(tag), func(string) string { return tag.Name })
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagParams are a tag's placeholder values; tags are not translatable.
|
||||||
|
func tagParams(tag models.Tag) func(string) [][2]string {
|
||||||
|
return func(string) [][2]string {
|
||||||
|
return [][2]string{{"id", strconv.FormatUint(uint64(tag.ID), 10)}, {"slug", tag.Slug}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m menuTypes) describeTag(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
||||||
|
id, ok := menuReferenceID(in.ID)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
db, err := m.contentDB(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var tags []models.Tag
|
||||||
|
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(tags) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
records, err := m.describeTags(ctx, db, tags, in.Pattern, in.UnprefixedLocale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &records[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m menuTypes) listTags(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
||||||
|
db, err := m.contentDB(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return pact.ContentPage{}, err
|
||||||
|
}
|
||||||
|
limit := pageLimit(in.Limit)
|
||||||
|
var tags []models.Tag
|
||||||
|
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&tags).Error; err != nil {
|
||||||
|
return pact.ContentPage{}, err
|
||||||
|
}
|
||||||
|
more := len(tags) > limit
|
||||||
|
if more {
|
||||||
|
tags = tags[:limit]
|
||||||
|
}
|
||||||
|
records, err := m.describeTags(ctx, db, tags, in.Pattern, in.UnprefixedLocale)
|
||||||
|
if err != nil {
|
||||||
|
return pact.ContentPage{}, err
|
||||||
|
}
|
||||||
|
return contentPage(records, more), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeTags describes tags, always published, one locale per enabled
|
||||||
|
// locale with the same name and slug; URLs are empty without tag pages, and
|
||||||
|
// in a locale in which none of the tag's published posts has content
|
||||||
|
// (P33-64).
|
||||||
|
func (m menuTypes) describeTags(ctx context.Context, db *gorm.DB, tags []models.Tag, pattern, unprefixed string) ([]pact.ContentRecord, error) {
|
||||||
|
locales, err := loadMenuLocales(ctx, db, unprefixed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pages := m.tagPages()
|
||||||
|
ids := make([]uint, 0, len(tags))
|
||||||
|
for _, tag := range tags {
|
||||||
|
ids = append(ids, tag.ID)
|
||||||
|
}
|
||||||
|
content, err := tagContentLocales(ctx, db, locales, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]pact.ContentRecord, 0, len(tags))
|
||||||
|
for _, tag := range tags {
|
||||||
|
rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(tag.ID), 10), Published: true, LastMod: copyTime(tag.UpdatedAt)}
|
||||||
|
desc := ""
|
||||||
|
if tag.Description != nil {
|
||||||
|
desc = plainText(*tag.Description)
|
||||||
|
}
|
||||||
|
for _, alt := range menuAlternates(pattern, locales, tagParams(tag), func(string) string { return tag.Name }) {
|
||||||
|
url := alt.URL
|
||||||
|
if !pages || !ownerHasContent(locales, content, tag.ID, alt.Locale) {
|
||||||
|
url = ""
|
||||||
|
}
|
||||||
|
rec.Locales = append(rec.Locales, pact.ContentLocale{Locale: alt.Locale, URL: url, Title: tag.Name, Description: desc})
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchTag is the Match of journal-tag, by slug or :id; nothing matches on
|
||||||
|
// a host without tag pages.
|
||||||
|
func (m menuTypes) matchTag(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
||||||
|
if !m.tagPages() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
db, err := m.contentDB(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
find := func(values map[string]string, _ string, _ menuLocales) (uint, error) {
|
||||||
|
q := db.Model(&models.Tag{})
|
||||||
|
switch {
|
||||||
|
case values["slug"] != "":
|
||||||
|
q = q.Where("slug = ?", values["slug"])
|
||||||
|
case values["id"] != "":
|
||||||
|
id, ok := menuReferenceID(values["id"])
|
||||||
|
if !ok {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
q = q.Where("id = ?", id)
|
||||||
|
default:
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
var ids []uint
|
||||||
|
if err := q.Order("id ASC").Limit(1).Pluck("id", &ids).Error; err != nil || len(ids) == 0 {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return ids[0], nil
|
||||||
|
}
|
||||||
|
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
|
||||||
|
var tags []models.Tag
|
||||||
|
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil || len(tags) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tag := tags[0]
|
||||||
|
return menuAlternates(in.Pattern, locales, tagParams(tag), func(string) string { return tag.Name }), nil
|
||||||
|
}
|
||||||
|
return matchContent(ctx, db, in, find, urls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathReading is one way to read a public path: the locale it addresses,
|
||||||
|
// the path without its locale prefix, and whether the prefix is the one the
|
||||||
|
// locale's URLs carry.
|
||||||
|
type pathReading struct {
|
||||||
|
locale string
|
||||||
|
rest string
|
||||||
|
prefixOK bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPath lists the readings of path: with a prefix of an enabled locale
|
||||||
|
// other than the unprefixed one, with the unprefixed locale's own code as a
|
||||||
|
// prefix (stale, never exact), and as a path of the unprefixed locale.
|
||||||
|
func readPath(path string, locales menuLocales) []pathReading {
|
||||||
|
out := []pathReading{}
|
||||||
|
seg, rest := path, "/"
|
||||||
|
if trimmed := strings.TrimPrefix(path, "/"); trimmed != path {
|
||||||
|
if i := strings.IndexByte(trimmed, '/'); i >= 0 {
|
||||||
|
seg, rest = trimmed[:i], trimmed[i:]
|
||||||
|
} else {
|
||||||
|
seg = trimmed
|
||||||
|
}
|
||||||
|
for _, code := range locales.codes {
|
||||||
|
if seg == code {
|
||||||
|
out = append(out, pathReading{locale: code, rest: rest, prefixOK: code != locales.unprefixed})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(out, pathReading{locale: locales.unprefixed, rest: path, prefixOK: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchContent reads the path in every way readPath lists, finds the record
|
||||||
|
// its placeholder values name (find) and compares the path with the
|
||||||
|
// record's URL in the reading's locale (urls). The first exact reading
|
||||||
|
// wins, else the first that found a record.
|
||||||
|
func matchContent(ctx context.Context, db *gorm.DB, in pact.ContentMatch, find func(values map[string]string, locale string, locales menuLocales) (uint, error), urls func(id uint) ([]pact.MenuItemAlternate, error)) (*pact.ContentMatchResult, error) {
|
||||||
|
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var fallback *pact.ContentMatchResult
|
||||||
|
for _, reading := range readPath(in.Path, locales) {
|
||||||
|
values, ok := signpost.MatchPattern(in.Pattern, reading.rest)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, err := find(values, reading.locale, locales)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if id == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alternates, err := urls(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
exact := false
|
||||||
|
for _, alt := range alternates {
|
||||||
|
if alt.Locale == reading.locale || (len(locales.codes) == 0 && alt.Locale == xDefaultLocale) {
|
||||||
|
exact = reading.prefixOK && alt.URL == in.Path
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res := &pact.ContentMatchResult{ID: strconv.FormatUint(uint64(id), 10), Locale: reading.locale, Exact: exact}
|
||||||
|
if exact {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
if fallback == nil {
|
||||||
|
fallback = res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMatchRecord finds the record a path's values name among scope: by
|
||||||
|
// :slug, first the slug of the path's locale (translate's slug index), then
|
||||||
|
// the default slug, then a slug of any other locale; else by :id. A value
|
||||||
|
// reaches the database only as a query parameter.
|
||||||
|
func findMatchRecord(ctx context.Context, db *gorm.DB, values map[string]string, locale string, locales menuLocales, morph string, scope func(*gorm.DB) *gorm.DB) (uint, error) {
|
||||||
|
first := func(ids []uint) (uint, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
var found []uint
|
||||||
|
if err := scope(db.Session(&gorm.Session{NewDB: true, Context: ctx})).Where("id IN ?", ids).Order("id ASC").Limit(1).Pluck("id", &found).Error; err != nil || len(found) == 0 {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return found[0], nil
|
||||||
|
}
|
||||||
|
if slug := values["slug"]; slug != "" {
|
||||||
|
if locale != "" && locale != locales.defaultCode {
|
||||||
|
ids, err := slugIndexIDs(ctx, db, morph, slug, locale)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if id, err := first(ids); err != nil || id != 0 {
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var hosts []uint
|
||||||
|
if err := scope(db.Session(&gorm.Session{NewDB: true, Context: ctx})).Where("slug = ?", slug).Order("id ASC").Pluck("id", &hosts).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if id, err := first(hosts); err != nil || id != 0 {
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
ids, err := slugIndexIDs(ctx, db, morph, slug, "")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return first(ids)
|
||||||
|
}
|
||||||
|
if raw := values["id"]; raw != "" {
|
||||||
|
id, ok := menuReferenceID(raw)
|
||||||
|
if !ok {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return first([]uint{id})
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// slugIndexIDs are the ids of morph records whose translated slug is slug
|
||||||
|
// in locale ("" for any locale), from translate's index table.
|
||||||
|
func slugIndexIDs(ctx context.Context, db *gorm.DB, morph, slug, locale string) ([]uint, error) {
|
||||||
|
q := db.WithContext(ctx).Table(translatemodels.Index{}.TableName()).
|
||||||
|
Where("model_type = ? AND item = ? AND value = ?", morph, "slug", slug)
|
||||||
|
if locale != "" {
|
||||||
|
q = q.Where("locale = ?", locale)
|
||||||
|
}
|
||||||
|
var raw []string
|
||||||
|
if err := q.Order("id ASC").Pluck("model_id", &raw).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids := make([]uint, 0, len(raw))
|
||||||
|
for _, r := range raw {
|
||||||
|
if id, ok := menuReferenceID(r); ok {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
211
classes/content_facts.go
Normal file
211
classes/content_facts.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
xhtml "golang.org/x/net/html"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The MCP tools a Journal content descriptor names (pact.ContentDescriptor
|
||||||
|
// Tools, D-96): Journal fixes its own content.
|
||||||
|
const (
|
||||||
|
toolUpdatePost = "journal_update_post"
|
||||||
|
toolFixHeadings = "journal_fix_headings"
|
||||||
|
toolSetImageAlt = "journal_set_image_alt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// contentFacts is what one locale's body says about itself: its images
|
||||||
|
// (src, alt, decorative, size attributes), its YouTube embeds and its
|
||||||
|
// heading outline, each in document order.
|
||||||
|
type contentFacts struct {
|
||||||
|
images []pact.ContentImage
|
||||||
|
videos []pact.ContentVideo
|
||||||
|
headings []pact.ContentHeading
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseContentFacts reads html once with the x/net/html tokenizer. An img
|
||||||
|
// without src is skipped; an img whose alt attribute is present and empty
|
||||||
|
// is decorative. A figure with data-embed="youtube" and a data-id is a
|
||||||
|
// video (the Blog Editor's embed placeholder). A heading's text is its
|
||||||
|
// text content with white space collapsed; a heading inside a heading is
|
||||||
|
// read as part of the outer one.
|
||||||
|
func parseContentFacts(html string) contentFacts {
|
||||||
|
var out contentFacts
|
||||||
|
if strings.TrimSpace(html) == "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
z := xhtml.NewTokenizer(strings.NewReader(html))
|
||||||
|
level := 0
|
||||||
|
var text strings.Builder
|
||||||
|
for {
|
||||||
|
tt := z.Next()
|
||||||
|
if tt == xhtml.ErrorToken {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
switch tt {
|
||||||
|
case xhtml.TextToken:
|
||||||
|
if level > 0 {
|
||||||
|
text.WriteString(z.Token().Data)
|
||||||
|
}
|
||||||
|
case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
|
||||||
|
tok := z.Token()
|
||||||
|
switch tok.Data {
|
||||||
|
case "img":
|
||||||
|
if img, ok := contentImage(tok); ok {
|
||||||
|
out.images = append(out.images, img)
|
||||||
|
}
|
||||||
|
case "figure":
|
||||||
|
if attr(tok, "data-embed") == "youtube" {
|
||||||
|
if id := attr(tok, "data-id"); id != "" {
|
||||||
|
out.videos = append(out.videos, pact.ContentVideo{Provider: "youtube", ID: id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||||
|
if level == 0 && tt == xhtml.StartTagToken {
|
||||||
|
level = int(tok.Data[1] - '0')
|
||||||
|
text.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if level > 0 && tt == xhtml.StartTagToken && !inlineListTags[tok.Data] && !isHeading(tok.Data) {
|
||||||
|
text.WriteByte(' ')
|
||||||
|
}
|
||||||
|
case xhtml.EndTagToken:
|
||||||
|
tok := z.Token()
|
||||||
|
if level > 0 && tok.Data == "h"+strconv.Itoa(level) {
|
||||||
|
out.headings = append(out.headings, pact.ContentHeading{
|
||||||
|
Level: level,
|
||||||
|
Text: strings.Join(strings.Fields(text.String()), " "),
|
||||||
|
})
|
||||||
|
level = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHeading(name string) bool {
|
||||||
|
return len(name) == 2 && name[0] == 'h' && name[1] >= '1' && name[1] <= '6'
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentImage(tok xhtml.Token) (pact.ContentImage, bool) {
|
||||||
|
src := strings.TrimSpace(attr(tok, "src"))
|
||||||
|
if src == "" {
|
||||||
|
return pact.ContentImage{}, false
|
||||||
|
}
|
||||||
|
img := pact.ContentImage{URL: src}
|
||||||
|
if alt, has := attrOK(tok, "alt"); has {
|
||||||
|
img.Alt = strings.TrimSpace(alt)
|
||||||
|
img.Decorative = img.Alt == ""
|
||||||
|
}
|
||||||
|
img.Width, _ = strconv.Atoi(strings.TrimSpace(attr(tok, "width")))
|
||||||
|
img.Height, _ = strconv.Atoi(strings.TrimSpace(attr(tok, "height")))
|
||||||
|
if img.Width < 0 {
|
||||||
|
img.Width = 0
|
||||||
|
}
|
||||||
|
if img.Height < 0 {
|
||||||
|
img.Height = 0
|
||||||
|
}
|
||||||
|
return img, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func attr(tok xhtml.Token, key string) string {
|
||||||
|
v, _ := attrOK(tok, key)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func attrOK(tok xhtml.Token, key string) (string, bool) {
|
||||||
|
for _, a := range tok.Attr {
|
||||||
|
if a.Namespace == "" && a.Key == key {
|
||||||
|
return a.Val, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// topHeading is a record's TopHeading: 2 while the H1 setting is on (the
|
||||||
|
// page title is the H1), 0 (any level) while it is off.
|
||||||
|
func topHeading(h1ToH2 bool) int {
|
||||||
|
if h1ToH2 {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// postCategoryRefs maps each post id to its categories as journal-category
|
||||||
|
// references, by category name, ties by id (the order :category takes its
|
||||||
|
// first category in).
|
||||||
|
func postCategoryRefs(ctx context.Context, db *gorm.DB, ids []uint) (map[uint][]pact.ContentRef, error) {
|
||||||
|
out := map[uint][]pact.ContentRef{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
PostID uint `gorm:"column:post_id"`
|
||||||
|
CategoryID uint `gorm:"column:category_id"`
|
||||||
|
}
|
||||||
|
err := db.WithContext(ctx).Table("golem15_journal_posts_categories AS pc").
|
||||||
|
Select("pc.post_id, pc.category_id").
|
||||||
|
Joins("JOIN golem15_journal_categories c ON c.id = pc.category_id").
|
||||||
|
Where("pc.post_id IN ?", ids).
|
||||||
|
Order("pc.post_id ASC, c.name ASC, c.id ASC").
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
out[r.PostID] = append(out[r.PostID], pact.ContentRef{Type: MenuTypeCategory, ID: strconv.FormatUint(uint64(r.CategoryID), 10)})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorRow is the part of a backend user a descriptor shows.
|
||||||
|
type authorRow struct {
|
||||||
|
ID uint `gorm:"column:id"`
|
||||||
|
FirstName string `gorm:"column:first_name"`
|
||||||
|
LastName string `gorm:"column:last_name"`
|
||||||
|
Login string `gorm:"column:login"`
|
||||||
|
AuthorSlug string `gorm:"column:author_slug"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorColumns is the only projection descriptors read from backend_users:
|
||||||
|
// no contact, credential or permission column.
|
||||||
|
const authorColumns = "id, COALESCE(first_name, '') AS first_name, COALESCE(last_name, '') AS last_name, " +
|
||||||
|
"login, COALESCE(golem15_bloghub_author_slug, '') AS author_slug"
|
||||||
|
|
||||||
|
// name is the author's display name: first and last name, else the login.
|
||||||
|
func (a authorRow) name() string {
|
||||||
|
if n := strings.TrimSpace(strings.TrimSpace(a.FirstName) + " " + strings.TrimSpace(a.LastName)); n != "" {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return a.Login
|
||||||
|
}
|
||||||
|
|
||||||
|
// slug is the :author value: the author slug, else the login (the author
|
||||||
|
// API's own lookup order).
|
||||||
|
func (a authorRow) slug() string {
|
||||||
|
if a.AuthorSlug != "" {
|
||||||
|
return a.AuthorSlug
|
||||||
|
}
|
||||||
|
return a.Login
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAuthors reads the live backend users with the given ids.
|
||||||
|
func loadAuthors(ctx context.Context, db *gorm.DB, ids []uint) (map[uint]authorRow, error) {
|
||||||
|
out := map[uint]authorRow{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
var rows []authorRow
|
||||||
|
if err := db.WithContext(ctx).Table("backend_users").Select(authorColumns).
|
||||||
|
Where("deleted_at IS NULL AND id IN ?", ids).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
out[r.ID] = r
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
69
classes/content_facts_test.go
Normal file
69
classes/content_facts_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestParseContentFacts pins what a body says about itself: images with
|
||||||
|
// alt, decorative (alt present and empty) and size attributes (negative
|
||||||
|
// and invalid sizes 0), an img without src skipped; YouTube embeds of the
|
||||||
|
// editor's figure; the heading outline with collapsed text, inline tags
|
||||||
|
// joined, block children spaced, a no-break space collapsed like any
|
||||||
|
// other, a heading inside a heading read as part
|
||||||
|
// of the outer one.
|
||||||
|
func TestParseContentFacts(t *testing.T) {
|
||||||
|
f := parseContentFacts(`<h2>Lwia <em>grzywa</em></h2>
|
||||||
|
<p><img src=" /m/a.jpg " alt=" Lion's mane " width="800" height="-1"><img src="/m/line.png" alt=""><img alt="no src"><img src="/m/b.jpg" width="x"></p>
|
||||||
|
<figure data-embed="youtube" data-id="abc123"></figure><figure data-embed="youtube"></figure><figure data-embed="vimeo" data-id="9"></figure>
|
||||||
|
<h3>Dawkowanie<br>i forma</h3><h4><span>Outer <h5>inner</h5> tail</span></h4><h2></h2>`)
|
||||||
|
wantImages := []pact.ContentImage{
|
||||||
|
{URL: "/m/a.jpg", Alt: "Lion's mane", Width: 800},
|
||||||
|
{URL: "/m/line.png", Decorative: true},
|
||||||
|
{URL: "/m/b.jpg"},
|
||||||
|
}
|
||||||
|
if len(f.images) != len(wantImages) {
|
||||||
|
t.Fatalf("images = %+v", f.images)
|
||||||
|
}
|
||||||
|
for i, w := range wantImages {
|
||||||
|
if f.images[i] != w {
|
||||||
|
t.Errorf("image %d = %+v, want %+v", i, f.images[i], w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(f.videos) != 1 || f.videos[0] != (pact.ContentVideo{Provider: "youtube", ID: "abc123"}) {
|
||||||
|
t.Fatalf("videos = %+v", f.videos)
|
||||||
|
}
|
||||||
|
wantHeadings := []pact.ContentHeading{{Level: 2, Text: "Lwia grzywa"}, {Level: 3, Text: "Dawkowanie i forma"}, {Level: 4, Text: "Outer inner tail"}, {Level: 2, Text: ""}}
|
||||||
|
if len(f.headings) != len(wantHeadings) {
|
||||||
|
t.Fatalf("headings = %+v", f.headings)
|
||||||
|
}
|
||||||
|
for i, w := range wantHeadings {
|
||||||
|
if f.headings[i] != w {
|
||||||
|
t.Errorf("heading %d = %+v, want %+v", i, f.headings[i], w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseContentFactsEmpty pins an empty body and the category pillar
|
||||||
|
// text read the same way (headings only).
|
||||||
|
func TestParseContentFactsEmpty(t *testing.T) {
|
||||||
|
if f := parseContentFacts(" "); f.images != nil || f.videos != nil || f.headings != nil {
|
||||||
|
t.Fatalf("blank = %+v", f)
|
||||||
|
}
|
||||||
|
pillar := parseContentFacts("<p>Intro</p><h2>Gatunki</h2><h4>Skipped</h4>")
|
||||||
|
if len(pillar.headings) != 2 || pillar.headings[1].Level != 4 {
|
||||||
|
t.Fatalf("pillar = %+v", pillar.headings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTopHeading pins the record's highest heading level: 2 while the
|
||||||
|
// H1 setting turns body H1s into H2s, 0 (any) otherwise.
|
||||||
|
func TestTopHeading(t *testing.T) {
|
||||||
|
if topHeading(true) != 2 || topHeading(false) != 0 {
|
||||||
|
t.Fatal("topHeading is wrong")
|
||||||
|
}
|
||||||
|
if toolUpdatePost != "journal_update_post" || toolFixHeadings != "journal_fix_headings" || toolSetImageAlt != "journal_set_image_alt" {
|
||||||
|
t.Fatal("the descriptor's tool names changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
312
classes/editor_policy_test.go
Normal file
312
classes/editor_policy_test.go
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
xhtml "golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// editorPlaceholder is the Blog Editor's YouTube embed placeholder.
|
||||||
|
const editorPlaceholder = `<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>`
|
||||||
|
|
||||||
|
// editorCorpus reads plan 02's editor-corpus.json, the editor's own output.
|
||||||
|
// A missing or empty file fails the test; it never skips.
|
||||||
|
func editorCorpus(t *testing.T) []struct{ Name, HTML string } {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile("../tests/blogeditor/fixtures/editor-corpus.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("editor corpus: %v", err)
|
||||||
|
}
|
||||||
|
var entries []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
HTML string `json:"html"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &entries); err != nil {
|
||||||
|
t.Fatalf("editor corpus: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
t.Fatal("editor corpus is empty")
|
||||||
|
}
|
||||||
|
out := make([]struct{ Name, HTML string }, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Name == "" || e.HTML == "" {
|
||||||
|
t.Fatalf("editor corpus entry %+v has no name or html", e)
|
||||||
|
}
|
||||||
|
out = append(out, struct{ Name, HTML string }{e.Name, e.HTML})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagAttrs returns the attribute names of every start tag named tag in doc,
|
||||||
|
// one slice per tag, repeats included.
|
||||||
|
func tagAttrs(doc, tag string) [][]string {
|
||||||
|
var out [][]string
|
||||||
|
z := xhtml.NewTokenizer(strings.NewReader(doc))
|
||||||
|
for {
|
||||||
|
tt := z.Next()
|
||||||
|
if tt == xhtml.ErrorToken {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if tt != xhtml.StartTagToken && tt != xhtml.SelfClosingTagToken {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw := rawAttrNames(z.Raw())
|
||||||
|
if z.Token().Data == tag {
|
||||||
|
out = append(out, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEditorOutputPassesPolicy pins Phase 16.2 D-05 (amended): the HTML
|
||||||
|
// policy keeps every piece of markup the Blog Editor writes, byte for byte
|
||||||
|
// and with an empty removal report, and its editor-output additions
|
||||||
|
// (figure data-embed and data-id, img alt, ol start, and since Phase 33 a rel
|
||||||
|
// of nofollow, sponsored and ugc tokens on a) admit nothing else.
|
||||||
|
func TestEditorOutputPassesPolicy(t *testing.T) {
|
||||||
|
t.Run("corpus is the editor's full output set", func(t *testing.T) {
|
||||||
|
corpus := editorCorpus(t)
|
||||||
|
if len(corpus) < 15 {
|
||||||
|
t.Fatalf("corpus has %d entries, want at least 15", len(corpus))
|
||||||
|
}
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, e := range corpus {
|
||||||
|
if e.Name == "" || strings.TrimSpace(e.HTML) == "" || names[e.Name] {
|
||||||
|
t.Fatalf("corpus entry %q is empty or repeated", e.Name)
|
||||||
|
}
|
||||||
|
names[e.Name] = true
|
||||||
|
}
|
||||||
|
for _, want := range []string{"embed-youtube", "image-with-caption", "image-without-caption", "ordered-list-start-3", "table-header-row", "links", "link-rel", "image-decorative", "combined"} {
|
||||||
|
if !names[want] {
|
||||||
|
t.Fatalf("corpus has no %s entry", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, e := range editorCorpus(t) {
|
||||||
|
t.Run("corpus "+e.Name, func(t *testing.T) {
|
||||||
|
if got := SanitizeHTML(e.HTML); got != e.HTML {
|
||||||
|
t.Fatalf("SanitizeHTML changed the editor's output:\n got %q\nwant %q", got, e.HTML)
|
||||||
|
}
|
||||||
|
if _, report := SanitizeHTMLReport(e.HTML); !report.Empty() {
|
||||||
|
t.Fatalf("report = %+v, want empty", report)
|
||||||
|
}
|
||||||
|
if err := CheckHTML(e.HTML); err != nil {
|
||||||
|
t.Fatalf("CheckHTML = %v", err)
|
||||||
|
}
|
||||||
|
assertNoActiveContent(t, e.HTML)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("placeholder is kept", func(t *testing.T) {
|
||||||
|
if got := SanitizeHTML(editorPlaceholder); got != editorPlaceholder {
|
||||||
|
t.Fatalf("SanitizeHTML(placeholder) = %q", got)
|
||||||
|
}
|
||||||
|
out, err := FormatHTML("Intro\n\n" + editorPlaceholder + "\n\nOutro")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, editorPlaceholder) {
|
||||||
|
t.Fatalf("FormatHTML dropped the placeholder: %q", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
stripped := []struct {
|
||||||
|
name, in, want string
|
||||||
|
}{
|
||||||
|
{"other provider", `<figure data-embed="vimeo" data-id="dQw4w9WgXcQ"></figure>`, `<figure data-id="dQw4w9WgXcQ"></figure>`},
|
||||||
|
{"provider with a suffix", `<figure data-embed="youtube2" data-id="dQw4w9WgXcQ"></figure>`, `<figure data-id="dQw4w9WgXcQ"></figure>`},
|
||||||
|
{"id of 10 characters", `<figure data-embed="youtube" data-id="dQw4w9WgXc"></figure>`, `<figure data-embed="youtube"></figure>`},
|
||||||
|
{"id of 12 characters", `<figure data-embed="youtube" data-id="dQw4w9WgXcQQ"></figure>`, `<figure data-embed="youtube"></figure>`},
|
||||||
|
{"id with a quote", `<figure data-embed="youtube" data-id="dQw4w9"gXcQ"></figure>`, `<figure data-embed="youtube"></figure>`},
|
||||||
|
{"id with a space", `<figure data-embed="youtube" data-id="dQw4w9 gXcQ"></figure>`, `<figure data-embed="youtube"></figure>`},
|
||||||
|
{"id with an angle bracket", `<figure data-embed="youtube" data-id="dQw4w9<gXcQ"></figure>`, `<figure data-embed="youtube"></figure>`},
|
||||||
|
{"embed attributes on div", `<div data-embed="youtube" data-id="dQw4w9WgXcQ">x</div>`, `<div>x</div>`},
|
||||||
|
{"embed attributes on img", `<img src="/a.jpg" data-embed="youtube" data-id="dQw4w9WgXcQ">`, `<img src="/a.jpg">`},
|
||||||
|
{"embed attributes on p", `<p data-embed="youtube" data-id="dQw4w9WgXcQ">x</p>`, `<p>x</p>`},
|
||||||
|
{"other data attribute on figure", `<figure data-embed="youtube" data-id="dQw4w9WgXcQ" data-src="https://evil.test/"></figure>`, editorPlaceholder},
|
||||||
|
{"data attribute alone on figure", `<figure data-url="https://evil.test/"></figure>`, `<figure></figure>`},
|
||||||
|
{"onload on the placeholder", `<figure data-embed="youtube" data-id="dQw4w9WgXcQ" onload="alert(1)"></figure>`, editorPlaceholder},
|
||||||
|
{"iframe next to the placeholder", editorPlaceholder + `<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe>`, editorPlaceholder},
|
||||||
|
{"start that is not a number", `<ol start="x"><li>a</li></ol>`, `<ol><li>a</li></ol>`},
|
||||||
|
{"negative start", `<ol start="-1"><li>a</li></ol>`, `<ol><li>a</li></ol>`},
|
||||||
|
{"start on ul", `<ul start="3"><li>a</li></ul>`, `<ul><li>a</li></ul>`},
|
||||||
|
{"alt on p", `<p alt="x">y</p>`, `<p>y</p>`},
|
||||||
|
{"upper-case provider", `<figure data-embed="YouTube" data-id="dQw4w9WgXcQ"></figure>`, `<figure data-id="dQw4w9WgXcQ"></figure>`},
|
||||||
|
{"provider with white space", `<figure data-embed=" youtube" data-id="dQw4w9WgXcQ"></figure>`, `<figure data-id="dQw4w9WgXcQ"></figure>`},
|
||||||
|
{"empty id", `<figure data-embed="youtube" data-id=""></figure>`, `<figure data-embed="youtube"></figure>`},
|
||||||
|
{"id with a URL", `<figure data-embed="youtube" data-id="javascript:1"></figure>`, `<figure data-embed="youtube"></figure>`},
|
||||||
|
{"iframe alone", `<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"></iframe>`, ``},
|
||||||
|
{"script inside the placeholder", `<figure data-embed="youtube" data-id="dQw4w9WgXcQ"><script>alert(1)</script></figure>`, editorPlaceholder},
|
||||||
|
{"embed attributes on section", `<section data-embed="youtube" data-id="dQw4w9WgXcQ">x</section>`, `<section>x</section>`},
|
||||||
|
{"embed attributes on a", `<a href="/x" data-embed="youtube" data-id="dQw4w9WgXcQ">x</a>`, `<a href="/x">x</a>`},
|
||||||
|
{"start with a fraction", `<ol start="1.5"><li>a</li></ol>`, `<ol><li>a</li></ol>`},
|
||||||
|
// D-83: rel keeps only nofollow, sponsored and ugc, on a only.
|
||||||
|
{"rel noopener", `<a href="https://p.test/" rel="noopener">x</a>`, `<a href="https://p.test/">x</a>`},
|
||||||
|
{"rel with another token", `<a href="https://p.test/" rel="nofollow noopener">x</a>`, `<a href="https://p.test/">x</a>`},
|
||||||
|
{"rel with two spaces", `<a href="https://p.test/" rel="nofollow ugc">x</a>`, `<a href="https://p.test/">x</a>`},
|
||||||
|
{"rel in upper case", `<a href="https://p.test/" rel="NOFOLLOW">x</a>`, `<a href="https://p.test/">x</a>`},
|
||||||
|
{"rel on p", `<p rel="nofollow">x</p>`, `<p>x</p>`},
|
||||||
|
}
|
||||||
|
for _, tc := range stripped {
|
||||||
|
t.Run("strips "+tc.name, func(t *testing.T) {
|
||||||
|
got, report := SanitizeHTMLReport(tc.in)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("SanitizeHTMLReport(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
if report.Empty() {
|
||||||
|
t.Fatalf("report is empty for a stripped input %q", tc.in)
|
||||||
|
}
|
||||||
|
if CheckHTML(tc.in) == nil {
|
||||||
|
t.Fatalf("CheckHTML(%q) = nil, want an error", tc.in)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("alt keeps any text as one attribute", func(t *testing.T) {
|
||||||
|
for _, in := range []string{
|
||||||
|
`<img src="/a.jpg" alt="Turkey Tail: czy to "grzyb odporności"?">`,
|
||||||
|
`<img src="/a.jpg" alt="x" onerror="alert(1)">`,
|
||||||
|
`<img src="/a.jpg" alt='x" onerror="alert(1)'>`,
|
||||||
|
`<img src="/a.jpg" alt="<script>alert(1)</script>">`,
|
||||||
|
} {
|
||||||
|
got, report := SanitizeHTMLReport(in)
|
||||||
|
if got != in || !report.Empty() {
|
||||||
|
t.Fatalf("SanitizeHTMLReport(%q) = %q, %+v, want it unchanged", in, got, report)
|
||||||
|
}
|
||||||
|
out := htmlPolicy.Sanitize(in)
|
||||||
|
if attrs := tagAttrs(out, "img"); len(attrs) != 1 || !slices.Equal(attrs[0], []string{"src", "alt"}) {
|
||||||
|
t.Fatalf("policy output %q has img attributes %v, want [src alt]", out, attrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ol start keeps a number", func(t *testing.T) {
|
||||||
|
const in = `<ol start="3"><li>c</li></ol>`
|
||||||
|
if got := SanitizeHTML(in); got != in {
|
||||||
|
t.Fatalf("SanitizeHTML(%q) = %q", in, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSanitizeHTMLReport pins the removal report the Blog Editor turns into
|
||||||
|
// admin notices (D-10 amended).
|
||||||
|
func TestSanitizeHTMLReport(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in, out string
|
||||||
|
elements []string
|
||||||
|
elementCount int
|
||||||
|
attributes []string
|
||||||
|
attributeCount int
|
||||||
|
comments int
|
||||||
|
other bool
|
||||||
|
}{
|
||||||
|
{name: "clean", in: `<p>Plain <a href="https://example.test/">link</a></p>`, out: `<p>Plain <a href="https://example.test/">link</a></p>`},
|
||||||
|
{name: "style element", in: `<p>a</p><style>p { color: red }</style>`, out: `<p>a</p>`, elements: []string{"style"}, elementCount: 1},
|
||||||
|
{name: "onclick attribute", in: `<p onclick="steal()">x</p>`, out: `<p>x</p>`, attributes: []string{"onclick"}, attributeCount: 1},
|
||||||
|
{name: "comment", in: `<p>a</p><!-- hidden --><!-- two -->`, out: `<p>a</p>`, comments: 2},
|
||||||
|
{name: "repeated attribute", in: `<p class="a" class="b">x</p>`, out: `<p class="a">x</p>`, attributes: []string{"class"}, attributeCount: 1},
|
||||||
|
{name: "style loses a property", in: `<p style="color: red; position: fixed">x</p>`, out: `<p style="color: red">x</p>`, attributes: []string{"style"}, attributeCount: 1},
|
||||||
|
{name: "javascript link", in: `<a href="javascript:alert(1)">x</a><a href="/ok">y</a>`, out: `x<a href="/ok">y</a>`, elements: []string{"a"}, elementCount: 1},
|
||||||
|
{
|
||||||
|
name: "more than ten elements",
|
||||||
|
in: `<script></script><iframe></iframe><object></object><embed><form></form><input><button></button><select></select><textarea></textarea><base><meta><link><script></script>`,
|
||||||
|
out: ``,
|
||||||
|
elements: []string{"script", "iframe", "object", "embed", "form", "input", "button", "select", "textarea", "base"},
|
||||||
|
elementCount: 13,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "more than ten attributes",
|
||||||
|
in: `<p onclick="a" onload="a" onerror="a" onmouseover="a" onfocus="a" onblur="a" onkeyup="a" onkeydown="a" onchange="a" onsubmit="a" oninput="a" data-x="a">x</p>`,
|
||||||
|
out: `<p>x</p>`,
|
||||||
|
attributes: []string{"onclick", "onload", "onerror", "onmouseover", "onfocus", "onblur", "onkeyup", "onkeydown", "onchange", "onsubmit"},
|
||||||
|
attributeCount: 12,
|
||||||
|
},
|
||||||
|
{name: "doctype", in: "<!DOCTYPE html><p>x</p>", out: "<p>x</p>", other: true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, r := SanitizeHTMLReport(tc.in)
|
||||||
|
if got != tc.out {
|
||||||
|
t.Fatalf("output = %q, want %q", got, tc.out)
|
||||||
|
}
|
||||||
|
if !slices.Equal(r.Elements, tc.elements) || r.ElementCount != tc.elementCount ||
|
||||||
|
!slices.Equal(r.Attributes, tc.attributes) || r.AttributeCount != tc.attributeCount ||
|
||||||
|
r.Comments != tc.comments || r.Other != tc.other {
|
||||||
|
t.Fatalf("report = %+v", r)
|
||||||
|
}
|
||||||
|
if r.Empty() != (got == tc.in) {
|
||||||
|
t.Fatalf("Empty() = %v but output changed = %v", r.Empty(), got != tc.in)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("cut lists end the notice names with an ellipsis", func(t *testing.T) {
|
||||||
|
_, r := SanitizeHTMLReport(cases[7].in)
|
||||||
|
notices := removalNotices(r)
|
||||||
|
if len(notices) != 1 || notices[0].Count != 13 || !strings.HasSuffix(notices[0].Params["names"], ", …") ||
|
||||||
|
!strings.HasPrefix(notices[0].Params["names"], "script, iframe, ") {
|
||||||
|
t.Fatalf("notices = %#v", notices)
|
||||||
|
}
|
||||||
|
_, r = SanitizeHTMLReport(`<p onclick="a">x</p><!-- c -->`)
|
||||||
|
notices = removalNotices(r)
|
||||||
|
if len(notices) != 2 || notices[0].Params["names"] != "onclick" || notices[1].Message != editorRemovedComments || notices[1].Count != 1 {
|
||||||
|
t.Fatalf("notices = %#v", notices)
|
||||||
|
}
|
||||||
|
_, r = SanitizeHTMLReport("<!DOCTYPE html><p>x</p>")
|
||||||
|
if notices = removalNotices(r); len(notices) != 1 || notices[0].Message != editorMarkupChanged || notices[0].Count != 0 {
|
||||||
|
t.Fatalf("notices = %#v", notices)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("report is empty exactly when the output equals the input", func(t *testing.T) {
|
||||||
|
inputs := []string{}
|
||||||
|
for _, tc := range unsafePayloads {
|
||||||
|
inputs = append(inputs, tc.src)
|
||||||
|
}
|
||||||
|
fixture, err := os.ReadFile("../tests/blogeditor/fixtures/imported-post.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inputs = append(inputs, string(fixture))
|
||||||
|
for _, e := range editorCorpus(t) {
|
||||||
|
inputs = append(inputs, e.HTML)
|
||||||
|
}
|
||||||
|
for _, in := range inputs {
|
||||||
|
got, r := SanitizeHTMLReport(in)
|
||||||
|
if r.Empty() != (got == in) {
|
||||||
|
t.Fatalf("SanitizeHTMLReport(%q): Empty() = %v, output %q", in, r.Empty(), got)
|
||||||
|
}
|
||||||
|
if got != SanitizeHTML(in) {
|
||||||
|
t.Fatalf("SanitizeHTMLReport and SanitizeHTML disagree on %q", in)
|
||||||
|
}
|
||||||
|
// Notices only when something was stripped (D-10 amended).
|
||||||
|
if notices := removalNotices(r); (len(notices) == 0) != (got == in) {
|
||||||
|
t.Fatalf("SanitizeHTMLReport(%q): %d notices, output changed = %v", in, len(notices), got != in)
|
||||||
|
}
|
||||||
|
// The report is stable: sanitizing the output again changes nothing.
|
||||||
|
if again, r2 := SanitizeHTMLReport(got); again != got || !r2.Empty() {
|
||||||
|
t.Fatalf("second pass over %q = %q, %+v", got, again, r2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the imported post is stripped of exactly its style block", func(t *testing.T) {
|
||||||
|
fixture, err := os.ReadFile("../tests/blogeditor/fixtures/imported-post.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, r := SanitizeHTMLReport(string(fixture))
|
||||||
|
if got == string(fixture) || r.Empty() {
|
||||||
|
t.Fatalf("the imported post passed the policy unchanged; the fixture should hold markup the policy strips")
|
||||||
|
}
|
||||||
|
if !slices.Contains(r.Elements, "style") {
|
||||||
|
t.Fatalf("report = %+v, want the style element named", r)
|
||||||
|
}
|
||||||
|
assertNoActiveContent(t, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,50 +2,509 @@ package classes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/microcosm-cc/bluemonday"
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
"github.com/yuin/goldmark/extension"
|
"github.com/yuin/goldmark/extension"
|
||||||
"github.com/yuin/goldmark/parser"
|
"github.com/yuin/goldmark/parser"
|
||||||
|
"github.com/yuin/goldmark/renderer/html"
|
||||||
|
xhtml "golang.org/x/net/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
markdownEngine = goldmark.New(
|
markdownEngine = goldmark.New(
|
||||||
goldmark.WithExtensions(extension.Footnote, extension.Table),
|
goldmark.WithExtensions(extension.Footnote, extension.Table),
|
||||||
goldmark.WithParserOptions(parser.WithAttribute()),
|
goldmark.WithParserOptions(parser.WithAttribute()),
|
||||||
|
// Winter's Markdown::parse passes raw HTML through, so imported HTML
|
||||||
|
// bodies round-trip (D-03, PHP parity); htmlPolicy then sanitizes the
|
||||||
|
// rendered result on every write path.
|
||||||
|
goldmark.WithRendererOptions(html.WithUnsafe()),
|
||||||
)
|
)
|
||||||
|
|
||||||
markdownUnsafeTag = regexp.MustCompile(`(?i)<(?:script|iframe|object|embed)\b`)
|
// htmlPolicy is the allow-list for stored journal HTML.
|
||||||
markdownEventHandler = regexp.MustCompile(`(?i)\son[a-z]+\s*=`)
|
htmlPolicy = newHTMLPolicy()
|
||||||
markdownDangerousURL = regexp.MustCompile(`(?i)(?:javascript|vbscript|data):`)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// newHTMLPolicy extends bluemonday's UGC policy only with what goldmark
|
||||||
|
// renders and what the imported posts and categories use.
|
||||||
|
func newHTMLPolicy() *bluemonday.Policy {
|
||||||
|
p := bluemonday.UGCPolicy()
|
||||||
|
// Winter neither adds nor requires rel="nofollow", so links keep their
|
||||||
|
// stored form.
|
||||||
|
p.RequireNoFollowOnLinks(false)
|
||||||
|
// Imported author footers: <footer class=... itemscope itemtype=...>.
|
||||||
|
p.AllowElements("footer")
|
||||||
|
p.AllowAttrs("class").Matching(bluemonday.SpaceSeparatedTokens).Globally()
|
||||||
|
p.AllowAttrs("itemscope").Matching(regexp.MustCompile(`^(|itemscope)$`)).Globally()
|
||||||
|
p.AllowAttrs("itemtype").Matching(regexp.MustCompile(`^https?://schema\.org/[A-Za-z]+$`)).Globally()
|
||||||
|
p.AllowAttrs("itemprop").Matching(bluemonday.SpaceSeparatedTokens).Globally()
|
||||||
|
// Inline styles in imported footers and goldmark's table cell alignment.
|
||||||
|
// bluemonday validates each property value.
|
||||||
|
p.AllowStyles("color", "font-size", "font-weight", "text-align").Globally()
|
||||||
|
// goldmark's footnote markup.
|
||||||
|
p.AllowAttrs("role").Matching(regexp.MustCompile(`^doc-(noteref|endnotes|backlink)$`)).OnElements("a", "div")
|
||||||
|
// Pasted chat transcripts in imported category bodies: dir="auto" (the
|
||||||
|
// UGC policy allows only ltr and rtl) and two data attributes.
|
||||||
|
p.AllowAttrs("dir").Matching(regexp.MustCompile(`(?i)^(ltr|rtl|auto)$`)).Globally()
|
||||||
|
p.AllowAttrs("data-message-author-role", "data-message-id").
|
||||||
|
Matching(regexp.MustCompile(`^[A-Za-z0-9_-]+$`)).OnElements("div")
|
||||||
|
// Blog Editor output (Phase 16.2 D-05), exactly what the editor writes
|
||||||
|
// and nothing more; plan 02's editor-corpus.json pins it
|
||||||
|
// (TestEditorOutputPassesPolicy) and the site layer mirrors these rules.
|
||||||
|
// The embed placeholder <figure data-embed="youtube" data-id="..."></figure>:
|
||||||
|
// the provider name and the 11-character video id, on figure only. The
|
||||||
|
// layer expands it; no iframe is ever stored.
|
||||||
|
p.AllowAttrs("data-embed").Matching(regexp.MustCompile(`^youtube$`)).OnElements("figure")
|
||||||
|
p.AllowAttrs("data-id").Matching(regexp.MustCompile(`^[A-Za-z0-9_-]{11}$`)).OnElements("figure")
|
||||||
|
// The editor's alt field takes free text, which UGC's Paragraph pattern
|
||||||
|
// cuts at ':', '?', '"' and more. bluemonday accepts an attribute when
|
||||||
|
// any of its rules matches and escapes the value on output; alt carries
|
||||||
|
// no URL.
|
||||||
|
p.AllowAttrs("alt").OnElements("img")
|
||||||
|
// TipTap's ordered-list input rule writes start when a list does not
|
||||||
|
// begin at 1.
|
||||||
|
p.AllowAttrs("start").Matching(regexp.MustCompile(`^[0-9]+$`)).OnElements("ol")
|
||||||
|
// The Blog Editor's link form writes rel on external links (Phase 33,
|
||||||
|
// D-83): only the nofollow, sponsored and ugc tokens, space-separated.
|
||||||
|
// Any other rel value is removed.
|
||||||
|
p.AllowAttrs("rel").Matching(regexp.MustCompile(`^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$`)).OnElements("a")
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
// FormatHTML converts journal post markdown to stored HTML using goldmark
|
// FormatHTML converts journal post markdown to stored HTML using goldmark
|
||||||
// footnote, table, and attribute extensions, then the same rejectUnsafe gate
|
// footnote, table, and attribute extensions. Raw HTML in the source passes
|
||||||
// as cabana.RenderMarkdown. It does not call cabana.RenderMarkdown.
|
// through the renderer, as it does in Winter's Markdown::parse. The result is
|
||||||
|
// then sanitized with a bluemonday allow-list that strips, as Winter's
|
||||||
|
// Html::clean does, what it does not allow: script, style, iframe, object,
|
||||||
|
// embed, form, base and meta elements, event handler attributes, and URLs
|
||||||
|
// other than http, https, mailto and relative ones. HTML that the policy
|
||||||
|
// keeps whole is returned byte for byte, so imported bodies round-trip. It
|
||||||
|
// does not call cabana.RenderMarkdown, whose admin preview stays without
|
||||||
|
// unsafe HTML.
|
||||||
func FormatHTML(src string) (string, error) {
|
func FormatHTML(src string) (string, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := markdownEngine.Convert([]byte(strings.TrimSpace(src)), &buf); err != nil {
|
if err := markdownEngine.Convert([]byte(strings.TrimSpace(src)), &buf); err != nil {
|
||||||
return "", fmt.Errorf("journal: markdown: %w", err)
|
return "", fmt.Errorf("journal: markdown: %w", err)
|
||||||
}
|
}
|
||||||
html := buf.String()
|
return SanitizeHTML(buf.String()), nil
|
||||||
if err := rejectUnsafe(html); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return html, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func rejectUnsafe(html string) error {
|
// SanitizeHTML filters doc, which is HTML and not markdown, through the
|
||||||
if markdownUnsafeTag.MatchString(html) {
|
// FormatHTML allow-list and strips what the policy does not allow. HTML the
|
||||||
return fmt.Errorf("journal: rendered HTML contains raw unsafe tags")
|
// policy keeps whole is returned byte for byte. It is the gate for
|
||||||
|
// content_html supplied without markdown, such as a translation that carries
|
||||||
|
// only content_html. It is the string half of SanitizeHTMLReport.
|
||||||
|
func SanitizeHTML(doc string) string {
|
||||||
|
out, _ := SanitizeHTMLReport(doc)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxRemovalNames is how many distinct names an HTMLRemovals list keeps.
|
||||||
|
const maxRemovalNames = 10
|
||||||
|
|
||||||
|
// HTMLRemovals reports what SanitizeHTMLReport removed from a document.
|
||||||
|
// Elements and Attributes are the distinct removed element and attribute
|
||||||
|
// names in first-seen order, at most ten each; ElementCount and
|
||||||
|
// AttributeCount count every removal, so they stay exact when a list is cut.
|
||||||
|
// An attribute counts as removed when the policy dropped it, when it is a
|
||||||
|
// repeat of a name the tag already has, or when the policy changed its value
|
||||||
|
// (a style attribute that lost a property counts under "style"). Comments
|
||||||
|
// counts removed HTML comments. Other is set when the policy changed the
|
||||||
|
// document in a way none of the other fields describe.
|
||||||
|
type HTMLRemovals struct {
|
||||||
|
Elements []string
|
||||||
|
ElementCount int
|
||||||
|
Attributes []string
|
||||||
|
AttributeCount int
|
||||||
|
Comments int
|
||||||
|
Other bool
|
||||||
|
|
||||||
|
// moreElements and moreAttributes are set when a list was cut.
|
||||||
|
moreElements bool
|
||||||
|
moreAttributes bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty reports whether nothing was removed, which is exactly when
|
||||||
|
// SanitizeHTMLReport returned its input unchanged.
|
||||||
|
func (r HTMLRemovals) Empty() bool {
|
||||||
|
return r.ElementCount == 0 && r.AttributeCount == 0 && r.Comments == 0 && !r.Other
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HTMLRemovals) addElement(name string) {
|
||||||
|
r.ElementCount++
|
||||||
|
r.Elements, r.moreElements = addRemovalName(r.Elements, r.moreElements, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HTMLRemovals) addAttribute(name string) {
|
||||||
|
r.AttributeCount++
|
||||||
|
r.Attributes, r.moreAttributes = addRemovalName(r.Attributes, r.moreAttributes, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addRemovalName(names []string, cut bool, name string) ([]string, bool) {
|
||||||
|
if slices.Contains(names, name) {
|
||||||
|
return names, cut
|
||||||
}
|
}
|
||||||
if markdownEventHandler.MatchString(html) {
|
if len(names) >= maxRemovalNames {
|
||||||
return fmt.Errorf("journal: rendered HTML contains event handlers")
|
return names, true
|
||||||
}
|
}
|
||||||
if markdownDangerousURL.MatchString(html) {
|
return append(names, name), cut
|
||||||
return fmt.Errorf("journal: rendered HTML contains a dangerous URL scheme")
|
}
|
||||||
|
|
||||||
|
// SanitizeHTMLReport is SanitizeHTML with a report of what the policy
|
||||||
|
// removed. HTML the policy keeps whole is returned byte for byte with an
|
||||||
|
// empty report. Otherwise the policy's output is returned and the report is
|
||||||
|
// built by aligning the start tags of doc with those of the output in order
|
||||||
|
// (the policy only removes, it never adds or reorders): an input tag without
|
||||||
|
// a counterpart is a removed element, and an attribute of a matched tag that
|
||||||
|
// the output lacks or holds with another value is a removed attribute.
|
||||||
|
// Comments are counted on their own. The report is empty exactly when the
|
||||||
|
// returned string equals doc.
|
||||||
|
func SanitizeHTMLReport(doc string) (string, HTMLRemovals) {
|
||||||
|
clean, changed := sanitizeHTML(doc)
|
||||||
|
if !changed || clean == doc {
|
||||||
|
return doc, HTMLRemovals{}
|
||||||
|
}
|
||||||
|
report := removalReport(doc, clean)
|
||||||
|
if report.Empty() {
|
||||||
|
report.Other = true
|
||||||
|
}
|
||||||
|
return clean, report
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportTag is one start tag of a document as removalReport sees it.
|
||||||
|
type reportTag struct {
|
||||||
|
name string
|
||||||
|
raw []string // attribute names in source order, repeats included
|
||||||
|
attrs map[string]string // decoded values, the first of a repeated name
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportTokens returns doc's start tags and its number of comments.
|
||||||
|
func reportTokens(doc string) ([]reportTag, int) {
|
||||||
|
var (
|
||||||
|
tags []reportTag
|
||||||
|
comments int
|
||||||
|
)
|
||||||
|
z := xhtml.NewTokenizer(strings.NewReader(doc))
|
||||||
|
for {
|
||||||
|
tt := z.Next()
|
||||||
|
switch tt {
|
||||||
|
case xhtml.ErrorToken:
|
||||||
|
return tags, comments
|
||||||
|
case xhtml.CommentToken:
|
||||||
|
comments++
|
||||||
|
case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
|
||||||
|
raw := rawAttrNames(z.Raw())
|
||||||
|
t := z.Token()
|
||||||
|
attrs := make(map[string]string, len(t.Attr))
|
||||||
|
for _, a := range t.Attr {
|
||||||
|
key := a.Key
|
||||||
|
if a.Namespace != "" {
|
||||||
|
key = a.Namespace + ":" + a.Key
|
||||||
|
}
|
||||||
|
if _, seen := attrs[key]; !seen {
|
||||||
|
attrs[key] = a.Val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tags = append(tags, reportTag{name: t.Data, raw: raw, attrs: attrs})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keeps reports whether out can be the policy's output of in: the same
|
||||||
|
// element, and every output attribute present in the input with the same
|
||||||
|
// value, except style, whose properties the policy filters one by one.
|
||||||
|
func (in reportTag) keeps(out reportTag) bool {
|
||||||
|
if in.name != out.name {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for key, val := range out.attrs {
|
||||||
|
got, ok := in.attrs[key]
|
||||||
|
if !ok || (got != val && key != "style") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// removalReport compares doc with the policy's output clean. It aligns the
|
||||||
|
// start tags greedily, preferring an output tag whose attributes the input
|
||||||
|
// tag can have produced; when that leaves output tags unmatched, it aligns
|
||||||
|
// by element name only, which always succeeds when the output's tags are a
|
||||||
|
// subsequence of the input's.
|
||||||
|
func removalReport(doc, clean string) HTMLRemovals {
|
||||||
|
in, inComments := reportTokens(doc)
|
||||||
|
out, outComments := reportTokens(clean)
|
||||||
|
match := alignTags(in, out, reportTag.keeps)
|
||||||
|
if match == nil {
|
||||||
|
match = alignTags(in, out, func(a, b reportTag) bool { return a.name == b.name })
|
||||||
|
}
|
||||||
|
var r HTMLRemovals
|
||||||
|
for i, tag := range in {
|
||||||
|
j := -1
|
||||||
|
if match != nil {
|
||||||
|
j = match[i]
|
||||||
|
}
|
||||||
|
if j < 0 {
|
||||||
|
r.addElement(tag.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept := out[j]
|
||||||
|
seen := make(map[string]bool, len(tag.raw))
|
||||||
|
for _, name := range tag.raw {
|
||||||
|
if seen[name] {
|
||||||
|
r.addAttribute(name) // a repeat the tokenizer dropped
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
val, ok := kept.attrs[name]
|
||||||
|
if !ok || val != tag.attrs[name] {
|
||||||
|
r.addAttribute(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inComments > outComments {
|
||||||
|
r.Comments = inComments - outComments
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// alignTags matches every tag of out to a tag of in, in order, with the
|
||||||
|
// first input tag for which same holds. The result maps each input index to
|
||||||
|
// its output index or -1. It is nil when an output tag stays unmatched.
|
||||||
|
func alignTags(in, out []reportTag, same func(in, out reportTag) bool) []int {
|
||||||
|
match := make([]int, len(in))
|
||||||
|
j := 0
|
||||||
|
for i := range in {
|
||||||
|
match[i] = -1
|
||||||
|
if j < len(out) && same(in[i], out[j]) {
|
||||||
|
match[i] = j
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if j < len(out) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTranslation sets attrs["content_html"] for one locale's translated
|
||||||
|
// post attributes, so a translated content_html is stored under the same
|
||||||
|
// policy as the host column. When attrs["content"] is not blank, content_html
|
||||||
|
// is rendered from it with FormatHTML and a supplied content_html is
|
||||||
|
// replaced, as the host column is always derived from content. Otherwise a
|
||||||
|
// supplied, non-null content_html is sanitized with SanitizeHTML; a value
|
||||||
|
// that is not a string is sanitized as its text form. attrs without content
|
||||||
|
// or content_html is left unchanged.
|
||||||
|
func FormatTranslation(attrs map[string]any) error {
|
||||||
|
if attrs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if content := textOf(attrs["content"]); strings.TrimSpace(content) != "" {
|
||||||
|
html, err := FormatHTML(content)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
attrs["content_html"] = html
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if v, has := attrs["content_html"]; has && v != nil {
|
||||||
|
attrs["content_html"] = SanitizeHTML(textOf(v))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func textOf(v any) string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
case string:
|
||||||
|
return t
|
||||||
|
default:
|
||||||
|
return fmt.Sprint(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckHTML reports whether the FormatHTML allow-list would remove or
|
||||||
|
// change any element, attribute or text in doc: it returns an error when it
|
||||||
|
// would, nil when the policy keeps doc whole. Since Phase 16.2 no admin or
|
||||||
|
// API write path refuses with it (admin saves strip and report through
|
||||||
|
// SanitizeHTMLReport, D-10 amended); it is the predicate counterpart of the
|
||||||
|
// site layer's isSafeJournalHtml, and the policy cross-check tests use it.
|
||||||
|
func CheckHTML(doc string) error {
|
||||||
|
if _, changed := sanitizeHTML(doc); changed {
|
||||||
|
return errors.New("journal: HTML contains elements, attributes or URLs that are not allowed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeHTML returns doc unchanged when htmlPolicy keeps all of it, and the
|
||||||
|
// policy's output with changed set otherwise. Keeping all of it means doc and
|
||||||
|
// the policy output tokenize to the same HTML tokens, so an output that only
|
||||||
|
// re-escapes text or attribute values is not a change. A tag that repeats an
|
||||||
|
// attribute is always a change: the tokenizer keeps only the first of them,
|
||||||
|
// so the comparison would not see the others in the stored bytes.
|
||||||
|
func sanitizeHTML(doc string) (string, bool) {
|
||||||
|
clean := htmlPolicy.Sanitize(doc)
|
||||||
|
if sameTokens(doc, clean) {
|
||||||
|
return doc, false
|
||||||
|
}
|
||||||
|
return clean, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameTokens reports whether a and b tokenize to the same sequence of HTML
|
||||||
|
// tokens: type, tag name or decoded text, and the set of decoded attributes.
|
||||||
|
// It is false when either repeats an attribute in a tag.
|
||||||
|
func sameTokens(a, b string) bool {
|
||||||
|
ta, oka := htmlTokens(a)
|
||||||
|
if !oka {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tb, okb := htmlTokens(b)
|
||||||
|
return okb && slices.Equal(ta, tb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmlTokens tokenizes doc. It is not ok when the tokenizer stops on an
|
||||||
|
// error other than EOF, or when a start tag's raw attributes differ from the
|
||||||
|
// attributes the tokenizer kept, which happens when a name repeats.
|
||||||
|
func htmlTokens(doc string) ([]string, bool) {
|
||||||
|
var out []string
|
||||||
|
z := xhtml.NewTokenizer(strings.NewReader(doc))
|
||||||
|
for {
|
||||||
|
tt := z.Next()
|
||||||
|
if tt == xhtml.ErrorToken {
|
||||||
|
return out, errors.Is(z.Err(), io.EOF)
|
||||||
|
}
|
||||||
|
var raw []string
|
||||||
|
if tt == xhtml.StartTagToken || tt == xhtml.SelfClosingTagToken {
|
||||||
|
raw = rawAttrNames(z.Raw())
|
||||||
|
}
|
||||||
|
t := z.Token()
|
||||||
|
if tt == xhtml.StartTagToken || tt == xhtml.SelfClosingTagToken {
|
||||||
|
if len(raw) != len(t.Attr) {
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
for i, a := range t.Attr {
|
||||||
|
if raw[i] != a.Key {
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attrs := make([]string, 0, len(t.Attr))
|
||||||
|
for _, a := range t.Attr {
|
||||||
|
attrs = append(attrs, a.Namespace+":"+a.Key+"="+a.Val)
|
||||||
|
}
|
||||||
|
slices.Sort(attrs)
|
||||||
|
out = append(out, fmt.Sprintf("%d|%s|%q", tt, t.Data, attrs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawAttrNames returns the ASCII-lowercased name of every attribute in the
|
||||||
|
// raw start tag, repeats included, in source order. It scans the tag as
|
||||||
|
// golang.org/x/net/html's tokenizer does (readTagName, readTagAttrKey and
|
||||||
|
// readTagAttrVal), which drops every repeat of a name it has seen. A scan
|
||||||
|
// that disagrees with the tokenizer only makes htmlTokens report a change.
|
||||||
|
func rawAttrNames(raw []byte) []string {
|
||||||
|
i, n := 2, len(raw) // past '<' and the first byte of the tag name
|
||||||
|
skipSpace := func() {
|
||||||
|
for i < n && isHTMLSpace(raw[i]) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i < n {
|
||||||
|
c := raw[i]
|
||||||
|
i++
|
||||||
|
if isHTMLSpace(c) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if c == '/' || c == '>' {
|
||||||
|
i--
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
skipSpace()
|
||||||
|
for i < n {
|
||||||
|
if raw[i] == '>' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Attribute name: an '=' that starts it is part of the name.
|
||||||
|
start, end := i, n
|
||||||
|
for i < n {
|
||||||
|
c := raw[i]
|
||||||
|
i++
|
||||||
|
if c == '=' && i == start+1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '=' || c == '/' || c == '>' || isHTMLSpace(c) {
|
||||||
|
i--
|
||||||
|
end = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Attribute value.
|
||||||
|
skipSpace()
|
||||||
|
if i < n {
|
||||||
|
c := raw[i]
|
||||||
|
i++
|
||||||
|
switch {
|
||||||
|
case c == '/':
|
||||||
|
case c != '=':
|
||||||
|
i--
|
||||||
|
default:
|
||||||
|
skipSpace()
|
||||||
|
if i < n {
|
||||||
|
q := raw[i]
|
||||||
|
i++
|
||||||
|
switch q {
|
||||||
|
case '>':
|
||||||
|
i--
|
||||||
|
case '"', '\'':
|
||||||
|
for i < n {
|
||||||
|
i++
|
||||||
|
if raw[i-1] == q {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
for i < n {
|
||||||
|
c := raw[i]
|
||||||
|
i++
|
||||||
|
if isHTMLSpace(c) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if c == '>' {
|
||||||
|
i--
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end > start {
|
||||||
|
names = append(names, asciiLower(raw[start:end]))
|
||||||
|
}
|
||||||
|
skipSpace()
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHTMLSpace(c byte) bool {
|
||||||
|
return c == ' ' || c == '\n' || c == '\r' || c == '\t' || c == '\f'
|
||||||
|
}
|
||||||
|
|
||||||
|
func asciiLower(b []byte) string {
|
||||||
|
out := []byte(string(b))
|
||||||
|
for i, c := range out {
|
||||||
|
if 'A' <= c && c <= 'Z' {
|
||||||
|
out[i] = c + 'a' - 'A'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,59 +3,372 @@ package classes
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
xhtml "golang.org/x/net/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestFormatHTMLRejectsUnsafeHTML substitutes JOURNAL-003/004 Twig XSS
|
// unsafePayloads are the stored-XSS inputs FormatHTML and CheckHTML must
|
||||||
// templates (Phase 16). Script, iframe, event handlers, and javascript /
|
// neutralize: the JOURNAL-003/004 Twig XSS templates (Phase 16) and the
|
||||||
// vbscript / data schemes must fail the reject gate. Footnotes and tables
|
// regex-gate bypasses from the Phase 16.1 code review (CR-01).
|
||||||
// from goldmark extensions still pass.
|
var unsafePayloads = []struct {
|
||||||
func TestFormatHTMLRejectsUnsafeHTML(t *testing.T) {
|
name string
|
||||||
|
src string
|
||||||
|
}{
|
||||||
|
{"script", "<script>alert(1)</script>"},
|
||||||
|
{"mixed-case script", "<ScRiPt>alert(1)</sCrIpT>"},
|
||||||
|
{"script inside an HTML block", "<div>\n<p>Intro</p>\n<script>alert(1)</script>\n</div>"},
|
||||||
|
{"script after markdown", "# Title\n\nText.\n\n<script src=\"https://evil.test/x.js\"></script>"},
|
||||||
|
{"iframe", `<iframe src="https://evil.test"></iframe>`},
|
||||||
|
{"upper-case iframe", `<IFRAME SRC="https://evil.test"></IFRAME>`},
|
||||||
|
{"object", `<object data="https://evil.test/x.swf"></object>`},
|
||||||
|
{"embed", `<embed src="https://evil.test/x.swf">`},
|
||||||
|
{"event", `<img src=x onerror="alert(1)">`},
|
||||||
|
{"svg onload", `<svg onload="alert(1)"><circle r="1"/></svg>`},
|
||||||
|
{"mixed-case event handler", `<p OnClick="alert(1)">Click</p>`},
|
||||||
|
{"event handler in an inline element", "Text with <span onmouseover=\"alert(1)\">hover</span>."},
|
||||||
|
{"event handler without a space", `<p><img src="x"onerror="alert(1)"></p>`},
|
||||||
|
{"slash-separated svg onload", "<div>\n<svg/onload=alert(1)>\n</div>"},
|
||||||
|
{"markdown attribute event handler", "# Title {onclick=\"alert(1)\"}"},
|
||||||
|
{"javascript", "[x](javascript:alert(1))"},
|
||||||
|
{"mixed-case javascript href", `<a href="JaVaScRiPt:alert(1)">x</a>`},
|
||||||
|
{"numeric entity colon", `<a href="javascript:alert(1)">x</a>`},
|
||||||
|
{"named entity colon", `<a href="javascript:alert(1)">x</a>`},
|
||||||
|
{"entity inside the scheme", `<a href="javascript:alert(1)">x</a>`},
|
||||||
|
{"tab inside the scheme", "<a href=\"java\tscript:alert(1)\">x</a>"},
|
||||||
|
{"newline inside the scheme", "<p><a href=\"java\nscript:alert(1)\">x</a></p>"},
|
||||||
|
{"leading space and control characters", "<p><a href=\" \x01javascript:alert(1)\">x</a></p>"},
|
||||||
|
{"mathml xlink href", "<div>\n<math><maction actiontype=statusline xlink:href=\"javascript:alert(1)\">x</maction></math>\n</div>"},
|
||||||
|
{"vbscript", "[x](vbscript:msgbox(1))"},
|
||||||
|
{"raw vbscript href", `<a href="VBScript:msgbox(1)">x</a>`},
|
||||||
|
{"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)"},
|
||||||
|
{"raw data URL attribute", `<img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=">`},
|
||||||
|
{"markdown image with a data URL", ""},
|
||||||
|
{"base", `<base href="https://evil.test/">`},
|
||||||
|
{"meta refresh", `<meta http-equiv="refresh" content="0;url=https://evil.test">`},
|
||||||
|
{"form", `<form action="https://evil.test"><input type=password name=p><button formaction="javascript:alert(1)">go</button></form>`},
|
||||||
|
{"style element", "<style>body { background: url(https://evil.test/x) }</style>"},
|
||||||
|
{"link stylesheet", `<link rel="stylesheet" href="https://evil.test/x.css">`},
|
||||||
|
{"html comment", "<p>a</p><!-- <script>alert(1)</script> -->"},
|
||||||
|
{"duplicate href", `<p><a href="https://x" href="javascript:alert(1)">x</a></p>`},
|
||||||
|
{"duplicate src", `<img src=ok src="javascript:alert(1)">`},
|
||||||
|
{"duplicate style", `<p style="color: red" style="position: fixed; background: url(javascript:alert(1))">x</p>`},
|
||||||
|
}
|
||||||
|
|
||||||
|
// forbiddenElements must never appear in sanitized journal HTML.
|
||||||
|
var forbiddenElements = map[string]bool{
|
||||||
|
"script": true, "iframe": true, "object": true, "embed": true, "base": true,
|
||||||
|
"meta": true, "form": true, "input": true, "button": true, "style": true,
|
||||||
|
"link": true, "svg": true, "math": true, "maction": true, "frame": true,
|
||||||
|
"frameset": true, "applet": true, "textarea": true, "select": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertNoActiveContent tokenizes doc and fails on forbidden elements, event
|
||||||
|
// handler attributes, any style other than the allowed properties, and
|
||||||
|
// javascript, vbscript or data URLs after entity decoding and after removing
|
||||||
|
// ASCII whitespace and control characters, as a browser's URL parser does.
|
||||||
|
func assertNoActiveContent(t *testing.T, doc string) {
|
||||||
|
t.Helper()
|
||||||
|
z := xhtml.NewTokenizer(strings.NewReader(doc))
|
||||||
|
for {
|
||||||
|
tt := z.Next()
|
||||||
|
if tt == xhtml.ErrorToken {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tt == xhtml.CommentToken {
|
||||||
|
t.Fatalf("comment survived: %q", doc)
|
||||||
|
}
|
||||||
|
if tt != xhtml.StartTagToken && tt != xhtml.SelfClosingTagToken && tt != xhtml.EndTagToken {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tok := z.Token()
|
||||||
|
if forbiddenElements[tok.Data] {
|
||||||
|
t.Fatalf("element <%s> survived: %q", tok.Data, doc)
|
||||||
|
}
|
||||||
|
for _, a := range tok.Attr {
|
||||||
|
key := strings.ToLower(a.Key)
|
||||||
|
if strings.HasPrefix(key, "on") {
|
||||||
|
t.Fatalf("event handler %s survived: %q", a.Key, doc)
|
||||||
|
}
|
||||||
|
if key == "action" || key == "formaction" || key == "srcdoc" || key == "http-equiv" {
|
||||||
|
t.Fatalf("attribute %s survived: %q", a.Key, doc)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(a.Val) {
|
||||||
|
if r > 0x20 {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v := b.String()
|
||||||
|
for _, scheme := range []string{"javascript:", "vbscript:", "data:"} {
|
||||||
|
if strings.Contains(v, scheme) {
|
||||||
|
t.Fatalf("%s URL survived in %s: %q", scheme, a.Key, doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatHTMLSanitizesUnsafeHTML pins CR-01: every payload is rendered
|
||||||
|
// without an error and with all active content stripped, as Winter's
|
||||||
|
// Html::clean strips rather than refuses. Footnotes and tables from goldmark
|
||||||
|
// extensions still render.
|
||||||
|
func TestFormatHTMLSanitizesUnsafeHTML(t *testing.T) {
|
||||||
html, err := FormatHTML("# Hello")
|
html, err := FormatHTML("# Hello")
|
||||||
if err != nil || !strings.Contains(html, "<h1>") {
|
if err != nil || !strings.Contains(html, "<h1>") {
|
||||||
t.Fatalf("safe heading: html=%s err=%v", html, err)
|
t.Fatalf("safe heading: html=%s err=%v", html, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
table, err := FormatHTML("| a | b |\n| - | - |\n| 1 | 2 |")
|
table, err := FormatHTML("| a | b |\n|:- | -:|\n| 1 | 2 |")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("table markdown: %v", err)
|
t.Fatalf("table markdown: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(table, "<table") {
|
if !strings.Contains(table, "<table") || !strings.Contains(table, "text-align: right") {
|
||||||
t.Fatalf("table did not render: %s", table)
|
t.Fatalf("table or its alignment did not render: %s", table)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn, err := FormatHTML("See footnote.[^1]\n\n[^1]: note")
|
fn, err := FormatHTML("See footnote.[^1]\n\n[^1]: note")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("footnote markdown: %v", err)
|
t.Fatalf("footnote markdown: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(strings.ToLower(fn), "footnote") && !strings.Contains(fn, "<sup") {
|
for _, want := range []string{`<sup id="fnref:1">`, `href="#fn:1"`, `role="doc-noteref"`, `role="doc-endnotes"`, `role="doc-backlink"`} {
|
||||||
t.Fatalf("footnote did not render: %s", fn)
|
if !strings.Contains(fn, want) {
|
||||||
|
t.Fatalf("footnote lacks %s: %s", want, fn)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
attrs, err := FormatHTML("# Title {#intro .lead}")
|
||||||
|
if err != nil || !strings.Contains(attrs, `<h1 id="intro" class="lead">Title</h1>`) {
|
||||||
|
t.Fatalf("markdown attributes: html=%s err=%v", attrs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range unsafePayloads {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
out, err := FormatHTML(tc.src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FormatHTML(%q) = %v, want sanitized output", tc.src, err)
|
||||||
|
}
|
||||||
|
assertNoActiveContent(t, out)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("safe parts of a payload survive", func(t *testing.T) {
|
||||||
|
out, err := FormatHTML(`<p>Zdjęcie: <img src="https://cdn.example.test/a.jpg" alt="a"onerror="alert(1)"></p>`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := `<p>Zdjęcie: <img src="https://cdn.example.test/a.jpg" alt="a"></p>`; out != want {
|
||||||
|
t.Fatalf("got %q, want %q", out, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatHTMLDuplicateAttributes pins W-2: the tokenizer keeps only the
|
||||||
|
// first of repeated attributes, so input that repeats one is never stored
|
||||||
|
// byte for byte. The sanitized output keeps the first value, as browsers do.
|
||||||
|
func TestFormatHTMLDuplicateAttributes(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name, src, want string
|
||||||
src string
|
|
||||||
needle string
|
|
||||||
}{
|
}{
|
||||||
{"script", "<script>alert(1)</script>", "<script"},
|
{"duplicate href", `<p><a href="https://x" href="javascript:alert(1)">x</a></p>`, `<p><a href="https://x">x</a></p>`},
|
||||||
{"iframe", `<iframe src="https://evil.test"></iframe>`, "<iframe"},
|
{"duplicate href in other case", `<p><a HREF="https://x" href="javascript:alert(1)">x</a></p>`, `<p><a href="https://x">x</a></p>`},
|
||||||
{"event", `<img src=x onerror="alert(1)">`, "onerror"},
|
{"duplicate src", `<img src=ok src="javascript:alert(1)">`, `<img src="ok">`},
|
||||||
{"javascript", "[x](javascript:alert(1))", "javascript:"},
|
{"duplicate style", `<p style="color: red" style="position: fixed">x</p>`, `<p style="color: red">x</p>`},
|
||||||
{"vbscript", "[x](vbscript:msgbox(1))", "vbscript:"},
|
{"duplicate class", `<p class="a" class="b">x</p>`, `<p class="a">x</p>`},
|
||||||
{"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)", "data:"},
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
out, err := FormatHTML(tc.src)
|
out, err := FormatHTML(tc.src)
|
||||||
if err == nil && strings.Contains(strings.ToLower(out), tc.needle) {
|
if err != nil {
|
||||||
t.Fatalf("unsafe HTML survived: %s", out)
|
t.Fatalf("FormatHTML(%q): %v", tc.src, err)
|
||||||
}
|
}
|
||||||
if err == nil && (tc.name == "script" || tc.name == "iframe" || tc.name == "event") && strings.Contains(strings.ToLower(out), tc.needle) {
|
if out != tc.want {
|
||||||
t.Fatalf("FormatHTML accepted %s: %s", tc.name, out)
|
t.Fatalf("got %q, want %q", out, tc.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRawAttrNames checks the raw attribute scan against the tokenizer: for
|
||||||
|
// tags without repeats both list the same names in the same order.
|
||||||
|
func TestRawAttrNames(t *testing.T) {
|
||||||
|
tags := []string{
|
||||||
|
`<p>`,
|
||||||
|
`<br/>`,
|
||||||
|
`<img src="a" alt='b' width=640>`,
|
||||||
|
`<img src="x"onerror="alert(1)">`,
|
||||||
|
`<svg/onload=alert(1)>`,
|
||||||
|
`<a href = "x" title=y/>`,
|
||||||
|
`<input disabled checked>`,
|
||||||
|
`<div =x a=b>`,
|
||||||
|
"<div\tA=1\nB='2'\fc=\"3\"\r>",
|
||||||
|
`<p title="a href=b" data-x='c src=d'>`,
|
||||||
|
`<a href=x/y title=z>`,
|
||||||
|
`<p a=">" b>`,
|
||||||
|
`<footer class="author-footer" itemscope itemtype="https://schema.org/Person">`,
|
||||||
|
}
|
||||||
|
for _, tag := range tags {
|
||||||
|
z := xhtml.NewTokenizer(strings.NewReader(tag))
|
||||||
|
tt := z.Next()
|
||||||
|
if tt != xhtml.StartTagToken && tt != xhtml.SelfClosingTagToken {
|
||||||
|
t.Fatalf("%q tokenized as %v", tag, tt)
|
||||||
|
}
|
||||||
|
got := rawAttrNames(z.Raw())
|
||||||
|
tok := z.Token()
|
||||||
|
want := make([]string, 0, len(tok.Attr))
|
||||||
|
for _, a := range tok.Attr {
|
||||||
|
want = append(want, a.Key)
|
||||||
|
}
|
||||||
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Errorf("rawAttrNames(%q) = %q, tokenizer kept %q", tag, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := rawAttrNames([]byte(`<a HREF="x" href="y" Href=z title=t>`)); strings.Join(got, ",") != "href,href,href,title" {
|
||||||
|
t.Fatalf("repeats = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatHTMLKeepsSchemeLikeText pins WR-01: text that merely contains
|
||||||
|
// "data:", "javascript:" or "vbscript:" is not a URL and passes unchanged.
|
||||||
|
func TestFormatHTMLKeepsSchemeLikeText(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, src, want string
|
||||||
|
}{
|
||||||
|
{"polish date label", "<p>Data: 12 maja 2024</p>", "<p>Data: 12 maja 2024</p>"},
|
||||||
|
{"markdown date label", "Data publikacji: 12 maja 2024", "<p>Data publikacji: 12 maja 2024</p>\n"},
|
||||||
|
{"metadata", "Some metadata: here", "<p>Some metadata: here</p>\n"},
|
||||||
|
{"big data in html", "<p>Big data: trends</p>", "<p>Big data: trends</p>"},
|
||||||
|
{"scheme names in prose", "<p>Pisze się javascript: i vbscript: w tekście.</p>", "<p>Pisze się javascript: i vbscript: w tekście.</p>"},
|
||||||
|
{"quotes and apostrophes", "<p>Lion's Mane, \"superior herb\" & inne</p>", "<p>Lion's Mane, \"superior herb\" & inne</p>"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
out, err := FormatHTML(tc.src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FormatHTML(%q): %v", tc.src, err)
|
||||||
|
}
|
||||||
|
if out != tc.want {
|
||||||
|
t.Fatalf("got %q, want %q", out, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatHTMLKeepsRawHTML pins D-03: raw HTML in content passes through
|
||||||
|
// the renderer, as Winter's Markdown::parse does, and HTML the policy allows
|
||||||
|
// is stored byte for byte, so an imported HTML body survives an admin save,
|
||||||
|
// an import or an API write.
|
||||||
|
func TestFormatHTMLKeepsRawHTML(t *testing.T) {
|
||||||
|
const paragraphs = "<p>Grzyby rosną w lesie.</p>\n<p>Druga <b>część</b>.</p>"
|
||||||
|
out, err := FormatHTML(paragraphs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("html paragraphs: %v", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out) != paragraphs {
|
||||||
|
t.Fatalf("html paragraphs changed:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
mixed, err := FormatHTML("# Tytuł\n\nTekst.\n\n<footer class=\"author\">Autor</footer>")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("markdown with a raw footer: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(mixed, "<h1>Tytuł</h1>") || !strings.Contains(mixed, `<footer class="author">Autor</footer>`) {
|
||||||
|
t.Fatalf("raw footer was dropped:\n%s", mixed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imported-style bodies: each round-trips unchanged after trimming.
|
||||||
|
bodies := []struct {
|
||||||
|
name string
|
||||||
|
src string
|
||||||
|
}{
|
||||||
|
{"paragraphs", "<p>Pierwszy akapit.</p>\n<p>Drugi akapit z <em>kursywą</em>.</p>\n<p>Trzeci.</p>"},
|
||||||
|
{"class attributes", `<div class="post-body"><p class="lead">Wstęp</p></div>`},
|
||||||
|
{"https link", `<p>Zobacz <a href="https://example.test/grzyby?a=1" class="ext" title="Źródło">źródło</a>.</p>`},
|
||||||
|
{"relative and mailto links", `<p><a href="/blog/grzyby">blog</a> <a href="#przypisy">przypisy</a> <a href="mailto:kontakt@example.test">mail</a></p>`},
|
||||||
|
{"https image", `<p><img src="https://cdn.example.test/img/fungi.jpg" alt="Grzyby" width="640" height="480"></p>`},
|
||||||
|
{"entity in an attribute", `<p><a href="https://example.test/?a=1&b=2" title="Tom i Jerry">link</a> <img src="https://cdn.example.test/a.jpg?w=1&h=2" alt="Tom i Jerry"></p>`},
|
||||||
|
{"table", "<table>\n<tr><td>A</td><td>B</td></tr>\n</table>"},
|
||||||
|
{"headings", "<h2>Co to jest Lion's Mane?</h2>\n<h3>Właściwości </h3>"},
|
||||||
|
{"pasted chat transcript", `<div data-message-author-role="assistant" data-message-id="a13366a9-f96b-4d6f-aaa5-0e433176381a" dir="auto"><p>Tekst</p></div>`},
|
||||||
|
{"surrounding whitespace", "\n\n <p>Trimmed.</p> \n"},
|
||||||
|
}
|
||||||
|
for _, tc := range bodies {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
out, err := FormatHTML(tc.src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FormatHTML(%q): %v", tc.src, err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out) != strings.TrimSpace(tc.src) {
|
||||||
|
t.Fatalf("html changed:\n got %q\nwant %q", strings.TrimSpace(out), strings.TrimSpace(tc.src))
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "raw HTML omitted") {
|
||||||
|
t.Fatalf("goldmark placeholder in output: %s", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("markdown with a trailing raw footer and style", func(t *testing.T) {
|
||||||
|
const footer = `<footer class="source"><p>Źródło: <a href="https://example.test/ref">ref</a></p></footer>`
|
||||||
|
const style = "<style>\n.source { color: #555; }\n</style>"
|
||||||
|
src := "## Nagłówek\n\nTekst z **pogrubieniem** i [linkiem](https://example.test).\n\n" + footer + "\n\n" + style
|
||||||
|
out, err := FormatHTML(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("markdown with footer and style: %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"<h2>Nagłówek</h2>",
|
||||||
|
"<strong>pogrubieniem</strong>",
|
||||||
|
`<a href="https://example.test">linkiem</a>`,
|
||||||
|
footer,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("output lacks %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The style element is stripped with its content, as Html::clean
|
||||||
|
// strips <style>.
|
||||||
|
if strings.Contains(out, "<style") || strings.Contains(out, ".source {") {
|
||||||
|
t.Fatalf("style element survived:\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "raw HTML omitted") {
|
||||||
|
t.Fatalf("goldmark placeholder in output: %s", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("imported author footer with microdata and inline styles", func(t *testing.T) {
|
||||||
|
src := "Tekst.\n\n<footer class=\"author-footer\" itemscope itemtype=\"https://schema.org/Person\">\n" +
|
||||||
|
" <div class=\"author-info\">\n" +
|
||||||
|
" <img src=\"https://cdn.example.test/autor.png\" alt=\"Autor\" class=\"author-photo\" itemprop=\"image\">\n" +
|
||||||
|
" <div style=\"font-size: 1.5em; font-weight: bold;\" itemprop=\"name\">Jan Kowalski</div>\n" +
|
||||||
|
" <span style=\"color: #138c74;\" itemprop=\"jobTitle\">Ekspert</span>\n" +
|
||||||
|
" </div>\n</footer>"
|
||||||
|
out, err := FormatHTML(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person">`,
|
||||||
|
`<img src="https://cdn.example.test/autor.png" alt="Autor" class="author-photo" itemprop="image">`,
|
||||||
|
`<div style="font-size: 1.5em; font-weight: bold" itemprop="name">Jan Kowalski</div>`,
|
||||||
|
`<span style="color: #138c74" itemprop="jobTitle">Ekspert</span>`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("output lacks %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("disallowed styles and attributes are stripped", func(t *testing.T) {
|
||||||
|
out, err := FormatHTML(`<p style="position: fixed; color: red" target="_blank" itemtype="https://evil.test/x">x</p>`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := `<p style="color: red">x</p>`; out != want {
|
||||||
|
t.Fatalf("got %q, want %q", out, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TestJournalEndToEnd keeps `go test ./... -run TestJournalEndToEnd`
|
// TestJournalEndToEnd keeps `go test ./... -run TestJournalEndToEnd`
|
||||||
// from reporting [no tests to run] in this package.
|
// from reporting [no tests to run] in this package.
|
||||||
func TestJournalEndToEnd(t *testing.T) {}
|
func TestJournalEndToEnd(t *testing.T) {}
|
||||||
|
|||||||
124
classes/format_translation_test.go
Normal file
124
classes/format_translation_test.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSanitizeHTML(t *testing.T) {
|
||||||
|
const clean = `<p>Zobacz <a href="https://example.test/a?b=1&c=2" class="ext">źródło</a>.</p>`
|
||||||
|
if got := SanitizeHTML(clean); got != clean {
|
||||||
|
t.Fatalf("clean HTML changed: %q", got)
|
||||||
|
}
|
||||||
|
if got, want := SanitizeHTML(`<p>a<img src=x onerror="alert(1)"></p>`), `<p>a<img src="x"></p>`; got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
// SanitizeHTML takes HTML, not markdown: markdown syntax stays text.
|
||||||
|
if got := SanitizeHTML("**bold**"); got != "**bold**" {
|
||||||
|
t.Fatalf("markdown was rendered: %q", got)
|
||||||
|
}
|
||||||
|
for _, tc := range unsafePayloads {
|
||||||
|
if tc.src[0] != '<' {
|
||||||
|
continue // markdown, not HTML
|
||||||
|
}
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assertNoActiveContent(t, SanitizeHTML(tc.src))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatTranslation(t *testing.T) {
|
||||||
|
const payload = `<p>Treść</p><img src="x" onerror="alert(1)"><a href="javascript:alert(1)">x</a>`
|
||||||
|
|
||||||
|
t.Run("content derives content_html", func(t *testing.T) {
|
||||||
|
attrs := map[string]any{"title": "Witaj", "content": "Witaj **świecie**"}
|
||||||
|
if err := FormatTranslation(attrs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := attrs["content_html"], "<p>Witaj <strong>świecie</strong></p>\n"; got != want {
|
||||||
|
t.Fatalf("content_html = %#v, want %q", got, want)
|
||||||
|
}
|
||||||
|
if attrs["content"] != "Witaj **świecie**" || attrs["title"] != "Witaj" {
|
||||||
|
t.Fatalf("other attributes changed: %#v", attrs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("content wins over a supplied content_html", func(t *testing.T) {
|
||||||
|
attrs := map[string]any{"content": "Tekst", "content_html": payload}
|
||||||
|
if err := FormatTranslation(attrs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := attrs["content_html"], "<p>Tekst</p>\n"; got != want {
|
||||||
|
t.Fatalf("content_html = %#v, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("raw HTML in content is sanitized", func(t *testing.T) {
|
||||||
|
attrs := map[string]any{"content": payload}
|
||||||
|
if err := FormatTranslation(attrs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertNoActiveContent(t, attrs["content_html"].(string))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("content_html alone is sanitized", func(t *testing.T) {
|
||||||
|
for _, attrs := range []map[string]any{
|
||||||
|
{"content_html": payload},
|
||||||
|
{"content": "", "content_html": payload},
|
||||||
|
{"content": " ", "content_html": payload},
|
||||||
|
{"content": nil, "content_html": payload},
|
||||||
|
} {
|
||||||
|
if err := FormatTranslation(attrs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, ok := attrs["content_html"].(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("content_html = %#v, want a string", attrs["content_html"])
|
||||||
|
}
|
||||||
|
assertNoActiveContent(t, got)
|
||||||
|
if want := `<p>Treść</p><img src="x">x`; got != want {
|
||||||
|
t.Fatalf("content_html = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("clean content_html alone is kept byte for byte", func(t *testing.T) {
|
||||||
|
const clean = "<p>Lion's Mane, \"superior herb\" & inne</p>"
|
||||||
|
attrs := map[string]any{"content_html": clean}
|
||||||
|
if err := FormatTranslation(attrs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if attrs["content_html"] != clean {
|
||||||
|
t.Fatalf("content_html = %#v, want %q", attrs["content_html"], clean)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-string content_html is sanitized as text", func(t *testing.T) {
|
||||||
|
attrs := map[string]any{"content_html": []any{`<img src=x onerror="alert(1)">`}}
|
||||||
|
if err := FormatTranslation(attrs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, ok := attrs["content_html"].(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("content_html = %#v, want a string", attrs["content_html"])
|
||||||
|
}
|
||||||
|
assertNoActiveContent(t, got)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("absent and null content_html stay as they are", func(t *testing.T) {
|
||||||
|
attrs := map[string]any{"title": "Tytuł"}
|
||||||
|
if err := FormatTranslation(attrs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, has := attrs["content_html"]; has {
|
||||||
|
t.Fatalf("content_html added: %#v", attrs)
|
||||||
|
}
|
||||||
|
null := map[string]any{"content_html": nil}
|
||||||
|
if err := FormatTranslation(null); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v, has := null["content_html"]; !has || v != nil {
|
||||||
|
t.Fatalf("null content_html changed: %#v", null)
|
||||||
|
}
|
||||||
|
if err := FormatTranslation(nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
217
classes/heading_fix.go
Normal file
217
classes/heading_fix.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// H1ConversionOffText is the refusal of journal:fix-headings and of
|
||||||
|
// POST /_journal/api/v1/fix-headings while the H1 setting is off (UI-SPEC
|
||||||
|
// C27).
|
||||||
|
const H1ConversionOffText = "H1 to H2 conversion is turned off in the Journal settings (Headings and links). Turn it on first, or keep the H1 headings."
|
||||||
|
|
||||||
|
// ErrH1ConversionOff is returned by FixStoredHeadings callers that check
|
||||||
|
// the setting; its text is H1ConversionOffText.
|
||||||
|
var ErrH1ConversionOff = errors.New(H1ConversionOffText)
|
||||||
|
|
||||||
|
// headingFixBatch is how many posts one query of FixStoredHeadings reads.
|
||||||
|
const headingFixBatch = 200
|
||||||
|
|
||||||
|
// HeadingFix counts what FixStoredHeadings converted, or would convert:
|
||||||
|
// the posts and categories with at least one H1 and the H1 headings in
|
||||||
|
// them (default and translated post content, category pillar text).
|
||||||
|
type HeadingFix struct {
|
||||||
|
Posts int `json:"posts"`
|
||||||
|
Categories int `json:"categories"`
|
||||||
|
Headings int `json:"headings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FixStoredHeadings converts H1 to H2 in stored Journal rich text: post
|
||||||
|
// content and content_html in the default locale and in every translation,
|
||||||
|
// and category pillar text. Without apply it only counts. With apply it
|
||||||
|
// writes in one transaction, fires a RecordSaved per changed post and
|
||||||
|
// category after the commit (Controller empty) and queues the host site
|
||||||
|
// cache purge for each. It does not read the H1 setting; callers refuse
|
||||||
|
// while it is off (ErrH1ConversionOff). app may be nil (no events, no
|
||||||
|
// purge).
|
||||||
|
func FixStoredHeadings(ctx context.Context, app *backpack.App, db *gorm.DB, apply bool) (HeadingFix, error) {
|
||||||
|
if db == nil {
|
||||||
|
return HeadingFix{}, errors.New("journal: database is not configured")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if !apply {
|
||||||
|
return fixHeadings(ctx, nil, db.WithContext(ctx), false)
|
||||||
|
}
|
||||||
|
var out HeadingFix
|
||||||
|
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
var err error
|
||||||
|
out, err = fixHeadings(ctx, app, tx, true)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return HeadingFix{}, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixHeadings(ctx context.Context, app *backpack.App, db *gorm.DB, apply bool) (HeadingFix, error) {
|
||||||
|
var out HeadingFix
|
||||||
|
locale, _ := towel.Locale(ctx)
|
||||||
|
purger := cooler.SiteCacheFromApp(app)
|
||||||
|
morph := models.Post{}.MorphName()
|
||||||
|
|
||||||
|
var batch []models.Post
|
||||||
|
res := db.Model(&models.Post{}).Select("id", "slug", "content", "content_html").
|
||||||
|
FindInBatches(&batch, headingFixBatch, func(tx *gorm.DB, _ int) error {
|
||||||
|
ids := make([]string, 0, len(batch))
|
||||||
|
for _, p := range batch {
|
||||||
|
ids = append(ids, strconv.FormatUint(uint64(p.ID), 10))
|
||||||
|
}
|
||||||
|
var attrs []translatemodels.Attribute
|
||||||
|
if err := db.Model(&translatemodels.Attribute{}).
|
||||||
|
Where("model_type = ? AND model_id IN ?", morph, ids).
|
||||||
|
Order("locale ASC").Find(&attrs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
byPost := map[string][]translatemodels.Attribute{}
|
||||||
|
for _, a := range attrs {
|
||||||
|
if a.ModelID != nil {
|
||||||
|
byPost[*a.ModelID] = append(byPost[*a.ModelID], a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range batch {
|
||||||
|
post := &batch[i]
|
||||||
|
id := strconv.FormatUint(uint64(post.ID), 10)
|
||||||
|
n, changes, err := fixPost(ctx, db, post, byPost[id], apply)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Posts++
|
||||||
|
out.Headings += n
|
||||||
|
if apply {
|
||||||
|
cabana.EmitRecordSaved(ctx, app, db, pact.RecordSaved{Model: morph, ID: id, Locale: locale, Changes: changes})
|
||||||
|
cooler.PurgeSiteCacheAfterCommit(ctx, db, purger, "post", post.Slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return HeadingFix{}, res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
var cats []models.Category
|
||||||
|
if err := db.Model(&models.Category{}).Select("id", "slug", "content").
|
||||||
|
Where("content IS NOT NULL").Order("id ASC").Find(&cats).Error; err != nil {
|
||||||
|
return HeadingFix{}, err
|
||||||
|
}
|
||||||
|
catMorph := models.Category{}.MorphName()
|
||||||
|
for _, c := range cats {
|
||||||
|
converted, n := ConvertH1(*c.Content)
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Categories++
|
||||||
|
out.Headings += n
|
||||||
|
if !apply {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := db.Model(&models.Category{}).Where("id = ?", c.ID).Update("content", converted).Error; err != nil {
|
||||||
|
return HeadingFix{}, err
|
||||||
|
}
|
||||||
|
id := strconv.FormatUint(uint64(c.ID), 10)
|
||||||
|
cabana.EmitRecordSaved(ctx, app, db, pact.RecordSaved{Model: catMorph, ID: id, Locale: locale, Changes: []pact.RecordChange{
|
||||||
|
{Attribute: "content", Old: *c.Content, New: converted},
|
||||||
|
}})
|
||||||
|
cooler.PurgeSiteCacheAfterCommit(ctx, db, purger, "category", c.Slug)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixPost converts one post's host content and its translations (attrs,
|
||||||
|
// the post's translate rows) and, with apply, writes what changed. It
|
||||||
|
// returns the number of headings and the record changes.
|
||||||
|
func fixPost(ctx context.Context, db *gorm.DB, post *models.Post, attrs []translatemodels.Attribute, apply bool) (int, []pact.RecordChange, error) {
|
||||||
|
total := 0
|
||||||
|
var changes []pact.RecordChange
|
||||||
|
content, html, n := ConvertContentH1(post.Content, post.ContentHTML)
|
||||||
|
if n > 0 {
|
||||||
|
total += n
|
||||||
|
if content != post.Content {
|
||||||
|
changes = append(changes, pact.RecordChange{Attribute: "content", Old: post.Content, New: content})
|
||||||
|
}
|
||||||
|
if html != post.ContentHTML {
|
||||||
|
changes = append(changes, pact.RecordChange{Attribute: "content_html", Old: post.ContentHTML, New: html})
|
||||||
|
}
|
||||||
|
if apply {
|
||||||
|
if err := db.Model(&models.Post{}).Where("id = ?", post.ID).
|
||||||
|
Updates(map[string]any{"content": content, "content_html": html}).Error; err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, a := range attrs {
|
||||||
|
if a.AttributeData == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(*a.AttributeData), &data); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
before := map[string]any{"content": data["content"], "content_html": data["content_html"]}
|
||||||
|
n := ConvertTranslationH1(data)
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += n
|
||||||
|
for _, field := range []string{"content", "content_html"} {
|
||||||
|
if data[field] != before[field] {
|
||||||
|
changes = append(changes, pact.RecordChange{Attribute: field, Locale: a.Locale, Old: before[field], New: data[field]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !apply {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The row is rewritten as it is, the other attributes kept: a
|
||||||
|
// translation in a locale disabled since keeps its content too.
|
||||||
|
raw, err := encodeAttributes(data)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
if err := db.Model(&translatemodels.Attribute{}).Where("id = ?", a.ID).
|
||||||
|
Update("attribute_data", raw).Error; err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total, changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeAttributes encodes a translate row's attribute map as the translate
|
||||||
|
// plugin does: JSON without HTML escaping.
|
||||||
|
func encodeAttributes(data map[string]any) (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&buf)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
if err := enc.Encode(data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(buf.String()), nil
|
||||||
|
}
|
||||||
140
classes/headings.go
Normal file
140
classes/headings.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
xhtml "golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConvertH1 turns every h1 element of html into an h2 and returns the
|
||||||
|
// result with the number of headings it converted. It works on the
|
||||||
|
// x/net/html tokenizer's raw tokens: only the tag name of an h1 start, end
|
||||||
|
// or self-closing tag changes, and every other byte (attributes, text,
|
||||||
|
// comments, the content of script or style) is copied as it is. Text that
|
||||||
|
// merely looks like a tag inside raw-text elements or comments is not a
|
||||||
|
// tag and is not touched. html without an h1 comes back unchanged with 0,
|
||||||
|
// and the bytes of an unfinished tag at the end of html are kept.
|
||||||
|
func ConvertH1(html string) (string, int) {
|
||||||
|
if !containsFold(html, "<h1") && !containsFold(html, "</h1") {
|
||||||
|
return html, 0
|
||||||
|
}
|
||||||
|
z := xhtml.NewTokenizer(strings.NewReader(html))
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.Grow(len(html))
|
||||||
|
count := 0
|
||||||
|
consumed := 0
|
||||||
|
for {
|
||||||
|
tt := z.Next()
|
||||||
|
if tt == xhtml.ErrorToken {
|
||||||
|
// The input may end inside something the tokenizer reads as a
|
||||||
|
// tag ("a <b"): those bytes are kept as they are.
|
||||||
|
b.WriteString(html[min(consumed, len(html)):])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
raw := z.Raw()
|
||||||
|
consumed += len(raw)
|
||||||
|
switch tt {
|
||||||
|
case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
|
||||||
|
if name, _ := z.TagName(); string(name) == "h1" && len(raw) >= 3 {
|
||||||
|
b.WriteString("<h2")
|
||||||
|
b.Write(raw[3:])
|
||||||
|
count++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case xhtml.EndTagToken:
|
||||||
|
if name, _ := z.TagName(); string(name) == "h1" && len(raw) >= 4 {
|
||||||
|
b.WriteString("</h2")
|
||||||
|
b.Write(raw[4:])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.Write(raw)
|
||||||
|
}
|
||||||
|
if count == 0 && b.String() == html {
|
||||||
|
return html, 0
|
||||||
|
}
|
||||||
|
return b.String(), count
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountH1 is the number of h1 start tags in html, as ConvertH1 counts them.
|
||||||
|
func CountH1(html string) int {
|
||||||
|
_, n := ConvertH1(html)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFold(s, sub string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(s), sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertContentH1 applies ConvertH1 to a post body and its rendered HTML
|
||||||
|
// (content and content_html) and returns both with the number of headings
|
||||||
|
// converted: content_html's count, or content's when content_html had none.
|
||||||
|
// content is read as markdown, as every render path reads it (FormatHTML
|
||||||
|
// passes raw HTML through, so an HTML body is HTML blocks and is converted
|
||||||
|
// as before): an <h1> inside a code block or code span keeps its tag
|
||||||
|
// whatever the body's first character, so a markdown post that opens with
|
||||||
|
// an HTML block keeps its code samples (33-REVIEW-GAPFIX WR-01), and a raw
|
||||||
|
// <h1> outside code becomes <h2>, as in the rendered HTML.
|
||||||
|
func ConvertContentH1(content, contentHTML string) (string, string, int) {
|
||||||
|
contentHTML, nHTML := ConvertH1(contentHTML)
|
||||||
|
content, nContent := convertH1OutsideCode(content)
|
||||||
|
if nHTML > 0 {
|
||||||
|
return content, contentHTML, nHTML
|
||||||
|
}
|
||||||
|
return content, contentHTML, nContent
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertH1OutsideCode applies ConvertH1 to the parts of markdown md
|
||||||
|
// outside its fenced and indented code blocks and code spans (goldmark's
|
||||||
|
// AST, the engine of FormatHTML): an h1 shown in a code sample keeps its
|
||||||
|
// tag. HTML blocks are not code, so an HTML body is converted whole. md
|
||||||
|
// without an h1 tag comes back unchanged with 0 without being parsed.
|
||||||
|
func convertH1OutsideCode(md string) (string, int) {
|
||||||
|
if !containsFold(md, "<h1") && !containsFold(md, "</h1") {
|
||||||
|
return md, 0
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(md))
|
||||||
|
count := 0
|
||||||
|
last := 0
|
||||||
|
apply := func(part string) {
|
||||||
|
out, n := ConvertH1(part)
|
||||||
|
count += n
|
||||||
|
b.WriteString(out)
|
||||||
|
}
|
||||||
|
for _, r := range markdownCodeRanges(md) {
|
||||||
|
apply(md[last:r[0]])
|
||||||
|
b.WriteString(md[r[0]:r[1]])
|
||||||
|
last = r[1]
|
||||||
|
}
|
||||||
|
apply(md[last:])
|
||||||
|
if count == 0 && b.String() == md {
|
||||||
|
return md, 0
|
||||||
|
}
|
||||||
|
return b.String(), count
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertTranslationH1 applies ConvertContentH1 to the string content and
|
||||||
|
// content_html of one locale's translated attributes, in place, and returns
|
||||||
|
// the number of headings converted. content is rewritten outside code
|
||||||
|
// only (ConvertContentH1). Other attributes and values that are not strings are
|
||||||
|
// left alone.
|
||||||
|
func ConvertTranslationH1(attrs map[string]any) int {
|
||||||
|
if attrs == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
content, hasContent := attrs["content"].(string)
|
||||||
|
html, hasHTML := attrs["content_html"].(string)
|
||||||
|
if !hasContent && !hasHTML {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
content, html, n := ConvertContentH1(content, html)
|
||||||
|
if hasContent {
|
||||||
|
attrs["content"] = content
|
||||||
|
}
|
||||||
|
if hasHTML {
|
||||||
|
attrs["content_html"] = html
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
143
classes/headings_test.go
Normal file
143
classes/headings_test.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestConvertH1 pins ConvertH1's byte rule: only h1 tag names change.
|
||||||
|
func TestConvertH1(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in, want string
|
||||||
|
n int
|
||||||
|
}{
|
||||||
|
{"<h1>A</h1><p>x</p>", "<h2>A</h2><p>x</p>", 1},
|
||||||
|
{`<h1 class="x" id='y'>T</h1>`, `<h2 class="x" id='y'>T</h2>`, 1},
|
||||||
|
{"<H1>Up</H1>", "<h2>Up</h2>", 1},
|
||||||
|
{"<h1>a</h1>\n<h1>b</h1>", "<h2>a</h2>\n<h2>b</h2>", 2},
|
||||||
|
{"<p>no heading & text</p>", "<p>no heading & text</p>", 0},
|
||||||
|
{"<!-- <h1>c</h1> --><p>x</p>", "<!-- <h1>c</h1> --><p>x</p>", 0},
|
||||||
|
{"<script>var s = '<h1>';</script>", "<script>var s = '<h1>';</script>", 0},
|
||||||
|
{"<h10>x</h10><h1>y</h1>", "<h10>x</h10><h2>y</h2>", 1},
|
||||||
|
{"# Markdown <h1>raw</h1> a < b", "# Markdown <h2>raw</h2> a < b", 1},
|
||||||
|
{"<p>unterminated <h1>x", "<p>unterminated <h2>x", 1},
|
||||||
|
{"text <", "text <", 0},
|
||||||
|
{"", "", 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, n := ConvertH1(c.in)
|
||||||
|
if got != c.want || n != c.n {
|
||||||
|
t.Errorf("ConvertH1(%q) = %q, %d; want %q, %d", c.in, got, n, c.want, c.n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// L1 WR-03: input without a match comes back identical, and an h1
|
||||||
|
// followed by an unfinished tag keeps the tail.
|
||||||
|
for _, in := range []string{"p a <b", "x<y", "<p>a <b class=\"c", "a < b"} {
|
||||||
|
if out, n := ConvertH1(in); out != in || n != 0 {
|
||||||
|
t.Errorf("ConvertH1(%q) = %q %d", in, out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out, n := ConvertH1("<h1>x</h1><p>a <b"); out != "<h2>x</h2><p>a <b" || n != 1 {
|
||||||
|
t.Errorf("unfinished tail = %q %d", out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConvertContentH1 pins the body pair: content_html's count when its
|
||||||
|
// HTML had H1s, content's otherwise (a markdown body's H1 is only in its
|
||||||
|
// rendered HTML), both converted.
|
||||||
|
func TestConvertContentH1(t *testing.T) {
|
||||||
|
c, h, n := ConvertContentH1("# Title\n\ntext", "<h1>Title</h1><p>text</p>")
|
||||||
|
if c != "# Title\n\ntext" || h != "<h2>Title</h2><p>text</p>" || n != 1 {
|
||||||
|
t.Fatalf("markdown body = %q %q %d", c, h, n)
|
||||||
|
}
|
||||||
|
c, h, n = ConvertContentH1("<h1>a</h1><h1>b</h1>", "")
|
||||||
|
if c != "<h2>a</h2><h2>b</h2>" || h != "" || n != 2 {
|
||||||
|
t.Fatalf("HTML body without rendered HTML = %q %q %d", c, h, n)
|
||||||
|
}
|
||||||
|
if _, _, n := ConvertContentH1("<p>x</p>", "<p>x</p>"); n != 0 {
|
||||||
|
t.Fatal("no H1, nothing converted")
|
||||||
|
}
|
||||||
|
if CountH1(`<h1>a</h1><div><h1 class="x">b</h1></div><h2>c</h2>`) != 2 || CountH1("") != 0 {
|
||||||
|
t.Fatal("CountH1 is wrong")
|
||||||
|
}
|
||||||
|
// L1 CR-02: markdown with a fenced html sample and an inline code h1 is
|
||||||
|
// kept byte for byte; its rendered HTML has the real heading converted
|
||||||
|
// and the code still escaped.
|
||||||
|
md := "# Title\n\nExample:\n\n```html\n<h1>Hello</h1>\n```\n\nInline `<h1>` tag."
|
||||||
|
rendered, err := FormatHTML(md)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, h, n = ConvertContentH1(md, rendered)
|
||||||
|
if c != md {
|
||||||
|
t.Fatalf("the markdown source was rewritten:\n%s", c)
|
||||||
|
}
|
||||||
|
if n != 1 || !strings.Contains(h, "<h2") || strings.Contains(h, "<h1") || !strings.Contains(h, "<h1>Hello</h1>") || !strings.Contains(h, "<code><h1></code>") {
|
||||||
|
t.Fatalf("rendered HTML = %q n=%d", h, n)
|
||||||
|
}
|
||||||
|
// A raw h1 outside code is a heading in the rendered page, so it is
|
||||||
|
// converted in the markdown source too (as v0.3.1 did), and counted
|
||||||
|
// when there is no rendered HTML.
|
||||||
|
if c, h, n := ConvertContentH1("plain <h1>x</h1> text", ""); c != "plain <h2>x</h2> text" || h != "" || n != 1 {
|
||||||
|
t.Fatalf("markdown without HTML = %q %q %d", c, h, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConvertContentH1HTMLLead pins markdown that starts with an HTML
|
||||||
|
// block (33-REVIEW-GAPFIX WR-01): the first byte does not make it HTML,
|
||||||
|
// so the h1 samples in its code keep their tags and only a raw h1 outside
|
||||||
|
// code is converted.
|
||||||
|
func TestConvertContentH1HTMLLead(t *testing.T) {
|
||||||
|
probe := "<p class=\"lead\">Intro</p>\n\n```html\n<h1>Hello</h1>\n```"
|
||||||
|
rendered, err := FormatHTML(probe)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, html := range []string{rendered, ""} {
|
||||||
|
c, h, n := ConvertContentH1(probe, html)
|
||||||
|
if c != probe || h != html || n != 0 {
|
||||||
|
t.Fatalf("probe with content_html %q = %q %q n=%d", html, c, h, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mixed := "<p class=\"lead\">Intro</p>\n\n<h1>Real</h1>\n\n```html\n<h1>Hello</h1>\n```"
|
||||||
|
want := "<p class=\"lead\">Intro</p>\n\n<h2>Real</h2>\n\n```html\n<h1>Hello</h1>\n```"
|
||||||
|
if c, h, n := ConvertContentH1(mixed, ""); c != want || h != "" || n != 1 {
|
||||||
|
t.Fatalf("raw h1 outside code = %q %q n=%d", c, h, n)
|
||||||
|
}
|
||||||
|
span := "<p>x</p>\n\nUse `<h1>` here"
|
||||||
|
if c, _, n := ConvertContentH1(span, ""); c != span || n != 0 {
|
||||||
|
t.Fatalf("code span = %q n=%d", c, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConvertTranslationH1 pins a locale's translated attributes,
|
||||||
|
// changed in place: content and content_html converted, other attributes
|
||||||
|
// and values that are not strings left alone (a category's pillar text
|
||||||
|
// takes the same path as a post body).
|
||||||
|
func TestConvertTranslationH1(t *testing.T) {
|
||||||
|
attrs := map[string]any{"title": "<h1>not content</h1>", "content": "<h1>Pillar</h1><p>x</p>", "content_html": 5}
|
||||||
|
if n := ConvertTranslationH1(attrs); n != 1 || attrs["content"] != "<h2>Pillar</h2><p>x</p>" || attrs["title"] != "<h1>not content</h1>" || attrs["content_html"] != 5 {
|
||||||
|
t.Fatalf("attrs = %v n=%d", attrs, n)
|
||||||
|
}
|
||||||
|
both := map[string]any{"content": "# T", "content_html": "<h1>T</h1>"}
|
||||||
|
if n := ConvertTranslationH1(both); n != 1 || both["content_html"] != "<h2>T</h2>" || both["content"] != "# T" {
|
||||||
|
t.Fatalf("both = %v n=%d", both, n)
|
||||||
|
}
|
||||||
|
if ConvertTranslationH1(nil) != 0 || ConvertTranslationH1(map[string]any{"title": "x"}) != 0 {
|
||||||
|
t.Fatal("nothing to convert")
|
||||||
|
}
|
||||||
|
md := "Sample:\n\n```html\n<h1>Hi</h1>\n```\n"
|
||||||
|
tr := map[string]any{"content": md, "content_html": "<pre><code><h1>Hi</h1></code></pre>"}
|
||||||
|
if n := ConvertTranslationH1(tr); n != 0 || tr["content"] != md {
|
||||||
|
t.Fatalf("a markdown translation was rewritten: %v n=%d", tr, n)
|
||||||
|
}
|
||||||
|
probe := "<p class=\"lead\">Intro</p>\n\n```html\n<h1>Hello</h1>\n```"
|
||||||
|
rendered, err := FormatHTML(probe)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lead := map[string]any{"content": probe, "content_html": rendered}
|
||||||
|
if n := ConvertTranslationH1(lead); n != 0 || lead["content"] != probe {
|
||||||
|
t.Fatalf("markdown with an HTML lead was rewritten: %v n=%d", lead, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
431
classes/image_alt.go
Normal file
431
classes/image_alt.go
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yuin/goldmark/ast"
|
||||||
|
gmtext "github.com/yuin/goldmark/text"
|
||||||
|
"github.com/yuin/goldmark/util"
|
||||||
|
xhtml "golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetImageAlt sets the alt attribute of every img in html whose src
|
||||||
|
// attribute (unescaped) equals src, adding it when missing. It returns the
|
||||||
|
// new HTML, the number of matching images and the alt the first match had
|
||||||
|
// (nil when it had none). It works on the x/net/html tokenizer: a matching
|
||||||
|
// img tag is written again from its token, so every attribute value,
|
||||||
|
// alt included, is escaped by the tokenizer and never concatenated; every
|
||||||
|
// other byte of html is copied as it is, the bytes of an unfinished tag at
|
||||||
|
// the end included. An empty alt marks a decorative image (alt="").
|
||||||
|
func SetImageAlt(html, src, alt string) (string, int, *string) {
|
||||||
|
if src == "" || !containsFold(html, "<img") {
|
||||||
|
return html, 0, nil
|
||||||
|
}
|
||||||
|
z := xhtml.NewTokenizer(strings.NewReader(html))
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.Grow(len(html) + len(alt))
|
||||||
|
matches := 0
|
||||||
|
var old *string
|
||||||
|
consumed := 0
|
||||||
|
for {
|
||||||
|
tt := z.Next()
|
||||||
|
if tt == xhtml.ErrorToken {
|
||||||
|
// The bytes of an unfinished tag at the end are kept.
|
||||||
|
b.WriteString(html[min(consumed, len(html)):])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
raw := z.Raw()
|
||||||
|
consumed += len(raw)
|
||||||
|
if tt == xhtml.StartTagToken || tt == xhtml.SelfClosingTagToken {
|
||||||
|
// Raw is copied before Token, which reads the tag; Token is
|
||||||
|
// the only reader of the tag (TagName would consume it).
|
||||||
|
kept := append([]byte(nil), raw...)
|
||||||
|
tok := z.Token()
|
||||||
|
if tok.Data == "img" && imgSrc(tok) == src {
|
||||||
|
prev := setAlt(&tok, alt)
|
||||||
|
if matches == 0 {
|
||||||
|
old = prev
|
||||||
|
}
|
||||||
|
matches++
|
||||||
|
b.WriteString(tok.String())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.Write(kept)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.Write(raw)
|
||||||
|
}
|
||||||
|
if matches == 0 {
|
||||||
|
return html, 0, nil
|
||||||
|
}
|
||||||
|
return b.String(), matches, old
|
||||||
|
}
|
||||||
|
|
||||||
|
func imgSrc(tok xhtml.Token) string {
|
||||||
|
for _, a := range tok.Attr {
|
||||||
|
if a.Namespace == "" && a.Key == "src" {
|
||||||
|
return a.Val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// setAlt sets tok's alt to alt and returns the previous value (nil when
|
||||||
|
// there was none). Repeated alt attributes collapse to one.
|
||||||
|
func setAlt(tok *xhtml.Token, alt string) *string {
|
||||||
|
var prev *string
|
||||||
|
attrs := tok.Attr[:0]
|
||||||
|
set := false
|
||||||
|
for _, a := range tok.Attr {
|
||||||
|
if a.Namespace == "" && a.Key == "alt" {
|
||||||
|
if prev == nil {
|
||||||
|
v := a.Val
|
||||||
|
prev = &v
|
||||||
|
}
|
||||||
|
if set {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.Val = alt
|
||||||
|
set = true
|
||||||
|
}
|
||||||
|
attrs = append(attrs, a)
|
||||||
|
}
|
||||||
|
if !set {
|
||||||
|
attrs = append(attrs, xhtml.Attribute{Key: "alt", Val: alt})
|
||||||
|
}
|
||||||
|
tok.Attr = attrs
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMarkdownImageAlt sets the alt text of every inline markdown image of
|
||||||
|
// md whose destination is src (P33-65):  and
|
||||||
|
// , outside fenced and indented code blocks, code
|
||||||
|
// spans and HTML blocks (the ranges come from goldmark's AST, the engine of
|
||||||
|
// FormatHTML). An image matches when its destination equals src as written,
|
||||||
|
// or as goldmark's HTML renderer escapes it into the img src attribute. The
|
||||||
|
// label is replaced by alt with \, [ and ] backslash-escaped and line breaks
|
||||||
|
// folded to spaces, so an alt can neither close the image nor add a link;
|
||||||
|
// every other byte of md is kept. It returns the new markdown, the number of
|
||||||
|
// matching images and the label the first match had. A reference-style
|
||||||
|
// image (![label][ref]) is not matched.
|
||||||
|
func SetMarkdownImageAlt(md, src, alt string) (string, int, *string) {
|
||||||
|
if src == "" || !strings.Contains(md, "![") {
|
||||||
|
return md, 0, nil
|
||||||
|
}
|
||||||
|
skip, images := markdownLayout(md)
|
||||||
|
if len(images) == 0 {
|
||||||
|
return md, 0, nil
|
||||||
|
}
|
||||||
|
label := escapeMarkdownLabel(alt)
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(md) + len(label))
|
||||||
|
matches := 0
|
||||||
|
var old *string
|
||||||
|
last := 0
|
||||||
|
for i := 0; i+1 < len(md); i++ {
|
||||||
|
if md[i] != '!' || md[i+1] != '[' || (i > 0 && md[i-1] == '\\') || inRanges(skip, i) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
img, ok := scanInlineImage(md, i)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dest := md[img.destStart:img.destEnd]
|
||||||
|
escaped := string(util.URLEscape([]byte(dest), true))
|
||||||
|
if !images[dest] || (dest != src && escaped != src) {
|
||||||
|
i = img.end - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if matches == 0 {
|
||||||
|
v := md[img.labelStart:img.labelEnd]
|
||||||
|
old = &v
|
||||||
|
}
|
||||||
|
matches++
|
||||||
|
b.WriteString(md[last:img.labelStart])
|
||||||
|
b.WriteString(label)
|
||||||
|
last = img.labelEnd
|
||||||
|
i = img.end - 1
|
||||||
|
}
|
||||||
|
if matches == 0 {
|
||||||
|
return md, 0, nil
|
||||||
|
}
|
||||||
|
b.WriteString(md[last:])
|
||||||
|
return b.String(), matches, old
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetImageAltOutsideCode applies SetImageAlt to the parts of markdown md
|
||||||
|
// outside its fenced and indented code blocks and code spans, so a raw
|
||||||
|
// <img> in the markdown gets the alt while an <img> shown in a code sample
|
||||||
|
// keeps its bytes.
|
||||||
|
func SetImageAltOutsideCode(md, src, alt string) (string, int, *string) {
|
||||||
|
if src == "" || !containsFold(md, "<img") {
|
||||||
|
return md, 0, nil
|
||||||
|
}
|
||||||
|
code := markdownCodeRanges(md)
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(md) + len(alt))
|
||||||
|
matches := 0
|
||||||
|
var old *string
|
||||||
|
last := 0
|
||||||
|
apply := func(part string) {
|
||||||
|
out, n, prev := SetImageAlt(part, src, alt)
|
||||||
|
if n > 0 && matches == 0 {
|
||||||
|
old = prev
|
||||||
|
}
|
||||||
|
matches += n
|
||||||
|
b.WriteString(out)
|
||||||
|
}
|
||||||
|
for _, r := range code {
|
||||||
|
apply(md[last:r[0]])
|
||||||
|
b.WriteString(md[r[0]:r[1]])
|
||||||
|
last = r[1]
|
||||||
|
}
|
||||||
|
apply(md[last:])
|
||||||
|
if matches == 0 {
|
||||||
|
return md, 0, nil
|
||||||
|
}
|
||||||
|
return b.String(), matches, old
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeMarkdownLabel is alt as an image label: \, [ and ] escaped, line
|
||||||
|
// breaks folded to spaces.
|
||||||
|
func escapeMarkdownLabel(alt string) string {
|
||||||
|
alt = strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ").Replace(alt)
|
||||||
|
return strings.NewReplacer(`\`, `\\`, "[", `\[`, "]", `\]`).Replace(alt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// inlineImage is the byte layout of one  in a text.
|
||||||
|
type inlineImage struct {
|
||||||
|
labelStart, labelEnd int
|
||||||
|
destStart, destEnd int
|
||||||
|
end int
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanInlineImage reads the inline image starting at md[i] ("!["), as
|
||||||
|
// goldmark's link parser reads one: a label with balanced brackets and
|
||||||
|
// backslash escapes, "(", optional spaces, a destination in <...> or
|
||||||
|
// without spaces with balanced parentheses, an optional title in quotes or
|
||||||
|
// parentheses, and ")".
|
||||||
|
func scanInlineImage(md string, i int) (inlineImage, bool) {
|
||||||
|
var img inlineImage
|
||||||
|
img.labelStart = i + 2
|
||||||
|
depth := 0
|
||||||
|
j := img.labelStart
|
||||||
|
for ; j < len(md); j++ {
|
||||||
|
c := md[j]
|
||||||
|
if c == '\\' && j+1 < len(md) {
|
||||||
|
j++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '[' {
|
||||||
|
depth++
|
||||||
|
} else if c == ']' {
|
||||||
|
if depth == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if j >= len(md) || j+1 >= len(md) || md[j+1] != '(' {
|
||||||
|
return img, false
|
||||||
|
}
|
||||||
|
img.labelEnd = j
|
||||||
|
k := skipLinkSpace(md, j+2)
|
||||||
|
if k >= len(md) {
|
||||||
|
return img, false
|
||||||
|
}
|
||||||
|
if md[k] == '<' {
|
||||||
|
e := k + 1
|
||||||
|
for ; e < len(md) && md[e] != '>' && md[e] != '\n'; e++ {
|
||||||
|
if md[e] == '\\' && e+1 < len(md) {
|
||||||
|
e++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e >= len(md) || md[e] != '>' {
|
||||||
|
return img, false
|
||||||
|
}
|
||||||
|
img.destStart, img.destEnd = k+1, e
|
||||||
|
k = e + 1
|
||||||
|
} else {
|
||||||
|
opened := 0
|
||||||
|
e := k
|
||||||
|
for ; e < len(md); e++ {
|
||||||
|
c := md[e]
|
||||||
|
if c == '\\' && e+1 < len(md) {
|
||||||
|
e++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '(' {
|
||||||
|
opened++
|
||||||
|
} else if c == ')' {
|
||||||
|
if opened == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
opened--
|
||||||
|
} else if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e == k {
|
||||||
|
return img, false
|
||||||
|
}
|
||||||
|
img.destStart, img.destEnd = k, e
|
||||||
|
k = e
|
||||||
|
}
|
||||||
|
k = skipLinkSpace(md, k)
|
||||||
|
if k < len(md) && (md[k] == '"' || md[k] == '\'' || md[k] == '(') {
|
||||||
|
closer := md[k]
|
||||||
|
if closer == '(' {
|
||||||
|
closer = ')'
|
||||||
|
}
|
||||||
|
e := k + 1
|
||||||
|
for ; e < len(md) && md[e] != closer; e++ {
|
||||||
|
if md[e] == '\\' && e+1 < len(md) {
|
||||||
|
e++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e >= len(md) {
|
||||||
|
return img, false
|
||||||
|
}
|
||||||
|
k = skipLinkSpace(md, e+1)
|
||||||
|
}
|
||||||
|
if k >= len(md) || md[k] != ')' {
|
||||||
|
return img, false
|
||||||
|
}
|
||||||
|
img.end = k + 1
|
||||||
|
return img, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipLinkSpace skips spaces, tabs and at most one line break.
|
||||||
|
func skipLinkSpace(md string, k int) int {
|
||||||
|
newline := false
|
||||||
|
for k < len(md) {
|
||||||
|
switch md[k] {
|
||||||
|
case ' ', '\t':
|
||||||
|
case '\n', '\r':
|
||||||
|
if newline {
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
newline = true
|
||||||
|
default:
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
k++
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
// markdownLayout parses md as FormatHTML does and answers the byte ranges
|
||||||
|
// an image is never read from (code blocks, code spans and HTML blocks)
|
||||||
|
// and the destinations of its inline images.
|
||||||
|
func markdownLayout(md string) ([][2]int, map[string]bool) {
|
||||||
|
images := map[string]bool{}
|
||||||
|
ranges := markdownRanges(md, true, func(n ast.Node) {
|
||||||
|
if img, ok := n.(*ast.Image); ok {
|
||||||
|
images[string(img.Destination)] = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return ranges, images
|
||||||
|
}
|
||||||
|
|
||||||
|
// markdownCodeRanges is the byte ranges of md's code blocks and code
|
||||||
|
// spans, in order.
|
||||||
|
func markdownCodeRanges(md string) [][2]int {
|
||||||
|
return markdownRanges(md, false, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// markdownRanges walks md's goldmark AST (parsed from the trimmed text, as
|
||||||
|
// FormatHTML converts it) and answers the sorted, merged byte ranges of its
|
||||||
|
// fenced and indented code blocks, its code spans and, with html, its HTML
|
||||||
|
// blocks; visit sees every node.
|
||||||
|
func markdownRanges(md string, html bool, visit func(ast.Node)) [][2]int {
|
||||||
|
trimmed := strings.TrimSpace(md)
|
||||||
|
lead := strings.Index(md, trimmed)
|
||||||
|
if trimmed == "" || lead < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
source := []byte(trimmed)
|
||||||
|
doc := markdownEngine.Parser().Parse(gmtext.NewReader(source))
|
||||||
|
var ranges [][2]int
|
||||||
|
lines := func(n ast.Node) {
|
||||||
|
l := n.Lines()
|
||||||
|
for i := 0; i < l.Len(); i++ {
|
||||||
|
s := l.At(i)
|
||||||
|
ranges = append(ranges, [2]int{lead + s.Start, lead + s.Stop})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
|
if !entering {
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
if visit != nil {
|
||||||
|
visit(n)
|
||||||
|
}
|
||||||
|
switch v := n.(type) {
|
||||||
|
case *ast.FencedCodeBlock:
|
||||||
|
lines(v)
|
||||||
|
if v.Info != nil {
|
||||||
|
ranges = append(ranges, [2]int{lead + v.Info.Segment.Start, lead + v.Info.Segment.Stop})
|
||||||
|
}
|
||||||
|
return ast.WalkSkipChildren, nil
|
||||||
|
case *ast.CodeBlock:
|
||||||
|
lines(v)
|
||||||
|
return ast.WalkSkipChildren, nil
|
||||||
|
case *ast.HTMLBlock:
|
||||||
|
if html {
|
||||||
|
lines(v)
|
||||||
|
}
|
||||||
|
return ast.WalkSkipChildren, nil
|
||||||
|
case *ast.CodeSpan:
|
||||||
|
start, stop := -1, -1
|
||||||
|
for c := v.FirstChild(); c != nil; c = c.NextSibling() {
|
||||||
|
if t, ok := c.(*ast.Text); ok {
|
||||||
|
if start < 0 || t.Segment.Start < start {
|
||||||
|
start = t.Segment.Start
|
||||||
|
}
|
||||||
|
stop = max(stop, t.Segment.Stop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start >= 0 {
|
||||||
|
// The backticks around the span belong to it.
|
||||||
|
for start > 0 && source[start-1] == '`' {
|
||||||
|
start--
|
||||||
|
}
|
||||||
|
for stop < len(source) && source[stop] == '`' {
|
||||||
|
stop++
|
||||||
|
}
|
||||||
|
ranges = append(ranges, [2]int{lead + start, lead + stop})
|
||||||
|
}
|
||||||
|
return ast.WalkSkipChildren, nil
|
||||||
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
})
|
||||||
|
sort.Slice(ranges, func(i, j int) bool { return ranges[i][0] < ranges[j][0] })
|
||||||
|
var merged [][2]int
|
||||||
|
for _, r := range ranges {
|
||||||
|
if r[0] >= r[1] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n := len(merged); n > 0 && r[0] <= merged[n-1][1] {
|
||||||
|
merged[n-1][1] = max(merged[n-1][1], r[1])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged = append(merged, r)
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// inRanges reports whether i lies in one of the sorted ranges.
|
||||||
|
func inRanges(ranges [][2]int, i int) bool {
|
||||||
|
for _, r := range ranges {
|
||||||
|
if i < r[0] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if i < r[1] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
109
classes/image_alt_test.go
Normal file
109
classes/image_alt_test.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSetImageAlt pins the alt rewrite: only the matching img tag is
|
||||||
|
// rewritten from its token, the alt is escaped, a missing alt is added and
|
||||||
|
// every other byte is kept (T-33-04).
|
||||||
|
func TestSetImageAlt(t *testing.T) {
|
||||||
|
const doc = `<p class="a">x & y</p><img src="https://cdn.example.test/a.jpg?x=1&y=2"><img src="b.jpg" alt="old"/>`
|
||||||
|
out, n, old := SetImageAlt(doc, "https://cdn.example.test/a.jpg?x=1&y=2", `Lion's "mane" <b>`)
|
||||||
|
want := `<p class="a">x & y</p><img src="https://cdn.example.test/a.jpg?x=1&y=2" alt="Lion's "mane" <b>"><img src="b.jpg" alt="old"/>`
|
||||||
|
if out != want || n != 1 || old != nil {
|
||||||
|
t.Fatalf("SetImageAlt = %q, %d, %v; want %q, 1, nil", out, n, old, want)
|
||||||
|
}
|
||||||
|
out, n, old = SetImageAlt(doc, "b.jpg", "")
|
||||||
|
if out != `<p class="a">x & y</p><img src="https://cdn.example.test/a.jpg?x=1&y=2"><img src="b.jpg" alt=""/>` || n != 1 || old == nil || *old != "old" {
|
||||||
|
t.Fatalf("decorative = %q, %d, %v", out, n, old)
|
||||||
|
}
|
||||||
|
if out, n, _ := SetImageAlt(doc, "missing.jpg", "x"); out != doc || n != 0 {
|
||||||
|
t.Fatalf("missing = %q, %d", out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetImageAltEdges pins the edges: every matching img is rewritten
|
||||||
|
// and counted (the first one's old alt answered), repeated alt attributes
|
||||||
|
// collapse to one, a src given escaped does not match the unescaped one,
|
||||||
|
// an empty src and a body without img change nothing, and text that
|
||||||
|
// looks like an img inside a comment or script is not touched.
|
||||||
|
func TestSetImageAltEdges(t *testing.T) {
|
||||||
|
doc := `<img src="a.jpg" alt="one"><p>t</p><img alt="x" src="a.jpg" alt="y"><!-- <img src="a.jpg"> --><script>"<img src=a.jpg>"</script>`
|
||||||
|
out, n, old := SetImageAlt(doc, "a.jpg", "new")
|
||||||
|
if n != 2 || old == nil || *old != "one" {
|
||||||
|
t.Fatalf("n=%d old=%v", n, old)
|
||||||
|
}
|
||||||
|
want := `<img src="a.jpg" alt="new"><p>t</p><img alt="new" src="a.jpg"><!-- <img src="a.jpg"> --><script>"<img src=a.jpg>"</script>`
|
||||||
|
if out != want {
|
||||||
|
t.Fatalf("out = %q\nwant %q", out, want)
|
||||||
|
}
|
||||||
|
if out, n, _ := SetImageAlt(`<img src="a.jpg?x=1&y=2">`, "a.jpg?x=1&y=2", "z"); n != 0 || out != `<img src="a.jpg?x=1&y=2">` {
|
||||||
|
t.Fatalf("escaped src matched: %q %d", out, n)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct{ html, src string }{{`<img src="a.jpg">`, ""}, {`<p>no images</p>`, "a.jpg"}, {"", "a.jpg"}} {
|
||||||
|
if out, n, old := SetImageAlt(tc.html, tc.src, "x"); out != tc.html || n != 0 || old != nil {
|
||||||
|
t.Errorf("SetImageAlt(%q, %q) = %q %d %v", tc.html, tc.src, out, n, old)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// L1 WR-03: the bytes of an unfinished tag at the end are kept.
|
||||||
|
for _, in := range []string{"p a <img", "x<y", `<img src="b.jpg"><p>a <b`} {
|
||||||
|
if out, n, _ := SetImageAlt(in, "a.jpg", "z"); out != in || n != 0 {
|
||||||
|
t.Errorf("SetImageAlt(%q) = %q %d", in, out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out, n, _ := SetImageAlt(`<img src="a.jpg"><p>a <b`, "a.jpg", "z"); out != `<img src="a.jpg" alt="z"><p>a <b` || n != 1 {
|
||||||
|
t.Errorf("match with an unfinished tail = %q %d", out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetMarkdownImageAlt pins P33-65 (L1 WR-05): inline images with or
|
||||||
|
// without a title get the alt; a destination written unescaped matches the
|
||||||
|
// src goldmark renders for it; an alt with ] and a line break is escaped
|
||||||
|
// and folded; the same image in a fence or a code span is left alone; a
|
||||||
|
// reference-style image is not matched.
|
||||||
|
func TestSetMarkdownImageAlt(t *testing.T) {
|
||||||
|
out, n, old := SetMarkdownImageAlt("A  and  and .", "/a.jpg", "Lion's mane")
|
||||||
|
if n != 2 || old == nil || *old != "" || out != "A  and  and ." {
|
||||||
|
t.Fatalf("plain = %q %d %v", out, n, old)
|
||||||
|
}
|
||||||
|
html, err := FormatHTML("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, `src="/a%20b.jpg"`) {
|
||||||
|
t.Fatalf("goldmark src = %s", html)
|
||||||
|
}
|
||||||
|
if out, n, _ := SetMarkdownImageAlt("", "/a%20b.jpg", "y"); n != 1 || out != "" {
|
||||||
|
t.Fatalf("escaped src = %q %d", out, n)
|
||||||
|
}
|
||||||
|
if out, n, _ := SetMarkdownImageAlt("", "/%C5%BC.jpg", "y"); n != 1 || out != "" {
|
||||||
|
t.Fatalf("non-ASCII src = %q %d", out, n)
|
||||||
|
}
|
||||||
|
if out, _, _ := SetMarkdownImageAlt("", "/a.jpg", "a ] b\nc [d] \\e"); out != `![a \] b c \[d\] \\e](/a.jpg)` {
|
||||||
|
t.Fatalf("escaped alt = %q", out)
|
||||||
|
}
|
||||||
|
code := "```\n\n```\n\nInline `` and\n\n \n"
|
||||||
|
if out, n, _ := SetMarkdownImageAlt(code, "/a.jpg", "y"); n != 0 || out != code {
|
||||||
|
t.Fatalf("code = %q %d", out, n)
|
||||||
|
}
|
||||||
|
ref := "![x][a]\n\n[a]: /a.jpg\n"
|
||||||
|
if out, n, _ := SetMarkdownImageAlt(ref, "/a.jpg", "y"); n != 0 || out != ref {
|
||||||
|
t.Fatalf("reference = %q %d", out, n)
|
||||||
|
}
|
||||||
|
// The rendered alt follows the markdown.
|
||||||
|
out, _, _ = SetMarkdownImageAlt("Text\n\n\n", "/a.jpg", "Grzyb [leśny]")
|
||||||
|
if html, _ := FormatHTML(out); !strings.Contains(html, `alt="Grzyb [leśny]"`) {
|
||||||
|
t.Fatalf("rendered = %s", html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetImageAltOutsideCode: a raw img gets the alt, one in a code sample
|
||||||
|
// keeps its bytes.
|
||||||
|
raw := "<img src=\"/a.jpg\">\n\n```html\n<img src=\"/a.jpg\">\n```\n\nand `<img src=\"/a.jpg\">`\n"
|
||||||
|
got, n, _ := SetImageAltOutsideCode(raw, "/a.jpg", "z")
|
||||||
|
want := "<img src=\"/a.jpg\" alt=\"z\">\n\n```html\n<img src=\"/a.jpg\">\n```\n\nand `<img src=\"/a.jpg\">`\n"
|
||||||
|
if n != 1 || got != want {
|
||||||
|
t.Fatalf("outside code = %q %d", got, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
292
classes/image_variants.go
Normal file
292
classes/image_variants.go
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"io"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/conga"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/diary"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach/webp"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
"gocloud.dev/gcerrors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The image variants job and its configuration (2026-10-28 image formats,
|
||||||
|
// D-85, P33-43).
|
||||||
|
const (
|
||||||
|
// ImageVariantsKind is the conga job kind golem15.journal.image_variants.
|
||||||
|
ImageVariantsKind = "golem15.journal.image_variants"
|
||||||
|
// ImageWidthsKey is the config key of the variant widths, in pixels.
|
||||||
|
ImageWidthsKey = "golem15.journal.image_widths"
|
||||||
|
// ImageVariantsMode is the thumbnail mode of a variant: the exact size
|
||||||
|
// of the original's aspect ratio at the variant's width.
|
||||||
|
ImageVariantsMode = "exact"
|
||||||
|
// FeaturedImagesField is the system_files field of a post's featured
|
||||||
|
// images.
|
||||||
|
FeaturedImagesField = "featured_images"
|
||||||
|
// MinImageWidth and MaxImageWidth bound a configured width.
|
||||||
|
MinImageWidth = 16
|
||||||
|
MaxImageWidth = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
// variantsLog logs the image variants as component journal.
|
||||||
|
var variantsLog = diary.For("journal")
|
||||||
|
|
||||||
|
// DefaultImageWidths are the variant widths without configuration.
|
||||||
|
var DefaultImageWidths = []int{480, 800, 1200}
|
||||||
|
|
||||||
|
// ImageVariantsArgs are the arguments of the image variants job: the
|
||||||
|
// system_files ids of the featured images to make variants of.
|
||||||
|
type ImageVariantsArgs struct {
|
||||||
|
FileIDs []uint `json:"file_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind is the job kind, golem15.journal.image_variants.
|
||||||
|
func (ImageVariantsArgs) Kind() string { return ImageVariantsKind }
|
||||||
|
|
||||||
|
// ImageWidths reads golem15.journal.image_widths: a list of widths or a
|
||||||
|
// comma-separated text (an environment override), each 16 to 4096, sorted
|
||||||
|
// and once each. An unset, empty or invalid value answers
|
||||||
|
// DefaultImageWidths.
|
||||||
|
func ImageWidths(cfg *compass.Config) []int {
|
||||||
|
if cfg == nil {
|
||||||
|
return slices.Clone(DefaultImageWidths)
|
||||||
|
}
|
||||||
|
raw, ok := cfg.Lookup(ImageWidthsKey)
|
||||||
|
if !ok || raw == nil {
|
||||||
|
return slices.Clone(DefaultImageWidths)
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case string:
|
||||||
|
parts = strings.Split(v, ",")
|
||||||
|
case []any:
|
||||||
|
for _, item := range v {
|
||||||
|
parts = append(parts, fmt.Sprint(item))
|
||||||
|
}
|
||||||
|
case []int:
|
||||||
|
for _, n := range v {
|
||||||
|
parts = append(parts, strconv.Itoa(n))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
parts = []string{fmt.Sprint(v)}
|
||||||
|
}
|
||||||
|
var out []int
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(p)
|
||||||
|
if err != nil || n < MinImageWidth || n > MaxImageWidth {
|
||||||
|
return slices.Clone(DefaultImageWidths)
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return slices.Clone(DefaultImageWidths)
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return slices.Compact(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VariantsResult is what one run of GenerateImageVariants did.
|
||||||
|
type VariantsResult struct {
|
||||||
|
Files int
|
||||||
|
Written int
|
||||||
|
Skipped int
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxHeaderBytes caps the bytes read for an original's size.
|
||||||
|
const maxHeaderBytes = 1 << 20
|
||||||
|
|
||||||
|
// sourceSize reads an original's size from its header; ok is false for a
|
||||||
|
// missing original, one that does not decode, or one over the thumbnailer's
|
||||||
|
// pixel limit (attach would store the broken-image picture for it).
|
||||||
|
func sourceSize(ctx context.Context, bucket *blob.Bucket, f attach.File) (w, h int, ok bool, err error) {
|
||||||
|
r, err := bucket.NewReader(ctx, attach.BlobKey(f.DiskName), nil)
|
||||||
|
if gcerrors.Code(err) == gcerrors.NotFound {
|
||||||
|
return 0, 0, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false, err
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(r, maxHeaderBytes))
|
||||||
|
closeErr := r.Close()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false, err
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return 0, 0, false, closeErr
|
||||||
|
}
|
||||||
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
|
||||||
|
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
|
||||||
|
return 0, 0, false, nil
|
||||||
|
}
|
||||||
|
if int64(cfg.Width)*int64(cfg.Height) > attach.MaxImagePixels {
|
||||||
|
return 0, 0, false, nil
|
||||||
|
}
|
||||||
|
return cfg.Width, cfg.Height, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateImageVariants writes the variants of the files ids (D-85): for
|
||||||
|
// each width of widths no larger than the original's, the thumbnail at that
|
||||||
|
// width (attach.File.ThumbKey, mode exact, the original's aspect ratio) and
|
||||||
|
// its WebP copy (webp.Variant), recorded in golem15_journal_image_variants.
|
||||||
|
// A width that already has its row is skipped, so a second run writes
|
||||||
|
// nothing. A missing, undecodable or oversized original is skipped and
|
||||||
|
// logged. A storage or database error is returned (the job retries).
|
||||||
|
func GenerateImageVariants(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, widths []int, ids []uint) (VariantsResult, error) {
|
||||||
|
var out VariantsResult
|
||||||
|
if db == nil || bucket == nil {
|
||||||
|
return out, errors.New("journal: image variants need the database and the uploads bucket")
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
var files []attach.File
|
||||||
|
if err := db.WithContext(ctx).Where("id IN ?", ids).Order("id ASC").Find(&files).Error; err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
out.Files++
|
||||||
|
var have []int
|
||||||
|
if err := db.WithContext(ctx).Model(&models.ImageVariant{}).Where("file_id = ?", f.ID).Pluck("width", &have).Error; err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
var todo []int
|
||||||
|
for _, w := range widths {
|
||||||
|
if !slices.Contains(have, w) {
|
||||||
|
todo = append(todo, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(todo) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sw, sh, ok, err := sourceSize(ctx, bucket, f)
|
||||||
|
if err != nil {
|
||||||
|
return out, fmt.Errorf("journal: read original %d: %w", f.ID, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
variantsLog.Warn("journal: no image variants for an unusable original", "file", f.ID)
|
||||||
|
out.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, w := range todo {
|
||||||
|
if w > sw {
|
||||||
|
out.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h := max(1, int((int64(sh)*int64(w)+int64(sw)/2)/int64(sw)))
|
||||||
|
if h > MaxImageWidth {
|
||||||
|
// A tall original (an infographic): the variant would leave
|
||||||
|
// the thumbnailer's edge limit, so this width is skipped like
|
||||||
|
// one wider than the original, and the job goes on.
|
||||||
|
variantsLog.Warn("journal: no image variant taller than the edge limit", "file", f.ID, "width", w, "height", h, "max", MaxImageWidth)
|
||||||
|
out.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, err := f.ThumbKey(ctx, bucket, w, h, ImageVariantsMode)
|
||||||
|
if err != nil {
|
||||||
|
return out, fmt.Errorf("journal: thumbnail %d at %d: %w", f.ID, w, err)
|
||||||
|
}
|
||||||
|
webpKey, err := webp.Variant(ctx, bucket, &f, w, h, ImageVariantsMode)
|
||||||
|
if err != nil {
|
||||||
|
return out, fmt.Errorf("journal: webp %d at %d: %w", f.ID, w, err)
|
||||||
|
}
|
||||||
|
row := models.ImageVariant{FileID: f.ID, Width: w, Height: h, Key: key, WebPKey: webpKey, SourceWidth: sw, SourceHeight: sh, CreatedAt: time.Now().UTC()}
|
||||||
|
res := db.WithContext(ctx).Exec(`INSERT INTO golem15_journal_image_variants (file_id, width, height, key, webp_key, source_width, source_height, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (file_id, width) DO NOTHING`, row.FileID, row.Width, row.Height, row.Key, row.WebPKey, row.SourceWidth, row.SourceHeight, row.CreatedAt)
|
||||||
|
if res.Error != nil {
|
||||||
|
return out, res.Error
|
||||||
|
}
|
||||||
|
out.Written += int(res.RowsAffected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// featuredFiles is the featured images of the posts, or of every post when
|
||||||
|
// postIDs is nil; missing is true to keep only the images without any
|
||||||
|
// variant row, or without a row for a width no larger than their original.
|
||||||
|
func featuredFiles(db *gorm.DB, postIDs []uint, widths []int, missing bool) ([]uint, error) {
|
||||||
|
q := db.Model(&attach.File{}).Where("attachment_type = ? AND field = ?", (models.Post{}).MorphName(), FeaturedImagesField)
|
||||||
|
if postIDs != nil {
|
||||||
|
keys := make([]string, 0, len(postIDs))
|
||||||
|
for _, id := range postIDs {
|
||||||
|
keys = append(keys, strconv.FormatUint(uint64(id), 10))
|
||||||
|
}
|
||||||
|
q = q.Where("attachment_id IN ?", keys)
|
||||||
|
}
|
||||||
|
if missing {
|
||||||
|
list := make([]string, 0, len(widths))
|
||||||
|
for _, w := range widths {
|
||||||
|
list = append(list, strconv.Itoa(w))
|
||||||
|
}
|
||||||
|
q = q.Where(`NOT EXISTS (SELECT 1 FROM golem15_journal_image_variants v WHERE v.file_id = system_files.id)
|
||||||
|
OR EXISTS (SELECT 1 FROM unnest(?::int[]) AS w(width)
|
||||||
|
WHERE w.width <= (SELECT MAX(v.source_width) FROM golem15_journal_image_variants v WHERE v.file_id = system_files.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM golem15_journal_image_variants v WHERE v.file_id = system_files.id AND v.width = w.width))`, "{"+strings.Join(list, ",")+"}")
|
||||||
|
}
|
||||||
|
var ids []uint
|
||||||
|
err := q.Order("id ASC").Pluck("id", &ids).Error
|
||||||
|
return ids, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeaturedImageIDs lists the featured images of every post (all) or only
|
||||||
|
// those missing a variant of a configured width no larger than their
|
||||||
|
// original (or without any variant yet).
|
||||||
|
func FeaturedImageIDs(db *gorm.DB, cfg *compass.Config, all bool) ([]uint, error) {
|
||||||
|
return featuredFiles(db, nil, ImageWidths(cfg), !all)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatchImageVariants queues the image variants job for the files ids.
|
||||||
|
// The upload or save that called it has committed; a queue that cannot
|
||||||
|
// take the job is logged and never fails that request (D-85).
|
||||||
|
func DispatchImageVariants(ctx context.Context, app *backpack.App, ids []uint) {
|
||||||
|
if app == nil || len(ids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := conga.From(app)
|
||||||
|
if err == nil {
|
||||||
|
_, err = m.Dispatch(ctx, nil, ImageVariantsArgs{FileIDs: ids}, conga.DispatchOpts{Label: "Journal image variants"})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
variantsLog.Warn("journal: image variants job not queued", "files", len(ids), "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatchPostImageVariantsAfterCommit queues the image variants job, once
|
||||||
|
// tx commits, for the post's featured images that lack a variant of some
|
||||||
|
// width (the admin save: the uploads it binds commit with it).
|
||||||
|
func DispatchPostImageVariantsAfterCommit(ctx context.Context, tx *gorm.DB, app *backpack.App, postID uint) {
|
||||||
|
if app == nil || postID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||||
|
db, ok := app.Lookup[*gorm.DB]()
|
||||||
|
if !ok || db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids, err := featuredFiles(db.WithContext(ctx), []uint{postID}, ImageWidths(app.Config), true)
|
||||||
|
if err != nil {
|
||||||
|
variantsLog.Warn("journal: image variants of a saved post", "post", postID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DispatchImageVariants(ctx, app, ids)
|
||||||
|
})
|
||||||
|
}
|
||||||
84
classes/journal_settings.go
Normal file
84
classes/journal_settings.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// H1ToH2Key is the config key of the H1 setting, used only when the Journal
|
||||||
|
// settings row does not exist (EffectiveH1ToH2). Its shipped default is true.
|
||||||
|
const H1ToH2Key = "golem15.journal.content_h1_to_h2"
|
||||||
|
|
||||||
|
// DefaultGenericAnchors are the link texts the editor warns about when the
|
||||||
|
// settings row has no list of its own: the English and the Polish seed list,
|
||||||
|
// each entry once.
|
||||||
|
var DefaultGenericAnchors = []string{
|
||||||
|
"click here", "here", "more", "read more", "learn more", "this link", "link",
|
||||||
|
"kliknij tutaj", "tutaj", "tu", "więcej", "czytaj więcej", "czytaj dalej", "ten link",
|
||||||
|
}
|
||||||
|
|
||||||
|
// headingSettings is the part of the settings row the heading rules read.
|
||||||
|
type headingSettings struct {
|
||||||
|
ContentH1ToH2 bool
|
||||||
|
GenericAnchors *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// readHeadingSettings reads the settings row (ID 1); found is false when
|
||||||
|
// there is no row or no database.
|
||||||
|
func readHeadingSettings(ctx context.Context, db *gorm.DB) (headingSettings, bool) {
|
||||||
|
if db == nil {
|
||||||
|
return headingSettings{}, false
|
||||||
|
}
|
||||||
|
if ctx != nil {
|
||||||
|
db = db.WithContext(ctx)
|
||||||
|
}
|
||||||
|
var rows []headingSettings
|
||||||
|
if err := db.Model(&models.Settings{}).Select("content_h1_to_h2", "generic_anchors").
|
||||||
|
Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
|
||||||
|
return headingSettings{}, false
|
||||||
|
}
|
||||||
|
return rows[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveH1ToH2 is the one reader of the H1 setting (P33-04): the settings
|
||||||
|
// row's content_h1_to_h2 decides; without a row (or a database) the config
|
||||||
|
// key golem15.journal.content_h1_to_h2 does, and true when that is unset
|
||||||
|
// too. While it is true every H1 saved in Journal rich text becomes an H2.
|
||||||
|
func EffectiveH1ToH2(ctx context.Context, db *gorm.DB, cfg *compass.Config) bool {
|
||||||
|
if row, ok := readHeadingSettings(ctx, db); ok {
|
||||||
|
return row.ContentH1ToH2
|
||||||
|
}
|
||||||
|
if cfg != nil && cfg.Has(H1ToH2Key) {
|
||||||
|
return cfg.Bool(H1ToH2Key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenericAnchors is the list of generic link texts: the settings row's
|
||||||
|
// generic_anchors, one entry per line, trimmed and lowercased with blank
|
||||||
|
// lines dropped, or DefaultGenericAnchors when the row has no list (no
|
||||||
|
// row, NULL or only blank lines).
|
||||||
|
func GenericAnchors(ctx context.Context, db *gorm.DB) []string {
|
||||||
|
row, ok := readHeadingSettings(ctx, db)
|
||||||
|
if !ok || row.GenericAnchors == nil {
|
||||||
|
return append([]string(nil), DefaultGenericAnchors...)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, line := range strings.Split(*row.GenericAnchors, "\n") {
|
||||||
|
entry := strings.ToLower(strings.TrimSpace(line))
|
||||||
|
if entry == "" || seen[entry] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[entry] = true
|
||||||
|
out = append(out, entry)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return append([]string(nil), DefaultGenericAnchors...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
61
classes/media_names.go
Normal file
61
classes/media_names.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxMediaStem is the longest file name stem MediaFileName keeps (D-85).
|
||||||
|
const MaxMediaStem = 80
|
||||||
|
|
||||||
|
// mediaExtPattern is an extension MediaFileName keeps.
|
||||||
|
var mediaExtPattern = regexp.MustCompile(`^\.[a-z0-9]+$`)
|
||||||
|
|
||||||
|
// polishLetters transliterates the Polish diacritics (lower case).
|
||||||
|
var polishLetters = map[rune]rune{'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', 'ź': 'z', 'ż': 'z'}
|
||||||
|
|
||||||
|
// MediaFileName is the stored name of an uploaded image (D-85, the Blog
|
||||||
|
// Editor's J4 rule, applied on the server whatever the client sends): the
|
||||||
|
// base name's stem lowercased, Polish diacritics transliterated, every other
|
||||||
|
// run of characters outside a-z and 0-9 turned into one hyphen, trimmed and
|
||||||
|
// cut to MaxMediaStem characters, "image" when nothing is left; and its
|
||||||
|
// extension lowercased when it is a dot and letters or digits (dropped
|
||||||
|
// otherwise). "Lwia Grzywa – w lesie!.JPG" is "lwia-grzywa-w-lesie.jpg".
|
||||||
|
// The result needs no escaping in a URL or an src attribute.
|
||||||
|
func MediaFileName(name string) string {
|
||||||
|
base := path.Base(strings.ReplaceAll(name, `\`, "/"))
|
||||||
|
if base == "." || base == "/" {
|
||||||
|
base = ""
|
||||||
|
}
|
||||||
|
stem, ext := base, ""
|
||||||
|
if dot := strings.LastIndexByte(base, '.'); dot >= 0 {
|
||||||
|
if e := strings.ToLower(base[dot:]); mediaExtPattern.MatchString(e) {
|
||||||
|
stem, ext = base[:dot], e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
dash := false
|
||||||
|
for _, r := range strings.ToLower(stem) {
|
||||||
|
if t, ok := polishLetters[r]; ok {
|
||||||
|
r = t
|
||||||
|
}
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||||
|
b.WriteRune(r)
|
||||||
|
dash = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !dash {
|
||||||
|
b.WriteByte('-')
|
||||||
|
dash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Trim(b.String(), "-")
|
||||||
|
if len(out) > MaxMediaStem {
|
||||||
|
out = strings.TrimRight(out[:MaxMediaStem], "-")
|
||||||
|
}
|
||||||
|
if out == "" {
|
||||||
|
out = "image"
|
||||||
|
}
|
||||||
|
return out + ext
|
||||||
|
}
|
||||||
30
classes/media_names_test.go
Normal file
30
classes/media_names_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMediaFileName pins the D-85 slug rule shared with the editor's J4
|
||||||
|
// form (UI-SPEC J4 backstop).
|
||||||
|
func TestMediaFileName(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"Lwia Grzywa – w lesie!.JPG": "lwia-grzywa-w-lesie.jpg",
|
||||||
|
"": "image",
|
||||||
|
".png": "image.png",
|
||||||
|
"...": "image",
|
||||||
|
"ŻÓŁĆ gęślą jaźń.webp": "zolc-gesla-jazn.webp",
|
||||||
|
`C:\\Users\\x\\My Photo.jpg`: "my-photo.jpg",
|
||||||
|
"../../etc/passwd.png": "passwd.png",
|
||||||
|
`x" onerror="alert(1).png`: "x-onerror-alert-1.png",
|
||||||
|
"name.with.dots.jpeg": "name-with-dots.jpeg",
|
||||||
|
"weird.ext$": "weird-ext",
|
||||||
|
"Okładka.png": "okladka.png",
|
||||||
|
strings.Repeat("a", 81) + ".gif": strings.Repeat("a", 80) + ".gif",
|
||||||
|
strings.Repeat("a", 79) + " b.gif": strings.Repeat("a", 79) + ".gif",
|
||||||
|
} {
|
||||||
|
if got := MediaFileName(in); got != want {
|
||||||
|
t.Errorf("MediaFileName(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
226
classes/media_store.go
Normal file
226
classes/media_store.go
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
"gocloud.dev/gcerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MediaMaxBytes is the largest journal media upload, 10240 KiB.
|
||||||
|
const MediaMaxBytes = 10240 << 10
|
||||||
|
|
||||||
|
// MediaFolderPattern is the characters a media upload folder may hold. A
|
||||||
|
// folder also holds at most 100 characters and no "..".
|
||||||
|
var MediaFolderPattern = regexp.MustCompile(`^[A-Za-z0-9_\-/]*$`)
|
||||||
|
|
||||||
|
// mediaMaxSuffix is how many numbered names uniqueMediaKey tries.
|
||||||
|
const mediaMaxSuffix = 1000
|
||||||
|
|
||||||
|
// mediaWriteAttempts is how many free names StoreMedia tries to write when
|
||||||
|
// another writer takes the chosen one first.
|
||||||
|
const mediaWriteAttempts = 10
|
||||||
|
|
||||||
|
// mediaReservations are the media keys a StoreMedia call in this process
|
||||||
|
// has chosen and is writing, per bucket, so two concurrent uploads of the
|
||||||
|
// same name never pick the same free key.
|
||||||
|
var mediaReservations = struct {
|
||||||
|
sync.Mutex
|
||||||
|
keys map[mediaReservation]bool
|
||||||
|
}{keys: map[mediaReservation]bool{}}
|
||||||
|
|
||||||
|
type mediaReservation struct {
|
||||||
|
bucket *blob.Bucket
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaBeforeWrite, when set, runs after a key is chosen and before it is
|
||||||
|
// written. Tests use it to stand in for a writer in another process.
|
||||||
|
var mediaBeforeWrite func(ctx context.Context, bucket *blob.Bucket, key string)
|
||||||
|
|
||||||
|
// MediaError is an upload StoreMedia refuses. Field is the request field the
|
||||||
|
// message belongs to ("file" or "folder") and Message is the validation
|
||||||
|
// message of the public upload route.
|
||||||
|
type MediaError struct {
|
||||||
|
Field string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns Message.
|
||||||
|
func (e *MediaError) Error() string { return e.Message }
|
||||||
|
|
||||||
|
// StoredMedia is a stored media object: its public URL and its path
|
||||||
|
// (/journal/<folder>/<name>).
|
||||||
|
type StoredMedia struct {
|
||||||
|
URL string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// validMediaFolder applies the upload folder rule.
|
||||||
|
func validMediaFolder(folder string) bool {
|
||||||
|
return len(folder) <= 100 && MediaFolderPattern.MatchString(folder) && !strings.Contains(folder, "..")
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreMedia stores an uploaded image in bucket under
|
||||||
|
// /journal/<folder>/<name>, with a numbered suffix when the name is taken,
|
||||||
|
// and returns its public URL and path. It is the storage of POST
|
||||||
|
// /_journal/api/v1/media/upload and of the Blog Editor's image upload. The
|
||||||
|
// file name is stored as MediaFileName makes it (lowercase, hyphens, at
|
||||||
|
// most 80 characters before the extension); its extension must be jpg,
|
||||||
|
// jpeg, png, gif or webp, and the body may hold at most MediaMaxBytes. A refused upload is a
|
||||||
|
// *MediaError with the public route's message; any other error (no bucket,
|
||||||
|
// a read or write failure) is not. It never replaces an existing object:
|
||||||
|
// concurrent calls in this process reserve the key they chose, and the
|
||||||
|
// write is conditional (IfNotExist), so a key another process stored after
|
||||||
|
// the existence check moves the upload to the next free name.
|
||||||
|
func StoreMedia(ctx context.Context, bucket *blob.Bucket, folder, filename string, body io.Reader) (StoredMedia, error) {
|
||||||
|
if !validMediaFolder(folder) {
|
||||||
|
return StoredMedia{}, &MediaError{Field: "folder", Message: "The folder format is invalid."}
|
||||||
|
}
|
||||||
|
// D-85: the stored name is slugged whatever the client sends.
|
||||||
|
name := MediaFileName(filename)
|
||||||
|
switch strings.TrimPrefix(path.Ext(name), ".") {
|
||||||
|
case "jpg", "jpeg", "png", "gif", "webp":
|
||||||
|
default:
|
||||||
|
return StoredMedia{}, &MediaError{Field: "file", Message: "The file must be an image."}
|
||||||
|
}
|
||||||
|
if bucket == nil {
|
||||||
|
return StoredMedia{}, errors.New("journal: media bucket is not configured")
|
||||||
|
}
|
||||||
|
keyPath := mediaObjectPath(folder, name)
|
||||||
|
data, err := io.ReadAll(io.LimitReader(body, MediaMaxBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return StoredMedia{}, fmt.Errorf("journal: read media upload: %w", err)
|
||||||
|
}
|
||||||
|
if len(data) > MediaMaxBytes {
|
||||||
|
return StoredMedia{}, &MediaError{Field: "file", Message: "The file may not be greater than 10240 kilobytes."}
|
||||||
|
}
|
||||||
|
for attempt := 0; attempt < mediaWriteAttempts; attempt++ {
|
||||||
|
chosen, release, err := reserveMediaKey(ctx, bucket, keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return StoredMedia{}, err
|
||||||
|
}
|
||||||
|
key := strings.TrimPrefix(chosen, "/")
|
||||||
|
if mediaBeforeWrite != nil {
|
||||||
|
mediaBeforeWrite(ctx, bucket, key)
|
||||||
|
}
|
||||||
|
err = writeNewMedia(ctx, bucket, key, data)
|
||||||
|
release()
|
||||||
|
if gcerrors.Code(err) == gcerrors.FailedPrecondition {
|
||||||
|
// Another writer (another process) stored this key after the
|
||||||
|
// existence check; take the next free name.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return StoredMedia{}, err
|
||||||
|
}
|
||||||
|
return StoredMedia{URL: attach.PublicURL(key), Path: chosen}, nil
|
||||||
|
}
|
||||||
|
return StoredMedia{}, fmt.Errorf("journal: no free media name for %s after %d attempts", keyPath, mediaWriteAttempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reserveMediaKey chooses a free key for pathName (uniqueMediaKey, skipping
|
||||||
|
// keys this process is writing) and reserves it until release is called.
|
||||||
|
func reserveMediaKey(ctx context.Context, bucket *blob.Bucket, pathName string) (string, func(), error) {
|
||||||
|
mediaReservations.Lock()
|
||||||
|
defer mediaReservations.Unlock()
|
||||||
|
reserved := func(p string) bool {
|
||||||
|
return mediaReservations.keys[mediaReservation{bucket: bucket, key: p}]
|
||||||
|
}
|
||||||
|
chosen, err := uniqueMediaKey(ctx, bucket, pathName, reserved)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
slot := mediaReservation{bucket: bucket, key: chosen}
|
||||||
|
mediaReservations.keys[slot] = true
|
||||||
|
release := func() {
|
||||||
|
mediaReservations.Lock()
|
||||||
|
delete(mediaReservations.keys, slot)
|
||||||
|
mediaReservations.Unlock()
|
||||||
|
}
|
||||||
|
return chosen, release, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeNewMedia writes data under key only when no object exists there
|
||||||
|
// (blob.WriterOptions.IfNotExist): an existing object is never replaced,
|
||||||
|
// and the error then has the gcerrors.FailedPrecondition code.
|
||||||
|
func writeNewMedia(ctx context.Context, bucket *blob.Bucket, key string, data []byte) error {
|
||||||
|
w, err := bucket.NewWriter(ctx, key, &blob.WriterOptions{IfNotExist: true})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("journal: media writer: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write(data); err != nil {
|
||||||
|
_ = w.Close()
|
||||||
|
return fmt.Errorf("journal: write media: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return fmt.Errorf("journal: close media: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaObjectPath is /journal/<folder>/<fileName> with empty, "." and ".."
|
||||||
|
// folder segments dropped and a leading "journal" segment not repeated.
|
||||||
|
func mediaObjectPath(folder, fileName string) string {
|
||||||
|
segments := make([]string, 0, 4)
|
||||||
|
for _, part := range strings.Split(strings.Trim(folder, "/"), "/") {
|
||||||
|
if part == "" || part == "." || part == ".." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
segments = append(segments, part)
|
||||||
|
}
|
||||||
|
if len(segments) > 0 && strings.EqualFold(segments[0], "journal") {
|
||||||
|
segments = segments[1:]
|
||||||
|
}
|
||||||
|
folder = strings.Join(segments, "/")
|
||||||
|
if folder == "" {
|
||||||
|
return "/journal/" + fileName
|
||||||
|
}
|
||||||
|
return "/journal/" + folder + "/" + fileName
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniqueMediaKey returns pathName, or pathName with "-1", "-2", ... before
|
||||||
|
// its extension when an object already exists there or reserved reports the
|
||||||
|
// path as taken. It never returns the path of an existing object: when every
|
||||||
|
// numbered name is taken it fails.
|
||||||
|
func uniqueMediaKey(ctx context.Context, bucket *blob.Bucket, pathName string, reserved func(string) bool) (string, error) {
|
||||||
|
exists := func(p string) (bool, error) {
|
||||||
|
if reserved != nil && reserved(p) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
ok, err := bucket.Exists(ctx, strings.TrimPrefix(p, "/"))
|
||||||
|
if err != nil && gcerrors.Code(err) != gcerrors.NotFound {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
|
taken, err := exists(pathName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !taken {
|
||||||
|
return pathName, nil
|
||||||
|
}
|
||||||
|
ext := path.Ext(pathName)
|
||||||
|
base := strings.TrimSuffix(pathName, ext)
|
||||||
|
for n := 1; n < mediaMaxSuffix; n++ {
|
||||||
|
candidate := base + "-" + strconv.Itoa(n) + ext
|
||||||
|
taken, err := exists(candidate)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !taken {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("journal: no free media name for %s", pathName)
|
||||||
|
}
|
||||||
258
classes/media_store_test.go
Normal file
258
classes/media_store_test.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
"gocloud.dev/blob/memblob"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMediaObjectPath(t *testing.T) {
|
||||||
|
got := mediaObjectPath("../etc", "cover.png")
|
||||||
|
if !strings.HasPrefix(got, "/journal/") {
|
||||||
|
t.Fatalf("path = %q, want /journal/ prefix", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "..") {
|
||||||
|
t.Fatalf("path still contains ..: %q", got)
|
||||||
|
}
|
||||||
|
if got := mediaObjectPath("photos", "a.png"); got != "/journal/photos/a.png" {
|
||||||
|
t.Fatalf("nested = %q", got)
|
||||||
|
}
|
||||||
|
if got := mediaObjectPath("journal/photos", "a.png"); got != "/journal/photos/a.png" {
|
||||||
|
t.Fatalf("prefixed folder = %q", got)
|
||||||
|
}
|
||||||
|
if got := mediaObjectPath("", "a.png"); got != "/journal/a.png" {
|
||||||
|
t.Fatalf("no folder = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaFolderPatternRejectsDotDot(t *testing.T) {
|
||||||
|
folder := "../etc"
|
||||||
|
if MediaFolderPattern.MatchString(folder) && !strings.Contains(folder, "..") {
|
||||||
|
t.Fatal("folder ../etc should be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(folder, "..") {
|
||||||
|
t.Fatal("test fixture lost ..")
|
||||||
|
}
|
||||||
|
if MediaFolderPattern.MatchString("ok_folder-1") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatal("ok_folder-1 should match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// failingReader fails every read.
|
||||||
|
type failingReader struct{}
|
||||||
|
|
||||||
|
func (failingReader) Read([]byte) (int, error) { return 0, errors.New("disk on fire") }
|
||||||
|
|
||||||
|
func TestStoreMedia(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
bucket := memblob.OpenBucket(nil)
|
||||||
|
t.Cleanup(func() { _ = bucket.Close() })
|
||||||
|
mediaErr := func(t *testing.T, err error, field, message string) {
|
||||||
|
t.Helper()
|
||||||
|
var refused *MediaError
|
||||||
|
if !errors.As(err, &refused) || refused.Field != field || refused.Message != message || refused.Error() != message {
|
||||||
|
t.Fatalf("error = %v, want MediaError on %s %q", err, field, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("allowed extensions, key layout and URL", func(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ folder, name, path string }{
|
||||||
|
{"photos", "a.jpg", "/journal/photos/a.jpg"},
|
||||||
|
{"/journal/photos/", "b.JPEG", "/journal/photos/b.jpeg"},
|
||||||
|
// D-85: the stored name is slugged.
|
||||||
|
{"photos", "Lwia Grzywa – w lesie!.JPG", "/journal/photos/lwia-grzywa-w-lesie.jpg"},
|
||||||
|
{"", "c.png", "/journal/c.png"},
|
||||||
|
{"x//y", "d.gif", "/journal/x/y/d.gif"},
|
||||||
|
{"Journal", "e.webp", "/journal/e.webp"},
|
||||||
|
{"posts", `C:\uploads\f.png`, "/journal/posts/f.png"},
|
||||||
|
{"posts", "../../g.png", "/journal/posts/g.png"},
|
||||||
|
} {
|
||||||
|
got, err := StoreMedia(ctx, bucket, tc.folder, tc.name, bytes.NewReader(testPNG))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s/%s: %v", tc.folder, tc.name, err)
|
||||||
|
}
|
||||||
|
key := strings.TrimPrefix(tc.path, "/")
|
||||||
|
if got.Path != tc.path || got.URL != attach.PublicURL(key) {
|
||||||
|
t.Fatalf("%s/%s = %+v, want path %s", tc.folder, tc.name, got, tc.path)
|
||||||
|
}
|
||||||
|
data, err := bucket.ReadAll(ctx, key)
|
||||||
|
if err != nil || !bytes.Equal(data, testPNG) {
|
||||||
|
t.Fatalf("stored %s: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a taken name gets the next numbered suffix", func(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
got, err := StoreMedia(ctx, bucket, "dup", "same.png", bytes.NewReader(testPNG))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
paths = append(paths, got.Path)
|
||||||
|
}
|
||||||
|
if strings.Join(paths, " ") != "/journal/dup/same.png /journal/dup/same-1.png /journal/dup/same-2.png" {
|
||||||
|
t.Fatalf("paths = %v", paths)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// WR-03: concurrent uploads of the same name each get their own key and
|
||||||
|
// keep their own bytes.
|
||||||
|
t.Run("concurrent uploads of one name never overwrite each other", func(t *testing.T) {
|
||||||
|
race := memblob.OpenBucket(nil)
|
||||||
|
t.Cleanup(func() { _ = race.Close() })
|
||||||
|
const uploads = 16
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([]StoredMedia, uploads)
|
||||||
|
errs := make([]error, uploads)
|
||||||
|
bodies := make([][]byte, uploads)
|
||||||
|
for i := range uploads {
|
||||||
|
bodies[i] = append(append([]byte{}, testPNG...), []byte(fmt.Sprintf("upload-%02d", i))...)
|
||||||
|
}
|
||||||
|
start := make(chan struct{})
|
||||||
|
for i := range uploads {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
results[i], errs[i] = StoreMedia(ctx, race, "editor", "image.png", bytes.NewReader(bodies[i]))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
seen := map[string]int{}
|
||||||
|
for i := range uploads {
|
||||||
|
if errs[i] != nil {
|
||||||
|
t.Fatalf("upload %d: %v", i, errs[i])
|
||||||
|
}
|
||||||
|
if prev, dup := seen[results[i].Path]; dup {
|
||||||
|
t.Fatalf("uploads %d and %d both stored %s", prev, i, results[i].Path)
|
||||||
|
}
|
||||||
|
seen[results[i].Path] = i
|
||||||
|
data, err := race.ReadAll(ctx, strings.TrimPrefix(results[i].Path, "/"))
|
||||||
|
if err != nil || !bytes.Equal(data, bodies[i]) {
|
||||||
|
t.Fatalf("upload %d at %s holds another upload's bytes (err %v)", i, results[i].Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mediaReservations.keys) != 0 {
|
||||||
|
t.Fatalf("reservations left behind: %v", mediaReservations.keys)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a key another process stores after the check moves the upload on", func(t *testing.T) {
|
||||||
|
other := memblob.OpenBucket(nil)
|
||||||
|
t.Cleanup(func() { _ = other.Close() })
|
||||||
|
planted := map[string]bool{}
|
||||||
|
mediaBeforeWrite = func(ctx context.Context, b *blob.Bucket, key string) {
|
||||||
|
if len(planted) < 2 {
|
||||||
|
planted[key] = true
|
||||||
|
if err := b.WriteAll(ctx, key, []byte("other process"), nil); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { mediaBeforeWrite = nil })
|
||||||
|
got, err := StoreMedia(ctx, other, "editor", "image.png", bytes.NewReader(testPNG))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Path != "/journal/editor/image-2.png" {
|
||||||
|
t.Fatalf("path = %s, want the first name nobody else wrote", got.Path)
|
||||||
|
}
|
||||||
|
for key := range planted {
|
||||||
|
if data, _ := other.ReadAll(ctx, key); string(data) != "other process" {
|
||||||
|
t.Fatalf("%s was overwritten", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data, _ := other.ReadAll(ctx, "journal/editor/image-2.png"); !bytes.Equal(data, testPNG) {
|
||||||
|
t.Fatal("the upload's bytes are not at its path")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a writer that always loses gives up with an opaque error", func(t *testing.T) {
|
||||||
|
busy := memblob.OpenBucket(nil)
|
||||||
|
t.Cleanup(func() { _ = busy.Close() })
|
||||||
|
mediaBeforeWrite = func(ctx context.Context, b *blob.Bucket, key string) {
|
||||||
|
_ = b.WriteAll(ctx, key, []byte("other process"), nil)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { mediaBeforeWrite = nil })
|
||||||
|
_, err := StoreMedia(ctx, busy, "editor", "image.png", bytes.NewReader(testPNG))
|
||||||
|
if err == nil || errors.As(err, new(*MediaError)) {
|
||||||
|
t.Fatalf("error = %v, want an opaque error", err)
|
||||||
|
}
|
||||||
|
if len(mediaReservations.keys) != 0 {
|
||||||
|
t.Fatalf("reservations left behind: %v", mediaReservations.keys)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a name with every suffix taken fails instead of overwriting", func(t *testing.T) {
|
||||||
|
full := memblob.OpenBucket(nil)
|
||||||
|
t.Cleanup(func() { _ = full.Close() })
|
||||||
|
keys := []string{"journal/full/a.png"}
|
||||||
|
for n := 1; n < mediaMaxSuffix; n++ {
|
||||||
|
keys = append(keys, "journal/full/a-"+strconv.Itoa(n)+".png")
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
if err := full.WriteAll(ctx, key, []byte("old"), nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := StoreMedia(ctx, full, "full", "a.png", bytes.NewReader(testPNG))
|
||||||
|
if err == nil || errors.As(err, new(*MediaError)) {
|
||||||
|
t.Fatalf("error = %v, want an opaque error", err)
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
if data, _ := full.ReadAll(ctx, key); string(data) != "old" {
|
||||||
|
t.Fatalf("%s was overwritten", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("other extensions are refused", func(t *testing.T) {
|
||||||
|
for _, name := range []string{"a.svg", "a.html", "a.bmp", "a", "a.png.exe", ".png.txt"} {
|
||||||
|
_, err := StoreMedia(ctx, bucket, "", name, bytes.NewReader(testPNG))
|
||||||
|
mediaErr(t, err, "file", "The file must be an image.")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the size limit is 10240 KiB", func(t *testing.T) {
|
||||||
|
if MediaMaxBytes != 10240*1024 {
|
||||||
|
t.Fatalf("MediaMaxBytes = %d", MediaMaxBytes)
|
||||||
|
}
|
||||||
|
exact := make([]byte, MediaMaxBytes)
|
||||||
|
if _, err := StoreMedia(ctx, bucket, "size", "exact.png", bytes.NewReader(exact)); err != nil {
|
||||||
|
t.Fatalf("exactly MediaMaxBytes: %v", err)
|
||||||
|
}
|
||||||
|
_, err := StoreMedia(ctx, bucket, "size", "over.png", bytes.NewReader(make([]byte, MediaMaxBytes+1)))
|
||||||
|
mediaErr(t, err, "file", "The file may not be greater than 10240 kilobytes.")
|
||||||
|
if ok, _ := bucket.Exists(ctx, "journal/size/over.png"); ok {
|
||||||
|
t.Fatal("an oversized file was stored")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the folder rule is checked", func(t *testing.T) {
|
||||||
|
for _, folder := range []string{"../up", "a..b", "a b", `a\b`, strings.Repeat("a", 101), "a?b"} {
|
||||||
|
_, err := StoreMedia(ctx, bucket, folder, "a.png", bytes.NewReader(testPNG))
|
||||||
|
mediaErr(t, err, "folder", "The folder format is invalid.")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a missing bucket and a read failure are opaque errors", func(t *testing.T) {
|
||||||
|
if _, err := StoreMedia(ctx, nil, "", "a.png", bytes.NewReader(testPNG)); err == nil || errors.As(err, new(*MediaError)) {
|
||||||
|
t.Fatalf("nil bucket error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := StoreMedia(ctx, bucket, "", "a.png", failingReader{}); err == nil || errors.As(err, new(*MediaError)) || !strings.Contains(err.Error(), "disk on fire") {
|
||||||
|
t.Fatalf("read failure error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
1228
classes/menu_item_types.go
Normal file
1228
classes/menu_item_types.go
Normal file
File diff suppressed because it is too large
Load Diff
246
classes/menu_item_types_test.go
Normal file
246
classes/menu_item_types_test.go
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMenuItemTypesPatterns pins the Docker-free half of D-04: the seven
|
||||||
|
// registered types (the five PHP ones, journal-tag and journal-author), the ordered placeholder fill of PHP buildUrlFromPattern,
|
||||||
|
// the post placeholders (date parts only for a published post), the
|
||||||
|
// alternates (x-default, unprefixed locale, prefixes, first alternate as the
|
||||||
|
// URL), the translation fallback, the reference parse, the category tree and
|
||||||
|
// the refusals without a booted app.
|
||||||
|
func TestMenuItemTypesPatterns(t *testing.T) {
|
||||||
|
t.Run("the seven types", func(t *testing.T) {
|
||||||
|
types := MenuItemTypes(nil)
|
||||||
|
var got []string
|
||||||
|
for _, typ := range types {
|
||||||
|
refs := typ.References != nil
|
||||||
|
got = append(got, fmt.Sprintf("%s|%s|%s|nest=%v|dyn=%v|refs=%v|%s|%s", typ.Key, typ.DefaultPattern, typ.PatternFrom, typ.Nesting, typ.DynamicItems, refs, strings.Join(typ.Placeholders, ","), typ.Label))
|
||||||
|
if typ.Resolve == nil {
|
||||||
|
t.Fatalf("%s has no Resolve", typ.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"journal-category|/blog/category/:slug||nest=true|dyn=true|refs=true|:slug,:id|golem15.journal::lang.menuitem.journal_category",
|
||||||
|
"all-journal-categories|/blog/category/:slug|journal-category|nest=false|dyn=true|refs=false|:slug,:id|golem15.journal::lang.menuitem.all_journal_categories",
|
||||||
|
"journal-post|/blog/:slug||nest=false|dyn=false|refs=true|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.journal_post",
|
||||||
|
"all-journal-posts|/blog/:slug|journal-post|nest=false|dyn=true|refs=false|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.all_journal_posts",
|
||||||
|
"category-journal-posts|/blog/:slug|journal-post|nest=false|dyn=true|refs=true|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.category_journal_posts",
|
||||||
|
"journal-tag|/blog/tag/:slug||nest=false|dyn=false|refs=true|:slug,:id|golem15.journal::lang.menuitem.journal_tag",
|
||||||
|
"journal-author|/blog/author/:author||nest=false|dyn=false|refs=true|:author,:id|golem15.journal::lang.menuitem.journal_author",
|
||||||
|
}
|
||||||
|
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||||
|
t.Fatalf("types:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||||
|
}
|
||||||
|
// Placeholders are copies: changing one type's list leaves the others.
|
||||||
|
types[2].Placeholders[0] = "changed"
|
||||||
|
if again := MenuItemTypes(nil); again[2].Placeholders[0] != ":slug" || types[3].Placeholders[0] != ":slug" {
|
||||||
|
t.Fatal("placeholder slices are shared")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ordered fill", func(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
pattern string
|
||||||
|
params [][2]string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"/blog/:slug", [][2]string{{"id", "7"}, {"slug", "hello"}}, "/blog/hello"},
|
||||||
|
// PHP replaces in params order: a slug holding ":id" was filled
|
||||||
|
// after :id, so it stays literal.
|
||||||
|
{"/:id/:slug", [][2]string{{"id", "7"}, {"slug", "a-:id"}}, "/7/a-:id"},
|
||||||
|
// A value replaced first can feed a later placeholder.
|
||||||
|
{"/:slug", [][2]string{{"slug", ":category"}, {"category", "news"}}, "/news"},
|
||||||
|
{"/:slug/:slug", [][2]string{{"slug", "x"}}, "/x/x"},
|
||||||
|
{"/:unknown/:slug", [][2]string{{"slug", "x"}}, "/:unknown/x"},
|
||||||
|
{"/:slugger", [][2]string{{"slug", "x"}}, "/xger"},
|
||||||
|
{"/static", nil, "/static"},
|
||||||
|
} {
|
||||||
|
if got := fillMenuPattern(tc.pattern, tc.params); got != tc.want {
|
||||||
|
t.Fatalf("fillMenuPattern(%q, %v) = %q, want %q", tc.pattern, tc.params, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("post placeholders", func(t *testing.T) {
|
||||||
|
at := time.Date(2026, 3, 4, 23, 30, 0, 0, time.FixedZone("plus2", 2*3600))
|
||||||
|
published := &models.Post{ID: 9, Slug: "s", Published: true, PublishedAt: &at}
|
||||||
|
if got := fmt.Sprint(postURLParams(published, "slug", "cat")); got != "[[id 9] [slug slug] [category cat] [year 2026] [month 03] [day 04]]" {
|
||||||
|
t.Fatalf("published params = %s", got)
|
||||||
|
}
|
||||||
|
late := time.Date(2026, 12, 31, 23, 30, 0, 0, time.FixedZone("minus5", -5*3600))
|
||||||
|
utc := &models.Post{ID: 1, Published: true, PublishedAt: &late}
|
||||||
|
if got := fmt.Sprint(postURLParams(utc, "s", "")); !strings.Contains(got, "[year 2027] [month 01] [day 01]") {
|
||||||
|
t.Fatalf("date parts are not UTC: %s", got)
|
||||||
|
}
|
||||||
|
for name, p := range map[string]*models.Post{
|
||||||
|
"draft with a date": {ID: 2, Published: false, PublishedAt: &at},
|
||||||
|
"published, no date": {ID: 3, Published: true},
|
||||||
|
"draft without dates": {ID: 4},
|
||||||
|
} {
|
||||||
|
params := postURLParams(p, "s", "c")
|
||||||
|
if len(params) != 3 {
|
||||||
|
t.Fatalf("%s: params = %v, want id, slug and category only", name, params)
|
||||||
|
}
|
||||||
|
if got := fillMenuPattern("/:year/:month/:day/:slug", params); got != "/:year/:month/:day/s" {
|
||||||
|
t.Fatalf("%s: url = %q", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("alternates", func(t *testing.T) {
|
||||||
|
params := func(locale string) [][2]string { return [][2]string{{"slug", "slug-" + locale}} }
|
||||||
|
title := func(locale string) string { return "T-" + locale }
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
locales menuLocales
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"no enabled locale", menuLocales{}, "x-default=/p/slug-|T-"},
|
||||||
|
{"default unprefixed", menuLocales{codes: []string{"pl", "en"}, defaultCode: "en", unprefixed: "en"}, "pl=/pl/p/slug-pl|T-pl en=/p/slug-en|T-en"},
|
||||||
|
{"another unprefixed", menuLocales{codes: []string{"en", "pl", "de"}, defaultCode: "en", unprefixed: "pl"}, "en=/en/p/slug-en|T-en pl=/p/slug-pl|T-pl de=/de/p/slug-de|T-de"},
|
||||||
|
} {
|
||||||
|
var parts []string
|
||||||
|
for _, alt := range menuAlternates("/p/:slug", tc.locales, params, title) {
|
||||||
|
parts = append(parts, alt.Locale+"="+alt.URL+"|"+alt.Title)
|
||||||
|
}
|
||||||
|
if got := strings.Join(parts, " "); got != tc.want {
|
||||||
|
t.Fatalf("%s: %s, want %s", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mtime := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
item := resolvedMenuItem("/p/:slug", menuLocales{codes: []string{"pl", "en"}, defaultCode: "en", unprefixed: "en"}, "Host", &mtime, params, title)
|
||||||
|
if item.URL != "/pl/p/slug-pl" || item.Title != "Host" || item.MTime == nil || !item.MTime.Equal(mtime) || item.MTime == &mtime {
|
||||||
|
t.Fatalf("item = %+v (URL is the first alternate's, MTime a copy)", item)
|
||||||
|
}
|
||||||
|
bare := resolvedMenuItem("/p/:slug", menuLocales{}, "Host", nil, params, title)
|
||||||
|
if bare.URL != "/p/slug-" || bare.MTime != nil || len(bare.Alternates) != 1 {
|
||||||
|
t.Fatalf("item without locales = %+v", bare)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("translation fallback", func(t *testing.T) {
|
||||||
|
tr := menuTranslations{defaultCode: "en", data: map[string]map[uint]map[string]any{
|
||||||
|
"pl": {1: {"slug": "witaj", "title": "", "zero": "0", "num": float64(2), "falsy": false, "list": []any{}}},
|
||||||
|
}}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
locale, field, want string
|
||||||
|
id uint
|
||||||
|
}{
|
||||||
|
{"pl", "slug", "witaj", 1},
|
||||||
|
{"pl", "title", "host", 1},
|
||||||
|
{"pl", "zero", "host", 1},
|
||||||
|
{"pl", "falsy", "host", 1},
|
||||||
|
{"pl", "list", "host", 1},
|
||||||
|
{"pl", "num", "2", 1},
|
||||||
|
{"pl", "missing", "host", 1},
|
||||||
|
{"pl", "slug", "host", 2},
|
||||||
|
{"de", "slug", "host", 1},
|
||||||
|
{"en", "slug", "host", 1},
|
||||||
|
{"", "slug", "host", 1},
|
||||||
|
} {
|
||||||
|
if got := tr.value(tc.locale, tc.id, tc.field, "host"); got != tc.want {
|
||||||
|
t.Fatalf("value(%s, %d, %s) = %q, want %q", tc.locale, tc.id, tc.field, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for v, want := range map[any]bool{nil: false, "": false, "0": false, "a": true, true: true, false: false, float64(0): false, float64(1): true} {
|
||||||
|
if phpTruthy(v) != want {
|
||||||
|
t.Fatalf("phpTruthy(%#v) != %v", v, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !phpTruthy(map[string]any{"a": 1}) || phpTruthy(map[string]any{}) || !phpTruthy([]any{1}) {
|
||||||
|
t.Fatal("phpTruthy of maps and lists")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("references", func(t *testing.T) {
|
||||||
|
for ref, want := range map[string]uint{"7": 7, " 12 ": 12} {
|
||||||
|
if got, ok := menuReferenceID(ref); !ok || got != want {
|
||||||
|
t.Fatalf("menuReferenceID(%q) = %d, %v", ref, got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ref := range []string{"", "0", "-1", "abc", "1.5", "99999999999"} {
|
||||||
|
if _, ok := menuReferenceID(ref); ok {
|
||||||
|
t.Fatalf("menuReferenceID(%q) accepted", ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("category tree", func(t *testing.T) {
|
||||||
|
parent := func(id uint) *uint { return &id }
|
||||||
|
cats := []models.Category{
|
||||||
|
{ID: 1, Name: "Root", Slug: "root"},
|
||||||
|
{ID: 2, Name: "Child", Slug: "child", ParentID: parent(1)},
|
||||||
|
{ID: 3, Name: "Grandchild", Slug: "grand", ParentID: parent(2)},
|
||||||
|
{ID: 4, Name: "Orphan", Slug: "orphan", ParentID: parent(99)},
|
||||||
|
{ID: 5, Name: "Self", Slug: "self", ParentID: parent(5)},
|
||||||
|
}
|
||||||
|
tree := categoryTree{cats: cats, index: map[uint]int{}, children: map[uint][]int{}}
|
||||||
|
for i, c := range cats {
|
||||||
|
tree.index[c.ID] = i
|
||||||
|
}
|
||||||
|
for i, c := range cats {
|
||||||
|
if c.ParentID != nil && *c.ParentID != 0 && *c.ParentID != c.ID {
|
||||||
|
if _, ok := tree.index[*c.ParentID]; ok {
|
||||||
|
tree.children[*c.ParentID] = append(tree.children[*c.ParentID], i)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tree.roots = append(tree.roots, i)
|
||||||
|
}
|
||||||
|
var walk func(refs []pact.MenuItemReference, depth int) string
|
||||||
|
walk = func(refs []pact.MenuItemReference, depth int) string {
|
||||||
|
var parts []string
|
||||||
|
for _, r := range refs {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d:%s=%s", depth, r.Value, r.Label)+walk(r.Items, depth+1))
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "[" + strings.Join(parts, " ") + "]"
|
||||||
|
}
|
||||||
|
if got := walk(tree.references(tree.roots), 0); got != "[0:1=Root[1:2=Child[2:3=Grandchild]] 0:4=Orphan 0:5=Self]" {
|
||||||
|
t.Fatalf("references = %s", got)
|
||||||
|
}
|
||||||
|
ci := categoryItems{pattern: "/c/:id/:slug", locales: menuLocales{}}
|
||||||
|
sub := ci.subtree(tree, 1)
|
||||||
|
if len(sub) != 1 || sub[0].URL != "/c/2/child" || len(sub[0].Items) != 1 || sub[0].Items[0].URL != "/c/3/grand" || len(sub[0].Items[0].Items) != 0 {
|
||||||
|
t.Fatalf("subtree = %+v", sub)
|
||||||
|
}
|
||||||
|
if got := ci.subtree(tree, 3); len(got) != 0 {
|
||||||
|
t.Fatalf("leaf subtree = %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("without a booted app every resolve is an error, and a bad reference resolves to nothing first", func(t *testing.T) {
|
||||||
|
for name, app := range map[string]func() *backpack.App{"nil accessor": nil, "nil app": func() *backpack.App { return nil }} {
|
||||||
|
for _, typ := range MenuItemTypes(app) {
|
||||||
|
got, err := typ.Resolve(context.Background(), pact.MenuItemResolve{Type: typ.Key, Reference: "1", Pattern: typ.DefaultPattern})
|
||||||
|
if err == nil || got != nil || !strings.HasPrefix(err.Error(), "golem15.journal: menu item types: ") {
|
||||||
|
t.Fatalf("%s %s: %+v %v", name, typ.Key, got, err)
|
||||||
|
}
|
||||||
|
if typ.References != nil {
|
||||||
|
if _, err := typ.References(context.Background()); err == nil {
|
||||||
|
t.Fatalf("%s %s references without an app", name, typ.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if typ.Key == MenuTypeAllCategories || typ.Key == MenuTypeAllPosts {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
got, err = typ.Resolve(context.Background(), pact.MenuItemResolve{Type: typ.Key, Reference: "abc"})
|
||||||
|
if err != nil || got != nil {
|
||||||
|
t.Fatalf("%s %s with a bad reference: %+v %v", name, typ.Key, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
176
classes/related.go
Normal file
176
classes/related.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Related posts and category neighbours of a post (D-82, P33-44). Opt-in:
|
||||||
|
// golem15.journal.related_mode is "category" by default, which keeps the
|
||||||
|
// post answer as it has always been (posts sharing a category, three,
|
||||||
|
// newest first). "scored" ranks the related posts by shared tags and shared
|
||||||
|
// categories and adds the neighbours within the post's first category.
|
||||||
|
const (
|
||||||
|
// RelatedModeKey is the config key of the related-posts mode.
|
||||||
|
RelatedModeKey = "golem15.journal.related_mode"
|
||||||
|
// RelatedCountKey is the config key of the number of scored related
|
||||||
|
// posts, clamped to RelatedCountMin..RelatedCountMax.
|
||||||
|
RelatedCountKey = "golem15.journal.related_count"
|
||||||
|
|
||||||
|
// RelatedModeCategory is the default: today's related posts.
|
||||||
|
RelatedModeCategory = "category"
|
||||||
|
// RelatedModeScored ranks by shared tags and categories.
|
||||||
|
RelatedModeScored = "scored"
|
||||||
|
|
||||||
|
RelatedCountDefault = 3
|
||||||
|
RelatedCountMin = 3
|
||||||
|
RelatedCountMax = 6
|
||||||
|
|
||||||
|
// Points per shared tag and per shared category.
|
||||||
|
relatedTagPoints = 2
|
||||||
|
relatedCategoryPoints = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// RelatedSettings is the related-posts configuration.
|
||||||
|
type RelatedSettings struct {
|
||||||
|
// Mode is RelatedModeCategory or RelatedModeScored.
|
||||||
|
Mode string
|
||||||
|
// Count is the number of scored related posts (3 to 6).
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scored reports whether the scored mode is on.
|
||||||
|
func (s RelatedSettings) Scored() bool { return s.Mode == RelatedModeScored }
|
||||||
|
|
||||||
|
// ReadRelatedSettings reads golem15.journal.related_mode (anything but
|
||||||
|
// "scored" is the category mode) and golem15.journal.related_count (unset
|
||||||
|
// or not a number: 3; otherwise clamped to 3..6).
|
||||||
|
func ReadRelatedSettings(cfg *compass.Config) RelatedSettings {
|
||||||
|
out := RelatedSettings{Mode: RelatedModeCategory, Count: RelatedCountDefault}
|
||||||
|
if cfg == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(cfg.String(RelatedModeKey)), RelatedModeScored) {
|
||||||
|
out.Mode = RelatedModeScored
|
||||||
|
}
|
||||||
|
if n := cfg.Int(RelatedCountKey); n > 0 {
|
||||||
|
out.Count = min(RelatedCountMax, max(RelatedCountMin, n))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishedClause is the visitor rule of the public API (applyPublished in
|
||||||
|
// controllers/api): published, with a publish time in the past.
|
||||||
|
const publishedClause = "p.published = ? AND p.published_at IS NOT NULL AND p.published_at < ?"
|
||||||
|
|
||||||
|
// ScoredRelatedPosts is the scored mode's related posts of post: other posts
|
||||||
|
// sharing a tag or a category, ranked by 2 points per shared tag plus 1
|
||||||
|
// point per shared category, ties by the newest publish time (then the
|
||||||
|
// higher id), at most count. Posts that share nothing are never related.
|
||||||
|
// Visitors see published posts only; editors (includeDrafts) also see
|
||||||
|
// drafts, as the category mode's related posts do. One query computes the
|
||||||
|
// scores; the rows come back with their author, categories and tags.
|
||||||
|
func ScoredRelatedPosts(ctx context.Context, db *gorm.DB, post *models.Post, count int, includeDrafts bool) ([]models.Post, error) {
|
||||||
|
if post == nil || count <= 0 {
|
||||||
|
return []models.Post{}, nil
|
||||||
|
}
|
||||||
|
db = db.WithContext(ctx)
|
||||||
|
q := db.Table("golem15_journal_posts AS p").
|
||||||
|
Select("p.id, ? * COALESCE(st.n, 0) + ? * COALESCE(sc.n, 0) AS score", relatedTagPoints, relatedCategoryPoints).
|
||||||
|
Joins(`LEFT JOIN (SELECT post_id, COUNT(*) AS n FROM golem15_journal_posts_tags
|
||||||
|
WHERE tag_id IN (SELECT tag_id FROM golem15_journal_posts_tags WHERE post_id = ?)
|
||||||
|
GROUP BY post_id) st ON st.post_id = p.id`, post.ID).
|
||||||
|
Joins(`LEFT JOIN (SELECT post_id, COUNT(*) AS n FROM golem15_journal_posts_categories
|
||||||
|
WHERE category_id IN (SELECT category_id FROM golem15_journal_posts_categories WHERE post_id = ?)
|
||||||
|
GROUP BY post_id) sc ON sc.post_id = p.id`, post.ID).
|
||||||
|
Where("p.id <> ?", post.ID).
|
||||||
|
Where("(st.n > 0 OR sc.n > 0)")
|
||||||
|
if !includeDrafts {
|
||||||
|
q = q.Where(publishedClause, true, time.Now())
|
||||||
|
}
|
||||||
|
var ranked []struct {
|
||||||
|
ID uint
|
||||||
|
Score int
|
||||||
|
}
|
||||||
|
if err := q.Order("score DESC, p.published_at DESC NULLS LAST, p.id DESC").Limit(count).Scan(&ranked).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(ranked) == 0 {
|
||||||
|
return []models.Post{}, nil
|
||||||
|
}
|
||||||
|
ids := make([]uint, 0, len(ranked))
|
||||||
|
for _, r := range ranked {
|
||||||
|
ids = append(ids, r.ID)
|
||||||
|
}
|
||||||
|
var rows []models.Post
|
||||||
|
if err := db.Model(&models.Post{}).Preload("User").Preload("Categories").Preload("Tags").
|
||||||
|
Where("id IN ?", ids).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byID := make(map[uint]models.Post, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
byID[r.ID] = r
|
||||||
|
}
|
||||||
|
out := make([]models.Post, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if r, ok := byID[id]; ok {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrimaryCategoryID is the post's first category by name, ties by id (the
|
||||||
|
// order the :category URL param takes its category in); 0 without one.
|
||||||
|
func PrimaryCategoryID(ctx context.Context, db *gorm.DB, postID uint) uint {
|
||||||
|
var rows []struct{ ID uint }
|
||||||
|
err := db.WithContext(ctx).Table("golem15_journal_categories AS c").
|
||||||
|
Select("c.id").
|
||||||
|
Joins("JOIN golem15_journal_posts_categories AS pc ON pc.category_id = c.id").
|
||||||
|
Where("pc.post_id = ?", postID).
|
||||||
|
Order("c.name ASC, c.id ASC").
|
||||||
|
Limit(1).
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil || len(rows) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return rows[0].ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryNeighbour is the post's neighbour by publish time within its
|
||||||
|
// first category, in the direction of the post answer's previous_post and
|
||||||
|
// next_post: previous is the nearest published post published after it,
|
||||||
|
// next the nearest one published before it (ties by id). nil without a
|
||||||
|
// category or a neighbour. Published posts only, as for previous_post.
|
||||||
|
func CategoryNeighbour(ctx context.Context, db *gorm.DB, post *models.Post, previous bool) *models.Post {
|
||||||
|
if post == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
category := PrimaryCategoryID(ctx, db, post.ID)
|
||||||
|
if category == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
op, dir := "<", "DESC"
|
||||||
|
if previous {
|
||||||
|
op, dir = ">", "ASC"
|
||||||
|
}
|
||||||
|
// The same query shape as the site-wide neighbours (siblingPost), plus
|
||||||
|
// the category, so the row is read the same way.
|
||||||
|
q := db.WithContext(ctx).Model(&models.Post{}).
|
||||||
|
Where("published = ? AND published_at IS NOT NULL AND published_at < ?", true, time.Now()).
|
||||||
|
Where("id <> ?", post.ID).
|
||||||
|
Where("id IN (SELECT post_id FROM golem15_journal_posts_categories WHERE category_id = ?)", category)
|
||||||
|
if post.PublishedAt != nil {
|
||||||
|
q = q.Where("(published_at "+op+" ?) OR (published_at = ? AND id "+op+" ?)", post.PublishedAt, post.PublishedAt, post.ID)
|
||||||
|
}
|
||||||
|
var out models.Post
|
||||||
|
if err := q.Order("published_at " + dir + ", id " + dir).Take(&out).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &out
|
||||||
|
}
|
||||||
47
classes/related_test.go
Normal file
47
classes/related_test.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestReadRelatedSettings pins the related-posts config: the category mode
|
||||||
|
// and 3 by default; "scored" in any case turns scoring on; the count
|
||||||
|
// clamped to 3..6, 0 or a non-number falling back to 3. The scoring and
|
||||||
|
// its ties by the newest post are TestJournalAPIMatrix's on the database.
|
||||||
|
func TestReadRelatedSettings(t *testing.T) {
|
||||||
|
if s := ReadRelatedSettings(nil); s.Mode != RelatedModeCategory || s.Count != 3 || s.Scored() {
|
||||||
|
t.Fatalf("nil config = %+v", s)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
mode string
|
||||||
|
count any
|
||||||
|
want RelatedSettings
|
||||||
|
}{
|
||||||
|
{"", nil, RelatedSettings{RelatedModeCategory, 3}},
|
||||||
|
{" Scored ", 5, RelatedSettings{RelatedModeScored, 5}},
|
||||||
|
{"scored", 1, RelatedSettings{RelatedModeScored, 3}},
|
||||||
|
{"scored", 99, RelatedSettings{RelatedModeScored, 6}},
|
||||||
|
{"category", 4, RelatedSettings{RelatedModeCategory, 4}},
|
||||||
|
{"tags", 0, RelatedSettings{RelatedModeCategory, 3}},
|
||||||
|
{"scored", "many", RelatedSettings{RelatedModeScored, 3}},
|
||||||
|
} {
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: t.TempDir()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tc.mode != "" {
|
||||||
|
_ = cfg.Set(RelatedModeKey, tc.mode)
|
||||||
|
}
|
||||||
|
if tc.count != nil {
|
||||||
|
_ = cfg.Set(RelatedCountKey, tc.count)
|
||||||
|
}
|
||||||
|
if got := ReadRelatedSettings(cfg); got != tc.want {
|
||||||
|
t.Errorf("mode %q count %v = %+v, want %+v", tc.mode, tc.count, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if relatedTagPoints != 2 || relatedCategoryPoints != 1 {
|
||||||
|
t.Fatal("a shared tag is 2 points, a shared category 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
60
classes/site_cache.go
Normal file
60
classes/site_cache.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The site-cache purge moved into the framework (cooler.SiteCachePurger,
|
||||||
|
// configured by site_cache.purge_url and site_cache.purge_token). The names
|
||||||
|
// below stay for one release as aliases, so existing deployments and
|
||||||
|
// importers keep working.
|
||||||
|
const (
|
||||||
|
// SiteCachePurgeURLKey is the Journal's old config key of the host
|
||||||
|
// site's cache purge URL (env SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL).
|
||||||
|
// It is read only when the framework keys are unset.
|
||||||
|
//
|
||||||
|
// Deprecated: set cooler.SiteCachePurgeURLKey (site_cache.purge_url).
|
||||||
|
SiteCachePurgeURLKey = "golem15.journal.site_cache.purge_url"
|
||||||
|
// SiteCachePurgeTokenKey is the Journal's old config key of the purge
|
||||||
|
// bearer token (env SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN). It
|
||||||
|
// is read only when the framework keys are unset.
|
||||||
|
//
|
||||||
|
// Deprecated: set cooler.SiteCachePurgeTokenKey (site_cache.purge_token).
|
||||||
|
SiteCachePurgeTokenKey = "golem15.journal.site_cache.purge_token"
|
||||||
|
|
||||||
|
// SiteCachePurgeTimeout bounds one purge request.
|
||||||
|
//
|
||||||
|
// Deprecated: use cooler.SiteCachePurgeTimeout.
|
||||||
|
SiteCachePurgeTimeout = cooler.SiteCachePurgeTimeout
|
||||||
|
|
||||||
|
// SiteCachePurgeMinTokenLength is the shortest purge token accepted.
|
||||||
|
//
|
||||||
|
// Deprecated: use cooler.SiteCachePurgeMinTokenLength.
|
||||||
|
SiteCachePurgeMinTokenLength = cooler.SiteCachePurgeMinTokenLength
|
||||||
|
)
|
||||||
|
|
||||||
|
// SiteCachePurger is the framework's site-cache purger.
|
||||||
|
//
|
||||||
|
// Deprecated: use cooler.SiteCachePurger.
|
||||||
|
type SiteCachePurger = cooler.SiteCachePurger
|
||||||
|
|
||||||
|
// NewSiteCachePurger returns the purger for purgeURL and token read from the
|
||||||
|
// Journal's old keys, whose names its errors carry; the rules are those of
|
||||||
|
// cooler.NewSiteCachePurgerFor. With both empty it returns nil, nil.
|
||||||
|
//
|
||||||
|
// Deprecated: configure site_cache.purge_url and site_cache.purge_token and
|
||||||
|
// use cooler.SiteCacheFromApp.
|
||||||
|
func NewSiteCachePurger(purgeURL, token string) (*SiteCachePurger, error) {
|
||||||
|
return cooler.NewSiteCachePurgerFor(SiteCachePurgeURLKey, SiteCachePurgeTokenKey, purgeURL, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeSiteCacheAfterCommit registers p.Purge(kind, slug) to run after the
|
||||||
|
// transaction db belongs to commits. A nil p does nothing.
|
||||||
|
//
|
||||||
|
// Deprecated: use cooler.PurgeSiteCacheAfterCommit.
|
||||||
|
func PurgeSiteCacheAfterCommit(ctx context.Context, db *gorm.DB, p *SiteCachePurger, kind, slug string) {
|
||||||
|
cooler.PurgeSiteCacheAfterCommit(ctx, db, p, kind, slug)
|
||||||
|
}
|
||||||
224
classes/site_cache_test.go
Normal file
224
classes/site_cache_test.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
purgeToken32 = "0123456789abcdef0123456789abcdef"
|
||||||
|
purgeToken40 = "site-cache-unit-token-0123456789abcdefgh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// captureLogs routes slog.Default to a text handler on a buffer for the
|
||||||
|
// test and returns the buffer.
|
||||||
|
func captureLogs(t *testing.T) *syncBuffer {
|
||||||
|
t.Helper()
|
||||||
|
buf := &syncBuffer{}
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||||
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
type syncBuffer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *syncBuffer) lines() []string {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
var out []string
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(b.buf.String()), "\n") {
|
||||||
|
if line != "" {
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSiteCachePurgeConfig(t *testing.T) {
|
||||||
|
refused := []struct {
|
||||||
|
name, url, token, key string
|
||||||
|
}{
|
||||||
|
{"only the URL", "http://127.0.0.1:3016/api/__journal__/cache/purge", "", SiteCachePurgeTokenKey},
|
||||||
|
{"only the token", "", purgeToken40, SiteCachePurgeURLKey},
|
||||||
|
{"a bad scheme", "ftp://127.0.0.1/purge", purgeToken40, SiteCachePurgeURLKey},
|
||||||
|
{"no scheme", "127.0.0.1:3016/purge", purgeToken40, SiteCachePurgeURLKey},
|
||||||
|
{"no host", "http:///purge", purgeToken40, SiteCachePurgeURLKey},
|
||||||
|
{"user info", "http://user:secret@127.0.0.1/purge", purgeToken40, SiteCachePurgeURLKey},
|
||||||
|
{"a fragment", "http://127.0.0.1/purge#top", purgeToken40, SiteCachePurgeURLKey},
|
||||||
|
{"an empty fragment", "http://127.0.0.1/purge#", purgeToken40, SiteCachePurgeURLKey},
|
||||||
|
{"http to a non-loopback host", "http://example.test/purge", purgeToken40, SiteCachePurgeURLKey},
|
||||||
|
{"http to a private address", "http://10.0.0.5:3016/purge", purgeToken40, SiteCachePurgeURLKey},
|
||||||
|
{"a 31-character token", "http://127.0.0.1/purge", purgeToken32[:31], SiteCachePurgeTokenKey},
|
||||||
|
}
|
||||||
|
for _, tc := range refused {
|
||||||
|
t.Run("refuses "+tc.name, func(t *testing.T) {
|
||||||
|
p, err := NewSiteCachePurger(tc.url, tc.token)
|
||||||
|
if err == nil || p != nil {
|
||||||
|
t.Fatalf("NewSiteCachePurger(%q) = %v, %v; want an error", tc.url, p, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.key) {
|
||||||
|
t.Fatalf("error %q does not name %s", err, tc.key)
|
||||||
|
}
|
||||||
|
if tc.token != "" && strings.Contains(err.Error(), tc.token) {
|
||||||
|
t.Fatalf("error %q carries the token", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("both empty disables the purge", func(t *testing.T) {
|
||||||
|
p, err := NewSiteCachePurger("", "")
|
||||||
|
if p != nil || err != nil {
|
||||||
|
t.Fatalf("NewSiteCachePurger(\"\", \"\") = %v, %v; want nil, nil", p, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
accepted := []struct {
|
||||||
|
url, endpoint string
|
||||||
|
}{
|
||||||
|
{"https://cache.example.test/api/__journal__/cache/purge", "cache.example.test/api/__journal__/cache/purge"},
|
||||||
|
{"http://127.0.0.1:3016/api/__journal__/cache/purge", "127.0.0.1:3016/api/__journal__/cache/purge"},
|
||||||
|
{"http://127.9.8.7/purge", "127.9.8.7/purge"},
|
||||||
|
{"http://[::1]:3000/purge", "[::1]:3000/purge"},
|
||||||
|
{"http://localhost:3000/purge?x=1", "localhost:3000/purge"},
|
||||||
|
}
|
||||||
|
for _, tc := range accepted {
|
||||||
|
t.Run("accepts "+tc.url, func(t *testing.T) {
|
||||||
|
p, err := NewSiteCachePurger(tc.url, purgeToken32)
|
||||||
|
if err != nil || p == nil {
|
||||||
|
t.Fatalf("NewSiteCachePurger(%q) = %v, %v; want a purger", tc.url, p, err)
|
||||||
|
}
|
||||||
|
if p.Endpoint() != tc.endpoint {
|
||||||
|
t.Fatalf("Endpoint() = %q, want %q", p.Endpoint(), tc.endpoint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var nilPurger *SiteCachePurger
|
||||||
|
if nilPurger.Endpoint() != "" {
|
||||||
|
t.Fatal("a nil purger has an endpoint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeHit is one request the test receiver got.
|
||||||
|
type purgeHit struct {
|
||||||
|
method, path, auth, contentType string
|
||||||
|
body map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func purgeReceiver(t *testing.T, handle func(w http.ResponseWriter, r *http.Request)) (*httptest.Server, func() []purgeHit) {
|
||||||
|
t.Helper()
|
||||||
|
var mu sync.Mutex
|
||||||
|
var hits []purgeHit
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
var body map[string]string
|
||||||
|
_ = json.Unmarshal(raw, &body)
|
||||||
|
mu.Lock()
|
||||||
|
hits = append(hits, purgeHit{r.Method, r.URL.Path, r.Header.Get("Authorization"), r.Header.Get("Content-Type"), body})
|
||||||
|
mu.Unlock()
|
||||||
|
handle(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv, func() []purgeHit {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return append([]purgeHit(nil), hits...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestPurger(t *testing.T, srv *httptest.Server) *SiteCachePurger {
|
||||||
|
t.Helper()
|
||||||
|
p, err := NewSiteCachePurger(srv.URL+"/api/__journal__/cache/purge", purgeToken40)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoToken(t *testing.T, logs *syncBuffer) {
|
||||||
|
t.Helper()
|
||||||
|
for _, line := range logs.lines() {
|
||||||
|
if strings.Contains(line, purgeToken40) || strings.Contains(line, "Bearer") || strings.Contains(line, "Authorization") {
|
||||||
|
t.Fatalf("log line carries the token or the Authorization header: %s", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSiteCachePurgeRequest checks that a purger built from the Journal's
|
||||||
|
// deprecated keys sends the framework's contract request; cooler's own tests
|
||||||
|
// cover failures, timeouts and cancellation.
|
||||||
|
func TestSiteCachePurgeRequest(t *testing.T) {
|
||||||
|
t.Run("sends the contract request and logs the purged count", func(t *testing.T) {
|
||||||
|
logs := captureLogs(t)
|
||||||
|
srv, hits := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"purged":7}`))
|
||||||
|
})
|
||||||
|
newTestPurger(t, srv).Purge(context.Background(), "category", "cordyceps")
|
||||||
|
got := hits()
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("%d requests, want 1", len(got))
|
||||||
|
}
|
||||||
|
h := got[0]
|
||||||
|
if h.method != http.MethodPost || h.path != "/api/__journal__/cache/purge" {
|
||||||
|
t.Fatalf("request %s %s", h.method, h.path)
|
||||||
|
}
|
||||||
|
if h.auth != "Bearer "+purgeToken40 || !strings.HasPrefix(h.contentType, "application/json") {
|
||||||
|
t.Fatalf("Authorization %q, Content-Type %q", h.auth, h.contentType)
|
||||||
|
}
|
||||||
|
if len(h.body) != 2 || h.body["kind"] != "category" || h.body["slug"] != "cordyceps" {
|
||||||
|
t.Fatalf("body = %#v", h.body)
|
||||||
|
}
|
||||||
|
lines := logs.lines()
|
||||||
|
if len(lines) != 1 || !strings.Contains(lines[0], "level=INFO") || !strings.Contains(lines[0], `msg="cooler: site cache purged"`) ||
|
||||||
|
!strings.Contains(lines[0], "kind=category") || !strings.Contains(lines[0], "slug=cordyceps") ||
|
||||||
|
!strings.Contains(lines[0], "status=200") || !strings.Contains(lines[0], "purged=7") {
|
||||||
|
t.Fatalf("log = %q", lines)
|
||||||
|
}
|
||||||
|
assertNoToken(t, logs)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a nil purger sends nothing", func(t *testing.T) {
|
||||||
|
logs := captureLogs(t)
|
||||||
|
var p *SiteCachePurger
|
||||||
|
p.Purge(context.Background(), "post", "nil")
|
||||||
|
PurgeSiteCacheAfterCommit(context.Background(), nil, nil, "post", "nil")
|
||||||
|
if lines := logs.lines(); len(lines) != 0 {
|
||||||
|
t.Fatalf("log = %q, want nothing", lines)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("outside a transaction the after-commit purge is queued at once", func(t *testing.T) {
|
||||||
|
captureLogs(t)
|
||||||
|
srv, hits := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"purged":2}`)) })
|
||||||
|
p := newTestPurger(t, srv)
|
||||||
|
PurgeSiteCacheAfterCommit(context.Background(), nil, p, "category", "now")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := p.Wait(ctx); err != nil {
|
||||||
|
t.Fatalf("Wait: %v", err)
|
||||||
|
}
|
||||||
|
if got := hits(); len(got) != 1 || got[0].body["kind"] != "category" || got[0].body["slug"] != "now" {
|
||||||
|
t.Fatalf("requests = %#v, want one", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
21
commands_documented_test.go
Normal file
21
commands_documented_test.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package journal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCommandsDocumented logs every console command whose help lacks a
|
||||||
|
// description, long help, examples or an option description
|
||||||
|
// (bonfire.CheckDocs). It only warns: the framework's own coverage test
|
||||||
|
// fails on a gap, plugin repositories report it.
|
||||||
|
func TestCommandsDocumented(t *testing.T) {
|
||||||
|
commands := (&Plugin{}).Commands()
|
||||||
|
if len(commands) == 0 {
|
||||||
|
t.Fatal("the plugin declares no commands")
|
||||||
|
}
|
||||||
|
for _, problem := range bonfire.CheckDocs(commands) {
|
||||||
|
t.Logf("%s", problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
config/config.yaml
Normal file
30
config/config.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# golem15.journal defaults, read as golem15.journal.<key>.
|
||||||
|
#
|
||||||
|
# content_h1_to_h2 is the H1 setting used when the Journal settings row
|
||||||
|
# (Settings › Journal, Headings and links) does not exist; the row's own
|
||||||
|
# value decides otherwise. On, every H1 saved in Journal rich text (post
|
||||||
|
# content and category pillar text) becomes an H2 and the editor offers
|
||||||
|
# H2 to H4. Off, H1 headings are stored as written and the editor offers
|
||||||
|
# H1 to H4. Changing it never rewrites stored content; journal:fix-headings
|
||||||
|
# --apply does.
|
||||||
|
content_h1_to_h2: true
|
||||||
|
|
||||||
|
# Related posts on GET /_journal/api/v1/posts/{slug} (opt-in).
|
||||||
|
# related_mode "category" (the default) answers as before: up to three
|
||||||
|
# posts sharing a category with the post, newest first. "scored" ranks
|
||||||
|
# posts by shared tags (2 points each) and shared categories (1 point
|
||||||
|
# each), ties by the newest, returns related_count of them (3 to 6,
|
||||||
|
# clamped) and adds previous_in_category and next_in_category, the post's
|
||||||
|
# neighbours by publish time within its first category by name.
|
||||||
|
related_mode: category
|
||||||
|
related_count: 3
|
||||||
|
|
||||||
|
# Featured image variants (golem15.journal.image_variants job,
|
||||||
|
# journal:image-variants): after a featured image upload commits, a
|
||||||
|
# background job writes a thumbnail at each width (pixels, 16 to 4096,
|
||||||
|
# never wider than the original) and a WebP copy of each next to it. The
|
||||||
|
# API lists them as featured_images[].variants; the journal layer serves
|
||||||
|
# <picture> with WebP and the original format as fallback. An environment
|
||||||
|
# override takes a comma-separated list, e.g.
|
||||||
|
# SUMMER_GOLEM15__JOURNAL__IMAGE_WIDTHS=480,960.
|
||||||
|
image_widths: [480, 800, 1200]
|
||||||
55
console/database.go
Normal file
55
console/database.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package console
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lookupDB returns the app's database. The generated CLI publishes none for
|
||||||
|
// plugin commands (only serve and the framework's own commands open one),
|
||||||
|
// so without a published database it opens database.dsn and publishes it,
|
||||||
|
// as lagoon's migrate does. done closes a pool this call opened and is a
|
||||||
|
// no-op otherwise.
|
||||||
|
func lookupDB(ctx context.Context, app *backpack.App) (*gorm.DB, func(), error) {
|
||||||
|
if app == nil {
|
||||||
|
return nil, nil, errors.New("journal: app is nil")
|
||||||
|
}
|
||||||
|
if db := mustDB(app); db != nil {
|
||||||
|
return db, func() {}, nil
|
||||||
|
}
|
||||||
|
if lagoon.DSN(app.Config) == "" {
|
||||||
|
return nil, nil, errors.New("journal: database is not configured (set SUMMER_DATABASE__DSN)")
|
||||||
|
}
|
||||||
|
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return gdb, func() { _ = sqlDB.Close() }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupBucket returns the published uploads bucket, or opens
|
||||||
|
// storage.uploads.bucket_url when the process has none (the generated
|
||||||
|
// CLI). done closes a bucket this call opened and is a no-op otherwise.
|
||||||
|
func lookupBucket(ctx context.Context, app *backpack.App) (*blob.Bucket, func(), error) {
|
||||||
|
if app == nil {
|
||||||
|
return nil, nil, errors.New("journal: app is nil")
|
||||||
|
}
|
||||||
|
if bucket, ok := app.Lookup[*blob.Bucket](); ok && bucket != nil {
|
||||||
|
return bucket, func() {}, nil
|
||||||
|
}
|
||||||
|
bucket, err := attach.OpenBucket(ctx, app.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return bucket, func() { _ = bucket.Close() }, nil
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
|
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,11 +25,26 @@ const translatableExportFields = "title,slug,content,content_html,excerpt,metada
|
|||||||
func ExportPosts(app *backpack.App) bonfire.Command {
|
func ExportPosts(app *backpack.App) bonfire.Command {
|
||||||
return bonfire.Command{
|
return bonfire.Command{
|
||||||
Name: "journal:export-posts",
|
Name: "journal:export-posts",
|
||||||
Description: "Export all journal posts with translations to JSON files (one per post).",
|
Description: "Export every journal post with its translations to JSON files, one per post",
|
||||||
|
Long: `Writes one JSON file per post (named after its slug) into the export
|
||||||
|
directory: title, slug, content, excerpt, metadata, sources, the published
|
||||||
|
state and date, the category slugs and names, the author's e-mail and every
|
||||||
|
translation of the enabled locales. File attachments (featured and content
|
||||||
|
images) are not exported.
|
||||||
|
|
||||||
|
Use it to move posts between applications or to keep a copy in version
|
||||||
|
control; journal:import-posts reads the same files. --dry-run lists the posts
|
||||||
|
with their status and translation count and writes nothing. It ends with
|
||||||
|
"Exported N posts to: <path>".`,
|
||||||
Flags: []bonfire.Flag{
|
Flags: []bonfire.Flag{
|
||||||
{Name: "path", Description: "Export directory (default: journal-export)"},
|
{Name: "path", Description: "Export directory (default: journal-export)"},
|
||||||
{Name: "dry-run", Description: "Show what would be exported without writing files", Bare: true},
|
{Name: "dry-run", Description: "Show what would be exported without writing files", Bare: true},
|
||||||
},
|
},
|
||||||
|
Examples: []bonfire.Example{
|
||||||
|
{Command: "journal:export-posts", Description: "Export every post into journal-export/"},
|
||||||
|
{Command: "journal:export-posts --path backups/journal", Description: "Export into another directory"},
|
||||||
|
{Command: "journal:export-posts --dry-run", Description: "List the posts that would be exported"},
|
||||||
|
},
|
||||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||||
path := flagOrDefault(in, "path", defaultExportPath)
|
path := flagOrDefault(in, "path", defaultExportPath)
|
||||||
dry := flagOn(in, "dry-run")
|
dry := flagOn(in, "dry-run")
|
||||||
@@ -88,10 +104,10 @@ func ExportToDir(ctx context.Context, gdb *gorm.DB, dir string, dry bool, out bo
|
|||||||
}
|
}
|
||||||
exported := 0
|
exported := 0
|
||||||
attachments := false
|
attachments := false
|
||||||
for i := range posts {
|
exportOne := func(i int) error {
|
||||||
data, err := buildPostFile(ctx, gdb, &posts[i])
|
data, err := buildPostFile(ctx, gdb, &posts[i])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return exported, attachments, err
|
return err
|
||||||
}
|
}
|
||||||
if dry {
|
if dry {
|
||||||
status := "draft"
|
status := "draft"
|
||||||
@@ -102,7 +118,7 @@ func ExportToDir(ctx context.Context, gdb *gorm.DB, dir string, dry bool, out bo
|
|||||||
} else {
|
} else {
|
||||||
raw, err := json.MarshalIndent(data, "", " ")
|
raw, err := json.MarshalIndent(data, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return exported, attachments, err
|
return err
|
||||||
}
|
}
|
||||||
name := posts[i].Slug
|
name := posts[i].Slug
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -110,13 +126,33 @@ func ExportToDir(ctx context.Context, gdb *gorm.DB, dir string, dry bool, out bo
|
|||||||
}
|
}
|
||||||
filePath := filepath.Join(dir, name+".json")
|
filePath := filepath.Join(dir, name+".json")
|
||||||
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
|
||||||
return exported, attachments, err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filePath, raw, 0o644); err != nil {
|
if err := os.WriteFile(filePath, raw, 0o644); err != nil {
|
||||||
return exported, attachments, err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
exported++
|
exported++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if out != nil && !dry {
|
||||||
|
// A real export prints nothing per post, so a progress bar over the
|
||||||
|
// posts shows how far it got (hidden under -q).
|
||||||
|
err := out.Progress(len(posts), func(p bonfire.Progress) error {
|
||||||
|
for i := range posts {
|
||||||
|
if err := exportOne(i); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Advance(1)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return exported, attachments, err
|
||||||
|
}
|
||||||
|
for i := range posts {
|
||||||
|
if err := exportOne(i); err != nil {
|
||||||
|
return exported, attachments, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return exported, attachments, nil
|
return exported, attachments, nil
|
||||||
}
|
}
|
||||||
@@ -216,6 +252,14 @@ func isEmptyValue(v any) bool {
|
|||||||
return ok && s == ""
|
return ok && s == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appConfig is app's config, nil without an application.
|
||||||
|
func appConfig(app *backpack.App) *compass.Config {
|
||||||
|
if app == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return app.Config
|
||||||
|
}
|
||||||
|
|
||||||
func mustDB(app *backpack.App) *gorm.DB {
|
func mustDB(app *backpack.App) *gorm.DB {
|
||||||
if app == nil {
|
if app == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
76
console/fix_headings.go
Normal file
76
console/fix_headings.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package console
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FixHeadings is journal:fix-headings: it converts H1 to H2 in stored
|
||||||
|
// Journal rich text (post content in every locale and category pillar text)
|
||||||
|
// while the Journal H1 setting is on. It is a dry run unless --apply is
|
||||||
|
// given; with the setting off it refuses with exit code 1. It runs from the
|
||||||
|
// generated application CLI: without a published database it opens
|
||||||
|
// database.dsn (lookupDB) and, without one, names SUMMER_DATABASE__DSN. Its
|
||||||
|
// output (UI-SPEC C27 with the P33-54 wording, English only):
|
||||||
|
//
|
||||||
|
// - dry run: "Would convert :count H1 headings in :posts posts and :categories categories. Run with --apply to save."
|
||||||
|
// - applied: "Converted :count H1 headings in :posts posts and :categories categories."
|
||||||
|
// - nothing to do: "No H1 headings found in post or category content."
|
||||||
|
// - refused (classes.H1ConversionOffText): "H1 to H2 conversion is turned off in the Journal settings (Headings and links). Turn it on first, or keep the H1 headings."
|
||||||
|
func FixHeadings(app *backpack.App) bonfire.Command {
|
||||||
|
return bonfire.Command{
|
||||||
|
Name: "journal:fix-headings",
|
||||||
|
Description: "Convert H1 headings to H2 in stored post and category content",
|
||||||
|
Long: `Finds every H1 heading in stored Journal content: post content and
|
||||||
|
content_html in the default locale and in every translation, and the pillar
|
||||||
|
text of every category. Switching the Journal H1 setting (Settings › Journal,
|
||||||
|
Headings and links) on never rewrites stored content; this command does.
|
||||||
|
|
||||||
|
By default it only counts ("Would convert N H1 headings in N posts and N
|
||||||
|
categories. Run with --apply to save."). With --apply it converts them in
|
||||||
|
one transaction, fires a record event per changed post and category (so an
|
||||||
|
SEO or search plugin hears about it) and queues the site cache purge.
|
||||||
|
|
||||||
|
While the H1 setting is off the command prints "H1 to H2 conversion is
|
||||||
|
turned off in the Journal settings (Headings and links). Turn it on first, or
|
||||||
|
keep the H1 headings." and exits with code 1. The MCP tool
|
||||||
|
journal_fix_headings runs the same conversion.`,
|
||||||
|
Flags: []bonfire.Flag{
|
||||||
|
{Name: "apply", Description: "Save the converted content (default: dry run)", Bare: true},
|
||||||
|
{Name: "dry-run", Description: "Only count the H1 headings (the default)", Bare: true},
|
||||||
|
},
|
||||||
|
Examples: []bonfire.Example{
|
||||||
|
{Command: "journal:fix-headings", Description: "Count the H1 headings that would be converted"},
|
||||||
|
{Command: "journal:fix-headings --apply", Description: "Convert them and save"},
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||||
|
db, done, err := lookupDB(ctx, app)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer done()
|
||||||
|
if !classes.EffectiveH1ToH2(ctx, db, appConfig(app)) {
|
||||||
|
out.Error(classes.H1ConversionOffText)
|
||||||
|
return bonfire.Reported(classes.ErrH1ConversionOff)
|
||||||
|
}
|
||||||
|
apply := flagOn(in, "apply") && !flagOn(in, "dry-run")
|
||||||
|
res, err := classes.FixStoredHeadings(ctx, app, db, apply)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case res.Headings == 0:
|
||||||
|
out.Info("No H1 headings found in post or category content.")
|
||||||
|
case apply:
|
||||||
|
out.Success(fmt.Sprintf("Converted %d H1 headings in %d posts and %d categories.", res.Headings, res.Posts, res.Categories))
|
||||||
|
default:
|
||||||
|
out.Info(fmt.Sprintf("Would convert %d H1 headings in %d posts and %d categories. Run with --apply to save.", res.Headings, res.Posts, res.Categories))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
71
console/image_variants.go
Normal file
71
console/image_variants.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package console
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImageVariants is journal:image-variants: it writes the thumbnails and
|
||||||
|
// WebP copies of the posts' featured images at golem15.journal.image_widths
|
||||||
|
// in this process (the backfill of the golem15.journal.image_variants job).
|
||||||
|
// Without --all it takes the images that lack a variant of some width.
|
||||||
|
func ImageVariants(app *backpack.App) bonfire.Command {
|
||||||
|
return bonfire.Command{
|
||||||
|
Name: "journal:image-variants",
|
||||||
|
Description: "Write the WebP and resized variants of featured images",
|
||||||
|
Long: `Writes, for every post's featured image, a thumbnail at each width of
|
||||||
|
golem15.journal.image_widths (480, 800 and 1200 by default) that is not
|
||||||
|
wider than the original, and a WebP copy of each next to it, and records
|
||||||
|
them in golem15_journal_image_variants. The Journal API lists them as
|
||||||
|
featured_images[].variants and the journal layer serves them through
|
||||||
|
<picture> with the original format as fallback.
|
||||||
|
|
||||||
|
New uploads get their variants from the golem15.journal.image_variants job,
|
||||||
|
queued after the upload commits. This command is the backfill: without
|
||||||
|
--all it takes the images that lack a variant of some width (for example
|
||||||
|
after adding a width), with --all it checks every featured image. A
|
||||||
|
variant that exists is never written again, so the command can run any
|
||||||
|
number of times.
|
||||||
|
|
||||||
|
It runs from the application's CLI as well as next to serve: without a
|
||||||
|
running server it opens database.dsn and storage.uploads.bucket_url
|
||||||
|
itself.`,
|
||||||
|
Flags: []bonfire.Flag{
|
||||||
|
{Name: "all", Description: "Check every featured image (default: only those missing a width)", Bare: true},
|
||||||
|
},
|
||||||
|
Examples: []bonfire.Example{
|
||||||
|
{Command: "journal:image-variants", Description: "Write the variants images are missing"},
|
||||||
|
{Command: "journal:image-variants --all", Description: "Check every featured image"},
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||||
|
db, done, err := lookupDB(ctx, app)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer done()
|
||||||
|
bucket, closeBucket, err := lookupBucket(ctx, app)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer closeBucket()
|
||||||
|
ids, err := classes.FeaturedImageIDs(db.WithContext(ctx), appConfig(app), flagOn(in, "all"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
out.Info("Every featured image has its variants.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
res, err := classes.GenerateImageVariants(ctx, db, bucket, classes.ImageWidths(appConfig(app)), ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Success(fmt.Sprintf("Checked %d featured images, wrote %d variants, skipped %d.", res.Files, res.Written, res.Skipped))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,30 +34,52 @@ type ImportStats struct {
|
|||||||
Updated int
|
Updated int
|
||||||
Skipped int
|
Skipped int
|
||||||
Failed int
|
Failed int
|
||||||
|
// H1Converted counts the H1 headings the import stored as H2 while the
|
||||||
|
// Journal H1 setting is on (default and translated content).
|
||||||
|
H1Converted int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportPosts is journal:import-posts (PHP ImportPosts.php).
|
// ImportPosts is journal:import-posts (PHP ImportPosts.php).
|
||||||
func ImportPosts(app *backpack.App) bonfire.Command {
|
func ImportPosts(app *backpack.App) bonfire.Command {
|
||||||
return bonfire.Command{
|
return bonfire.Command{
|
||||||
Name: "journal:import-posts",
|
Name: "journal:import-posts",
|
||||||
Description: "Import journal posts with translations from JSON files.",
|
Description: "Import journal posts with their translations from JSON files",
|
||||||
|
Long: `Reads every *.json file of the import directory (the files
|
||||||
|
journal:export-posts writes) and creates a post for each slug that does not
|
||||||
|
exist yet, with its translations and categories (missing categories are
|
||||||
|
created). A post whose slug already exists is skipped unless --force is
|
||||||
|
given, which updates it from the file. Titles lose the draft lock mark.
|
||||||
|
|
||||||
|
The whole import runs in one transaction; each post prints one line
|
||||||
|
(created, updated or skipped) and a file that is not valid JSON or has no
|
||||||
|
slug is reported and counted as failed. --dry-run walks the files without
|
||||||
|
writing anything. It ends with "Results: N created, N updated, N skipped, N
|
||||||
|
failed".`,
|
||||||
Flags: []bonfire.Flag{
|
Flags: []bonfire.Flag{
|
||||||
{Name: "path", Description: "Import directory (default: journal-export)"},
|
{Name: "path", Description: "Import directory (default: journal-export)"},
|
||||||
{Name: "force", Description: "Update existing posts (default: skip existing)", Bare: true},
|
{Name: "force", Description: "Update existing posts (default: skip existing)", Bare: true},
|
||||||
{Name: "dry-run", Description: "Show what would be imported without making changes", Bare: true},
|
{Name: "dry-run", Description: "Show what would be imported without making changes", Bare: true},
|
||||||
},
|
},
|
||||||
|
Examples: []bonfire.Example{
|
||||||
|
{Command: "journal:import-posts --dry-run", Description: "Show what journal-export/ would create or skip"},
|
||||||
|
{Command: "journal:import-posts --path backups/journal", Description: "Create the posts that do not exist yet"},
|
||||||
|
{Command: "journal:import-posts --force", Description: "Also update the posts that already exist"},
|
||||||
|
},
|
||||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||||
path := flagOrDefault(in, "path", defaultExportPath)
|
path := flagOrDefault(in, "path", defaultExportPath)
|
||||||
force := flagOn(in, "force")
|
force := flagOn(in, "force")
|
||||||
dry := flagOn(in, "dry-run")
|
dry := flagOn(in, "dry-run")
|
||||||
stats, err := ImportFromDir(ctx, mustDB(app), path, force, dry, out, nil)
|
stats, err := ImportFromDirConfig(ctx, mustDB(app), appConfig(app), path, force, dry, out, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if dry {
|
if dry {
|
||||||
out.Info("Dry run complete (no changes made).")
|
out.Info("Dry run complete (no changes made).")
|
||||||
}
|
}
|
||||||
out.Info(fmt.Sprintf("Results: %d created, %d updated, %d skipped, %d failed",
|
if stats.H1Converted > 0 {
|
||||||
|
out.Info(fmt.Sprintf("Converted %d H1 headings to H2.", stats.H1Converted))
|
||||||
|
}
|
||||||
|
out.Success(fmt.Sprintf("Results: %d created, %d updated, %d skipped, %d failed",
|
||||||
stats.Created, stats.Updated, stats.Skipped, stats.Failed))
|
stats.Created, stats.Updated, stats.Skipped, stats.Failed))
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -64,7 +87,17 @@ func ImportPosts(app *backpack.App) bonfire.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ImportFromDir reads *.json files from dir and creates or updates posts.
|
// ImportFromDir reads *.json files from dir and creates or updates posts.
|
||||||
|
// It is ImportFromDirConfig without a config: the Journal H1 setting is read
|
||||||
|
// from the settings row alone (on when there is none).
|
||||||
func ImportFromDir(ctx context.Context, gdb *gorm.DB, dir string, force, dry bool, out bonfire.Output, guard ImportGuard) (ImportStats, error) {
|
func ImportFromDir(ctx context.Context, gdb *gorm.DB, dir string, force, dry bool, out bonfire.Output, guard ImportGuard) (ImportStats, error) {
|
||||||
|
return ImportFromDirConfig(ctx, gdb, nil, dir, force, dry, out, guard)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportFromDirConfig is ImportFromDir with the application config the
|
||||||
|
// Journal H1 setting falls back to (classes.EffectiveH1ToH2). While the
|
||||||
|
// setting is on, every H1 the import stores becomes an H2, counted in
|
||||||
|
// ImportStats.H1Converted.
|
||||||
|
func ImportFromDirConfig(ctx context.Context, gdb *gorm.DB, cfg *compass.Config, dir string, force, dry bool, out bonfire.Output, guard ImportGuard) (ImportStats, error) {
|
||||||
var stats ImportStats
|
var stats ImportStats
|
||||||
if gdb == nil {
|
if gdb == nil {
|
||||||
return stats, fmt.Errorf("journal: database is not configured")
|
return stats, fmt.Errorf("journal: database is not configured")
|
||||||
@@ -84,6 +117,7 @@ func ImportFromDir(ctx context.Context, gdb *gorm.DB, dir string, force, dry boo
|
|||||||
return stats, fmt.Errorf("no JSON files found in: %s", dir)
|
return stats, fmt.Errorf("no JSON files found in: %s", dir)
|
||||||
}
|
}
|
||||||
work := func(tx *gorm.DB) error {
|
work := func(tx *gorm.DB) error {
|
||||||
|
h1ToH2 := classes.EffectiveH1ToH2(ctx, tx, cfg)
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
raw, err := os.ReadFile(file)
|
raw, err := os.ReadFile(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -108,10 +142,11 @@ func ImportFromDir(ctx context.Context, gdb *gorm.DB, dir string, force, dry boo
|
|||||||
stats.Failed++
|
stats.Failed++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result, err := importPost(ctx, tx, data, force, dry, out, guard)
|
result, converted, err := importPost(ctx, tx, data, force, dry, h1ToH2, out, guard)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
stats.H1Converted += converted
|
||||||
switch result {
|
switch result {
|
||||||
case "created":
|
case "created":
|
||||||
stats.Created++
|
stats.Created++
|
||||||
@@ -137,16 +172,18 @@ func ImportFromDir(ctx context.Context, gdb *gorm.DB, dir string, force, dry boo
|
|||||||
return stats, err
|
return stats, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func importPost(ctx context.Context, db *gorm.DB, data PostFile, force, dry bool, out bonfire.Output, guard ImportGuard) (string, error) {
|
// importPost writes one post file. converted is the number of H1 headings
|
||||||
|
// stored as H2 (h1ToH2 on); a dry run converts nothing.
|
||||||
|
func importPost(ctx context.Context, db *gorm.DB, data PostFile, force, dry, h1ToH2 bool, out bonfire.Output, guard ImportGuard) (string, int, error) {
|
||||||
var existing models.Post
|
var existing models.Post
|
||||||
err := db.Where("slug = ?", data.Slug).Take(&existing).Error
|
err := db.Where("slug = ?", data.Slug).Take(&existing).Error
|
||||||
found := err == nil
|
found := err == nil
|
||||||
if err != nil && err != gorm.ErrRecordNotFound {
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
return "failed", err
|
return "failed", 0, err
|
||||||
}
|
}
|
||||||
if found && !force {
|
if found && !force {
|
||||||
line(out, " [skip] %s (already exists)", data.Slug)
|
line(out, " [skip] %s (already exists)", data.Slug)
|
||||||
return "skipped", nil
|
return "skipped", 0, nil
|
||||||
}
|
}
|
||||||
post := &models.Post{}
|
post := &models.Post{}
|
||||||
label := "created"
|
label := "created"
|
||||||
@@ -168,44 +205,50 @@ func importPost(ctx context.Context, db *gorm.DB, data PostFile, force, dry bool
|
|||||||
fillPost(db, draft, data)
|
fillPost(db, draft, data)
|
||||||
if guard != nil {
|
if guard != nil {
|
||||||
if err := guard(draft); err != nil {
|
if err := guard(draft); err != nil {
|
||||||
return "failed", err
|
return "failed", 0, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if found {
|
if found {
|
||||||
return "updated", nil
|
return "updated", 0, nil
|
||||||
}
|
}
|
||||||
return "created", nil
|
return "created", 0, nil
|
||||||
}
|
}
|
||||||
fillPost(db, post, data)
|
fillPost(db, post, data)
|
||||||
if guard != nil {
|
if guard != nil {
|
||||||
if err := guard(post); err != nil {
|
if err := guard(post); err != nil {
|
||||||
return "failed", err
|
return "failed", 0, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
html, err := classes.FormatHTML(post.Content)
|
html, err := classes.FormatHTML(post.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "failed", err
|
return "failed", 0, err
|
||||||
}
|
}
|
||||||
post.ContentHTML = html
|
post.ContentHTML = html
|
||||||
|
converted := 0
|
||||||
|
if h1ToH2 {
|
||||||
|
post.Content, post.ContentHTML, converted = classes.ConvertContentH1(post.Content, post.ContentHTML)
|
||||||
|
}
|
||||||
if found {
|
if found {
|
||||||
if err := db.Save(post).Error; err != nil {
|
if err := db.Save(post).Error; err != nil {
|
||||||
return "failed", err
|
return "failed", 0, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := db.Create(post).Error; err != nil {
|
if err := db.Create(post).Error; err != nil {
|
||||||
return "failed", err
|
return "failed", 0, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := syncCategories(ctx, db, post, data, out); err != nil {
|
if err := syncCategories(ctx, db, post, data, out); err != nil {
|
||||||
return "failed", err
|
return "failed", 0, err
|
||||||
}
|
}
|
||||||
if err := syncTranslations(ctx, db, post, data.Translations); err != nil {
|
n, err := syncTranslations(ctx, db, post, data.Translations, h1ToH2)
|
||||||
return "failed", err
|
if err != nil {
|
||||||
|
return "failed", 0, err
|
||||||
}
|
}
|
||||||
|
converted += n
|
||||||
if found {
|
if found {
|
||||||
return "updated", nil
|
return "updated", converted, nil
|
||||||
}
|
}
|
||||||
return "created", nil
|
return "created", converted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fillPost(db *gorm.DB, post *models.Post, data PostFile) {
|
func fillPost(db *gorm.DB, post *models.Post, data PostFile) {
|
||||||
@@ -306,12 +349,23 @@ func titleFromSlug(slug string) string {
|
|||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, translations map[string]map[string]any) error {
|
// syncTranslations writes the file's translations and returns the number
|
||||||
|
// of H1 headings stored as H2 (h1ToH2 on).
|
||||||
|
func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, translations map[string]map[string]any, h1ToH2 bool) (int, error) {
|
||||||
if len(translations) == 0 {
|
if len(translations) == 0 {
|
||||||
return nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
converted := 0
|
||||||
fields := []string{"title", "slug", "content", "content_html", "excerpt", "metadata"}
|
fields := []string{"title", "slug", "content", "content_html", "excerpt", "metadata"}
|
||||||
for locale, attrs := range translations {
|
for locale, attrs := range translations {
|
||||||
|
// A translated content_html is derived from content or sanitized,
|
||||||
|
// as the host column is; Winter's ImportPosts stored it verbatim.
|
||||||
|
if err := classes.FormatTranslation(attrs); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if h1ToH2 {
|
||||||
|
converted += classes.ConvertTranslationH1(attrs)
|
||||||
|
}
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
val, ok := attrs[field]
|
val, ok := attrs[field]
|
||||||
if !ok || val == nil {
|
if !ok || val == nil {
|
||||||
@@ -323,11 +377,11 @@ func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, trans
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := translate.SetTranslated(ctx, db, post, field, locale, val); err != nil {
|
if err := translate.SetTranslated(ctx, db, post, field, locale, val); err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return converted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func stripUnpublishedTitlePrefix(title string) string {
|
func stripUnpublishedTitlePrefix(title string) string {
|
||||||
|
|||||||
7
console/logger.go
Normal file
7
console/logger.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package console
|
||||||
|
|
||||||
|
import "git.golem15.com/golem15/summercms/modules/diary"
|
||||||
|
|
||||||
|
// pluginLog is the plugin's logger: its records carry component=journal, so
|
||||||
|
// config/log.yaml can raise or lower them (log.components.journal).
|
||||||
|
var pluginLog = diary.For("journal")
|
||||||
141
console/reindex.go
Normal file
141
console/reindex.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package console
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reindexBatch is how many posts one database read loads.
|
||||||
|
const reindexBatch = 200
|
||||||
|
|
||||||
|
// ReindexResult counts what a reindex did: posts upserted into the index,
|
||||||
|
// posts removed from it (unpublished or scheduled) and posts whose sync
|
||||||
|
// failed.
|
||||||
|
type ReindexResult struct {
|
||||||
|
Synced int
|
||||||
|
Removed int
|
||||||
|
Failed int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reindex is journal:reindex.
|
||||||
|
func Reindex(app *backpack.App) bonfire.Command {
|
||||||
|
return bonfire.Command{
|
||||||
|
Name: "journal:reindex",
|
||||||
|
Description: "Sync every journal post into the search index",
|
||||||
|
Long: `Upserts the search document of every published post and removes the
|
||||||
|
document of every unpublished or scheduled one, through the same path as the
|
||||||
|
sync after a save. Posts written before search was turned on reach the index
|
||||||
|
this way.
|
||||||
|
|
||||||
|
--fresh first drops the collection and recreates it from the post schema
|
||||||
|
(models.Post.SearchIndexSchema). Use it once on a collection that was
|
||||||
|
auto-created without the schema, or after the schema changed; search answers
|
||||||
|
from SQL while the collection is empty.
|
||||||
|
|
||||||
|
The command refuses, and sends nothing, when the search engine is not
|
||||||
|
configured (search.driver null, or no search.typesense.api_key) or the
|
||||||
|
Journal setting search_use_typesense is off. It ends with
|
||||||
|
"Reindexed: N synced, N removed, N failed" and exits non-zero when any post
|
||||||
|
failed.`,
|
||||||
|
Flags: []bonfire.Flag{
|
||||||
|
{Name: "fresh", Description: "Drop and recreate the collection from the schema before syncing", Bare: true},
|
||||||
|
},
|
||||||
|
Examples: []bonfire.Example{
|
||||||
|
{Command: "journal:reindex", Description: "Sync every post into the existing collection"},
|
||||||
|
{Command: "journal:reindex --fresh", Description: "Recreate the collection from the schema, then sync every post"},
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||||
|
res, err := ReindexPosts(ctx, app, flagOn(in, "fresh"), out)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("Reindexed: %d synced, %d removed, %d failed", res.Synced, res.Removed, res.Failed)
|
||||||
|
if res.Failed > 0 {
|
||||||
|
out.Error(msg)
|
||||||
|
return fmt.Errorf("journal: %d posts failed to sync", res.Failed)
|
||||||
|
}
|
||||||
|
out.Success(msg)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReindexPosts syncs every post through beachcomber, in id batches. With
|
||||||
|
// fresh it drops the collection and recreates it from the schema first. It
|
||||||
|
// returns an error, before any request, when the engine is not configured
|
||||||
|
// or search_use_typesense is off. A post that fails to sync is counted and
|
||||||
|
// reported on out; the rest still run.
|
||||||
|
func ReindexPosts(ctx context.Context, app *backpack.App, fresh bool, out bonfire.Output) (ReindexResult, error) {
|
||||||
|
var res ReindexResult
|
||||||
|
gdb := mustDB(app)
|
||||||
|
if gdb == nil {
|
||||||
|
return res, errors.New("journal: database is not configured")
|
||||||
|
}
|
||||||
|
svc, err := beachcomber.From(app)
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
engine := svc.Engine()
|
||||||
|
if engine == nil || !engine.Configured() {
|
||||||
|
return res, errors.New("journal: the search engine is not configured (set search.driver and search.typesense.api_key)")
|
||||||
|
}
|
||||||
|
var on bool
|
||||||
|
if err := gdb.WithContext(ctx).Raw(`SELECT search_use_typesense FROM golem15_journal_settings WHERE id = 1`).Scan(&on).Error; err != nil {
|
||||||
|
return res, fmt.Errorf("journal: read search_use_typesense: %w", err)
|
||||||
|
}
|
||||||
|
if !on {
|
||||||
|
return res, errors.New("journal: search is off; turn on search_use_typesense in the Journal settings first")
|
||||||
|
}
|
||||||
|
models.SetSearchGate(true)
|
||||||
|
|
||||||
|
index := svc.IndexName(&models.Post{})
|
||||||
|
schema := models.Post{}.SearchIndexSchema()
|
||||||
|
if fresh {
|
||||||
|
existed, err := beachcomber.DropIndex(ctx, engine, index)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("journal: drop %s: %w", index, err)
|
||||||
|
}
|
||||||
|
if existed {
|
||||||
|
line(out, "Dropped %s", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := beachcomber.EnsureIndex(ctx, engine, index, schema); err != nil {
|
||||||
|
return res, fmt.Errorf("journal: create %s: %w", index, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var batch []models.Post
|
||||||
|
err = gdb.WithContext(ctx).Model(&models.Post{}).
|
||||||
|
Select("id", "published", "published_at").
|
||||||
|
Order("id").
|
||||||
|
FindInBatches(&batch, reindexBatch, func(tx *gorm.DB, _ int) error {
|
||||||
|
for i := range batch {
|
||||||
|
post := &batch[i]
|
||||||
|
if err := svc.Sync(ctx, gdb, post); err != nil {
|
||||||
|
res.Failed++
|
||||||
|
if out != nil {
|
||||||
|
out.Warning(fmt.Sprintf("post %d: %v", post.ID, err))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if post.ShouldBeSearchable() {
|
||||||
|
res.Synced++
|
||||||
|
} else {
|
||||||
|
res.Removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
}).Error
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
pluginLog.Info("journal: reindexed posts", "index", index, "fresh", fresh,
|
||||||
|
"synced", res.Synced, "removed", res.Removed, "failed", res.Failed)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
505
content_descriptors_test.go
Normal file
505
content_descriptors_test.go
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// contentSeed is the records the descriptor tests read: a published post
|
||||||
|
// with a Polish slug in the news category (Polish slug wiadomosci), a
|
||||||
|
// draft, and a tag.
|
||||||
|
type contentSeed struct {
|
||||||
|
news models.Category
|
||||||
|
hello models.Post
|
||||||
|
draft models.Post
|
||||||
|
tag models.Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedContent(t *testing.T, gdb *gorm.DB) contentSeed {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, row := range []struct {
|
||||||
|
code, name string
|
||||||
|
def bool
|
||||||
|
order int
|
||||||
|
}{{"en", "English", true, 1}, {"pl", "Polski", false, 2}} {
|
||||||
|
if err := gdb.Exec(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order)
|
||||||
|
SELECT ?, ?, ?, TRUE, ? WHERE NOT EXISTS (SELECT 1 FROM golem15_translate_locales WHERE code = ?)`,
|
||||||
|
row.code, row.name, row.def, row.order, row.code).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var s contentSeed
|
||||||
|
s.news = menuCategory(t, gdb, "News", "news", nil, 1, 2)
|
||||||
|
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
|
||||||
|
s.hello = models.Post{Title: "Hello", Slug: "hello", Excerpt: "<p>Short & sweet</p>", Content: "x", ContentHTML: "<p>One two three</p>", Published: true, PublishedAt: &past}
|
||||||
|
if err := gdb.Create(&s.hello).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.draft = menuPost(t, gdb, "Draft", "draft", false, &past)
|
||||||
|
attachCategory(t, gdb, s.hello, s.news)
|
||||||
|
for field, value := range map[string]string{"slug": "witaj", "title": "Witaj"} {
|
||||||
|
if err := translate.SetTranslated(ctx, gdb, &s.hello, field, "pl", value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The category and the tag have content in pl (P33-64): the category
|
||||||
|
// its own translated name, the tag through its published post Hello,
|
||||||
|
// translated into pl.
|
||||||
|
for field, value := range map[string]string{"slug": "wiadomosci", "name": "Wiadomości"} {
|
||||||
|
if err := translate.SetTranslated(ctx, gdb, &s.news, field, "pl", value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.tag = models.Tag{Name: "Mushrooms", Slug: "mushrooms"}
|
||||||
|
if err := gdb.Create(&s.tag).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := gdb.Create(&models.PostTag{PostID: s.hello.ID, TagID: s.tag.ID}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentKeys(types []pact.MenuItemType) string {
|
||||||
|
keys := []string{}
|
||||||
|
for _, t := range types {
|
||||||
|
keys = append(keys, t.Key)
|
||||||
|
}
|
||||||
|
return strings.Join(keys, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func localeURLs(rec *pact.ContentRecord) string {
|
||||||
|
parts := []string{}
|
||||||
|
for _, l := range rec.Locales {
|
||||||
|
parts = append(parts, l.Locale+"="+l.URL)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchPost runs journal-post's Match directly with a pattern.
|
||||||
|
func matchPost(t *testing.T, reg *signpost.Registry, path, pattern string) *pact.ContentMatchResult {
|
||||||
|
t.Helper()
|
||||||
|
typ, ok := reg.ContentType(classes.MenuTypePost)
|
||||||
|
if !ok || typ.Content.Match == nil {
|
||||||
|
t.Fatal("journal-post has no Match")
|
||||||
|
}
|
||||||
|
res, err := typ.Content.Match(context.Background(), pact.ContentMatch{Path: path, Pattern: pattern})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContentDescriptors (D-93, D-11, D-20c): Journal's post and category
|
||||||
|
// descriptors describe, page and match their records through signpost;
|
||||||
|
// strict segments make a wrong category, a stale locale prefix or another
|
||||||
|
// locale's slug inexact, and drafts never match.
|
||||||
|
func TestContentDescriptors(t *testing.T) {
|
||||||
|
requirePostgres(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
||||||
|
s := seedContent(t, gdb)
|
||||||
|
|
||||||
|
if got := contentKeys(reg.ContentTypes()); got != "journal-category journal-post journal-tag journal-author" {
|
||||||
|
t.Fatalf("ContentTypes = %s", got)
|
||||||
|
}
|
||||||
|
for controller, key := range map[string]string{
|
||||||
|
"golem15.journal.posts": classes.MenuTypePost,
|
||||||
|
"golem15.journal.categories": classes.MenuTypeCategory,
|
||||||
|
"golem15.journal.tags": classes.MenuTypeTag,
|
||||||
|
} {
|
||||||
|
if typ, ok := reg.ContentTypeForController(controller); !ok || typ.Key != key {
|
||||||
|
t.Fatalf("ContentTypeForController(%s) = %q %v", controller, typ.Key, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if typ, ok := reg.ContentTypeForModel(models.Category{}.MorphName()); !ok || typ.Key != classes.MenuTypeCategory {
|
||||||
|
t.Fatalf("category model maps to %q %v", typ.Key, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("describe", func(t *testing.T) {
|
||||||
|
rec, err := reg.Describe(ctx, classes.MenuTypePost, id(s.hello.ID), "")
|
||||||
|
if err != nil || rec == nil {
|
||||||
|
t.Fatalf("Describe post = %+v, %v", rec, err)
|
||||||
|
}
|
||||||
|
if got := localeURLs(rec); got != "en=/blog/hello pl=/pl/blog/witaj" {
|
||||||
|
t.Fatalf("post URLs = %s", got)
|
||||||
|
}
|
||||||
|
en := rec.Locales[0]
|
||||||
|
if !rec.Published || en.Title != "Hello" || rec.Locales[1].Title != "Witaj" || en.Description != "Short & sweet" || en.Words == nil || *en.Words != 3 {
|
||||||
|
t.Fatalf("post record = %+v, en %+v", rec, en)
|
||||||
|
}
|
||||||
|
draft, err := reg.Describe(ctx, classes.MenuTypePost, id(s.draft.ID), "")
|
||||||
|
if err != nil || draft == nil || draft.Published {
|
||||||
|
t.Fatalf("Describe draft = %+v, %v", draft, err)
|
||||||
|
}
|
||||||
|
cat, err := reg.Describe(ctx, classes.MenuTypeCategory, id(s.news.ID), "")
|
||||||
|
if err != nil || cat == nil || localeURLs(cat) != "en=/blog/category/news pl=/pl/blog/category/wiadomosci" || !cat.Published {
|
||||||
|
t.Fatalf("Describe category = %+v, %v", cat, err)
|
||||||
|
}
|
||||||
|
if missing, err := reg.Describe(ctx, classes.MenuTypePost, "999999", ""); err != nil || missing != nil {
|
||||||
|
t.Fatalf("Describe of a missing post = %+v, %v", missing, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("records", func(t *testing.T) {
|
||||||
|
page, err := reg.Records(ctx, classes.MenuTypePost, "", 1, "")
|
||||||
|
if err != nil || len(page.Records) != 1 || page.Records[0].ID != id(s.hello.ID) || page.Next != id(s.hello.ID) {
|
||||||
|
t.Fatalf("first page = %+v, %v", page, err)
|
||||||
|
}
|
||||||
|
page, err = reg.Records(ctx, classes.MenuTypePost, page.Next, 1, "")
|
||||||
|
if err != nil || len(page.Records) != 1 || page.Records[0].ID != id(s.draft.ID) || page.Next != "" {
|
||||||
|
t.Fatalf("last page = %+v, %v", page, err)
|
||||||
|
}
|
||||||
|
// The migrations seed a default category before news.
|
||||||
|
cats, err := reg.Records(ctx, classes.MenuTypeCategory, "", 0, "")
|
||||||
|
if err != nil || len(cats.Records) == 0 || cats.Records[len(cats.Records)-1].ID != id(s.news.ID) || cats.Next != "" {
|
||||||
|
t.Fatalf("category page = %+v, %v", cats, err)
|
||||||
|
}
|
||||||
|
tags, err := reg.Records(ctx, classes.MenuTypeTag, "", 10, "")
|
||||||
|
if err != nil || len(tags.Records) != 1 || tags.Records[0].ID != id(s.tag.ID) {
|
||||||
|
t.Fatalf("tag page = %+v, %v", tags, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("match", func(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
path, key, locale string
|
||||||
|
exact bool
|
||||||
|
}{
|
||||||
|
{"/blog/hello", classes.MenuTypePost, "en", true},
|
||||||
|
{"/pl/blog/witaj", classes.MenuTypePost, "pl", true},
|
||||||
|
{"/pl/blog/hello", classes.MenuTypePost, "pl", false},
|
||||||
|
{"/blog/witaj", classes.MenuTypePost, "en", false},
|
||||||
|
{"/en/blog/hello", classes.MenuTypePost, "en", false},
|
||||||
|
{"/blog/category/news", classes.MenuTypeCategory, "en", true},
|
||||||
|
{"/pl/blog/category/wiadomosci", classes.MenuTypeCategory, "pl", true},
|
||||||
|
{"/blog/tag/mushrooms", classes.MenuTypeTag, "en", true},
|
||||||
|
} {
|
||||||
|
key, res, err := reg.MatchPath(ctx, tc.path, "")
|
||||||
|
if err != nil || key != tc.key || res == nil || res.Locale != tc.locale || res.Exact != tc.exact {
|
||||||
|
t.Fatalf("MatchPath(%s) = %q %+v %v, want %s %s exact=%v", tc.path, key, res, err, tc.key, tc.locale, tc.exact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/blog/draft", "/blog/nothing", "/blog/hello/extra", "/elsewhere"} {
|
||||||
|
if key, res, err := reg.MatchPath(ctx, path, ""); err != nil || res != nil {
|
||||||
|
t.Fatalf("MatchPath(%s) = %q %+v %v, want no match", path, key, res, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strict segments under a host pattern with the category.
|
||||||
|
if res := matchPost(t, reg, "/news/hello", "/:category/:slug"); res == nil || !res.Exact || res.ID != id(s.hello.ID) {
|
||||||
|
t.Fatalf("right category = %+v", res)
|
||||||
|
}
|
||||||
|
if res := matchPost(t, reg, "/sport/hello", "/:category/:slug"); res == nil || res.Exact || res.ID != id(s.hello.ID) {
|
||||||
|
t.Fatalf("wrong category = %+v, want the post, inexact", res)
|
||||||
|
}
|
||||||
|
if res := matchPost(t, reg, "/pl/wiadomosci/witaj", "/:category/:slug"); res == nil || !res.Exact || res.Locale != "pl" {
|
||||||
|
t.Fatalf("Polish category path = %+v", res)
|
||||||
|
}
|
||||||
|
if res := matchPost(t, reg, "/blog/"+id(s.hello.ID)+"-x", "/blog/:id-x"); res != nil {
|
||||||
|
t.Fatalf("a placeholder inside a segment matched: %+v", res)
|
||||||
|
}
|
||||||
|
if res := matchPost(t, reg, "/p/"+id(s.hello.ID), "/p/:id"); res == nil || !res.Exact {
|
||||||
|
t.Fatalf("id pattern = %+v", res)
|
||||||
|
}
|
||||||
|
if res := matchPost(t, reg, "/p/"+id(s.draft.ID), "/p/:id"); res != nil {
|
||||||
|
t.Fatalf("draft by id matched: %+v", res)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalTagType (D-102, P-11): journal-tag lists tags as references,
|
||||||
|
// resolves to /blog/tag/<slug>, and is described and matched; with
|
||||||
|
// golem15.journal.tag_pages false its URLs are empty and its paths never
|
||||||
|
// match.
|
||||||
|
func TestJournalTagType(t *testing.T) {
|
||||||
|
requirePostgres(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
||||||
|
s := seedContent(t, gdb)
|
||||||
|
|
||||||
|
typ, ok := reg.Type(classes.MenuTypeTag)
|
||||||
|
if !ok || typ.DefaultPattern != classes.DefaultTagPattern || typ.Label != "golem15.journal::lang.menuitem.journal_tag" || typ.Content == nil {
|
||||||
|
t.Fatalf("journal-tag = %+v %v", typ, ok)
|
||||||
|
}
|
||||||
|
refs, err := signpost.ReferenceOptions(ctx, reg, classes.MenuTypeTag)
|
||||||
|
if err != nil || !refs.Applies || len(refs.Options) != 1 || refs.Options[0].Value != id(s.tag.ID) || refs.Options[0].Label != "Mushrooms" {
|
||||||
|
t.Fatalf("tag references = %+v, %v", refs, err)
|
||||||
|
}
|
||||||
|
item := resolve(t, reg, classes.MenuTypeTag, id(s.tag.ID), "", false)
|
||||||
|
if item.URL != "/blog/tag/mushrooms" || item.Title != "Mushrooms" {
|
||||||
|
t.Fatalf("resolved tag = %+v", item)
|
||||||
|
}
|
||||||
|
rec, err := reg.Describe(ctx, classes.MenuTypeTag, id(s.tag.ID), "")
|
||||||
|
if err != nil || rec == nil || localeURLs(rec) != "en=/blog/tag/mushrooms pl=/pl/blog/tag/mushrooms" {
|
||||||
|
t.Fatalf("Describe tag = %+v, %v", rec, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, offDB, off := bootMenuItemTypes(t, map[string]string{"golem15.yaml": "journal:\n tag_pages: false\n"})
|
||||||
|
offSeed := seedContent(t, offDB)
|
||||||
|
rec, err = off.Describe(ctx, classes.MenuTypeTag, id(offSeed.tag.ID), "")
|
||||||
|
if err != nil || rec == nil || localeURLs(rec) != "en= pl=" {
|
||||||
|
t.Fatalf("Describe tag without tag pages = %+v, %v", rec, err)
|
||||||
|
}
|
||||||
|
if key, res, err := off.MatchPath(ctx, "/blog/tag/mushrooms", ""); err != nil || res != nil {
|
||||||
|
t.Fatalf("tag path matched without tag pages: %q %+v %v", key, res, err)
|
||||||
|
}
|
||||||
|
if got, err := off.Resolve(ctx, classes.MenuTypeTag, id(offSeed.tag.ID), "", false, ""); err != nil || got != nil {
|
||||||
|
t.Fatalf("tag resolved without tag pages: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContentDescriptorsMatrix (D-93, D-20c, D-58): the descriptor matrix
|
||||||
|
// for posts, categories and tags: Describe per locale with and without an
|
||||||
|
// unprefixed locale, Match exact and inexact per locale, Records paging,
|
||||||
|
// host patterns, and unknown or malformed ids.
|
||||||
|
func TestContentDescriptorsMatrix(t *testing.T) {
|
||||||
|
requirePostgres(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("describe and match with Polish unprefixed", func(t *testing.T) {
|
||||||
|
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
||||||
|
s := seedContent(t, gdb)
|
||||||
|
for _, tc := range []struct{ key, id, want string }{
|
||||||
|
{classes.MenuTypePost, id(s.hello.ID), "en=/en/blog/hello pl=/blog/witaj"},
|
||||||
|
{classes.MenuTypeCategory, id(s.news.ID), "en=/en/blog/category/news pl=/blog/category/wiadomosci"},
|
||||||
|
{classes.MenuTypeTag, id(s.tag.ID), "en=/en/blog/tag/mushrooms pl=/blog/tag/mushrooms"},
|
||||||
|
} {
|
||||||
|
rec, err := reg.Describe(ctx, tc.key, tc.id, "pl")
|
||||||
|
if err != nil || rec == nil || localeURLs(rec) != tc.want {
|
||||||
|
t.Fatalf("Describe(%s) = %+v, %v; want %s", tc.key, rec, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
path, key, locale string
|
||||||
|
exact bool
|
||||||
|
}{
|
||||||
|
{"/blog/witaj", classes.MenuTypePost, "pl", true},
|
||||||
|
{"/en/blog/hello", classes.MenuTypePost, "en", true},
|
||||||
|
{"/pl/blog/witaj", classes.MenuTypePost, "pl", false},
|
||||||
|
{"/blog/hello", classes.MenuTypePost, "pl", false},
|
||||||
|
{"/blog/category/wiadomosci", classes.MenuTypeCategory, "pl", true},
|
||||||
|
{"/en/blog/category/news", classes.MenuTypeCategory, "en", true},
|
||||||
|
{"/blog/category/news", classes.MenuTypeCategory, "pl", false},
|
||||||
|
{"/blog/tag/mushrooms", classes.MenuTypeTag, "pl", true},
|
||||||
|
{"/en/blog/tag/mushrooms", classes.MenuTypeTag, "en", true},
|
||||||
|
} {
|
||||||
|
key, res, err := reg.MatchPath(ctx, tc.path, "pl")
|
||||||
|
if err != nil || key != tc.key || res == nil || res.Locale != tc.locale || res.Exact != tc.exact {
|
||||||
|
t.Fatalf("MatchPath(%s, pl) = %q %+v %v, want %s %s exact=%v", tc.path, key, res, err, tc.key, tc.locale, tc.exact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("match per locale without an unprefixed locale", func(t *testing.T) {
|
||||||
|
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
||||||
|
s := seedContent(t, gdb)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
path, key, locale string
|
||||||
|
exact bool
|
||||||
|
}{
|
||||||
|
{"/blog/category/wiadomosci", classes.MenuTypeCategory, "en", false},
|
||||||
|
{"/pl/blog/category/news", classes.MenuTypeCategory, "pl", false},
|
||||||
|
{"/en/blog/category/news", classes.MenuTypeCategory, "en", false},
|
||||||
|
{"/pl/blog/tag/mushrooms", classes.MenuTypeTag, "pl", true},
|
||||||
|
} {
|
||||||
|
key, res, err := reg.MatchPath(ctx, tc.path, "")
|
||||||
|
if err != nil || key != tc.key || res == nil || res.Locale != tc.locale || res.Exact != tc.exact {
|
||||||
|
t.Fatalf("MatchPath(%s) = %q %+v %v, want %s %s exact=%v", tc.path, key, res, err, tc.key, tc.locale, tc.exact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/pl/blog/draft", "/blog/tag/missing", "/blog/category/missing", "/blog/category/news/extra", "/blog/tag/"} {
|
||||||
|
if key, res, err := reg.MatchPath(ctx, path, ""); err != nil || res != nil {
|
||||||
|
t.Fatalf("MatchPath(%s) = %q %+v %v, want no match", path, key, res, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tc := range []struct{ key, id string }{
|
||||||
|
{classes.MenuTypePost, "999999"}, {classes.MenuTypePost, "abc"},
|
||||||
|
{classes.MenuTypeCategory, "999999"}, {classes.MenuTypeTag, "999999"}, {classes.MenuTypeTag, ""},
|
||||||
|
} {
|
||||||
|
if rec, err := reg.Describe(ctx, tc.key, tc.id, ""); err != nil || rec != nil {
|
||||||
|
t.Fatalf("Describe(%s, %q) = %+v, %v; want nothing", tc.key, tc.id, rec, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draft, err := reg.Describe(ctx, classes.MenuTypePost, id(s.draft.ID), "")
|
||||||
|
// The draft has no pl translation (P33-64): no pl address.
|
||||||
|
if err != nil || draft == nil || draft.Published || localeURLs(draft) != "en=/blog/draft pl=" {
|
||||||
|
t.Fatalf("Describe draft = %+v, %v", draft, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("records paging", func(t *testing.T) {
|
||||||
|
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
||||||
|
s := seedContent(t, gdb)
|
||||||
|
second := models.Tag{Name: "Forest", Slug: "forest"}
|
||||||
|
if err := gdb.Create(&second).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
all, err := reg.Records(ctx, classes.MenuTypePost, "", 0, "")
|
||||||
|
if err != nil || len(all.Records) != 2 || all.Next != "" {
|
||||||
|
t.Fatalf("limit 0 = %+v, %v", all, err)
|
||||||
|
}
|
||||||
|
if again, err := reg.Records(ctx, classes.MenuTypePost, "not-an-id", 0, ""); err != nil || len(again.Records) != 2 {
|
||||||
|
t.Fatalf("a malformed cursor = %+v, %v; want the first page", again, err)
|
||||||
|
}
|
||||||
|
if end, err := reg.Records(ctx, classes.MenuTypePost, id(s.draft.ID), 10, ""); err != nil || len(end.Records) != 0 || end.Next != "" {
|
||||||
|
t.Fatalf("after the last post = %+v, %v", end, err)
|
||||||
|
}
|
||||||
|
first, err := reg.Records(ctx, classes.MenuTypeTag, "", 1, "")
|
||||||
|
if err != nil || len(first.Records) != 1 || first.Records[0].ID != id(s.tag.ID) || first.Next != id(s.tag.ID) {
|
||||||
|
t.Fatalf("first tag page = %+v, %v", first, err)
|
||||||
|
}
|
||||||
|
next, err := reg.Records(ctx, classes.MenuTypeTag, first.Next, 1, "")
|
||||||
|
if err != nil || len(next.Records) != 1 || next.Records[0].ID != id(second.ID) || next.Next != "" {
|
||||||
|
t.Fatalf("second tag page = %+v, %v", next, err)
|
||||||
|
}
|
||||||
|
// Forest has no published post with content in pl (P33-64).
|
||||||
|
if got := localeURLs(&next.Records[0]); got != "en=/blog/tag/forest pl=" {
|
||||||
|
t.Fatalf("paged tag URLs = %s", got)
|
||||||
|
}
|
||||||
|
if !all.Records[0].Published || all.Records[1].Published {
|
||||||
|
t.Fatalf("published flags = %v %v", all.Records[0].Published, all.Records[1].Published)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("host patterns", func(t *testing.T) {
|
||||||
|
_, gdb, reg := bootMenuItemTypes(t, map[string]string{"menu_items.yaml": "patterns:\n journal-category: /c/:slug\n journal-tag: /t/:slug\n journal-post: /:category/:slug\n"})
|
||||||
|
s := seedContent(t, gdb)
|
||||||
|
for _, tc := range []struct{ key, id, want string }{
|
||||||
|
{classes.MenuTypeCategory, id(s.news.ID), "en=/c/news pl=/pl/c/wiadomosci"},
|
||||||
|
{classes.MenuTypeTag, id(s.tag.ID), "en=/t/mushrooms pl=/pl/t/mushrooms"},
|
||||||
|
{classes.MenuTypePost, id(s.hello.ID), "en=/news/hello pl=/pl/wiadomosci/witaj"},
|
||||||
|
} {
|
||||||
|
rec, err := reg.Describe(ctx, tc.key, tc.id, "")
|
||||||
|
if err != nil || rec == nil || localeURLs(rec) != tc.want {
|
||||||
|
t.Fatalf("Describe(%s) = %+v, %v; want %s", tc.key, rec, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
path, key string
|
||||||
|
exact bool
|
||||||
|
}{
|
||||||
|
{"/c/news", classes.MenuTypeCategory, true},
|
||||||
|
{"/t/mushrooms", classes.MenuTypeTag, true},
|
||||||
|
{"/news/hello", classes.MenuTypePost, true},
|
||||||
|
{"/pl/wiadomosci/witaj", classes.MenuTypePost, true},
|
||||||
|
} {
|
||||||
|
key, res, err := reg.MatchPath(ctx, tc.path, "")
|
||||||
|
if err != nil || key != tc.key || res == nil || res.Exact != tc.exact {
|
||||||
|
t.Fatalf("MatchPath(%s) = %q %+v %v", tc.path, key, res, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key, res, err := reg.MatchPath(ctx, "/blog/category/news", ""); err != nil || res != nil {
|
||||||
|
t.Fatalf("the default pattern still matched: %q %+v %v", key, res, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// localeTitles lists a record's locales with their URL and title.
|
||||||
|
func localeTitles(rec *pact.ContentRecord) string {
|
||||||
|
parts := []string{}
|
||||||
|
for _, l := range rec.Locales {
|
||||||
|
parts = append(parts, l.Locale+"="+l.URL+"|"+l.Title)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContentDescriptorsTranslatedLocales pins G3 (D-55, D-57, P33-64): with
|
||||||
|
// pl as translate's default and en and de enabled, the descriptors give a
|
||||||
|
// locale a URL only when the record has content in it: a post and a
|
||||||
|
// category by their own translation, a tag and an author through one of
|
||||||
|
// their published posts, the default locale always. Every enabled locale
|
||||||
|
// keeps its entry (URL "" and the default locale's values for one without
|
||||||
|
// content), and the menu item types' Resolve still lists all three.
|
||||||
|
func TestContentDescriptorsTranslatedLocales(t *testing.T) {
|
||||||
|
requirePostgres(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
||||||
|
if err := gdb.Exec(`DELETE FROM golem15_translate_locales`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, row := range []struct {
|
||||||
|
code string
|
||||||
|
def bool
|
||||||
|
order int
|
||||||
|
}{{"pl", true, 1}, {"en", false, 2}, {"de", false, 3}} {
|
||||||
|
if err := gdb.Exec(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order) VALUES (?, ?, ?, TRUE, ?)`,
|
||||||
|
row.code, row.code, row.def, row.order).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
|
||||||
|
ada := seedAuthor(t, gdb, "ada", "Ada", "Lovelace", "ada")
|
||||||
|
bob := seedAuthor(t, gdb, "bob", "Bob", "Builder", "bob")
|
||||||
|
translated := models.Post{Title: "Grzyby", Slug: "grzyby", Content: "Treść", Published: true, PublishedAt: &past, UserID: &ada}
|
||||||
|
plain := models.Post{Title: "Tylko po polsku", Slug: "tylko", Content: "Treść", Published: true, PublishedAt: &past, UserID: &bob}
|
||||||
|
for _, p := range []*models.Post{&translated, &plain} {
|
||||||
|
if err := gdb.Create(p).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for field, value := range map[string]string{"title": "Mushrooms", "content": "Body", "slug": "mushrooms"} {
|
||||||
|
if err := translate.SetTranslated(ctx, gdb, &translated, field, "en", value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A slug alone is no content.
|
||||||
|
if err := translate.SetTranslated(ctx, gdb, &plain, "slug", "de", "nur"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cat := menuCategory(t, gdb, "Leśne", "lesne", nil, 1, 2)
|
||||||
|
bare := menuCategory(t, gdb, "Puste", "puste", nil, 3, 4)
|
||||||
|
if err := translate.SetTranslated(ctx, gdb, &cat, "name", "en", "Forest"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
attachCategory(t, gdb, translated, cat)
|
||||||
|
tag := models.Tag{Name: "Las", Slug: "las"}
|
||||||
|
if err := gdb.Create(&tag).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := gdb.Create(&models.PostTag{PostID: translated.ID, TagID: tag.ID}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
key, id, want string
|
||||||
|
}{
|
||||||
|
{classes.MenuTypePost, id(translated.ID), "pl=/blog/grzyby|Grzyby en=/en/blog/mushrooms|Mushrooms de=|Grzyby"},
|
||||||
|
{classes.MenuTypePost, id(plain.ID), "pl=/blog/tylko|Tylko po polsku en=|Tylko po polsku de=|Tylko po polsku"},
|
||||||
|
{classes.MenuTypeCategory, id(cat.ID), "pl=/blog/category/lesne|Leśne en=/en/blog/category/lesne|Forest de=|Leśne"},
|
||||||
|
{classes.MenuTypeCategory, id(bare.ID), "pl=/blog/category/puste|Puste en=|Puste de=|Puste"},
|
||||||
|
{classes.MenuTypeTag, id(tag.ID), "pl=/blog/tag/las|Las en=/en/blog/tag/las|Las de=|Las"},
|
||||||
|
{classes.MenuTypeAuthor, id(ada), "pl=/blog/author/ada|Ada Lovelace en=/en/blog/author/ada|Ada Lovelace de=|Ada Lovelace"},
|
||||||
|
{classes.MenuTypeAuthor, id(bob), "pl=/blog/author/bob|Bob Builder en=|Bob Builder de=|Bob Builder"},
|
||||||
|
} {
|
||||||
|
rec := describe(t, reg, tc.key, tc.id)
|
||||||
|
if got := localeTitles(rec); got != tc.want {
|
||||||
|
t.Errorf("Describe(%s, %s) = %s\nwant %s", tc.key, tc.id, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The untranslated locale keeps the default locale's word count.
|
||||||
|
if rec := describe(t, reg, classes.MenuTypePost, id(translated.ID)); rec.Locales[2].Words == nil || *rec.Locales[2].Words != *rec.Locales[0].Words {
|
||||||
|
t.Errorf("de words = %v, want the pl count", rec.Locales[2].Words)
|
||||||
|
}
|
||||||
|
// The menu item types' Resolve is unchanged: every enabled locale.
|
||||||
|
for key, ref := range map[string]string{classes.MenuTypePost: id(translated.ID), classes.MenuTypeCategory: id(cat.ID)} {
|
||||||
|
item, err := reg.Resolve(ctx, key, ref, "", false, "")
|
||||||
|
if err != nil || item == nil || len(item.Alternates) != 3 {
|
||||||
|
t.Fatalf("Resolve(%s) = %+v, %v", key, item, err)
|
||||||
|
}
|
||||||
|
for _, a := range item.Alternates {
|
||||||
|
if a.URL == "" {
|
||||||
|
t.Errorf("Resolve(%s) %s has no URL", key, a.Locale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,13 +27,21 @@ func init() {
|
|||||||
var _ pact.FilterOptions = postsAdminController{}
|
var _ pact.FilterOptions = postsAdminController{}
|
||||||
var _ pact.FormBeforeCreate = postsAdminController{}
|
var _ pact.FormBeforeCreate = postsAdminController{}
|
||||||
var _ pact.FormBeforeUpdate = postsAdminController{}
|
var _ pact.FormBeforeUpdate = postsAdminController{}
|
||||||
|
var _ pact.FormAfterCreate = postsAdminController{}
|
||||||
|
var _ pact.FormAfterUpdate = postsAdminController{}
|
||||||
var _ pact.FormBeforeDelete = postsAdminController{}
|
var _ pact.FormBeforeDelete = postsAdminController{}
|
||||||
|
var _ pact.FormAfterDelete = postsAdminController{}
|
||||||
var _ pact.HasAdminActions = postsAdminController{}
|
var _ pact.HasAdminActions = postsAdminController{}
|
||||||
var _ cabana.FieldRelationProvider = postsAdminController{}
|
var _ cabana.FieldRelationProvider = postsAdminController{}
|
||||||
|
|
||||||
var _ pact.AdminController = categoriesAdminController{}
|
var _ pact.AdminController = categoriesAdminController{}
|
||||||
var _ pact.AdminPermissioned = categoriesAdminController{}
|
var _ pact.AdminPermissioned = categoriesAdminController{}
|
||||||
var _ pact.AdminRecordSource = categoriesAdminController{}
|
var _ pact.AdminRecordSource = categoriesAdminController{}
|
||||||
|
var _ pact.FormBeforeCreate = categoriesAdminController{}
|
||||||
|
var _ pact.FormBeforeUpdate = categoriesAdminController{}
|
||||||
|
var _ pact.FormAfterCreate = categoriesAdminController{}
|
||||||
|
var _ pact.FormAfterUpdate = categoriesAdminController{}
|
||||||
|
var _ pact.FormAfterDelete = categoriesAdminController{}
|
||||||
var _ cabana.FieldRelationProvider = categoriesAdminController{}
|
var _ cabana.FieldRelationProvider = categoriesAdminController{}
|
||||||
|
|
||||||
var _ pact.AdminController = tagsAdminController{}
|
var _ pact.AdminController = tagsAdminController{}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -28,7 +27,7 @@ const (
|
|||||||
permAccessOtherPosts = "golem15.journal.access_other_posts"
|
permAccessOtherPosts = "golem15.journal.access_other_posts"
|
||||||
permAccessPublish = "golem15.journal.access_publish"
|
permAccessPublish = "golem15.journal.access_publish"
|
||||||
|
|
||||||
backendJWTBlacklistTable = "backend_jwt_blacklist"
|
journalCover = "golem15.journal.*"
|
||||||
)
|
)
|
||||||
|
|
||||||
func writeAPIError(w http.ResponseWriter, status int, message string) {
|
func writeAPIError(w http.ResponseWriter, status int, message string) {
|
||||||
@@ -55,55 +54,43 @@ func hasBearer(r *http.Request) bool {
|
|||||||
return ok && strings.TrimSpace(token) != ""
|
return ok && strings.TrimSpace(token) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBackendGuard(app *backpack.App, write func(http.ResponseWriter, error)) bouncer.Guard {
|
// resolveError maps cabana.ResolveBackend errors to the PHP Journal API bodies.
|
||||||
secret := ""
|
func resolveError(w http.ResponseWriter, err error) {
|
||||||
if app != nil && app.Config != nil {
|
if errors.Is(err, cabana.ErrTokenScope) {
|
||||||
secret = strings.TrimSpace(app.Config.String("admin.jwt.secret"))
|
writeAPIError(w, http.StatusForbidden, errInsufficientPerms)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
var gdb *gorm.DB
|
writeAuthRequired(w, err)
|
||||||
var sqlDB *sql.DB
|
|
||||||
var reg *cabana.Registry
|
|
||||||
if app != nil {
|
|
||||||
gdb, _ = app.Lookup[*gorm.DB]()
|
|
||||||
sqlDB, _ = app.Lookup[*sql.DB]()
|
|
||||||
reg, _ = app.Lookup[*cabana.Registry]()
|
|
||||||
if sqlDB == nil && gdb != nil {
|
|
||||||
if db, err := gdb.DB(); err == nil {
|
|
||||||
sqlDB = db
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var bl bouncer.BlacklistStore
|
|
||||||
if sqlDB != nil {
|
|
||||||
bl = bouncer.NewPostgresBlacklist(sqlDB, backendJWTBlacklistTable)
|
|
||||||
}
|
|
||||||
return bouncer.NewBackendJWTGuard(secret, cabana.BackendUsers{DB: gdb, Registry: reg}, bl, write)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// optionalBackendPrincipal verifies a backend JWT only when Authorization
|
// optionalBackendPrincipal verifies a backend JWT or personal API token when
|
||||||
// Bearer is present. Failure is anonymous: public GET must not 401.
|
// one is presented, and honours the in-process dispatch marker the MCP
|
||||||
|
// endpoint sets (no Authorization header then, D-17). Failure is anonymous:
|
||||||
|
// public GET must not 401 (ResolveBackend answers ErrUnauthenticated for a
|
||||||
|
// request without a bearer). Uses cabana.ResolveBackend with cover
|
||||||
|
// golem15.journal.*.
|
||||||
func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request {
|
func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request {
|
||||||
if r == nil || !hasBearer(r) {
|
if r == nil {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
pr, err := newBackendGuard(app, nil).Authenticate(r)
|
out, _, err := cabana.ResolveBackend(app, r, journalCover)
|
||||||
if err != nil || pr == nil {
|
if err != nil || out == nil {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
return r.WithContext(bouncer.WithUser(r.Context(), pr))
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireBackendPrincipal authenticates a backend-audience JWT and writes the
|
// requireBackendPrincipal authenticates a backend JWT or personal API token
|
||||||
// PHP {error:"Authentication required"} body when the token is missing or
|
// and writes the PHP {error:"Authentication required"} or
|
||||||
// invalid (D-15). It does not use cabana's admin unauthenticated envelope.
|
// {error:"Insufficient permissions"} body (D-15). It does not use cabana's
|
||||||
|
// admin unauthenticated envelope.
|
||||||
func requireBackendPrincipal(app *backpack.App, w http.ResponseWriter, r *http.Request) (*http.Request, *bouncer.Principal, bool) {
|
func requireBackendPrincipal(app *backpack.App, w http.ResponseWriter, r *http.Request) (*http.Request, *bouncer.Principal, bool) {
|
||||||
pr, err := newBackendGuard(app, writeAuthRequired).Authenticate(r)
|
out, pr, err := cabana.ResolveBackend(app, r, journalCover)
|
||||||
if err != nil || pr == nil {
|
if err != nil || pr == nil {
|
||||||
writeAuthRequired(w, err)
|
resolveError(w, err)
|
||||||
return r, nil, false
|
return r, nil, false
|
||||||
}
|
}
|
||||||
r = r.WithContext(bouncer.WithUser(r.Context(), pr))
|
return out, pr, true
|
||||||
return r, pr, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isEditor(pr *bouncer.Principal) bool {
|
func isEditor(pr *bouncer.Principal) bool {
|
||||||
|
|||||||
70
controllers/api/auth_test.go
Normal file
70
controllers/api/auth_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveErrorMapping(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ErrTokenScope",
|
||||||
|
err: cabana.ErrTokenScope,
|
||||||
|
status: http.StatusForbidden,
|
||||||
|
body: `{"error":"Insufficient permissions"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrapped ErrTokenScope",
|
||||||
|
err: fmt.Errorf("cover: %w", cabana.ErrTokenScope),
|
||||||
|
status: http.StatusForbidden,
|
||||||
|
body: `{"error":"Insufficient permissions"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ErrUnauthenticated",
|
||||||
|
err: cabana.ErrUnauthenticated,
|
||||||
|
status: http.StatusUnauthorized,
|
||||||
|
body: `{"error":"Authentication required"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generic error",
|
||||||
|
err: errors.New("boom"),
|
||||||
|
status: http.StatusUnauthorized,
|
||||||
|
body: `{"error":"Authentication required"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
resolveError(rec, tc.err)
|
||||||
|
if rec.Code != tc.status {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, tc.status, rec.Body.String())
|
||||||
|
}
|
||||||
|
got := rec.Body.Bytes()
|
||||||
|
var wantObj, gotObj map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(tc.body), &wantObj); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(got, &gotObj); err != nil {
|
||||||
|
t.Fatalf("body json: %s err=%v", got, err)
|
||||||
|
}
|
||||||
|
wantRaw, _ := json.Marshal(wantObj)
|
||||||
|
gotRaw, _ := json.Marshal(gotObj)
|
||||||
|
if string(wantRaw) != string(gotRaw) {
|
||||||
|
t.Fatalf("body = %s, want %s", got, tc.body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
210
controllers/api/authors.go
Normal file
210
controllers/api/authors.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/wire"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
errAuthorNotFound = "Author not found"
|
||||||
|
maxAuthorParamBytes = 255
|
||||||
|
|
||||||
|
// authorColumns is the only projection read from backend_users. Contact,
|
||||||
|
// credential, activation, persist, reset, permission and role columns are
|
||||||
|
// never selected, so they cannot reach a public payload. metadata is
|
||||||
|
// read for its subtitle and description keys only.
|
||||||
|
authorColumns = "id, COALESCE(first_name, '') AS first_name, COALESCE(last_name, '') AS last_name, " +
|
||||||
|
"login, COALESCE(golem15_bloghub_author_slug, '') AS author_slug, metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// authorRow is the public projection of one backend_users row.
|
||||||
|
type authorRow struct {
|
||||||
|
ID uint `gorm:"column:id"`
|
||||||
|
FirstName string `gorm:"column:first_name"`
|
||||||
|
LastName string `gorm:"column:last_name"`
|
||||||
|
Login string `gorm:"column:login"`
|
||||||
|
AuthorSlug string `gorm:"column:author_slug"`
|
||||||
|
Metadata *string `gorm:"column:metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// validAuthorParam reports whether an already trimmed {author} path value is
|
||||||
|
// worth a lookup: non-empty, at most 255 bytes, valid UTF-8, no control runes.
|
||||||
|
func validAuthorParam(s string) bool {
|
||||||
|
if s == "" || len(s) > maxAuthorParamBytes || !utf8.ValidString(s) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.IndexFunc(s, unicode.IsControl) < 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAuthor finds a live backend user by author slug, then by login. A
|
||||||
|
// login counts only when that user has at least one published post. A miss
|
||||||
|
// is (nil, nil); only database failures return an error.
|
||||||
|
func resolveAuthor(db *gorm.DB, param string) (*authorRow, error) {
|
||||||
|
row, err := findAuthorRow(db, "golem15_bloghub_author_slug = ?", param)
|
||||||
|
if err != nil || row != nil {
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
row, err = findAuthorRow(db, "login = ?", param)
|
||||||
|
if err != nil || row == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
if err := applyPublished(db.Model(&models.Post{})).Where("user_id = ?", row.ID).Count(&n).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findAuthorRow(db *gorm.DB, cond, param string) (*authorRow, error) {
|
||||||
|
var rows []authorRow
|
||||||
|
if err := db.Table("backend_users").Select(authorColumns).
|
||||||
|
Where("deleted_at IS NULL").Where(cond, param).
|
||||||
|
Order("id ASC").Limit(1).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &rows[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorProfile is the public author object: id, slug (the canonical author
|
||||||
|
// slug, else the login), first_name, last_name, subtitle and description
|
||||||
|
// (backend_users.metadata, "" when unset) and avatar (the public URL of the
|
||||||
|
// admin avatar, or null).
|
||||||
|
func authorProfile(ctx context.Context, db *gorm.DB, row *authorRow) map[string]any {
|
||||||
|
slug := row.AuthorSlug
|
||||||
|
if slug == "" {
|
||||||
|
slug = row.Login
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"id": row.ID,
|
||||||
|
"slug": slug,
|
||||||
|
"first_name": row.FirstName,
|
||||||
|
"last_name": row.LastName,
|
||||||
|
"subtitle": cabana.BackendUser{Metadata: row.Metadata}.Subtitle(),
|
||||||
|
"description": metadataText(row.Metadata, "description"),
|
||||||
|
"avatar": authorAvatar(ctx, db, row.ID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// metadataText is the string value of key in a backend user's metadata JSON,
|
||||||
|
// or "" when the metadata, the key or a string value is missing.
|
||||||
|
func metadataText(raw *string, key string) string {
|
||||||
|
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var values map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(*raw), &values); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(values[key], &s); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorAvatar is the public URL of the author's admin avatar, or nil when
|
||||||
|
// there is none, it is not public or the lookup failed (logged).
|
||||||
|
func authorAvatar(ctx context.Context, db *gorm.DB, userID uint) any {
|
||||||
|
if db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f, err := cabana.BackendUserAvatar(ctx, db, userID)
|
||||||
|
if err != nil {
|
||||||
|
pluginLog.WarnContext(ctx, "journal api: author avatar lookup failed", "author", userID, "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if f == nil || !f.Public() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return f.URL()
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorOrRespond resolves the {author} path value for both author routes. It
|
||||||
|
// writes the JSON 404 or 500 itself and returns ok=false when the handler
|
||||||
|
// must stop.
|
||||||
|
func authorOrRespond(app *backpack.App, w http.ResponseWriter, r *http.Request) (*gorm.DB, *authorRow, bool) {
|
||||||
|
db, err := gdb(app)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
db = db.WithContext(r.Context())
|
||||||
|
param := strings.TrimSpace(r.PathValue("author"))
|
||||||
|
if !validAuthorParam(param) {
|
||||||
|
writeAPIError(w, http.StatusNotFound, errAuthorNotFound)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
row, err := resolveAuthor(db, param)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
if row == nil {
|
||||||
|
writeAPIError(w, http.StatusNotFound, errAuthorNotFound)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
return db, row, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorShow is GET /_journal/api/v1/authors/{author}. {author} is an author
|
||||||
|
// slug, or a login whose user has a published post. Bearer tokens are ignored.
|
||||||
|
func AuthorShow(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
db, row, ok := authorOrRespond(app, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": authorProfile(r.Context(), db, row)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorPosts is GET /_journal/api/v1/authors/{author}/posts: that author's
|
||||||
|
// published posts, paginated like GET /posts. Bearer tokens are ignored.
|
||||||
|
func AuthorPosts(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
db, author, ok := authorOrRespond(app, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
perPage := clampPerPage(r.URL.Query().Get("per_page"))
|
||||||
|
page := max(1, atoiDefault(r.URL.Query().Get("page"), 1))
|
||||||
|
|
||||||
|
q := applyPublished(db.Model(&models.Post{}).
|
||||||
|
Preload("User").Preload("Categories").Preload("Tags")).
|
||||||
|
Where("user_id = ?", author.ID)
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
order := listOrder(strings.TrimSpace(r.URL.Query().Get("sort")), false)
|
||||||
|
var posts []models.Post
|
||||||
|
if err := q.Order(order).Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows := make([]map[string]any, 0, len(posts))
|
||||||
|
for i := range posts {
|
||||||
|
rows = append(rows, serializePost(&posts[i], db))
|
||||||
|
}
|
||||||
|
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))
|
||||||
|
}
|
||||||
|
}
|
||||||
34
controllers/api/authors_test.go
Normal file
34
controllers/api/authors_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidAuthorParam(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"slug", "jane-doe", true},
|
||||||
|
{"dotted login", "jan.kowalski", true},
|
||||||
|
{"at sign", "a@b", true},
|
||||||
|
{"max bytes", strings.Repeat("a", 255), true},
|
||||||
|
{"multibyte", "zażółć", true},
|
||||||
|
{"empty", "", false},
|
||||||
|
{"whitespace only", strings.TrimSpace(" \t "), false},
|
||||||
|
{"over max bytes", strings.Repeat("a", 256), false},
|
||||||
|
{"nul byte", "a\x00b", false},
|
||||||
|
{"newline", "a\nb", false},
|
||||||
|
{"tab", "a\tb", false},
|
||||||
|
{"invalid utf8", "\xff", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := validAuthorParam(tc.in); got != tc.want {
|
||||||
|
t.Fatalf("validAuthorParam(%q) = %v, want %v", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
62
controllers/api/categories_test.go
Normal file
62
controllers/api/categories_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sortedCategoryKeys(row map[string]any) []string {
|
||||||
|
keys := make([]string, 0, len(row))
|
||||||
|
for k := range row {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSerializeCategoryNodeContent(t *testing.T) {
|
||||||
|
html := "<h2>Acme</h2>\n<p>Pillar copy</p>\n"
|
||||||
|
var parentID uint = 1
|
||||||
|
root := models.Category{ID: 1, Name: "News", Slug: "news", Content: &html}
|
||||||
|
child := models.Category{ID: 2, Name: "Quiet", Slug: "quiet", ParentID: &parentID}
|
||||||
|
byParent := map[uint][]models.Category{1: {child}}
|
||||||
|
counts := map[uint]int64{1: 2}
|
||||||
|
|
||||||
|
row := serializeCategoryNode(&root, byParent, counts, true, true)
|
||||||
|
if row == nil {
|
||||||
|
t.Fatal("root row is nil")
|
||||||
|
}
|
||||||
|
if got, ok := row["content"].(string); !ok || got != html {
|
||||||
|
t.Fatalf("root content = %#v, want %q", row["content"], html)
|
||||||
|
}
|
||||||
|
wantRoot := []string{"children", "content", "content_h1_to_h2", "description", "id", "name", "nest_depth", "parent_id", "post_count", "slug"}
|
||||||
|
if got := sortedCategoryKeys(row); !reflect.DeepEqual(got, wantRoot) {
|
||||||
|
t.Fatalf("root keys = %v, want %v", got, wantRoot)
|
||||||
|
}
|
||||||
|
kids, ok := row["children"].([]map[string]any)
|
||||||
|
if !ok || len(kids) != 1 {
|
||||||
|
t.Fatalf("children = %#v, want one row", row["children"])
|
||||||
|
}
|
||||||
|
v, present := kids[0]["content"]
|
||||||
|
if !present || v != nil {
|
||||||
|
t.Fatalf("child content = %#v (present %v), want nil", v, present)
|
||||||
|
}
|
||||||
|
wantChild := []string{"content", "content_h1_to_h2", "description", "id", "name", "nest_depth", "parent_id", "post_count", "slug"}
|
||||||
|
if got := sortedCategoryKeys(kids[0]); !reflect.DeepEqual(got, wantChild) {
|
||||||
|
t.Fatalf("child keys = %v, want %v", got, wantChild)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed := serializeCategoryNode(&root, byParent, counts, false, true)
|
||||||
|
if listed == nil {
|
||||||
|
t.Fatal("root omitted without include_empty")
|
||||||
|
}
|
||||||
|
if _, has := listed["children"]; has {
|
||||||
|
t.Fatalf("empty child listed without include_empty: %#v", listed["children"])
|
||||||
|
}
|
||||||
|
if got, ok := listed["content"].(string); !ok || got != html {
|
||||||
|
t.Fatalf("root content without include_empty = %#v, want %q", listed["content"], html)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
controllers/api/logger.go
Normal file
7
controllers/api/logger.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "git.golem15.com/golem15/summercms/modules/diary"
|
||||||
|
|
||||||
|
// pluginLog is the plugin's logger: its records carry component=journal, so
|
||||||
|
// config/log.yaml can raise or lower them (log.components.journal).
|
||||||
|
var pluginLog = diary.For("journal")
|
||||||
344
controllers/api/maintenance.go
Normal file
344
controllers/api/maintenance.go
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/wire"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxImageAltChanges caps one POST /images/alt batch.
|
||||||
|
const maxImageAltChanges = 500
|
||||||
|
|
||||||
|
// FixHeadings is POST /_journal/api/v1/fix-headings {dry_run}: the
|
||||||
|
// journal:fix-headings conversion of stored H1 headings (posts in every
|
||||||
|
// locale and category pillar text), for golem15.journal.manage_settings. It
|
||||||
|
// is a dry run unless dry_run is false, and answers {posts, categories,
|
||||||
|
// headings, dry_run}. While the H1 setting is off it refuses with 409 and
|
||||||
|
// the refusal phrase. MCP journal_fix_headings.
|
||||||
|
func FixHeadings(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r, pr, ok := requireBackendPrincipal(app, w, r)
|
||||||
|
if !ok || !requireManageSettings(w, pr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, err := gdb(app)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := readJSONObject(r)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, errValidationFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dry := true
|
||||||
|
if v, has := body["dry_run"]; has && v != nil {
|
||||||
|
dry = asBool(v)
|
||||||
|
}
|
||||||
|
if !classes.EffectiveH1ToH2(r.Context(), db, appConfig(app)) {
|
||||||
|
writeAPIError(w, http.StatusConflict, classes.H1ConversionOffText)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := classes.FixStoredHeadings(r.Context(), app, db, !dry)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wire.WriteJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"posts": res.Posts,
|
||||||
|
"categories": res.Categories,
|
||||||
|
"headings": res.Headings,
|
||||||
|
"dry_run": dry,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageAltChange is one requested alt change.
|
||||||
|
type imageAltChange struct {
|
||||||
|
postID uint
|
||||||
|
locale string
|
||||||
|
src string
|
||||||
|
alt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageAltText is the stored body of one post in one locale ("" is the
|
||||||
|
// host columns) while a batch is applied.
|
||||||
|
type imageAltText struct {
|
||||||
|
post *models.Post
|
||||||
|
locale string
|
||||||
|
content, contentHTML string
|
||||||
|
oldContent, oldHTML string
|
||||||
|
}
|
||||||
|
|
||||||
|
// errMarkdownImage refuses an alt for an image a markdown post's source
|
||||||
|
// does not hold as an inline image or a raw img.
|
||||||
|
const errMarkdownImage = "The image is not an inline image of the post's markdown; edit the post."
|
||||||
|
|
||||||
|
// errForbiddenPost stops a batch that names a post the caller may not edit.
|
||||||
|
var errForbiddenPost = errors.New("forbidden post")
|
||||||
|
|
||||||
|
// SetImageAlt is POST /_journal/api/v1/images/alt {dry_run, changes:
|
||||||
|
// [{post_id, locale, src, alt}]}: it sets the alt attribute of the img whose
|
||||||
|
// src equals src in the post's content_html, in the default locale when
|
||||||
|
// locale is empty and in that translation otherwise; alt "" marks a
|
||||||
|
// decorative image. The post's content gets it too, read as markdown (an
|
||||||
|
// HTML body included): its inline image  or a raw img outside
|
||||||
|
// code (classes.SetMarkdownImageAlt, classes.SetImageAltOutsideCode), so
|
||||||
|
// the next content save keeps it. An image the markdown does not hold that
|
||||||
|
// way (a reference-style image, for example) refuses the change with 422
|
||||||
|
// changes.N.src. A locale whose content is blank (a translation that
|
||||||
|
// carries only content_html) gets the alt in content_html alone. It needs
|
||||||
|
// golem15.journal.access_posts and may edit only the posts the caller may
|
||||||
|
// edit (403). The batch is all or nothing: a missing post or an src that is
|
||||||
|
// not in the post refuses it with 422 (errors keyed changes.N.field). It
|
||||||
|
// answers {changes: [{post_id, locale, src, old_alt, alt, changed}],
|
||||||
|
// dry_run}; a dry run (dry_run true) writes nothing. Applied changes write
|
||||||
|
// in one transaction and fire one RecordSaved per changed post after the
|
||||||
|
// commit. Alt text is post content, so it is a Journal tool (P33-05):
|
||||||
|
// MCP journal_set_image_alt.
|
||||||
|
func SetImageAlt(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r, pr, ok := requireBackendPrincipal(app, w, r)
|
||||||
|
if !ok || !requireAccessPosts(w, pr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, err := gdb(app)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := readJSONObject(r)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, errValidationFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dry := false
|
||||||
|
if v, has := body["dry_run"]; has && v != nil {
|
||||||
|
dry = asBool(v)
|
||||||
|
}
|
||||||
|
changes, verrs := parseImageAltChanges(body["changes"])
|
||||||
|
if len(verrs) > 0 {
|
||||||
|
writeValidation(w, verrs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var answer []map[string]any
|
||||||
|
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
texts := map[string]*imageAltText{}
|
||||||
|
posts := map[uint]*models.Post{}
|
||||||
|
answer = make([]map[string]any, 0, len(changes))
|
||||||
|
for i, c := range changes {
|
||||||
|
post, found := posts[c.postID]
|
||||||
|
if !found {
|
||||||
|
p, err := findPostByID(tx, strconv.FormatUint(uint64(c.postID), 10))
|
||||||
|
if err != nil || p == nil {
|
||||||
|
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.post_id", i), "The selected post is invalid.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !p.CanEdit(pr) {
|
||||||
|
return errForbiddenPost
|
||||||
|
}
|
||||||
|
posts[c.postID], post = p, p
|
||||||
|
}
|
||||||
|
key := strconv.FormatUint(uint64(c.postID), 10) + "|" + c.locale
|
||||||
|
text, found := texts[key]
|
||||||
|
if !found {
|
||||||
|
text, err = loadImageAltText(ctx, tx, post, c.locale)
|
||||||
|
if err != nil {
|
||||||
|
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.locale", i), "The locale is invalid.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
texts[key] = text
|
||||||
|
}
|
||||||
|
html, n, old := classes.SetImageAlt(text.contentHTML, c.src, c.alt)
|
||||||
|
if n == 0 {
|
||||||
|
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.src", i), "The image is not in the post.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(text.content) == "":
|
||||||
|
// content_html is the body (a translation that holds
|
||||||
|
// only content_html, or a row without content): nothing
|
||||||
|
// else to keep in sync (33-REVIEW-GAPFIX WR-02).
|
||||||
|
default:
|
||||||
|
// content is read as markdown, as FormatHTML renders it
|
||||||
|
// on every API path, and the next save derives
|
||||||
|
// content_html from it again, so the alt must land in it
|
||||||
|
// (L1 WR-05): an inline image, or a raw img outside
|
||||||
|
// code. An HTML body (the rich editor's) is HTML blocks
|
||||||
|
// to goldmark, so its img is a raw img outside code and
|
||||||
|
// gets the alt here too. The first character no longer
|
||||||
|
// picks the branch, so markdown that opens with a tag
|
||||||
|
// keeps its code samples and is never answered changed
|
||||||
|
// and reverted later.
|
||||||
|
content, m, _ := classes.SetMarkdownImageAlt(text.content, c.src, c.alt)
|
||||||
|
content, r, _ := classes.SetImageAltOutsideCode(content, c.src, c.alt)
|
||||||
|
if m+r == 0 {
|
||||||
|
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.src", i), errMarkdownImage)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text.content = content
|
||||||
|
}
|
||||||
|
text.contentHTML = html
|
||||||
|
var oldAlt any
|
||||||
|
if old != nil {
|
||||||
|
oldAlt = *old
|
||||||
|
}
|
||||||
|
answer = append(answer, map[string]any{
|
||||||
|
"post_id": c.postID, "locale": c.locale, "src": c.src,
|
||||||
|
"old_alt": oldAlt, "alt": c.alt, "changed": old == nil || *old != c.alt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(verrs) > 0 || dry {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return writeImageAltTexts(ctx, app, tx, texts)
|
||||||
|
})
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errForbiddenPost):
|
||||||
|
writeAPIError(w, http.StatusForbidden, errEditPermission)
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
pluginLog.ErrorContext(r.Context(), "journal api: image alt failed", "error", err)
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
case len(verrs) > 0:
|
||||||
|
writeValidation(w, verrs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"changes": answer, "dry_run": dry})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseImageAltChanges reads the changes array: post_id (a positive id),
|
||||||
|
// src (not blank) and alt (a string, "" allowed) are required, locale is
|
||||||
|
// optional.
|
||||||
|
func parseImageAltChanges(raw any) ([]imageAltChange, map[string][]string) {
|
||||||
|
items, ok := raw.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, map[string][]string{"changes": {"The changes must be an array."}}
|
||||||
|
}
|
||||||
|
if len(items) > maxImageAltChanges {
|
||||||
|
return nil, map[string][]string{"changes": {fmt.Sprintf("The changes may not have more than %d items.", maxImageAltChanges)}}
|
||||||
|
}
|
||||||
|
var verrs map[string][]string
|
||||||
|
out := make([]imageAltChange, 0, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
m, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d", i), "The change must be an object.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var c imageAltChange
|
||||||
|
id, ok := asUint(m["post_id"])
|
||||||
|
if !ok || id == 0 {
|
||||||
|
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.post_id", i), "The post id field is required.")
|
||||||
|
}
|
||||||
|
c.postID = id
|
||||||
|
c.src = strings.TrimSpace(asString(m["src"]))
|
||||||
|
if c.src == "" {
|
||||||
|
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.src", i), "The src field is required.")
|
||||||
|
}
|
||||||
|
alt, isString := m["alt"].(string)
|
||||||
|
if !isString {
|
||||||
|
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.alt", i), "The alt must be a string.")
|
||||||
|
}
|
||||||
|
c.alt = strings.TrimSpace(alt)
|
||||||
|
c.locale = strings.TrimSpace(asString(m["locale"]))
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, verrs
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadImageAltText reads a post's content and content_html in locale: the
|
||||||
|
// host columns for "", the translation otherwise (the host columns for the
|
||||||
|
// default locale).
|
||||||
|
func loadImageAltText(ctx context.Context, tx *gorm.DB, post *models.Post, locale string) (*imageAltText, error) {
|
||||||
|
t := &imageAltText{post: post, locale: locale}
|
||||||
|
if locale == "" {
|
||||||
|
t.content, t.contentHTML = post.Content, post.ContentHTML
|
||||||
|
} else {
|
||||||
|
for _, field := range []string{"content", "content_html"} {
|
||||||
|
v, _, err := translate.TranslatedExact(ctx, tx, post, field, locale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
text, _ := v.(string)
|
||||||
|
if field == "content" {
|
||||||
|
t.content = text
|
||||||
|
} else {
|
||||||
|
t.contentHTML = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.oldContent, t.oldHTML = t.content, t.contentHTML
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeImageAltTexts stores the changed bodies and fires one RecordSaved
|
||||||
|
// per changed post, with a site cache purge.
|
||||||
|
func writeImageAltTexts(ctx context.Context, app *backpack.App, tx *gorm.DB, texts map[string]*imageAltText) error {
|
||||||
|
keys := make([]string, 0, len(texts))
|
||||||
|
for key := range texts {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
byPost := map[uint][]pact.RecordChange{}
|
||||||
|
var order []*models.Post
|
||||||
|
for _, key := range keys {
|
||||||
|
t := texts[key]
|
||||||
|
var changes []pact.RecordChange
|
||||||
|
if t.content != t.oldContent {
|
||||||
|
changes = append(changes, pact.RecordChange{Attribute: "content", Locale: t.locale, Old: t.oldContent, New: t.content})
|
||||||
|
}
|
||||||
|
if t.contentHTML != t.oldHTML {
|
||||||
|
changes = append(changes, pact.RecordChange{Attribute: "content_html", Locale: t.locale, Old: t.oldHTML, New: t.contentHTML})
|
||||||
|
}
|
||||||
|
if len(changes) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t.locale == "" {
|
||||||
|
if err := tx.Model(&models.Post{}).Where("id = ?", t.post.ID).
|
||||||
|
Updates(map[string]any{"content": t.content, "content_html": t.contentHTML}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, c := range changes {
|
||||||
|
if err := translate.SetTranslated(ctx, tx, t.post, c.Attribute, t.locale, c.New); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, seen := byPost[t.post.ID]; !seen {
|
||||||
|
order = append(order, t.post)
|
||||||
|
}
|
||||||
|
byPost[t.post.ID] = append(byPost[t.post.ID], changes...)
|
||||||
|
}
|
||||||
|
locale, _ := towel.Locale(ctx)
|
||||||
|
purger := cooler.SiteCacheFromApp(app)
|
||||||
|
for _, post := range order {
|
||||||
|
cabana.EmitRecordSaved(ctx, app, tx, pact.RecordSaved{
|
||||||
|
Model: post.MorphName(),
|
||||||
|
ID: strconv.FormatUint(uint64(post.ID), 10),
|
||||||
|
Locale: locale,
|
||||||
|
Changes: byPost[post.ID],
|
||||||
|
})
|
||||||
|
cooler.PurgeSiteCacheAfterCommit(ctx, tx, purger, "post", post.Slug)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,24 +1,20 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
||||||
"git.golem15.com/golem15/summercms/modules/wire"
|
"git.golem15.com/golem15/summercms/modules/wire"
|
||||||
"gocloud.dev/blob"
|
"gocloud.dev/blob"
|
||||||
"gocloud.dev/gcerrors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const mediaMaxBytes = 10240 << 10
|
// UploadMedia is POST /_journal/api/v1/media/upload (JOURNAL-006). The
|
||||||
|
// route authenticates, checks permissions, the folder and the form, then
|
||||||
var mediaFolderPattern = regexp.MustCompile(`^[A-Za-z0-9_\-/]*$`)
|
// stores the file with classes.StoreMedia, which the Blog Editor's image
|
||||||
|
// upload shares.
|
||||||
// UploadMedia is POST /_journal/api/v1/media/upload (JOURNAL-006).
|
|
||||||
func UploadMedia(app *backpack.App) http.HandlerFunc {
|
func UploadMedia(app *backpack.App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
r, pr, ok := requireBackendPrincipal(app, w, r)
|
r, pr, ok := requireBackendPrincipal(app, w, r)
|
||||||
@@ -29,7 +25,7 @@ func UploadMedia(app *backpack.App) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
folder := strings.TrimSpace(r.FormValue("folder"))
|
folder := strings.TrimSpace(r.FormValue("folder"))
|
||||||
if len(folder) > 100 || !mediaFolderPattern.MatchString(folder) || strings.Contains(folder, "..") {
|
if len(folder) > 100 || !classes.MediaFolderPattern.MatchString(folder) || strings.Contains(folder, "..") {
|
||||||
writeValidation(w, map[string][]string{"folder": {"The folder format is invalid."}})
|
writeValidation(w, map[string][]string{"folder": {"The folder format is invalid."}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -39,109 +35,29 @@ func UploadMedia(app *backpack.App) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
name := path.Base(strings.ReplaceAll(header.Filename, `\`, "/"))
|
bucket, _ := app.Lookup[*blob.Bucket]()
|
||||||
ext := strings.ToLower(strings.TrimPrefix(path.Ext(name), "."))
|
stored, err := classes.StoreMedia(r.Context(), bucket, folder, header.Filename, file)
|
||||||
switch ext {
|
|
||||||
case "jpg", "jpeg", "png", "gif", "webp":
|
|
||||||
default:
|
|
||||||
writeValidation(w, map[string][]string{"file": {"The file must be an image."}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bucket, ok := app.Lookup[*blob.Bucket]()
|
|
||||||
if !ok || bucket == nil {
|
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
keyPath := mediaObjectPath(folder, name)
|
|
||||||
body, err := io.ReadAll(io.LimitReader(file, mediaMaxBytes+1))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
writeMediaStoreError(w, err)
|
||||||
return
|
|
||||||
}
|
|
||||||
if int64(len(body)) > mediaMaxBytes {
|
|
||||||
writeValidation(w, map[string][]string{"file": {"The file may not be greater than 10240 kilobytes."}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
keyPath, err = uniqueMediaKey(r, bucket, keyPath)
|
|
||||||
if err != nil {
|
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
wtr, err := bucket.NewWriter(r.Context(), strings.TrimPrefix(keyPath, "/"), nil)
|
|
||||||
if err != nil {
|
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := wtr.Write(body); err != nil {
|
|
||||||
_ = wtr.Close()
|
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := wtr.Close(); err != nil {
|
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
wire.WriteJSON(w, http.StatusCreated, map[string]any{
|
wire.WriteJSON(w, http.StatusCreated, map[string]any{
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
"url": attach.PublicURL(strings.TrimPrefix(keyPath, "/")),
|
"url": stored.URL,
|
||||||
"path": keyPath,
|
"path": stored.Path,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mediaObjectPath(folder, fileName string) string {
|
// writeMediaStoreError answers a StoreMedia failure as the route always
|
||||||
segments := make([]string, 0, 4)
|
// has: a classes.MediaError is a 422 on its field with its message, any
|
||||||
for _, part := range strings.Split(strings.Trim(folder, "/"), "/") {
|
// other error the opaque 500.
|
||||||
if part == "" || part == "." || part == ".." {
|
func writeMediaStoreError(w http.ResponseWriter, err error) {
|
||||||
continue
|
var refused *classes.MediaError
|
||||||
}
|
if errors.As(err, &refused) && refused != nil {
|
||||||
segments = append(segments, part)
|
writeValidation(w, map[string][]string{refused.Field: {refused.Message}})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if len(segments) > 0 && strings.EqualFold(segments[0], "journal") {
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
segments = segments[1:]
|
|
||||||
}
|
|
||||||
folder = strings.Join(segments, "/")
|
|
||||||
if folder == "" {
|
|
||||||
return "/journal/" + fileName
|
|
||||||
}
|
|
||||||
return "/journal/" + folder + "/" + fileName
|
|
||||||
}
|
|
||||||
|
|
||||||
func uniqueMediaKey(r *http.Request, bucket *blob.Bucket, pathName string) (string, error) {
|
|
||||||
key := strings.TrimPrefix(pathName, "/")
|
|
||||||
ok, err := bucket.Exists(r.Context(), key)
|
|
||||||
if err != nil && gcerrors.Code(err) != gcerrors.NotFound {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return pathName, nil
|
|
||||||
}
|
|
||||||
ext := path.Ext(pathName)
|
|
||||||
base := strings.TrimSuffix(pathName, ext)
|
|
||||||
for n := 1; n < 1000; n++ {
|
|
||||||
candidate := base + "-" + itoa(n) + ext
|
|
||||||
exists, err := bucket.Exists(r.Context(), strings.TrimPrefix(candidate, "/"))
|
|
||||||
if err != nil && gcerrors.Code(err) != gcerrors.NotFound {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
return candidate, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pathName, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func itoa(n int) string {
|
|
||||||
if n == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
var b [16]byte
|
|
||||||
i := len(b)
|
|
||||||
for n > 0 {
|
|
||||||
i--
|
|
||||||
b[i] = byte('0' + n%10)
|
|
||||||
n /= 10
|
|
||||||
}
|
|
||||||
return string(b[i:])
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,47 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMediaObjectPath(t *testing.T) {
|
// TestWriteMediaStoreError pins the media upload route's error bodies (D-09)
|
||||||
got := mediaObjectPath("../etc", "cover.png")
|
// after the storage moved to classes.StoreMedia: a MediaError is the same
|
||||||
if !strings.HasPrefix(got, "/journal/") {
|
// 422 body writeValidation always wrote, anything else the opaque 500.
|
||||||
t.Fatalf("path = %q, want /journal/ prefix", got)
|
func TestWriteMediaStoreError(t *testing.T) {
|
||||||
}
|
for _, tc := range []struct {
|
||||||
if strings.Contains(got, "..") {
|
name string
|
||||||
t.Fatalf("path still contains ..: %q", got)
|
err error
|
||||||
}
|
status int
|
||||||
if got := mediaObjectPath("photos", "a.png"); got != "/journal/photos/a.png" {
|
body string
|
||||||
t.Fatalf("nested = %q", got)
|
}{
|
||||||
}
|
{"size", &classes.MediaError{Field: "file", Message: "The file may not be greater than 10240 kilobytes."}, http.StatusUnprocessableEntity,
|
||||||
if got := mediaObjectPath("journal/photos", "a.png"); got != "/journal/photos/a.png" {
|
`{"error":"Validation failed","errors":{"file":["The file may not be greater than 10240 kilobytes."]}}`},
|
||||||
t.Fatalf("prefixed folder = %q", got)
|
{"extension", &classes.MediaError{Field: "file", Message: "The file must be an image."}, http.StatusUnprocessableEntity,
|
||||||
|
`{"error":"Validation failed","errors":{"file":["The file must be an image."]}}`},
|
||||||
|
{"other", errors.New("disk full"), http.StatusInternalServerError, `{"error":"Internal server error"}`},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
writeMediaStoreError(rec, tc.err)
|
||||||
|
want := httptest.NewRecorder()
|
||||||
|
if tc.status == http.StatusUnprocessableEntity {
|
||||||
|
var refused *classes.MediaError
|
||||||
|
errors.As(tc.err, &refused)
|
||||||
|
writeValidation(want, map[string][]string{refused.Field: {refused.Message}})
|
||||||
|
} else {
|
||||||
|
writeAPIError(want, http.StatusInternalServerError, "Internal server error")
|
||||||
|
}
|
||||||
|
if rec.Code != tc.status || rec.Body.String() != want.Body.String() {
|
||||||
|
t.Fatalf("got %d %s, want %d %s", rec.Code, rec.Body.String(), tc.status, want.Body.String())
|
||||||
|
}
|
||||||
|
if got := rec.Body.String(); got != tc.body && got != tc.body+"\n" {
|
||||||
|
t.Fatalf("body = %q, want %q", got, tc.body)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMediaFolderPatternRejectsDotDot(t *testing.T) {
|
|
||||||
folder := "../etc"
|
|
||||||
if mediaFolderPattern.MatchString(folder) && !strings.Contains(folder, "..") {
|
|
||||||
t.Fatal("folder ../etc should be rejected")
|
|
||||||
}
|
|
||||||
if !strings.Contains(folder, "..") {
|
|
||||||
t.Fatal("test fixture lost ..")
|
|
||||||
}
|
|
||||||
if mediaFolderPattern.MatchString("ok_folder-1") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.Fatal("ok_folder-1 should match")
|
|
||||||
}
|
|
||||||
|
|||||||
41
controllers/api/optional_principal_test.go
Normal file
41
controllers/api/optional_principal_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestOptionalBackendPrincipalDispatch pins the D-17 fix: a request the MCP
|
||||||
|
// endpoint dispatches in-process carries no Authorization header but the
|
||||||
|
// backend dispatch marker, and the list and show handlers must see its
|
||||||
|
// principal; a request with neither stays anonymous.
|
||||||
|
func TestOptionalBackendPrincipalDispatch(t *testing.T) {
|
||||||
|
app := backpack.New(nil)
|
||||||
|
admin := &bouncer.Principal{ID: 42, Backend: true}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/_journal/api/v1/posts", nil)
|
||||||
|
req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: admin}))
|
||||||
|
out := optionalBackendPrincipal(app, req)
|
||||||
|
if pr, ok := bouncer.User(out.Context()); !ok || pr != admin {
|
||||||
|
t.Fatalf("dispatched request principal = %+v (%v), want the marker's admin", pr, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
anon := httptest.NewRequest(http.MethodGet, "/_journal/api/v1/posts", nil)
|
||||||
|
if out := optionalBackendPrincipal(app, anon); out != anon {
|
||||||
|
t.Fatal("a request without a bearer or marker was changed")
|
||||||
|
}
|
||||||
|
if _, ok := bouncer.User(anon.Context()); ok {
|
||||||
|
t.Fatal("an anonymous request gained a principal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A marker for another guard is not the backend's.
|
||||||
|
other := httptest.NewRequest(http.MethodGet, "/_journal/api/v1/posts", nil)
|
||||||
|
other = other.WithContext(bouncer.WithDispatch(other.Context(), &bouncer.Dispatch{Guards: []string{"api_token"}, Principal: &bouncer.Principal{ID: 7}}))
|
||||||
|
if out := optionalBackendPrincipal(app, other); out != other {
|
||||||
|
t.Fatal("a marker of another guard yielded a backend principal")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"html"
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,12 +10,16 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"git.golem15.com/golem15/summercms/modules/wire"
|
"git.golem15.com/golem15/summercms/modules/wire"
|
||||||
"gocloud.dev/blob"
|
"gocloud.dev/blob"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -23,10 +28,17 @@ import (
|
|||||||
const (
|
const (
|
||||||
defaultPerPage = 9
|
defaultPerPage = 9
|
||||||
maxPerPage = 30
|
maxPerPage = 30
|
||||||
|
|
||||||
|
// fieldsSuggest is the ?fields value of the suggestion rows.
|
||||||
|
fieldsSuggest = "suggest"
|
||||||
|
// suggestExcerptRunes is about how long a suggestion's excerpt runs.
|
||||||
|
suggestExcerptRunes = 160
|
||||||
)
|
)
|
||||||
|
|
||||||
// Index is GET /_journal/api/v1/posts. Anonymous callers see published posts
|
// Index is GET /_journal/api/v1/posts. Anonymous callers see published posts
|
||||||
// only; an editor with access_posts may include drafts unless ?published=true.
|
// only; an editor with access_posts may include drafts unless ?published=true.
|
||||||
|
// With ?fields=suggest each row is the short suggestion shape
|
||||||
|
// (serializeSuggestion).
|
||||||
func Index(app *backpack.App) http.HandlerFunc {
|
func Index(app *backpack.App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
r = optionalBackendPrincipal(app, r)
|
r = optionalBackendPrincipal(app, r)
|
||||||
@@ -41,8 +53,11 @@ func Index(app *backpack.App) http.HandlerFunc {
|
|||||||
perPage := clampPerPage(r.URL.Query().Get("per_page"))
|
perPage := clampPerPage(r.URL.Query().Get("per_page"))
|
||||||
page := max(1, atoiDefault(r.URL.Query().Get("page"), 1))
|
page := max(1, atoiDefault(r.URL.Query().Get("page"), 1))
|
||||||
|
|
||||||
q := db.WithContext(r.Context()).Model(&models.Post{}).
|
suggest := r.URL.Query().Get("fields") == fieldsSuggest
|
||||||
Preload("User").Preload("Categories").Preload("Tags")
|
q := db.WithContext(r.Context()).Model(&models.Post{})
|
||||||
|
if !suggest {
|
||||||
|
q = q.Preload("User").Preload("Categories").Preload("Tags")
|
||||||
|
}
|
||||||
|
|
||||||
publishedOnly := true
|
publishedOnly := true
|
||||||
if editor {
|
if editor {
|
||||||
@@ -76,8 +91,11 @@ func Index(app *backpack.App) http.HandlerFunc {
|
|||||||
if len([]rune(search)) < 3 {
|
if len([]rune(search)) < 3 {
|
||||||
search = ""
|
search = ""
|
||||||
}
|
}
|
||||||
|
// rank is the engine's order of the candidates, nil for a SQL search.
|
||||||
|
var rank []uint
|
||||||
if search != "" {
|
if search != "" {
|
||||||
if ids, used := searchWithEngine(app, r, db.WithContext(r.Context()), search, publishedOnly, page, perPage); used {
|
if ids, used := searchWithEngine(app, r, db.WithContext(r.Context()), search, publishedOnly); used {
|
||||||
|
rank = ids
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
q = q.Where("1 = 0")
|
q = q.Where("1 = 0")
|
||||||
} else {
|
} else {
|
||||||
@@ -99,7 +117,11 @@ func Index(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
|
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
|
||||||
order := listOrder(sort, queryBool(r.URL.Query().Get("pinned_first")))
|
pinnedFirst := queryBool(r.URL.Query().Get("pinned_first"))
|
||||||
|
order := listOrder(sort, pinnedFirst)
|
||||||
|
if len(rank) > 0 && sort == "" {
|
||||||
|
order = rankOrder(rank, pinnedFirst)
|
||||||
|
}
|
||||||
var posts []models.Post
|
var posts []models.Post
|
||||||
if err := q.Order(order).Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
|
if err := q.Order(order).Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
@@ -107,7 +129,11 @@ func Index(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
rows := make([]map[string]any, 0, len(posts))
|
rows := make([]map[string]any, 0, len(posts))
|
||||||
for i := range posts {
|
for i := range posts {
|
||||||
rows = append(rows, serializePost(&posts[i], db.WithContext(r.Context())))
|
if suggest {
|
||||||
|
rows = append(rows, serializeSuggestion(&posts[i], db.WithContext(r.Context())))
|
||||||
|
} else {
|
||||||
|
rows = append(rows, serializePost(&posts[i], db.WithContext(r.Context())))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))
|
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))
|
||||||
}
|
}
|
||||||
@@ -126,7 +152,7 @@ func Show(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
db = db.WithContext(r.Context())
|
db = db.WithContext(r.Context())
|
||||||
slug := strings.TrimSpace(r.PathValue("slug"))
|
slug := strings.TrimSpace(r.PathValue("slug"))
|
||||||
post, err := findPostBySlugOrID(db, slug)
|
post, err := findPostBySlugOrID(db, slug, requestLocale(r))
|
||||||
if err != nil || post == nil {
|
if err != nil || post == nil {
|
||||||
writeAPIError(w, http.StatusNotFound, errPostNotFound)
|
writeAPIError(w, http.StatusNotFound, errPostNotFound)
|
||||||
return
|
return
|
||||||
@@ -143,12 +169,32 @@ func Show(app *backpack.App) http.HandlerFunc {
|
|||||||
payload := serializePost(post, db)
|
payload := serializePost(post, db)
|
||||||
payload["previous_post"] = serializeSibling(previousPost(db, post))
|
payload["previous_post"] = serializeSibling(previousPost(db, post))
|
||||||
payload["next_post"] = serializeSibling(nextPost(db, post))
|
payload["next_post"] = serializeSibling(nextPost(db, post))
|
||||||
related := relatedPosts(db, post, isEditor(pr))
|
// golem15.journal.related_mode: the default category mode answers as
|
||||||
|
// it always has; scored (opt-in, P33-44) ranks the related posts by
|
||||||
|
// shared tags and categories and adds the neighbours within the
|
||||||
|
// post's first category (additive keys, scored mode only).
|
||||||
|
relatedCfg := classes.ReadRelatedSettings(appConfig(app))
|
||||||
|
var related []models.Post
|
||||||
|
if relatedCfg.Scored() {
|
||||||
|
related, err = classes.ScoredRelatedPosts(r.Context(), db, post, relatedCfg.Count, isEditor(pr))
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload["previous_in_category"] = serializeSibling(classes.CategoryNeighbour(r.Context(), db, post, true))
|
||||||
|
payload["next_in_category"] = serializeSibling(classes.CategoryNeighbour(r.Context(), db, post, false))
|
||||||
|
} else {
|
||||||
|
related = relatedPosts(db, post, isEditor(pr))
|
||||||
|
}
|
||||||
rows := make([]map[string]any, 0, len(related))
|
rows := make([]map[string]any, 0, len(related))
|
||||||
for i := range related {
|
for i := range related {
|
||||||
rows = append(rows, serializePost(&related[i], db))
|
rows = append(rows, serializePost(&related[i], db))
|
||||||
}
|
}
|
||||||
payload["related_posts"] = rows
|
payload["related_posts"] = rows
|
||||||
|
// Additive (2026-10-28 Journal H1 setting): whether H1 in post
|
||||||
|
// content is stored as H2, so a frontend knows whether the page
|
||||||
|
// title is the only H1.
|
||||||
|
payload["content_h1_to_h2"] = classes.EffectiveH1ToH2(r.Context(), db, appConfig(app))
|
||||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": payload})
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": payload})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,7 +209,8 @@ func Categories(app *backpack.App) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
includeEmpty := queryBool(r.URL.Query().Get("include_empty"))
|
includeEmpty := queryBool(r.URL.Query().Get("include_empty"))
|
||||||
data, err := serializeCategoryTree(db.WithContext(r.Context()), includeEmpty)
|
h1ToH2 := classes.EffectiveH1ToH2(r.Context(), db, appConfig(app))
|
||||||
|
data, err := serializeCategoryTree(db.WithContext(r.Context()), includeEmpty, h1ToH2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
return
|
return
|
||||||
@@ -218,19 +265,36 @@ func Store(app *backpack.App) http.HandlerFunc {
|
|||||||
writeAPIError(w, http.StatusForbidden, errPublishPermission)
|
writeAPIError(w, http.StatusForbidden, errPublishPermission)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := db.WithContext(r.Context()).Create(post).Error; err != nil {
|
// D-92/D-102: the row, its associations and translations commit
|
||||||
|
// together, and RecordSaved fires only once they have.
|
||||||
|
var h1 *h1Conversion
|
||||||
|
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
// D-50: while the H1 setting is on, H1 in the stored HTML
|
||||||
|
// becomes H2, in the same transaction as the write.
|
||||||
|
h1 = newH1Conversion(ctx, tx, app)
|
||||||
|
h1.convertPost(post)
|
||||||
|
if err := tx.Create(post).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tags, err := applyPostAssocs(ctx, tx, post, body, h1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := loadPostAssocs(tx, post); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cabana.EmitRecordSaved(ctx, app, tx, postSavedEvent(ctx, tx, nil, post, body))
|
||||||
|
for _, tag := range tags {
|
||||||
|
cabana.EmitRecordSaved(ctx, app, tx, tagSavedEvent(ctx, &tag))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil {
|
purgeSiteCache(r, app, post)
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
wire.WriteJSON(w, http.StatusCreated, h1.answer(r, app, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))}))
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil {
|
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +327,9 @@ func Update(app *backpack.App) http.HandlerFunc {
|
|||||||
writeAPIError(w, http.StatusBadRequest, errValidationFailed)
|
writeAPIError(w, http.StatusBadRequest, errValidationFailed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// D-92/D-102: the values before the write, for the RecordSaved
|
||||||
|
// subscribers hear (a slug changed over MCP gets its redirect).
|
||||||
|
before := snapshotPost(r.Context(), db.WithContext(r.Context()), post, body)
|
||||||
if verrs := applyPostUpdate(r, db.WithContext(r.Context()), post, body); verrs != nil {
|
if verrs := applyPostUpdate(r, db.WithContext(r.Context()), post, body); verrs != nil {
|
||||||
writeValidation(w, verrs)
|
writeValidation(w, verrs)
|
||||||
return
|
return
|
||||||
@@ -271,19 +338,38 @@ func Update(app *backpack.App) http.HandlerFunc {
|
|||||||
writeAPIError(w, http.StatusForbidden, errPublishPermission)
|
writeAPIError(w, http.StatusForbidden, errPublishPermission)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := db.WithContext(r.Context()).Save(post).Error; err != nil {
|
// D-92/D-102: a slug change commits with its translations and
|
||||||
|
// associations, or not at all, and RecordSaved follows the commit.
|
||||||
|
var h1 *h1Conversion
|
||||||
|
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
// D-50: only a submitted content is converted; an update
|
||||||
|
// that does not write the body leaves stored H1 alone.
|
||||||
|
h1 = newH1Conversion(ctx, tx, app)
|
||||||
|
if _, has := body["content"]; has {
|
||||||
|
h1.convertPost(post)
|
||||||
|
}
|
||||||
|
if err := tx.Save(post).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tags, err := applyPostAssocs(ctx, tx, post, body, h1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := loadPostAssocs(tx, post); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cabana.EmitRecordSaved(ctx, app, tx, postSavedEvent(ctx, tx, before, post, body))
|
||||||
|
for _, tag := range tags {
|
||||||
|
cabana.EmitRecordSaved(ctx, app, tx, tagSavedEvent(ctx, &tag))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil {
|
purgeSiteCache(r, app, post)
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
wire.WriteJSON(w, http.StatusOK, h1.answer(r, app, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))}))
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil {
|
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,6 +401,11 @@ func Destroy(app *backpack.App) http.HandlerFunc {
|
|||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
purgeSiteCache(r, app, post)
|
||||||
|
cabana.EmitRecordDeleted(r.Context(), app, nil, pact.RecordDeleted{
|
||||||
|
Model: post.MorphName(),
|
||||||
|
ID: strconv.FormatUint(uint64(post.ID), 10),
|
||||||
|
})
|
||||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": msgPostDeleted})
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": msgPostDeleted})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,6 +467,9 @@ func UploadFeaturedImage(app *backpack.App) http.HandlerFunc {
|
|||||||
stored.AttachmentType = post.MorphName()
|
stored.AttachmentType = post.MorphName()
|
||||||
stored.AttachmentID = idStr
|
stored.AttachmentID = idStr
|
||||||
stored.Field = "featured_images"
|
stored.Field = "featured_images"
|
||||||
|
purgeSiteCache(r, app, post)
|
||||||
|
// The variants are written by a job, never in this request (D-85).
|
||||||
|
classes.DispatchImageVariants(r.Context(), app, []uint{stored.ID})
|
||||||
wire.WriteJSON(w, http.StatusCreated, map[string]any{
|
wire.WriteJSON(w, http.StatusCreated, map[string]any{
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
"id": stored.ID,
|
"id": stored.ID,
|
||||||
@@ -387,6 +481,22 @@ func UploadFeaturedImage(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appConfig is app's config, nil without an application.
|
||||||
|
func appConfig(app *backpack.App) *compass.Config {
|
||||||
|
if app == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return app.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeSiteCache queues the host site's cache purge for post, as the admin
|
||||||
|
// save hooks do. The API writes run outside a transaction, so the purge is
|
||||||
|
// queued at once and never delays the response. Without site_cache.purge_url
|
||||||
|
// it does nothing.
|
||||||
|
func purgeSiteCache(r *http.Request, app *backpack.App, post *models.Post) {
|
||||||
|
cooler.PurgeSiteCacheAfterCommit(r.Context(), nil, cooler.SiteCacheFromApp(app), "post", post.Slug)
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteFeaturedImage is DELETE /_journal/api/v1/posts/{id}/featured-images/{fileId}.
|
// DeleteFeaturedImage is DELETE /_journal/api/v1/posts/{id}/featured-images/{fileId}.
|
||||||
func DeleteFeaturedImage(app *backpack.App) http.HandlerFunc {
|
func DeleteFeaturedImage(app *backpack.App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -427,9 +537,14 @@ func DeleteFeaturedImage(app *backpack.App) http.HandlerFunc {
|
|||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The variants' blobs share the thumbnail prefix of BlobKeys.
|
||||||
|
if err := db.WithContext(r.Context()).Where("file_id = ?", file.ID).Delete(&models.ImageVariant{}).Error; err != nil {
|
||||||
|
pluginLog.Warn("journal: delete image variants", "file", file.ID, "error", err)
|
||||||
|
}
|
||||||
if bucket, ok := app.Lookup[*blob.Bucket](); ok && bucket != nil {
|
if bucket, ok := app.Lookup[*blob.Bucket](); ok && bucket != nil {
|
||||||
_ = attach.DeleteKeys(r.Context(), bucket, keys)
|
_ = attach.DeleteKeys(r.Context(), bucket, keys)
|
||||||
}
|
}
|
||||||
|
purgeSiteCache(r, app, post)
|
||||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": msgImageRemoved})
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": msgImageRemoved})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -540,10 +655,7 @@ func serializePost(p *models.Post, db *gorm.DB) map[string]any {
|
|||||||
tags = append(tags, map[string]any{"id": t.ID, "name": t.Name, "slug": t.Slug})
|
tags = append(tags, map[string]any{"id": t.ID, "name": t.Name, "slug": t.Slug})
|
||||||
}
|
}
|
||||||
featured := featuredImages(db, p)
|
featured := featuredImages(db, p)
|
||||||
var featuredURL any
|
featuredURL := firstImageURL(featured)
|
||||||
if len(featured) > 0 {
|
|
||||||
featuredURL = featured[0]["url"]
|
|
||||||
}
|
|
||||||
row := map[string]any{
|
row := map[string]any{
|
||||||
"id": p.ID,
|
"id": p.ID,
|
||||||
"title": p.Title,
|
"title": p.Title,
|
||||||
@@ -557,7 +669,7 @@ func serializePost(p *models.Post, db *gorm.DB) map[string]any {
|
|||||||
"is_pinned": p.IsPinned,
|
"is_pinned": p.IsPinned,
|
||||||
"metadata": jsonField(p.Metadata),
|
"metadata": jsonField(p.Metadata),
|
||||||
"sources": jsonField(p.Sources),
|
"sources": jsonField(p.Sources),
|
||||||
"author": serializeAuthor(p.User),
|
"author": serializeAuthor(db, p.User),
|
||||||
"categories": cats,
|
"categories": cats,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"featured_images": featured,
|
"featured_images": featured,
|
||||||
@@ -569,15 +681,78 @@ func serializePost(p *models.Post, db *gorm.DB) map[string]any {
|
|||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
func serializeAuthor(user *cabana.BackendUser) any {
|
// serializeSuggestion is the ?fields=suggest row: a subset of serializePost's
|
||||||
|
// keys with the same value formats, except that excerpt is plain text cut to
|
||||||
|
// about suggestExcerptRunes on a word boundary, taken from the content when
|
||||||
|
// the post has no excerpt.
|
||||||
|
func serializeSuggestion(p *models.Post, db *gorm.DB) map[string]any {
|
||||||
|
if p == nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"id": p.ID,
|
||||||
|
"title": p.Title,
|
||||||
|
"slug": p.Slug,
|
||||||
|
"excerpt": suggestionExcerpt(p),
|
||||||
|
"featured_image_url": firstImageURL(featuredImages(db, p)),
|
||||||
|
"published_at": carbonTime(p.PublishedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestionExcerpt(p *models.Post) string {
|
||||||
|
text := models.PlainText(p.Excerpt)
|
||||||
|
if text == "" {
|
||||||
|
body := p.ContentHTML
|
||||||
|
if strings.TrimSpace(body) == "" {
|
||||||
|
body = p.Content
|
||||||
|
}
|
||||||
|
text = models.PlainText(body)
|
||||||
|
}
|
||||||
|
return truncateWords(text, suggestExcerptRunes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateWords cuts s to at most n runes at the last space before the cut,
|
||||||
|
// with an ellipsis; s is returned whole when it fits.
|
||||||
|
func truncateWords(s string, n int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
cut := runes[:n]
|
||||||
|
if i := strings.LastIndexFunc(string(cut), unicode.IsSpace); i > 0 {
|
||||||
|
return strings.TrimRightFunc(string(cut)[:i], func(r rune) bool { return unicode.IsSpace(r) || unicode.IsPunct(r) }) + "…"
|
||||||
|
}
|
||||||
|
return string(cut) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstImageURL(featured []map[string]any) any {
|
||||||
|
if len(featured) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return featured[0]["url"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// serializeAuthor is a post's author object. slug is the author's canonical
|
||||||
|
// identifier, as on GET /authors/{author}: the author slug when set, otherwise
|
||||||
|
// the login, so a consumer links to the author page by slug.
|
||||||
|
func serializeAuthor(db *gorm.DB, user *cabana.BackendUser) any {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
slug := user.Login
|
||||||
|
if db != nil {
|
||||||
|
var authorSlug string
|
||||||
|
if err := db.Table("backend_users").Select("COALESCE(golem15_bloghub_author_slug, '')").
|
||||||
|
Where("id = ?", user.ID).Scan(&authorSlug).Error; err == nil && authorSlug != "" {
|
||||||
|
slug = authorSlug
|
||||||
|
}
|
||||||
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"first_name": user.FirstName,
|
"first_name": user.FirstName,
|
||||||
"last_name": user.LastName,
|
"last_name": user.LastName,
|
||||||
"login": user.Login,
|
"login": user.Login,
|
||||||
|
"slug": slug,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,13 +767,48 @@ func featuredImages(db *gorm.DB, p *models.Post) []map[string]any {
|
|||||||
).Order("sort_order ASC, id ASC").Find(&files).Error; err != nil {
|
).Order("sort_order ASC, id ASC").Find(&files).Error; err != nil {
|
||||||
return []map[string]any{}
|
return []map[string]any{}
|
||||||
}
|
}
|
||||||
|
variants := imageVariants(db, files)
|
||||||
out := make([]map[string]any, 0, len(files))
|
out := make([]map[string]any, 0, len(files))
|
||||||
for i := range files {
|
for i := range files {
|
||||||
|
list := variants[files[i].ID]
|
||||||
|
if list == nil {
|
||||||
|
list = []map[string]any{}
|
||||||
|
}
|
||||||
out = append(out, map[string]any{
|
out = append(out, map[string]any{
|
||||||
"id": files[i].ID,
|
"id": files[i].ID,
|
||||||
"url": files[i].URL(),
|
"url": files[i].URL(),
|
||||||
"file_name": files[i].FileName,
|
"file_name": files[i].FileName,
|
||||||
"file_size": files[i].FileSize,
|
"file_size": files[i].FileSize,
|
||||||
|
"variants": list,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageVariants lists the variants of files in one query, by file and
|
||||||
|
// width (D-85): {width, height, url, webp_url} and, on each, the
|
||||||
|
// original's source_width and source_height.
|
||||||
|
func imageVariants(db *gorm.DB, files []attach.File) map[uint][]map[string]any {
|
||||||
|
out := map[uint][]map[string]any{}
|
||||||
|
if len(files) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
ids := make([]uint, 0, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
ids = append(ids, f.ID)
|
||||||
|
}
|
||||||
|
var rows []models.ImageVariant
|
||||||
|
if err := db.Where("file_id IN ?", ids).Order("file_id ASC, width ASC").Find(&rows).Error; err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, v := range rows {
|
||||||
|
out[v.FileID] = append(out[v.FileID], map[string]any{
|
||||||
|
"width": v.Width,
|
||||||
|
"height": v.Height,
|
||||||
|
"url": attach.PublicURL(v.Key),
|
||||||
|
"webp_url": attach.PublicURL(v.WebPKey),
|
||||||
|
"source_width": v.SourceWidth,
|
||||||
|
"source_height": v.SourceHeight,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -16,8 +16,12 @@ import (
|
|||||||
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
|
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
|
||||||
|
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,12 +56,29 @@ func ctypeDigit(s string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func findPostBySlugOrID(db *gorm.DB, slug string) (*models.Post, error) {
|
// findPostBySlugOrID finds a post by its default-locale slug, then by its
|
||||||
|
// slug in locale (the request locale, D-58: translate's slug index), then
|
||||||
|
// by numeric id. Drafts are found too; the caller decides who may see them.
|
||||||
|
func findPostBySlugOrID(db *gorm.DB, slug, locale string) (*models.Post, error) {
|
||||||
q := postQuery(db)
|
q := postQuery(db)
|
||||||
var post models.Post
|
var post models.Post
|
||||||
if err := q.Where("slug = ?", slug).Take(&post).Error; err == nil {
|
if err := q.Where("slug = ?", slug).Take(&post).Error; err == nil {
|
||||||
return &post, nil
|
return &post, nil
|
||||||
}
|
}
|
||||||
|
if locale != "" && slug != "" {
|
||||||
|
var ids []string
|
||||||
|
err := db.Session(&gorm.Session{NewDB: true, Context: db.Statement.Context}).
|
||||||
|
Table(translatemodels.Index{}.TableName()).
|
||||||
|
Where("model_type = ? AND locale = ? AND item = ? AND value = ?", models.Post{}.MorphName(), locale, "slug", slug).
|
||||||
|
Order("id ASC").
|
||||||
|
Pluck("model_id", &ids).Error
|
||||||
|
if err == nil && len(ids) > 0 {
|
||||||
|
var translated models.Post
|
||||||
|
if err := postQuery(db).Where("CAST(id AS TEXT) IN ?", ids).Order("id ASC").Take(&translated).Error; err == nil {
|
||||||
|
return &translated, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if ctypeDigit(slug) {
|
if ctypeDigit(slug) {
|
||||||
id, err := strconv.ParseUint(slug, 10, 64)
|
id, err := strconv.ParseUint(slug, 10, 64)
|
||||||
if err == nil && id > 0 {
|
if err == nil && id > 0 {
|
||||||
@@ -152,7 +173,10 @@ func relatedPosts(db *gorm.DB, p *models.Post, editor bool) []models.Post {
|
|||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
func serializeCategoryTree(db *gorm.DB, includeEmpty bool) ([]map[string]any, error) {
|
// serializeCategoryTree is the category tree of GET /categories. h1ToH2 is
|
||||||
|
// the effective H1 setting every row reports as content_h1_to_h2 (additive
|
||||||
|
// key), so a frontend knows whether pillar text keeps H1 headings.
|
||||||
|
func serializeCategoryTree(db *gorm.DB, includeEmpty, h1ToH2 bool) ([]map[string]any, error) {
|
||||||
var cats []models.Category
|
var cats []models.Category
|
||||||
if err := db.Order("nest_left ASC NULLS LAST, id ASC").Find(&cats).Error; err != nil {
|
if err := db.Order("nest_left ASC NULLS LAST, id ASC").Find(&cats).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -169,21 +193,21 @@ func serializeCategoryTree(db *gorm.DB, includeEmpty bool) ([]map[string]any, er
|
|||||||
}
|
}
|
||||||
out := make([]map[string]any, 0, len(roots))
|
out := make([]map[string]any, 0, len(roots))
|
||||||
for i := range roots {
|
for i := range roots {
|
||||||
if row := serializeCategoryNode(&roots[i], byParent, counts, includeEmpty); row != nil {
|
if row := serializeCategoryNode(&roots[i], byParent, counts, includeEmpty, h1ToH2); row != nil {
|
||||||
out = append(out, row)
|
out = append(out, row)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Category, counts map[uint]int64, includeEmpty bool) map[string]any {
|
func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Category, counts map[uint]int64, includeEmpty, h1ToH2 bool) map[string]any {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
kids := byParent[c.ID]
|
kids := byParent[c.ID]
|
||||||
childRows := make([]map[string]any, 0, len(kids))
|
childRows := make([]map[string]any, 0, len(kids))
|
||||||
for i := range kids {
|
for i := range kids {
|
||||||
if row := serializeCategoryNode(&kids[i], byParent, counts, includeEmpty); row != nil {
|
if row := serializeCategoryNode(&kids[i], byParent, counts, includeEmpty, h1ToH2); row != nil {
|
||||||
childRows = append(childRows, row)
|
childRows = append(childRows, row)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,6 +219,10 @@ func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Catego
|
|||||||
if c.Description != nil {
|
if c.Description != nil {
|
||||||
desc = *c.Description
|
desc = *c.Description
|
||||||
}
|
}
|
||||||
|
var content any
|
||||||
|
if c.Content != nil {
|
||||||
|
content = *c.Content
|
||||||
|
}
|
||||||
var parent any
|
var parent any
|
||||||
if c.ParentID != nil {
|
if c.ParentID != nil {
|
||||||
parent = *c.ParentID
|
parent = *c.ParentID
|
||||||
@@ -208,9 +236,12 @@ func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Catego
|
|||||||
"name": c.Name,
|
"name": c.Name,
|
||||||
"slug": c.Slug,
|
"slug": c.Slug,
|
||||||
"description": desc,
|
"description": desc,
|
||||||
|
"content": content,
|
||||||
"parent_id": parent,
|
"parent_id": parent,
|
||||||
"nest_depth": depth,
|
"nest_depth": depth,
|
||||||
"post_count": postCount,
|
"post_count": postCount,
|
||||||
|
// Additive (2026-10-28 H1 conversion scope).
|
||||||
|
"content_h1_to_h2": h1ToH2,
|
||||||
}
|
}
|
||||||
if len(childRows) > 0 {
|
if len(childRows) > 0 {
|
||||||
row["children"] = childRows
|
row["children"] = childRows
|
||||||
@@ -671,54 +702,62 @@ func asUintSlice(raw any) ([]uint, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyPostAssocs(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any) error {
|
// applyPostAssocs writes the post's categories, tags and translations the
|
||||||
|
// body submits. created are the tags the write created by name, for their
|
||||||
|
// record events.
|
||||||
|
//
|
||||||
|
// h1 converts H1 in the translated content while the H1 setting is on; it
|
||||||
|
// may be nil.
|
||||||
|
func applyPostAssocs(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any, h1 *h1Conversion) (created []models.Tag, err error) {
|
||||||
if _, has := body["categories"]; has {
|
if _, has := body["categories"]; has {
|
||||||
ids, _ := asUintSlice(body["categories"])
|
ids, _ := asUintSlice(body["categories"])
|
||||||
if err := db.Where("post_id = ?", post.ID).Delete(&models.PostCategory{}).Error; err != nil {
|
if err := db.Where("post_id = ?", post.ID).Delete(&models.PostCategory{}).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if err := db.Create(&models.PostCategory{PostID: post.ID, CategoryID: id}).Error; err != nil {
|
if err := db.Create(&models.PostCategory{PostID: post.ID, CategoryID: id}).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, has := body["tags"]; has {
|
if _, has := body["tags"]; has {
|
||||||
ids, err := resolveTagIDs(db, body["tags"])
|
var ids []uint
|
||||||
|
ids, created, err = resolveTagIDs(db, body["tags"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := db.Where("post_id = ?", post.ID).Delete(&models.PostTag{}).Error; err != nil {
|
if err := db.Where("post_id = ?", post.ID).Delete(&models.PostTag{}).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if err := db.Create(&models.PostTag{PostID: post.ID, TagID: id}).Error; err != nil {
|
if err := db.Create(&models.PostTag{PostID: post.ID, TagID: id}).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if raw, has := body["translations"]; has {
|
if raw, has := body["translations"]; has {
|
||||||
if err := syncTranslations(ctx, db, post, raw); err != nil {
|
if err := syncTranslations(ctx, db, post, raw, h1); err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveTagIDs(db *gorm.DB, raw any) ([]uint, error) {
|
// resolveTagIDs turns the submitted tags (ids or names) into tag ids; a name
|
||||||
|
// no tag has yet creates the tag, returned in created.
|
||||||
|
func resolveTagIDs(db *gorm.DB, raw any) (ids []uint, created []models.Tag, err error) {
|
||||||
items, ok := raw.([]any)
|
items, ok := raw.([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
if raw == nil {
|
if raw == nil {
|
||||||
return nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("tags must be an array")
|
return nil, nil, fmt.Errorf("tags must be an array")
|
||||||
}
|
}
|
||||||
var ids []uint
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if id, ok := asUint(item); ok {
|
if id, ok := asUint(item); ok {
|
||||||
var n int64
|
var n int64
|
||||||
if err := db.Model(&models.Tag{}).Where("id = ?", id).Count(&n).Error; err != nil {
|
if err := db.Model(&models.Tag{}).Where("id = ?", id).Count(&n).Error; err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
ids = append(ids, id)
|
ids = append(ids, id)
|
||||||
@@ -741,14 +780,15 @@ func resolveTagIDs(db *gorm.DB, raw any) ([]uint, error) {
|
|||||||
}
|
}
|
||||||
tag = models.Tag{Name: name, Slug: slug}
|
tag = models.Tag{Name: name, Slug: slug}
|
||||||
if err := db.Create(&tag).Error; err != nil {
|
if err := db.Create(&tag).Error; err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
ids = append(ids, tag.ID)
|
ids = append(ids, tag.ID)
|
||||||
|
created = append(created, tag)
|
||||||
}
|
}
|
||||||
return ids, nil
|
return ids, created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw any) error {
|
func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw any, h1 *h1Conversion) error {
|
||||||
m, ok := raw.(map[string]any)
|
m, ok := raw.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
@@ -759,13 +799,13 @@ func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw a
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if content := asString(attrs["content"]); strings.TrimSpace(content) != "" && strings.TrimSpace(asString(attrs["content_html"])) == "" {
|
// Every translated content_html goes through the host column's
|
||||||
html, err := classes.FormatHTML(content)
|
// policy: derived from content, or sanitized when only content_html
|
||||||
if err != nil {
|
// is supplied. Winter stored a supplied content_html verbatim.
|
||||||
return err
|
if err := classes.FormatTranslation(attrs); err != nil {
|
||||||
}
|
return err
|
||||||
attrs["content_html"] = html
|
|
||||||
}
|
}
|
||||||
|
h1.convertTranslation(locale, attrs)
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
if val, has := attrs[field]; has && val != nil {
|
if val, has := attrs[field]; has && val != nil {
|
||||||
if err := translate.SetTranslated(ctx, db, post, field, locale, val); err != nil {
|
if err := translate.SetTranslated(ctx, db, post, field, locale, val); err != nil {
|
||||||
@@ -776,3 +816,90 @@ func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw a
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requestLocale is the request's locale (towel, set by the router from the
|
||||||
|
// locale resolver or Accept-Language) as a lower-case language code: the
|
||||||
|
// first language of an Accept-Language list, without its region.
|
||||||
|
func requestLocale(r *http.Request) string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw, _ := towel.Locale(r.Context())
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if i := strings.IndexAny(raw, ",;"); i >= 0 {
|
||||||
|
raw = raw[:i]
|
||||||
|
}
|
||||||
|
if i := strings.IndexAny(raw, "-_"); i >= 0 {
|
||||||
|
raw = raw[:i]
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
// h1Conversion converts H1 to H2 in the post content an API write stores
|
||||||
|
// while the Journal H1 setting is on (D-50), and collects one notice per
|
||||||
|
// locale that had headings converted. A nil *h1Conversion converts nothing.
|
||||||
|
type h1Conversion struct {
|
||||||
|
on bool
|
||||||
|
locales []string
|
||||||
|
counts []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// newH1Conversion reads the H1 setting on tx (EffectiveH1ToH2).
|
||||||
|
func newH1Conversion(ctx context.Context, tx *gorm.DB, app *backpack.App) *h1Conversion {
|
||||||
|
return &h1Conversion{on: classes.EffectiveH1ToH2(ctx, tx, appConfig(app))}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *h1Conversion) add(locale string, n int) {
|
||||||
|
if n > 0 {
|
||||||
|
h.locales = append(h.locales, locale)
|
||||||
|
h.counts = append(h.counts, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertPost converts the post's host content and content_html.
|
||||||
|
func (h *h1Conversion) convertPost(post *models.Post) {
|
||||||
|
if h == nil || !h.on || post == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
post.Content, post.ContentHTML, n = classes.ConvertContentH1(post.Content, post.ContentHTML)
|
||||||
|
h.add("", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertTranslation converts one locale's submitted content and
|
||||||
|
// content_html, after FormatTranslation derived or sanitized them.
|
||||||
|
func (h *h1Conversion) convertTranslation(locale string, attrs map[string]any) {
|
||||||
|
if h == nil || !h.on {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.add(locale, classes.ConvertTranslationH1(attrs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// answer adds the additive notices key to a write answer when headings
|
||||||
|
// were converted: one {message, count} per locale, message being
|
||||||
|
// golem15.journal::lang.editor.h1_converted in the request locale, with the
|
||||||
|
// locale key on a translation's notice. Without a conversion the answer is
|
||||||
|
// unchanged.
|
||||||
|
func (h *h1Conversion) answer(r *http.Request, app *backpack.App, payload map[string]any) map[string]any {
|
||||||
|
if h == nil || len(h.counts) == 0 {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
var tr *phrasebook.Translator
|
||||||
|
if app != nil {
|
||||||
|
tr, _ = app.Lookup[*phrasebook.Translator]()
|
||||||
|
}
|
||||||
|
notices := make([]map[string]any, 0, len(h.counts))
|
||||||
|
for i, n := range h.counts {
|
||||||
|
message := classes.EditorH1Converted
|
||||||
|
if tr != nil {
|
||||||
|
message = tr.Choice(r.Context(), classes.EditorH1Converted, n, nil)
|
||||||
|
}
|
||||||
|
notice := map[string]any{"message": message, "count": n}
|
||||||
|
if h.locales[i] != "" {
|
||||||
|
notice["locale"] = h.locales[i]
|
||||||
|
}
|
||||||
|
notices = append(notices, notice)
|
||||||
|
}
|
||||||
|
payload["notices"] = notices
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/beachcomber/typesense"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search string, publishedOnly bool, page, perPage int) ([]uint, bool) {
|
// searchWindow is how many engine candidates one search re-gates in SQL.
|
||||||
if app == nil || db == nil || !models.SearchGateEnabled() {
|
// The list pages over the gated rows, so a page never depends on how the
|
||||||
|
// engine paged.
|
||||||
|
const searchWindow = typesense.MaxPerPage
|
||||||
|
|
||||||
|
// searchWithEngine returns the candidate post ids for search in the engine's
|
||||||
|
// rank order, and whether the engine answered. It asks for the whole result
|
||||||
|
// window (the first searchWindow hits), not one page: the caller re-gates
|
||||||
|
// the ids in SQL, counts them and pages over them. When it reports false the
|
||||||
|
// caller falls back to SQL search.
|
||||||
|
func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search string, publishedOnly bool) ([]uint, bool) {
|
||||||
|
if app == nil || db == nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
svc, err := beachcomber.From(app)
|
svc, err := beachcomber.From(app)
|
||||||
@@ -21,26 +32,39 @@ func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search st
|
|||||||
}
|
}
|
||||||
var settings models.Settings
|
var settings models.Settings
|
||||||
if err := db.First(&settings, 1).Error; err != nil {
|
if err := db.First(&settings, 1).Error; err != nil {
|
||||||
|
if !models.SearchGateEnabled() {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
settings.SearchTitleWeight = 5
|
settings.SearchTitleWeight = 5
|
||||||
settings.SearchExcerptWeight = 3
|
settings.SearchExcerptWeight = 3
|
||||||
settings.SearchContentWeight = 1
|
settings.SearchContentWeight = 1
|
||||||
|
} else {
|
||||||
|
// The row is the gate: the in-memory one is set only once a sync
|
||||||
|
// has run since boot.
|
||||||
|
models.SetSearchGate(settings.SearchUseTypesense)
|
||||||
|
if !settings.SearchUseTypesense {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
q := beachcomber.Query{
|
q := beachcomber.Query{
|
||||||
Q: search,
|
Q: search,
|
||||||
QueryBy: []string{"title", "excerpt", "content"},
|
QueryBy: []string{"title", "excerpt", "content"},
|
||||||
QueryByWeights: []int{settings.SearchTitleWeight, settings.SearchExcerptWeight, settings.SearchContentWeight},
|
QueryByWeights: []int{settings.SearchTitleWeight, settings.SearchExcerptWeight, settings.SearchContentWeight},
|
||||||
SortBy: "_text_match:desc,published_at:desc",
|
SortBy: "_text_match:desc,published_at:desc",
|
||||||
Page: page,
|
Page: 1,
|
||||||
PerPage: perPage,
|
PerPage: searchWindow,
|
||||||
}
|
}
|
||||||
if publishedOnly {
|
if publishedOnly {
|
||||||
q.FilterBy = "published:=1"
|
q.FilterBy = "published:=1"
|
||||||
}
|
}
|
||||||
res, err := beachcomber.SearchPage(r.Context(), svc.Engine(), svc.IndexName(&models.Post{}), q)
|
res, err := beachcomber.SearchPage(r.Context(), svc.Engine(), svc.IndexName(&models.Post{}), q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Default().Warn("journal: Typesense search failed, falling back to DB search", "err", err)
|
pluginLog.Warn("journal: Typesense search failed, falling back to DB search", "err", err)
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
if res.Found > searchWindow {
|
||||||
|
pluginLog.Debug("journal: search matched more posts than one window; keeping the first", "found", res.Found, "kept", searchWindow)
|
||||||
|
}
|
||||||
ids := make([]uint, 0, len(res.IDs))
|
ids := make([]uint, 0, len(res.IDs))
|
||||||
for _, raw := range res.IDs {
|
for _, raw := range res.IDs {
|
||||||
n, err := strconv.ParseUint(raw, 10, 64)
|
n, err := strconv.ParseUint(raw, 10, 64)
|
||||||
@@ -51,3 +75,18 @@ func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search st
|
|||||||
}
|
}
|
||||||
return ids, true
|
return ids, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rankOrder orders rows by their position in ids, the engine's rank, with
|
||||||
|
// pinned posts first when pinnedFirst. The ids are parsed integers, so the
|
||||||
|
// array literal carries no caller text.
|
||||||
|
func rankOrder(ids []uint, pinnedFirst bool) string {
|
||||||
|
parts := make([]string, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
parts[i] = strconv.FormatUint(uint64(id), 10)
|
||||||
|
}
|
||||||
|
order := "array_position(ARRAY[" + strings.Join(parts, ",") + "]::bigint[], id), id DESC"
|
||||||
|
if pinnedFirst {
|
||||||
|
return "is_pinned DESC, " + order
|
||||||
|
}
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|||||||
191
controllers/api/record_events.go
Normal file
191
controllers/api/record_events.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// postEventColumns are the post columns a record event reports, in order.
|
||||||
|
var postEventColumns = []struct {
|
||||||
|
name string
|
||||||
|
value func(p *models.Post) any
|
||||||
|
}{
|
||||||
|
{"user_id", func(p *models.Post) any { return derefValue(p.UserID) }},
|
||||||
|
{"redactor_id", func(p *models.Post) any { return derefValue(p.RedactorID) }},
|
||||||
|
{"title", func(p *models.Post) any { return p.Title }},
|
||||||
|
{"slug", func(p *models.Post) any { return p.Slug }},
|
||||||
|
{"excerpt", func(p *models.Post) any { return p.Excerpt }},
|
||||||
|
{"content", func(p *models.Post) any { return p.Content }},
|
||||||
|
{"content_html", func(p *models.Post) any { return p.ContentHTML }},
|
||||||
|
{"published_at", func(p *models.Post) any { return derefValue(p.PublishedAt) }},
|
||||||
|
{"published", func(p *models.Post) any { return p.Published }},
|
||||||
|
{"is_pinned", func(p *models.Post) any { return p.IsPinned }},
|
||||||
|
{"metadata", func(p *models.Post) any { return derefValue(p.Metadata) }},
|
||||||
|
{"sources", func(p *models.Post) any { return derefValue(p.Sources) }},
|
||||||
|
}
|
||||||
|
|
||||||
|
// translatedEventFields are the translated fields a write may submit under
|
||||||
|
// "translations" (syncTranslations).
|
||||||
|
var translatedEventFields = []string{"title", "slug", "content", "content_html", "excerpt", "metadata"}
|
||||||
|
|
||||||
|
// postSnapshot is a post's values before an API write: its columns and the
|
||||||
|
// stored translations of the fields the request submits per locale.
|
||||||
|
type postSnapshot struct {
|
||||||
|
columns map[string]any
|
||||||
|
translations map[string]map[string]any // locale -> field -> old (nil when missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshotPost takes the values an update may change, before it changes
|
||||||
|
// them. A translation read error leaves that value out (reported as new).
|
||||||
|
func snapshotPost(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any) *postSnapshot {
|
||||||
|
snap := &postSnapshot{columns: map[string]any{}, translations: map[string]map[string]any{}}
|
||||||
|
for _, col := range postEventColumns {
|
||||||
|
snap.columns[col.name] = col.value(post)
|
||||||
|
}
|
||||||
|
for locale, fields := range submittedTranslations(body) {
|
||||||
|
snap.translations[locale] = map[string]any{}
|
||||||
|
for _, field := range fields {
|
||||||
|
text, ok, err := translate.TranslatedExact(ctx, db, post, field, locale)
|
||||||
|
if err == nil && ok {
|
||||||
|
snap.translations[locale][field] = text
|
||||||
|
} else {
|
||||||
|
snap.translations[locale][field] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
|
// postSavedEvent is the RecordSaved of an API write, which the handler
|
||||||
|
// fires with cabana.EmitRecordSaved inside the write's transaction, so it
|
||||||
|
// reaches subscribers once the write commits. before is nil on create:
|
||||||
|
// every column with a value and every submitted translation is then
|
||||||
|
// reported with a nil Old. Controller is "": the write did not go through an
|
||||||
|
// admin form.
|
||||||
|
func postSavedEvent(ctx context.Context, db *gorm.DB, before *postSnapshot, post *models.Post, body map[string]any) pact.RecordSaved {
|
||||||
|
changes := []pact.RecordChange{}
|
||||||
|
for _, col := range postEventColumns {
|
||||||
|
next := col.value(post)
|
||||||
|
if before == nil {
|
||||||
|
if next == nil || reflect.ValueOf(next).IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changes = append(changes, pact.RecordChange{Attribute: col.name, New: next})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prev := before.columns[col.name]
|
||||||
|
if sameEventValue(prev, next) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changes = append(changes, pact.RecordChange{Attribute: col.name, Old: prev, New: next})
|
||||||
|
}
|
||||||
|
submitted := submittedTranslations(body)
|
||||||
|
locales := make([]string, 0, len(submitted))
|
||||||
|
for locale := range submitted {
|
||||||
|
locales = append(locales, locale)
|
||||||
|
}
|
||||||
|
sort.Strings(locales)
|
||||||
|
for _, locale := range locales {
|
||||||
|
for _, field := range submitted[locale] {
|
||||||
|
text, ok, err := translate.TranslatedExact(ctx, db, post, field, locale)
|
||||||
|
if err != nil || !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var prev any
|
||||||
|
if before != nil {
|
||||||
|
prev = before.translations[locale][field]
|
||||||
|
}
|
||||||
|
if old, _ := prev.(string); old == text {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changes = append(changes, pact.RecordChange{Attribute: field, Locale: locale, Old: prev, New: text})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
locale, _ := towel.Locale(ctx)
|
||||||
|
return pact.RecordSaved{
|
||||||
|
Model: post.MorphName(),
|
||||||
|
ID: strconv.FormatUint(uint64(post.ID), 10),
|
||||||
|
Locale: locale,
|
||||||
|
Created: before == nil,
|
||||||
|
Changes: changes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagSavedEvent is the RecordSaved of a tag a post write created by name
|
||||||
|
// (journal-tag is a content type, D-102): Created, with its name and slug.
|
||||||
|
func tagSavedEvent(ctx context.Context, tag *models.Tag) pact.RecordSaved {
|
||||||
|
locale, _ := towel.Locale(ctx)
|
||||||
|
return pact.RecordSaved{
|
||||||
|
Model: tag.MorphName(),
|
||||||
|
ID: strconv.FormatUint(uint64(tag.ID), 10),
|
||||||
|
Locale: locale,
|
||||||
|
Created: true,
|
||||||
|
Changes: []pact.RecordChange{
|
||||||
|
{Attribute: "name", New: tag.Name},
|
||||||
|
{Attribute: "slug", New: tag.Slug},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// submittedTranslations lists, per locale, the translated fields a request
|
||||||
|
// body submits with a value, in translatedEventFields order. content_html is
|
||||||
|
// listed whenever a non-blank content is, as the write derives it from the
|
||||||
|
// content (classes.FormatTranslation): the snapshot before the write and the
|
||||||
|
// event after it then cover the same fields, whether or not the write has
|
||||||
|
// already added content_html to the body.
|
||||||
|
func submittedTranslations(body map[string]any) map[string][]string {
|
||||||
|
out := map[string][]string{}
|
||||||
|
m, ok := body["translations"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for locale, raw := range m {
|
||||||
|
attrs, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, field := range translatedEventFields {
|
||||||
|
v, has := attrs[field]
|
||||||
|
if (has && v != nil) || (field == "content_html" && derivesContentHTML(attrs)) {
|
||||||
|
out[locale] = append(out[locale], field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// derivesContentHTML reports whether a translation's attributes carry a
|
||||||
|
// content the write turns into content_html (a non-blank value).
|
||||||
|
func derivesContentHTML(attrs map[string]any) bool {
|
||||||
|
content, has := attrs["content"]
|
||||||
|
return has && content != nil && strings.TrimSpace(fmt.Sprint(content)) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// derefValue is a pointer column's value, nil for a nil pointer.
|
||||||
|
func derefValue[T any](p *T) any {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameEventValue compares two column values; times compare as instants.
|
||||||
|
func sameEventValue(a, b any) bool {
|
||||||
|
ta, okA := a.(time.Time)
|
||||||
|
tb, okB := b.(time.Time)
|
||||||
|
if okA && okB {
|
||||||
|
return ta.Equal(tb)
|
||||||
|
}
|
||||||
|
return reflect.DeepEqual(a, b)
|
||||||
|
}
|
||||||
219
controllers/api/settings.go
Normal file
219
controllers/api/settings.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/wire"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// permManageSettings guards the settings and maintenance routes.
|
||||||
|
const permManageSettings = "golem15.journal.manage_settings"
|
||||||
|
|
||||||
|
// requireManageSettings answers 403 {"error":"Insufficient permissions"}
|
||||||
|
// without golem15.journal.manage_settings.
|
||||||
|
func requireManageSettings(w http.ResponseWriter, pr *bouncer.Principal) bool {
|
||||||
|
if !cabana.Allows(pr, []string{permManageSettings}) {
|
||||||
|
writeAPIError(w, http.StatusForbidden, errInsufficientPerms)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// serializeSettings is the settings answer: every fillable column by name.
|
||||||
|
func serializeSettings(s models.Settings) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"show_all_posts": s.ShowAllPosts,
|
||||||
|
"use_rich_editor": s.UseRichEditor,
|
||||||
|
"search_use_typesense": s.SearchUseTypesense,
|
||||||
|
"search_title_weight": s.SearchTitleWeight,
|
||||||
|
"search_excerpt_weight": s.SearchExcerptWeight,
|
||||||
|
"search_content_weight": s.SearchContentWeight,
|
||||||
|
"rss_enabled": s.RSSEnabled,
|
||||||
|
"rss_include_content": s.RSSIncludeContent,
|
||||||
|
"rss_title": s.RSSTitle,
|
||||||
|
"rss_description": s.RSSDescription,
|
||||||
|
"rss_language": s.RSSLanguage,
|
||||||
|
"rss_copyright": s.RSSCopyright,
|
||||||
|
"rss_image_url": s.RSSImageURL,
|
||||||
|
"rss_posts_per_feed": s.RSSPostsPerFeed,
|
||||||
|
"content_h1_to_h2": s.ContentH1ToH2,
|
||||||
|
"generic_anchors": s.GenericAnchors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSettings reads the settings row (ID 1); without one it is the
|
||||||
|
// migration's defaults, not yet stored.
|
||||||
|
func loadSettings(db *gorm.DB) (models.Settings, error) {
|
||||||
|
var s models.Settings
|
||||||
|
err := db.First(&s, 1).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return models.Settings{
|
||||||
|
ID: 1, ShowAllPosts: true, SearchTitleWeight: 5, SearchExcerptWeight: 3, SearchContentWeight: 1,
|
||||||
|
RSSEnabled: true, RSSLanguage: "en-us", RSSPostsPerFeed: 20, ContentH1ToH2: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowSettings is GET /_journal/api/v1/settings: the Journal settings row,
|
||||||
|
// for golem15.journal.manage_settings (MCP journal_get_settings).
|
||||||
|
func ShowSettings(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r, pr, ok := requireBackendPrincipal(app, w, r)
|
||||||
|
if !ok || !requireManageSettings(w, pr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, err := gdb(app)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, err := loadSettings(db.WithContext(r.Context()))
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializeSettings(s)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings is PUT /_journal/api/v1/settings: it writes the submitted
|
||||||
|
// fillable columns (models.Settings.Fillable) after checking them against
|
||||||
|
// the model's Rules, and answers the row. Other keys are ignored. Changing
|
||||||
|
// content_h1_to_h2 never rewrites stored content (D-50). MCP
|
||||||
|
// journal_update_settings.
|
||||||
|
func UpdateSettings(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r, pr, ok := requireBackendPrincipal(app, w, r)
|
||||||
|
if !ok || !requireManageSettings(w, pr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, err := gdb(app)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := readJSONObject(r)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, errValidationFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fillable := models.Settings{}.Fillable()
|
||||||
|
values := map[string]any{}
|
||||||
|
for key, v := range body {
|
||||||
|
if slices.Contains(fillable, key) {
|
||||||
|
values[key] = plainJSON(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var tr *phrasebook.Translator
|
||||||
|
if app != nil {
|
||||||
|
tr, _ = app.Lookup[*phrasebook.Translator]()
|
||||||
|
}
|
||||||
|
var saved models.Settings
|
||||||
|
var verrs map[string][]string
|
||||||
|
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
s, err := loadSettings(tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rules := map[string]string{}
|
||||||
|
for key, rule := range s.Rules() {
|
||||||
|
if _, has := values[key]; has {
|
||||||
|
rules[key], verrs = settingsRule(key, rule, values[key], verrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(verrs) > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if verrs, err = lagoon.Validate(ctx, tx, &s, rules, values, tr); err != nil || len(verrs) > 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := lagoon.Fill(&s, fillable, values, true); err != nil {
|
||||||
|
var typeErr *lagoon.FillTypeError
|
||||||
|
if errors.As(err, &typeErr) {
|
||||||
|
verrs = map[string][]string{typeErr.Key: {"The " + typeErr.Key + " field is invalid."}}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.ID = 1
|
||||||
|
if err := tx.Save(&s).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
saved = s
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pluginLog.ErrorContext(r.Context(), "journal api: settings update failed", "error", err)
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(verrs) > 0 {
|
||||||
|
writeValidation(w, verrs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializeSettings(saved)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainJSON turns a json.Number (readJSONObject decodes with UseNumber)
|
||||||
|
// into an int64, or a float64 when it is not an integer.
|
||||||
|
func plainJSON(v any) any {
|
||||||
|
n, ok := v.(json.Number)
|
||||||
|
if !ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if i, err := n.Int64(); err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
if f, err := n.Float64(); err == nil {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingsRule checks the string and url tokens of a Settings rule, which
|
||||||
|
// lagoon.Validate does not know, and returns the rule without them for
|
||||||
|
// lagoon.Validate. A failed check is added to verrs.
|
||||||
|
func settingsRule(key, rule string, value any, verrs map[string][]string) (string, map[string][]string) {
|
||||||
|
var kept []string
|
||||||
|
for _, tok := range strings.Split(rule, "|") {
|
||||||
|
switch tok {
|
||||||
|
case "string":
|
||||||
|
if _, ok := value.(string); !ok && value != nil {
|
||||||
|
verrs = addSettingsError(verrs, key, "The "+key+" must be a string.")
|
||||||
|
}
|
||||||
|
case "url":
|
||||||
|
if text, _ := value.(string); text != "" {
|
||||||
|
u, err := url.Parse(text)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
verrs = addSettingsError(verrs, key, "The "+key+" format is invalid.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
kept = append(kept, tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(kept, "|"), verrs
|
||||||
|
}
|
||||||
|
|
||||||
|
func addSettingsError(verrs map[string][]string, key, msg string) map[string][]string {
|
||||||
|
if verrs == nil {
|
||||||
|
verrs = map[string][]string{}
|
||||||
|
}
|
||||||
|
verrs[key] = append(verrs[key], msg)
|
||||||
|
return verrs
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
@@ -40,9 +45,76 @@ func (categoriesAdminController) AdminFieldRelations() []cabana.FieldRelationCon
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (categoriesAdminController) FormBeforeCreate(ctx context.Context, model any) error {
|
||||||
|
cat, ok := model.(*models.Category)
|
||||||
|
if !ok || cat == nil {
|
||||||
|
return errors.New("categories admin: form model is not a category")
|
||||||
|
}
|
||||||
|
return prepareCategoryWrite(cat)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (categoriesAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||||
|
cat, ok := model.(*models.Category)
|
||||||
|
if !ok || cat == nil {
|
||||||
|
return errors.New("categories admin: form model is not a category")
|
||||||
|
}
|
||||||
|
return prepareCategoryWrite(cat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormAfterCreate purges the host site's page caches once the save
|
||||||
|
// transaction commits (G-16.2-2, D-19).
|
||||||
|
func (c categoriesAdminController) FormAfterCreate(ctx context.Context, model any) error {
|
||||||
|
return c.purgeAfterCommit(ctx, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormAfterUpdate purges the host site's page caches once the save
|
||||||
|
// transaction commits (G-16.2-2, D-19).
|
||||||
|
func (c categoriesAdminController) FormAfterUpdate(ctx context.Context, model any) error {
|
||||||
|
return c.purgeAfterCommit(ctx, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormAfterDelete purges the host site's page caches once the delete
|
||||||
|
// commits.
|
||||||
|
func (c categoriesAdminController) FormAfterDelete(ctx context.Context, model any) error {
|
||||||
|
return c.purgeAfterCommit(ctx, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c categoriesAdminController) purgeAfterCommit(ctx context.Context, model any) error {
|
||||||
|
cat, ok := model.(*models.Category)
|
||||||
|
if !ok || cat == nil {
|
||||||
|
return errors.New("categories admin: form model is not a category")
|
||||||
|
}
|
||||||
|
purger, tx := siteCachePurger(ctx, c.app)
|
||||||
|
classes.PurgeSiteCacheAfterCommit(ctx, tx, purger, "category", cat.Slug)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareCategoryWrite stores blank category content (white space only) as
|
||||||
|
// NULL and accepts any other content it receives. Since Phase 16.2 the
|
||||||
|
// content field is the blogeditor type, whose Decode already stored the HTML
|
||||||
|
// policy's output (classes.SanitizeHTMLReport) and told the administrator
|
||||||
|
// what it removed, so an admin save is never refused because of markup
|
||||||
|
// (D-10 amended, see the bluemonday decision note). Rows written by SQL or
|
||||||
|
// by the import commands do not pass through here and are not sanitized by
|
||||||
|
// it.
|
||||||
|
func prepareCategoryWrite(cat *models.Category) error {
|
||||||
|
if cat.Content == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(*cat.Content) == "" {
|
||||||
|
cat.Content = nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
_ pact.AdminController = categoriesAdminController{}
|
_ pact.AdminController = categoriesAdminController{}
|
||||||
_ pact.AdminPermissioned = categoriesAdminController{}
|
_ pact.AdminPermissioned = categoriesAdminController{}
|
||||||
_ pact.AdminRecordSource = categoriesAdminController{}
|
_ pact.AdminRecordSource = categoriesAdminController{}
|
||||||
|
_ pact.FormBeforeCreate = categoriesAdminController{}
|
||||||
|
_ pact.FormBeforeUpdate = categoriesAdminController{}
|
||||||
|
_ pact.FormAfterCreate = categoriesAdminController{}
|
||||||
|
_ pact.FormAfterUpdate = categoriesAdminController{}
|
||||||
|
_ pact.FormAfterDelete = categoriesAdminController{}
|
||||||
_ cabana.FieldRelationProvider = categoriesAdminController{}
|
_ cabana.FieldRelationProvider = categoriesAdminController{}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -29,6 +30,9 @@ const (
|
|||||||
// PermissionAccessImportExport gates journal:export-posts / import-posts
|
// PermissionAccessImportExport gates journal:export-posts / import-posts
|
||||||
// toolbar actions (D-13).
|
// toolbar actions (D-13).
|
||||||
PermissionAccessImportExport = "golem15.journal.access_import_export"
|
PermissionAccessImportExport = "golem15.journal.access_import_export"
|
||||||
|
// PermissionManageSettings is the Journal settings permission; the
|
||||||
|
// /_journal/api/v1 settings and fix-headings routes require it.
|
||||||
|
PermissionManageSettings = "golem15.journal.manage_settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type postsAdminController struct {
|
type postsAdminController struct {
|
||||||
@@ -210,13 +214,103 @@ func preparePostWrite(ctx context.Context, post *models.Post) error {
|
|||||||
"published_at": []string{"golem15.journal::lang.post.published_validation"},
|
"published_at": []string{"golem15.journal::lang.post.published_validation"},
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
html, err := classes.FormatHTML(post.Content)
|
// D-02: the Blog Editor's Decode already stored the policy's output in
|
||||||
if err != nil {
|
// content; content_html gets the same HTML, without goldmark. Content the
|
||||||
return &cabana.ValidationError{Details: map[string]any{
|
// save does not write (untouched, D-03) leaves content_html as it is.
|
||||||
"content": []string{err.Error()},
|
if writes, ok := cabana.FieldWritesFromContext(ctx); ok {
|
||||||
}}
|
for _, w := range writes["content"] {
|
||||||
|
if !w.Default {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if html, isString := w.Value.(string); isString {
|
||||||
|
post.ContentHTML = html
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormAfterCreate writes content_html for every non-default locale whose
|
||||||
|
// content the save wrote (D-02), then registers the host site cache purge
|
||||||
|
// for after the commit.
|
||||||
|
func (c postsAdminController) FormAfterCreate(ctx context.Context, model any) error {
|
||||||
|
return c.afterPostWrite(ctx, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormAfterUpdate writes content_html for every non-default locale whose
|
||||||
|
// content the save wrote (D-02). Untouched locales are not listed and keep
|
||||||
|
// their content_html. It then registers the host site cache purge for after
|
||||||
|
// the commit; an untouched save purges too, which is harmless.
|
||||||
|
func (c postsAdminController) FormAfterUpdate(ctx context.Context, model any) error {
|
||||||
|
return c.afterPostWrite(ctx, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormAfterDelete registers the host site cache purge for after the delete
|
||||||
|
// commits.
|
||||||
|
func (c postsAdminController) FormAfterDelete(ctx context.Context, model any) error {
|
||||||
|
post, ok := model.(*models.Post)
|
||||||
|
if !ok || post == nil {
|
||||||
|
return errors.New("posts admin: form model is not a post")
|
||||||
|
}
|
||||||
|
purger, tx := siteCachePurger(ctx, c.app)
|
||||||
|
classes.PurgeSiteCacheAfterCommit(ctx, tx, purger, "post", post.Slug)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c postsAdminController) afterPostWrite(ctx context.Context, model any) error {
|
||||||
|
if err := c.writeContentHTMLTranslations(ctx, model); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if post, ok := model.(*models.Post); ok && post != nil {
|
||||||
|
purger, tx := siteCachePurger(ctx, c.app)
|
||||||
|
classes.PurgeSiteCacheAfterCommit(ctx, tx, purger, "post", post.Slug)
|
||||||
|
// Featured images uploaded in the form are bound by this save;
|
||||||
|
// their variants are queued once it commits (D-85).
|
||||||
|
classes.DispatchPostImageVariantsAfterCommit(ctx, tx, c.application(), post.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeContentHTMLTranslations stores each written non-default content
|
||||||
|
// value as that locale's content_html through the published
|
||||||
|
// cabana.TranslationWriter, inside the save transaction. Without a writer
|
||||||
|
// there is nothing to write.
|
||||||
|
func (c postsAdminController) writeContentHTMLTranslations(ctx context.Context, model any) error {
|
||||||
|
post, ok := model.(*models.Post)
|
||||||
|
if !ok || post == nil {
|
||||||
|
return errors.New("posts admin: form model is not a post")
|
||||||
|
}
|
||||||
|
writes, ok := cabana.FieldWritesFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pending []cabana.FieldWrite
|
||||||
|
for _, w := range writes["content"] {
|
||||||
|
if !w.Default && w.Locale != "" {
|
||||||
|
pending = append(pending, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
app := c.application()
|
||||||
|
if app == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
writer, ok := app.Lookup[cabana.TranslationWriter]()
|
||||||
|
if !ok || writer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tx := c.db(ctx)
|
||||||
|
for _, w := range pending {
|
||||||
|
html, isString := w.Value.(string)
|
||||||
|
if !isString {
|
||||||
|
return fmt.Errorf("posts admin: content for %s is %T, want a string", w.Locale, w.Value)
|
||||||
|
}
|
||||||
|
if err := writer.WriteTranslated(ctx, tx, post, "content_html", w.Locale, html); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
post.ContentHTML = html
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +352,11 @@ func (c postsAdminController) runExportPosts(ctx context.Context, in pact.AdminA
|
|||||||
|
|
||||||
func (c postsAdminController) runImportPosts(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
|
func (c postsAdminController) runImportPosts(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
|
||||||
path, dry, force := console.IOPayloadPath(in.Payload, "journal-export")
|
path, dry, force := console.IOPayloadPath(in.Payload, "journal-export")
|
||||||
stats, err := console.ImportFromDir(ctx, c.db(ctx), path, force, dry, nil, func(post *models.Post) error {
|
var cfg *compass.Config
|
||||||
|
if app := c.application(); app != nil {
|
||||||
|
cfg = app.Config
|
||||||
|
}
|
||||||
|
stats, err := console.ImportFromDirConfig(ctx, c.db(ctx), cfg, path, force, dry, nil, func(post *models.Post) error {
|
||||||
return refuseUnpublishedWrite(ctx, post)
|
return refuseUnpublishedWrite(ctx, post)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
603
controllers/posts_editor_smoke_test.go
Normal file
603
controllers/posts_editor_smoke_test.go
Normal file
@@ -0,0 +1,603 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||||
|
"gocloud.dev/blob/memblob"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordingWriter is a cabana.TranslationWriter that records writes.
|
||||||
|
type recordingWriter struct {
|
||||||
|
writes []recordedWrite
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordedWrite struct {
|
||||||
|
field, locale, value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*recordingWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) { return "pl", nil }
|
||||||
|
|
||||||
|
func (*recordingWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) {
|
||||||
|
return []string{"pl", "en"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *recordingWriter) WriteTranslated(_ context.Context, _ *gorm.DB, _ any, field, locale, value string) error {
|
||||||
|
w.writes = append(w.writes, recordedWrite{field: field, locale: locale, value: value})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*recordingWriter) TranslatedExact(context.Context, *gorm.DB, any, string, string) (string, bool, error) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// blogType returns the named Blog Editor field type.
|
||||||
|
func blogType(t *testing.T, name string) pact.FormFieldType {
|
||||||
|
t.Helper()
|
||||||
|
for _, ft := range classes.BlogEditorTypes(nil) {
|
||||||
|
if ft.Name == name {
|
||||||
|
return ft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no field type %s", name)
|
||||||
|
return pact.FormFieldType{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formField returns a compiled form field by name.
|
||||||
|
func formField(t *testing.T, schema *cabana.FormSchema, name string) cabana.FormField {
|
||||||
|
t.Helper()
|
||||||
|
for _, f := range schema.Fields {
|
||||||
|
if f.Name == name {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("form has no field %s", name)
|
||||||
|
return cabana.FormField{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorPrincipal is a backend administrator who may edit and publish any
|
||||||
|
// post.
|
||||||
|
func editorPrincipal() *bouncer.Principal {
|
||||||
|
return &bouncer.Principal{ID: 7, Backend: true, IsSuperuser: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBlogEditorTracerSmoke is the Phase 16.2 plan 03 tracer: post content
|
||||||
|
// compiles as mlblogeditor, Decode stores the policy's output with notices
|
||||||
|
// naming what it removed, Load serves HTML and markdown posts per D-07, and
|
||||||
|
// the Form hooks write the written content to content_html per locale
|
||||||
|
// without goldmark.
|
||||||
|
func TestBlogEditorTracerSmoke(t *testing.T) {
|
||||||
|
ml := blogType(t, "mlblogeditor")
|
||||||
|
|
||||||
|
t.Run("posts form compiles with mlblogeditor", func(t *testing.T) {
|
||||||
|
schema, err := cabana.CompileFormWithTypes("golem15.journal", postsAdminController{}, os.DirFS(".."), classes.BlogEditorTypes(nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := formField(t, schema, "content")
|
||||||
|
if content.Type != "mlblogeditor" || content.Element != classes.BlogEditorElement || !content.Translatable {
|
||||||
|
t.Fatalf("content = type %q element %q translatable %v", content.Type, content.Element, content.Translatable)
|
||||||
|
}
|
||||||
|
if content.TypeOptions["htmlColumn"] != "content_html" || content.TypeOptions["uploadFolder"] != "posts" {
|
||||||
|
t.Fatalf("content typeOptions = %#v", content.TypeOptions)
|
||||||
|
}
|
||||||
|
if excerpt := formField(t, schema, "excerpt"); excerpt.Type != "mltextarea" {
|
||||||
|
t.Fatalf("excerpt type = %q, want mltextarea", excerpt.Type)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("type definitions", func(t *testing.T) {
|
||||||
|
if ml.Element != "golem15-journal-blogeditor" || len(ml.Scripts) != 1 || ml.Scripts[0] != "assets/dist/blogeditor.js" ||
|
||||||
|
len(ml.Styles) != 1 || ml.Styles[0] != "assets/dist/blogeditor.css" {
|
||||||
|
t.Fatalf("mlblogeditor = %+v", ml)
|
||||||
|
}
|
||||||
|
for _, asset := range append(append([]string{}, ml.Scripts...), ml.Styles...) {
|
||||||
|
if _, err := os.Stat("../" + asset); err != nil {
|
||||||
|
t.Fatalf("asset %s: %v", asset, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plain := blogType(t, "blogeditor")
|
||||||
|
if plain.Translatable || len(plain.Options) != 1 || plain.Options[0] != "uploadFolder" {
|
||||||
|
t.Fatalf("blogeditor = %+v", plain)
|
||||||
|
}
|
||||||
|
for _, bad := range []map[string]any{
|
||||||
|
{"htmlColumn": "content html"},
|
||||||
|
{"htmlColumn": 3},
|
||||||
|
{"uploadFolder": "../up"},
|
||||||
|
{"uploadFolder": "a b"},
|
||||||
|
{"uploadFolder": strings.Repeat("a", 101)},
|
||||||
|
} {
|
||||||
|
if err := ml.CheckOptions(bad); err == nil {
|
||||||
|
t.Fatalf("CheckOptions(%v) = nil, want an error", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := ml.CheckOptions(map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts/2026"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
decode := func(t *testing.T, value any) (pact.FormFieldDecoded, error) {
|
||||||
|
t.Helper()
|
||||||
|
return ml.Decode(context.Background(), pact.FormFieldValue{Field: "content", Locale: "pl", Default: true, Value: value})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("decode keeps clean HTML byte for byte", func(t *testing.T) {
|
||||||
|
const clean = "<h2>Grzyby</h2>\n<p>Tekst z <a href=\"https://example.test/a\">linkiem</a> & <strong>pogrubieniem</strong>.</p>\n"
|
||||||
|
got, err := decode(t, clean)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Value != clean || len(got.Notices) != 0 {
|
||||||
|
t.Fatalf("Decode(clean) = %#v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("decode keeps the embed placeholder", func(t *testing.T) {
|
||||||
|
const placeholder = `<p>Film:</p><figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>`
|
||||||
|
got, err := decode(t, placeholder)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Value != placeholder || len(got.Notices) != 0 {
|
||||||
|
t.Fatalf("Decode(placeholder) = %#v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("decode strips and reports", func(t *testing.T) {
|
||||||
|
got, err := decode(t, `<p>a<script>alert(1)</script></p><img src="https://example.test/x.png" alt="x" onerror="alert(1)">`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Value != `<p>a</p><img src="https://example.test/x.png" alt="x">` {
|
||||||
|
t.Fatalf("Decode value = %q", got.Value)
|
||||||
|
}
|
||||||
|
if len(got.Notices) != 2 {
|
||||||
|
t.Fatalf("notices = %#v, want two", got.Notices)
|
||||||
|
}
|
||||||
|
if n := got.Notices[0]; n.Message != "golem15.journal::lang.editor.removed_elements" || n.Count != 1 || n.Params["names"] != "script" {
|
||||||
|
t.Fatalf("first notice = %#v", n)
|
||||||
|
}
|
||||||
|
if n := got.Notices[1]; n.Message != "golem15.journal::lang.editor.removed_attributes" || n.Count != 1 || n.Params["names"] != "onerror" {
|
||||||
|
t.Fatalf("second notice = %#v", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("decode nil and non-string", func(t *testing.T) {
|
||||||
|
got, err := decode(t, nil)
|
||||||
|
if err != nil || got.Value != "" || len(got.Notices) != 0 {
|
||||||
|
t.Fatalf("Decode(nil) = %#v, %v", got, err)
|
||||||
|
}
|
||||||
|
_, err = decode(t, 42.0)
|
||||||
|
var refused *pact.FormFieldError
|
||||||
|
if !errors.As(err, &refused) || len(refused.Messages) != 1 || refused.Messages[0] != "golem15.journal::lang.editor.content_invalid" {
|
||||||
|
t.Fatalf("Decode(42) error = %v, want a FormFieldError", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
load := func(t *testing.T, value any, translated func(string) (string, bool, error)) any {
|
||||||
|
t.Helper()
|
||||||
|
got, err := ml.Load(context.Background(), pact.FormFieldValue{
|
||||||
|
Field: "content",
|
||||||
|
Options: map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts"},
|
||||||
|
Locale: "en",
|
||||||
|
Value: value,
|
||||||
|
Translated: translated,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
t.Run("load serves HTML content as it is", func(t *testing.T) {
|
||||||
|
const html = " <!-- lead -->\n<p>Stored <em>HTML</em></p>"
|
||||||
|
for _, stored := range []string{"", html, html + "\n"} {
|
||||||
|
if got := load(t, html, func(string) (string, bool, error) { return stored, true, nil }); got != html {
|
||||||
|
t.Fatalf("Load(HTML) with content_html %q = %q", stored, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := load(t, html, nil); got != html {
|
||||||
|
t.Fatalf("Load(HTML, no reader) = %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("load serves markdown that starts with a tag as content_html", func(t *testing.T) {
|
||||||
|
const source = "<img src=\"/storage/hero.png\" alt=\"Hero\">\n\n## Heading\n\n**bold**"
|
||||||
|
rendered, err := classes.FormatHTML(source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := load(t, source, func(string) (string, bool, error) { return rendered, true, nil }); got != rendered {
|
||||||
|
t.Fatalf("Load(markdown starting with <img>) = %q, want content_html %q", got, rendered)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("load serves a markdown post's content_html", func(t *testing.T) {
|
||||||
|
got := load(t, "Hello **world**", func(attribute string) (string, bool, error) {
|
||||||
|
if attribute != "content_html" {
|
||||||
|
t.Fatalf("Translated(%q), want content_html", attribute)
|
||||||
|
}
|
||||||
|
return "<p>Stored render</p>", true, nil
|
||||||
|
})
|
||||||
|
if got != "<p>Stored render</p>" {
|
||||||
|
t.Fatalf("Load(markdown) = %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("load renders markdown when content_html is empty", func(t *testing.T) {
|
||||||
|
got := load(t, "Hello **world**", func(string) (string, bool, error) { return "", true, nil })
|
||||||
|
if got != "<p>Hello <strong>world</strong></p>\n" {
|
||||||
|
t.Fatalf("Load(markdown, empty content_html) = %q", got)
|
||||||
|
}
|
||||||
|
if got := load(t, "Hello **world**", nil); got != "<p>Hello <strong>world</strong></p>\n" {
|
||||||
|
t.Fatalf("Load(markdown, no reader) = %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// CR-01: a translation that carries only content_html opens with that
|
||||||
|
// body. An untouched save echoes the served value, which cabana skips;
|
||||||
|
// an edit writes the edited HTML to both content and content_html.
|
||||||
|
t.Run("blank content opens with content_html and an edit writes both columns", func(t *testing.T) {
|
||||||
|
const apiBody = "<h2>Translated</h2>\n<p>Written through the API.</p>\n"
|
||||||
|
served := load(t, "", func(attribute string) (string, bool, error) {
|
||||||
|
if attribute != "content_html" {
|
||||||
|
t.Fatalf("Translated(%q), want content_html", attribute)
|
||||||
|
}
|
||||||
|
return apiBody, true, nil
|
||||||
|
})
|
||||||
|
if served != apiBody {
|
||||||
|
t.Fatalf("Load(blank content) = %q, want the stored content_html", served)
|
||||||
|
}
|
||||||
|
|
||||||
|
const edited = "<h2>Translated</h2>\n<p>Edited in the admin.</p>\n"
|
||||||
|
decoded, err := ml.Decode(context.Background(), pact.FormFieldValue{Field: "content", Locale: "en", Value: edited})
|
||||||
|
if err != nil || decoded.Value != edited {
|
||||||
|
t.Fatalf("Decode(edited) = %#v, %v", decoded, err)
|
||||||
|
}
|
||||||
|
writer := &recordingWriter{}
|
||||||
|
app := backpack.New(nil)
|
||||||
|
if err := app.Publish[cabana.TranslationWriter](writer); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctl := postsAdminController{app: func() *backpack.App { return app }}
|
||||||
|
ctx := bouncer.WithUser(context.Background(), editorPrincipal())
|
||||||
|
ctx = cabana.WithFieldWrites(ctx, map[string][]cabana.FieldWrite{
|
||||||
|
"content": {{Locale: "en", Value: decoded.Value}},
|
||||||
|
})
|
||||||
|
post := &models.Post{Content: "<p>Host</p>", ContentHTML: "<p>Host</p>"}
|
||||||
|
if err := ctl.FormAfterUpdate(ctx, post); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(writer.writes) != 1 || writer.writes[0] != (recordedWrite{field: "content_html", locale: "en", value: edited}) {
|
||||||
|
t.Fatalf("translation writes = %#v, want en content_html = the edited value", writer.writes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("list value", func(t *testing.T) {
|
||||||
|
got := ml.ListValue(context.Background(), "<h2>Title</h2><p>Some <strong>bold</strong>\n text</p><style>p{}</style>")
|
||||||
|
if got != "Title Some bold text" {
|
||||||
|
t.Fatalf("ListValue = %q", got)
|
||||||
|
}
|
||||||
|
long := ml.ListValue(context.Background(), "<p>"+strings.Repeat("ż", 130)+"</p>")
|
||||||
|
if []rune(long)[120] != '…' || len([]rune(long)) != 121 {
|
||||||
|
t.Fatalf("ListValue(long) = %q", long)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("hooks write content_html per written locale", func(t *testing.T) {
|
||||||
|
writer := &recordingWriter{}
|
||||||
|
app := backpack.New(nil)
|
||||||
|
if err := app.Publish[cabana.TranslationWriter](writer); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctl := postsAdminController{app: func() *backpack.App { return app }}
|
||||||
|
ctx := bouncer.WithUser(context.Background(), editorPrincipal())
|
||||||
|
ctx = cabana.WithFieldWrites(ctx, map[string][]cabana.FieldWrite{
|
||||||
|
"content": {
|
||||||
|
{Locale: "pl", Default: true, Value: "<p>A</p>"},
|
||||||
|
{Locale: "en", Value: "<p>B</p>"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
post := &models.Post{Content: "<p>A</p>", ContentHTML: "old"}
|
||||||
|
if err := ctl.FormBeforeUpdate(ctx, post); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if post.ContentHTML != "<p>A</p>" {
|
||||||
|
t.Fatalf("ContentHTML = %q, want <p>A</p>", post.ContentHTML)
|
||||||
|
}
|
||||||
|
if err := ctl.FormAfterUpdate(ctx, post); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(writer.writes) != 1 || writer.writes[0] != (recordedWrite{field: "content_html", locale: "en", value: "<p>B</p>"}) {
|
||||||
|
t.Fatalf("translation writes = %#v", writer.writes)
|
||||||
|
}
|
||||||
|
if err := ctl.FormAfterCreate(ctx, post); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(writer.writes) != 2 {
|
||||||
|
t.Fatalf("FormAfterCreate writes = %#v", writer.writes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("hooks leave untouched content alone", func(t *testing.T) {
|
||||||
|
writer := &recordingWriter{}
|
||||||
|
app := backpack.New(nil)
|
||||||
|
if err := app.Publish[cabana.TranslationWriter](writer); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctl := postsAdminController{app: func() *backpack.App { return app }}
|
||||||
|
ctx := bouncer.WithUser(context.Background(), editorPrincipal())
|
||||||
|
const markdown = "Hello **world**"
|
||||||
|
post := &models.Post{Content: markdown, ContentHTML: "<p>kept</p>"}
|
||||||
|
if err := ctl.FormBeforeUpdate(ctx, post); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if post.ContentHTML != "<p>kept</p>" {
|
||||||
|
t.Fatalf("ContentHTML = %q, want it unchanged (no goldmark)", post.ContentHTML)
|
||||||
|
}
|
||||||
|
ctx = cabana.WithFieldWrites(ctx, map[string][]cabana.FieldWrite{"title": {{Locale: "en", Value: "x"}}})
|
||||||
|
if err := ctl.FormBeforeUpdate(ctx, post); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ctl.FormAfterUpdate(ctx, post); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if post.ContentHTML != "<p>kept</p>" || len(writer.writes) != 0 {
|
||||||
|
t.Fatalf("ContentHTML = %q, writes = %#v", post.ContentHTML, writer.writes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("notice phrases have plural forms", func(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{}
|
||||||
|
for _, locale := range []string{"en", "pl"} {
|
||||||
|
raw, err := os.ReadFile("../lang/" + locale + "/lang.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fsys["lang/"+locale+"/lang.yaml"] = &fstest.MapFile{Data: raw}
|
||||||
|
}
|
||||||
|
cat := phrasebook.NewCatalog()
|
||||||
|
if err := cat.Load("golem15.journal", fsys); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
|
||||||
|
names := map[string]string{"names": "script, iframe"}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
locale, key string
|
||||||
|
count int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"en", "removed_elements", 1, "1 element was removed: script, iframe"},
|
||||||
|
{"en", "removed_elements", 2, "2 elements were removed: script, iframe"},
|
||||||
|
{"en", "removed_attributes", 3, "3 attributes were removed: script, iframe"},
|
||||||
|
{"en", "removed_comments", 1, "1 HTML comment was removed"},
|
||||||
|
{"pl", "removed_elements", 1, "Usunięto 1 element: script, iframe"},
|
||||||
|
{"pl", "removed_elements", 3, "Usunięto 3 elementy: script, iframe"},
|
||||||
|
{"pl", "removed_attributes", 5, "Usunięto 5 atrybutów: script, iframe"},
|
||||||
|
{"pl", "removed_comments", 2, "Usunięto 2 komentarze HTML"},
|
||||||
|
} {
|
||||||
|
if got := tr.ChoiceIn(tc.locale, "golem15.journal::lang.editor."+tc.key, tc.count, names); got != tc.want {
|
||||||
|
t.Fatalf("%s %s %d = %q, want %q", tc.locale, tc.key, tc.count, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := tr.GetIn("pl", "golem15.journal::lang.editor.content_invalid", nil); got != "Treść musi być tekstem HTML." {
|
||||||
|
t.Fatalf("pl content_invalid = %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// pngBytes, jpegBytes and htmlBytes are file heads http.DetectContentType
|
||||||
|
// recognizes.
|
||||||
|
var (
|
||||||
|
pngBytes = append([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"), make([]byte, 64)...)
|
||||||
|
jpegBytes = append([]byte("\xff\xd8\xff\xe0\x00\x10JFIF\x00"), make([]byte, 64)...)
|
||||||
|
gifBytes = append([]byte("GIF89a"), make([]byte, 64)...)
|
||||||
|
webpBytes = append([]byte("RIFF\x24\x00\x00\x00WEBPVP8 "), make([]byte, 64)...)
|
||||||
|
htmlBytes = []byte("<!DOCTYPE html><html><body><script>alert(1)</script></body></html>")
|
||||||
|
svgBytes = []byte(`<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBlogEditorUploadSmoke covers plan 03 Task 2: the media storage shared
|
||||||
|
// by the public upload route and the editor Upload, the editor Upload's
|
||||||
|
// magic-byte check and safe names, and category content on blogeditor.
|
||||||
|
func TestBlogEditorUploadSmoke(t *testing.T) {
|
||||||
|
bucket := memblob.OpenBucket(nil)
|
||||||
|
t.Cleanup(func() { _ = bucket.Close() })
|
||||||
|
app := backpack.New(nil)
|
||||||
|
if err := app.Publish(bucket); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
stored := func(t *testing.T, key string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
data, err := bucket.ReadAll(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", key, err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
mediaError := func(t *testing.T, err error, message string) {
|
||||||
|
t.Helper()
|
||||||
|
var refused *classes.MediaError
|
||||||
|
if !errors.As(err, &refused) || refused.Field != "file" || refused.Message != message {
|
||||||
|
t.Fatalf("error = %v, want MediaError on file %q", err, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("StoreMedia keeps the public route's layout", func(t *testing.T) {
|
||||||
|
got, err := classes.StoreMedia(ctx, bucket, "photos", "cover.png", bytes.NewReader(pngBytes))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Path != "/journal/photos/cover.png" || got.URL != attach.PublicURL("journal/photos/cover.png") {
|
||||||
|
t.Fatalf("StoreMedia = %+v", got)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(stored(t, "journal/photos/cover.png"), pngBytes) {
|
||||||
|
t.Fatal("stored bytes differ")
|
||||||
|
}
|
||||||
|
again, err := classes.StoreMedia(ctx, bucket, "/journal/photos/", `C:\up\cover.png`, bytes.NewReader(pngBytes))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again.Path != "/journal/photos/cover-1.png" {
|
||||||
|
t.Fatalf("second StoreMedia path = %q, want the unique suffix", again.Path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("StoreMedia refuses other extensions and large files", func(t *testing.T) {
|
||||||
|
_, err := classes.StoreMedia(ctx, bucket, "", "logo.svg", bytes.NewReader(svgBytes))
|
||||||
|
mediaError(t, err, "The file must be an image.")
|
||||||
|
big := append(append([]byte{}, pngBytes...), make([]byte, classes.MediaMaxBytes)...)
|
||||||
|
_, err = classes.StoreMedia(ctx, bucket, "", "big.png", bytes.NewReader(big))
|
||||||
|
mediaError(t, err, "The file may not be greater than 10240 kilobytes.")
|
||||||
|
if exists, _ := bucket.Exists(ctx, "journal/big.png"); exists {
|
||||||
|
t.Fatal("a refused file was stored")
|
||||||
|
}
|
||||||
|
if _, err := classes.StoreMedia(ctx, nil, "", "a.png", bytes.NewReader(pngBytes)); err == nil {
|
||||||
|
t.Fatal("StoreMedia without a bucket = nil error")
|
||||||
|
} else if errors.As(err, new(*classes.MediaError)) {
|
||||||
|
t.Fatalf("StoreMedia without a bucket = %v, want an opaque error", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
types := classes.BlogEditorTypes(func() *backpack.App { return app })
|
||||||
|
for _, ft := range types {
|
||||||
|
if ft.Upload == nil {
|
||||||
|
t.Fatalf("%s has no Upload", ft.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upload := func(ft pact.FormFieldType, options map[string]any, name string, body []byte) (pact.FormFieldUploadResult, error) {
|
||||||
|
return ft.Upload(ctx, pact.FormFieldUpload{Field: "content", Options: options, Filename: name, Body: bytes.NewReader(body)})
|
||||||
|
}
|
||||||
|
refused := func(t *testing.T, err error, message string) {
|
||||||
|
t.Helper()
|
||||||
|
var fe *pact.FormFieldError
|
||||||
|
if !errors.As(err, &fe) || len(fe.Messages) != 1 || fe.Messages[0] != message {
|
||||||
|
t.Fatalf("error = %v, want FormFieldError %q", err, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
posts := map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts"}
|
||||||
|
|
||||||
|
t.Run("editor upload stores a sniffed image under a safe name", func(t *testing.T) {
|
||||||
|
got, err := upload(types[1], posts, "Zdjęcie 1.PNG", pngBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.URL != attach.PublicURL("journal/posts/zdjecie-1.png") {
|
||||||
|
t.Fatalf("URL = %q", got.URL)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(stored(t, "journal/posts/zdjecie-1.png"), pngBytes) {
|
||||||
|
t.Fatal("stored bytes differ from the upload")
|
||||||
|
}
|
||||||
|
for name, body := range map[string][]byte{"a.jpg": jpegBytes, "b.jpeg": jpegBytes, "c.gif": gifBytes, "d.webp": webpBytes} {
|
||||||
|
if _, err := upload(types[0], map[string]any{"uploadFolder": "categories"}, name, body); err != nil {
|
||||||
|
t.Fatalf("upload %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err = upload(types[0], nil, "...", pngBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(got.URL, attach.PublicURL("journal/editor/")) {
|
||||||
|
t.Fatalf("default folder URL = %q, want journal/editor/", got.URL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("editor upload refuses what does not sniff as its image type", func(t *testing.T) {
|
||||||
|
const invalid = "golem15.journal::lang.editor.image_invalid"
|
||||||
|
_, err := upload(types[1], posts, "x.png", htmlBytes)
|
||||||
|
refused(t, err, invalid)
|
||||||
|
_, err = upload(types[1], posts, "logo.svg", svgBytes)
|
||||||
|
refused(t, err, invalid)
|
||||||
|
_, err = upload(types[1], posts, "photo.jpg", pngBytes)
|
||||||
|
refused(t, err, invalid)
|
||||||
|
_, err = upload(types[1], posts, "photo.svg", pngBytes)
|
||||||
|
refused(t, err, "The file must be an image.")
|
||||||
|
big := append(append([]byte{}, pngBytes...), make([]byte, classes.MediaMaxBytes)...)
|
||||||
|
_, err = upload(types[1], posts, "big.png", big)
|
||||||
|
refused(t, err, "The file may not be greater than 10240 kilobytes.")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("editor upload without a bucket is an opaque error", func(t *testing.T) {
|
||||||
|
bare := classes.BlogEditorTypes(func() *backpack.App { return backpack.New(nil) })
|
||||||
|
_, err := bare[1].Upload(ctx, pact.FormFieldUpload{Field: "content", Options: posts, Filename: "a.png", Body: bytes.NewReader(pngBytes)})
|
||||||
|
if err == nil || errors.As(err, new(*pact.FormFieldError)) {
|
||||||
|
t.Fatalf("error = %v, want an opaque error", err)
|
||||||
|
}
|
||||||
|
nilApp := classes.BlogEditorTypes(nil)
|
||||||
|
if _, err := nilApp[1].Upload(ctx, pact.FormFieldUpload{Field: "content", Filename: "a.png", Body: bytes.NewReader(pngBytes)}); err == nil {
|
||||||
|
t.Fatal("upload without an app = nil error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("category content compiles as blogeditor", func(t *testing.T) {
|
||||||
|
schema, err := cabana.CompileFormWithTypes("golem15.journal", categoriesAdminController{}, os.DirFS(".."), classes.BlogEditorTypes(nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := formField(t, schema, "content")
|
||||||
|
if content.Type != "blogeditor" || content.Element != classes.BlogEditorElement || content.Translatable {
|
||||||
|
t.Fatalf("content = type %q element %q translatable %v", content.Type, content.Element, content.Translatable)
|
||||||
|
}
|
||||||
|
if content.TypeOptions["uploadFolder"] != "categories" || content.Size != "huge" {
|
||||||
|
t.Fatalf("content typeOptions = %#v size %q", content.TypeOptions, content.Size)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
plain := types[0]
|
||||||
|
t.Run("category load serves the stored HTML", func(t *testing.T) {
|
||||||
|
html := "<p>Pillar</p>"
|
||||||
|
for _, tc := range []struct {
|
||||||
|
value any
|
||||||
|
want string
|
||||||
|
}{{&html, html}, {(*string)(nil), ""}, {nil, ""}, {"plain text", "plain text"}} {
|
||||||
|
got, err := plain.Load(ctx, pact.FormFieldValue{Field: "content", Options: map[string]any{"uploadFolder": "categories"}, Default: true, Value: tc.value})
|
||||||
|
if err != nil || got != tc.want {
|
||||||
|
t.Fatalf("Load(%#v) = %#v, %v, want %q", tc.value, got, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("category decode strips with a notice", func(t *testing.T) {
|
||||||
|
got, err := plain.Decode(ctx, pact.FormFieldValue{Field: "content", Default: true, Value: `<p onclick="steal()">x</p>`})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Value != "<p>x</p>" || len(got.Notices) != 1 {
|
||||||
|
t.Fatalf("Decode = %#v", got)
|
||||||
|
}
|
||||||
|
if n := got.Notices[0]; n.Message != "golem15.journal::lang.editor.removed_attributes" || n.Count != 1 || n.Params["names"] != "onclick" {
|
||||||
|
t.Fatalf("notice = %#v", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("category hooks map blank to NULL and accept markup", func(t *testing.T) {
|
||||||
|
ctl := categoriesAdminController{}
|
||||||
|
blank := " \n\t"
|
||||||
|
cat := &models.Category{Content: &blank}
|
||||||
|
if err := ctl.FormBeforeUpdate(ctx, cat); err != nil || cat.Content != nil {
|
||||||
|
t.Fatalf("blank: content = %v, err = %v, want NULL", cat.Content, err)
|
||||||
|
}
|
||||||
|
body := `<p onclick="steal()">x</p>`
|
||||||
|
cat = &models.Category{Content: &body}
|
||||||
|
if err := ctl.FormBeforeCreate(ctx, cat); err != nil {
|
||||||
|
t.Fatalf("FormBeforeCreate refused markup: %v", err)
|
||||||
|
}
|
||||||
|
if cat.Content == nil || *cat.Content != body {
|
||||||
|
t.Fatalf("content = %v, want it as received", cat.Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
197
controllers/posts_editor_test.go
Normal file
197
controllers/posts_editor_test.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPostsEditorHooks pins the posts controller's Form hooks around the
|
||||||
|
// Blog Editor (Phase 16.2 D-02, D-03): the default-locale content write is
|
||||||
|
// copied to content_html before the save, each written non-default locale's
|
||||||
|
// content is written as that locale's content_html after it, and content the
|
||||||
|
// save does not write is never touched.
|
||||||
|
func TestPostsEditorHooks(t *testing.T) {
|
||||||
|
withWriter := func(t *testing.T) (postsAdminController, *recordingWriter) {
|
||||||
|
t.Helper()
|
||||||
|
writer := &recordingWriter{}
|
||||||
|
app := backpack.New(nil)
|
||||||
|
if err := app.Publish[cabana.TranslationWriter](writer); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return postsAdminController{app: func() *backpack.App { return app }}, writer
|
||||||
|
}
|
||||||
|
principal := func() context.Context {
|
||||||
|
return bouncer.WithUser(context.Background(), editorPrincipal())
|
||||||
|
}
|
||||||
|
writes := func(ctx context.Context, list ...cabana.FieldWrite) context.Context {
|
||||||
|
return cabana.WithFieldWrites(ctx, map[string][]cabana.FieldWrite{"content": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
type hook func(postsAdminController, context.Context, any) error
|
||||||
|
before := map[string]hook{
|
||||||
|
"create": postsAdminController.FormBeforeCreate,
|
||||||
|
"update": postsAdminController.FormBeforeUpdate,
|
||||||
|
}
|
||||||
|
after := map[string]hook{
|
||||||
|
"create": postsAdminController.FormAfterCreate,
|
||||||
|
"update": postsAdminController.FormAfterUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, op := range []string{"create", "update"} {
|
||||||
|
t.Run(op+": default and non-default writes", func(t *testing.T) {
|
||||||
|
ctl, writer := withWriter(t)
|
||||||
|
ctx := writes(principal(),
|
||||||
|
cabana.FieldWrite{Locale: "pl", Default: true, Value: "<p>PL</p>"},
|
||||||
|
cabana.FieldWrite{Locale: "en", Value: "<p>EN</p>"},
|
||||||
|
cabana.FieldWrite{Locale: "de", Value: "<p>DE</p>"},
|
||||||
|
)
|
||||||
|
post := &models.Post{Content: "<p>PL</p>", ContentHTML: "old"}
|
||||||
|
if err := before[op](ctl, ctx, post); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if post.ContentHTML != "<p>PL</p>" {
|
||||||
|
t.Fatalf("ContentHTML = %q, want the default write", post.ContentHTML)
|
||||||
|
}
|
||||||
|
if len(writer.writes) != 0 {
|
||||||
|
t.Fatalf("translations written before the save: %#v", writer.writes)
|
||||||
|
}
|
||||||
|
if err := after[op](ctl, ctx, post); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []recordedWrite{{"content_html", "en", "<p>EN</p>"}, {"content_html", "de", "<p>DE</p>"}}
|
||||||
|
if len(writer.writes) != 2 || writer.writes[0] != want[0] || writer.writes[1] != want[1] {
|
||||||
|
t.Fatalf("writes = %#v, want %#v", writer.writes, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run(op+": only the default locale written", func(t *testing.T) {
|
||||||
|
ctl, writer := withWriter(t)
|
||||||
|
ctx := writes(principal(), cabana.FieldWrite{Locale: "pl", Default: true, Value: "<p>PL</p>"})
|
||||||
|
post := &models.Post{ContentHTML: "old"}
|
||||||
|
if err := before[op](ctl, ctx, post); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := after[op](ctl, ctx, post); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if post.ContentHTML != "<p>PL</p>" || len(writer.writes) != 0 {
|
||||||
|
t.Fatalf("ContentHTML = %q, writes = %#v", post.ContentHTML, writer.writes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run(op+": only a non-default locale written", func(t *testing.T) {
|
||||||
|
ctl, writer := withWriter(t)
|
||||||
|
ctx := writes(principal(), cabana.FieldWrite{Locale: "en", Value: "<p>EN</p>"})
|
||||||
|
post := &models.Post{Content: "markdown", ContentHTML: "<p>imported</p>"}
|
||||||
|
if err := before[op](ctl, ctx, post); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := after[op](ctl, ctx, post); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if post.ContentHTML != "<p>imported</p>" {
|
||||||
|
t.Fatalf("ContentHTML = %q, want the untouched default kept", post.ContentHTML)
|
||||||
|
}
|
||||||
|
if len(writer.writes) != 1 || writer.writes[0] != (recordedWrite{"content_html", "en", "<p>EN</p>"}) {
|
||||||
|
t.Fatalf("writes = %#v", writer.writes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run(op+": no content written", func(t *testing.T) {
|
||||||
|
ctl, writer := withWriter(t)
|
||||||
|
for name, ctx := range map[string]context.Context{
|
||||||
|
"outside a save": principal(),
|
||||||
|
"no plugin field": cabana.WithFieldWrites(principal(), nil),
|
||||||
|
"other fields": cabana.WithFieldWrites(principal(), map[string][]cabana.FieldWrite{"excerpt": {{Locale: "en", Value: "x"}}}),
|
||||||
|
} {
|
||||||
|
post := &models.Post{Content: "markdown", ContentHTML: "<p>imported</p>"}
|
||||||
|
if err := before[op](ctl, ctx, post); err != nil {
|
||||||
|
t.Fatalf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
if err := after[op](ctl, ctx, post); err != nil {
|
||||||
|
t.Fatalf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
if post.ContentHTML != "<p>imported</p>" || len(writer.writes) != 0 {
|
||||||
|
t.Fatalf("%s: ContentHTML = %q, writes = %#v", name, post.ContentHTML, writer.writes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run(op+": no translation writer or no application", func(t *testing.T) {
|
||||||
|
ctx := writes(principal(), cabana.FieldWrite{Locale: "en", Value: "<p>EN</p>"})
|
||||||
|
for name, ctl := range map[string]postsAdminController{
|
||||||
|
"no writer": {app: func() *backpack.App { return backpack.New(nil) }},
|
||||||
|
"nil app": {app: func() *backpack.App { return nil }},
|
||||||
|
"no app": {},
|
||||||
|
} {
|
||||||
|
if err := after[op](ctl, ctx, &models.Post{}); err != nil {
|
||||||
|
t.Fatalf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("a non-string value is not copied and fails the translation write", func(t *testing.T) {
|
||||||
|
ctl, writer := withWriter(t)
|
||||||
|
post := &models.Post{ContentHTML: "old"}
|
||||||
|
ctx := writes(principal(), cabana.FieldWrite{Locale: "pl", Default: true, Value: 42})
|
||||||
|
if err := ctl.FormBeforeUpdate(ctx, post); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if post.ContentHTML != "old" {
|
||||||
|
t.Fatalf("ContentHTML = %q", post.ContentHTML)
|
||||||
|
}
|
||||||
|
ctx = writes(principal(), cabana.FieldWrite{Locale: "en", Value: 42})
|
||||||
|
if err := ctl.FormAfterUpdate(ctx, post); err == nil || len(writer.writes) != 0 {
|
||||||
|
t.Fatalf("err = %v, writes = %#v", err, writer.writes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a model that is not a post is an error", func(t *testing.T) {
|
||||||
|
ctl, _ := withWriter(t)
|
||||||
|
ctx := writes(principal(), cabana.FieldWrite{Locale: "en", Value: "<p>EN</p>"})
|
||||||
|
for name, fn := range map[string]hook{
|
||||||
|
"FormBeforeCreate": postsAdminController.FormBeforeCreate,
|
||||||
|
"FormBeforeUpdate": postsAdminController.FormBeforeUpdate,
|
||||||
|
"FormAfterCreate": postsAdminController.FormAfterCreate,
|
||||||
|
"FormAfterUpdate": postsAdminController.FormAfterUpdate,
|
||||||
|
} {
|
||||||
|
if err := fn(ctl, ctx, &models.Category{}); err == nil {
|
||||||
|
t.Fatalf("%s accepted a category", name)
|
||||||
|
}
|
||||||
|
if err := fn(ctl, ctx, (*models.Post)(nil)); err == nil {
|
||||||
|
t.Fatalf("%s accepted a nil post", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a translation writer error fails the save", func(t *testing.T) {
|
||||||
|
boom := errors.New("write failed")
|
||||||
|
app := backpack.New(nil)
|
||||||
|
if err := app.Publish[cabana.TranslationWriter](failingWriter{recordingWriter: &recordingWriter{}, err: boom}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctl := postsAdminController{app: func() *backpack.App { return app }}
|
||||||
|
ctx := writes(principal(), cabana.FieldWrite{Locale: "en", Value: "<p>EN</p>"})
|
||||||
|
if err := ctl.FormAfterCreate(ctx, &models.Post{}); !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("err = %v, want %v", err, boom)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// failingWriter is a cabana.TranslationWriter whose writes fail.
|
||||||
|
type failingWriter struct {
|
||||||
|
*recordingWriter
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w failingWriter) WriteTranslated(context.Context, *gorm.DB, any, string, string, string) error {
|
||||||
|
return w.err
|
||||||
|
}
|
||||||
36
controllers/site_cache.go
Normal file
36
controllers/site_cache.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// siteCachePurger returns the application's site-cache purger
|
||||||
|
// (cooler.SiteCacheFromApp: the framework keys, or the purger the plugin
|
||||||
|
// published from its deprecated keys) and the save transaction of ctx, for
|
||||||
|
// classes.PurgeSiteCacheAfterCommit. Without a purger (no purge configured)
|
||||||
|
// or outside a save transaction it returns a nil purger, which
|
||||||
|
// PurgeSiteCacheAfterCommit ignores.
|
||||||
|
func siteCachePurger(ctx context.Context, app func() *backpack.App) (*classes.SiteCachePurger, *gorm.DB) {
|
||||||
|
if app == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
a := app()
|
||||||
|
if a == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
tx, ok := cabana.TxFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
purger := cooler.SiteCacheFromApp(a)
|
||||||
|
if purger == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return purger, tx
|
||||||
|
}
|
||||||
21
field_types.go
Normal file
21
field_types.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package journal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormFieldTypes registers the Blog Editor field types, blogeditor and
|
||||||
|
// mlblogeditor (classes.BlogEditorTypes), with a lazy accessor of the booted
|
||||||
|
// app.
|
||||||
|
func (p *Plugin) FormFieldTypes() []pact.FormFieldType {
|
||||||
|
return classes.BlogEditorTypes(func() *backpack.App {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.app
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ pact.HasFormFieldTypes = (*Plugin)(nil)
|
||||||
64
go.mod
64
go.mod
@@ -5,22 +5,62 @@ go 1.27.0
|
|||||||
require (
|
require (
|
||||||
git.golem15.com/golem15/sm-translate-plugin v0.1.0
|
git.golem15.com/golem15/sm-translate-plugin v0.1.0
|
||||||
git.golem15.com/golem15/sm-user-plugin v0.1.0
|
git.golem15.com/golem15/sm-user-plugin v0.1.0
|
||||||
git.golem15.com/golem15/summercms v0.1.9
|
git.golem15.com/golem15/summercms v0.6.1
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||||
|
github.com/goccy/go-yaml v1.19.2
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/testcontainers/testcontainers-go v0.44.0
|
github.com/testcontainers/testcontainers-go v0.44.0
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
|
||||||
github.com/yuin/goldmark v1.8.6
|
github.com/yuin/goldmark v1.8.6
|
||||||
gocloud.dev v0.46.0
|
gocloud.dev v0.46.0
|
||||||
|
golang.org/x/net v0.58.0
|
||||||
gorm.io/gorm v1.31.2
|
gorm.io/gorm v1.31.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
charm.land/bubbles/v2 v2.2.1 // indirect
|
||||||
|
charm.land/bubbletea/v2 v2.1.0 // indirect
|
||||||
|
charm.land/huh/v2 v2.0.3 // indirect
|
||||||
|
charm.land/lipgloss/v2 v2.0.6 // indirect
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect
|
||||||
|
github.com/aws/smithy-go v1.26.0 // indirect
|
||||||
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
|
github.com/catppuccin/go v0.2.0 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/containerd/errdefs v1.0.0 // indirect
|
github.com/containerd/errdefs v1.0.0 // indirect
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
@@ -30,10 +70,12 @@ require (
|
|||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/go-connections v0.7.0 // indirect
|
github.com/docker/go-connections v0.7.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/ebitengine/purego v0.10.1 // indirect
|
github.com/ebitengine/purego v0.10.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||||
|
github.com/gen2brain/webp v0.6.4 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
@@ -41,10 +83,12 @@ require (
|
|||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.4 // indirect
|
github.com/go-playground/validator/v10 v10.30.4 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
|
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/google/wire v0.7.0 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
|
||||||
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
@@ -60,9 +104,13 @@ require (
|
|||||||
github.com/knadh/koanf/v2 v2.3.6 // indirect
|
github.com/knadh/koanf/v2 v2.3.6 // indirect
|
||||||
github.com/leodido/go-urn v1.5.0 // indirect
|
github.com/leodido/go-urn v1.5.0 // indirect
|
||||||
github.com/lib/pq v1.12.3 // indirect
|
github.com/lib/pq v1.12.3 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||||
github.com/magiconair/properties v1.8.10 // indirect
|
github.com/magiconair/properties v1.8.10 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.30 // indirect
|
||||||
|
github.com/maypok86/otter/v2 v2.3.0 // indirect
|
||||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/go-archive v0.2.0 // indirect
|
github.com/moby/go-archive v0.2.0 // indirect
|
||||||
@@ -73,16 +121,22 @@ require (
|
|||||||
github.com/moby/sys/user v0.4.0 // indirect
|
github.com/moby/sys/user v0.4.0 // indirect
|
||||||
github.com/moby/sys/userns v0.1.0 // indirect
|
github.com/moby/sys/userns v0.1.0 // indirect
|
||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
|
github.com/redis/go-redis/v9 v9.23.0 // indirect
|
||||||
github.com/riverqueue/river v0.47.0 // indirect
|
github.com/riverqueue/river v0.47.0 // indirect
|
||||||
github.com/riverqueue/river/riverdriver v0.47.0 // indirect
|
github.com/riverqueue/river/riverdriver v0.47.0 // indirect
|
||||||
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 // indirect
|
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 // indirect
|
||||||
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 // indirect
|
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 // indirect
|
||||||
github.com/riverqueue/river/rivershared v0.47.0 // indirect
|
github.com/riverqueue/river/rivershared v0.47.0 // indirect
|
||||||
github.com/riverqueue/river/rivertype v0.47.0 // indirect
|
github.com/riverqueue/river/rivertype v0.47.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
|
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||||
github.com/spf13/cobra v1.10.2 // indirect
|
github.com/spf13/cobra v1.10.2 // indirect
|
||||||
@@ -95,6 +149,8 @@ require (
|
|||||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||||
github.com/wneessen/go-mail v0.8.1 // indirect
|
github.com/wneessen/go-mail v0.8.1 // indirect
|
||||||
|
github.com/xo/terminfo v1.0.0 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||||
@@ -103,14 +159,16 @@ require (
|
|||||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||||
|
go.uber.org/atomic v1.12.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/crypto v0.57.0 // indirect
|
golang.org/x/crypto v0.57.0 // indirect
|
||||||
golang.org/x/image v0.46.0 // indirect
|
golang.org/x/image v0.46.0 // indirect
|
||||||
golang.org/x/net v0.58.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/term v0.46.0 // indirect
|
golang.org/x/term v0.46.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
google.golang.org/api v0.272.0 // indirect
|
google.golang.org/api v0.272.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||||
|
|||||||
106
go.sum
106
go.sum
@@ -1,5 +1,13 @@
|
|||||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||||
|
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||||
|
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||||
|
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
||||||
|
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||||
|
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||||
|
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||||
|
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||||
|
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||||
@@ -32,8 +40,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55
|
|||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.40.0 h1:6IgTQbvm2JiMYZ8yur/acNJzVjtSOWCelxXbk/LUBXc=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.40.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
||||||
@@ -72,10 +86,50 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3
|
|||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
|
||||||
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||||
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
||||||
|
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
|
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||||
|
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
|
||||||
|
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||||
|
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||||
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||||
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||||
|
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||||
|
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||||
|
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
|
||||||
|
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
@@ -100,6 +154,8 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf
|
|||||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||||
@@ -113,6 +169,8 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5
|
|||||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||||
|
github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU=
|
||||||
|
github.com/gen2brain/webp v0.6.4/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE=
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg=
|
github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg=
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog=
|
github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
@@ -143,6 +201,14 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
|
|||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo=
|
||||||
|
github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI=
|
||||||
|
github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk=
|
||||||
|
github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg=
|
||||||
|
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||||
|
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||||
|
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -153,6 +219,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA
|
|||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||||
github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE=
|
github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE=
|
||||||
github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA=
|
github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA=
|
||||||
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
|
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
|
||||||
@@ -171,6 +239,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||||
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
||||||
github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU=
|
github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU=
|
||||||
@@ -187,16 +257,26 @@ github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
|
|||||||
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
||||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||||
|
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w=
|
||||||
|
github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8=
|
||||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||||
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
@@ -217,6 +297,10 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
|
|||||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
@@ -228,6 +312,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s=
|
||||||
|
github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0=
|
||||||
github.com/riverqueue/river v0.47.0 h1:j8HOEyiOE8gRRhVS2wllKams372TH1WWiH6xCakXHqc=
|
github.com/riverqueue/river v0.47.0 h1:j8HOEyiOE8gRRhVS2wllKams372TH1WWiH6xCakXHqc=
|
||||||
github.com/riverqueue/river v0.47.0/go.mod h1:Wgmwx475ZBd8lQnNrJgyG2DWH7BfyNiSAtv0rC9bJBQ=
|
github.com/riverqueue/river v0.47.0/go.mod h1:Wgmwx475ZBd8lQnNrJgyG2DWH7BfyNiSAtv0rC9bJBQ=
|
||||||
github.com/riverqueue/river/riverdriver v0.47.0 h1:qU8VkjdMl9plqeRg57SxsDUM/i/eECaSYejZ7HynC60=
|
github.com/riverqueue/river/riverdriver v0.47.0 h1:qU8VkjdMl9plqeRg57SxsDUM/i/eECaSYejZ7HynC60=
|
||||||
@@ -240,9 +326,15 @@ github.com/riverqueue/river/rivershared v0.47.0 h1:jdtFsBexCvLqTXf8wnDnGXvB/eeOt
|
|||||||
github.com/riverqueue/river/rivershared v0.47.0/go.mod h1:w8Pi1T+6ypyko5/hs9Mv7IIIKo4fAL9eXYnkVV/Y418=
|
github.com/riverqueue/river/rivershared v0.47.0/go.mod h1:w8Pi1T+6ypyko5/hs9Mv7IIIKo4fAL9eXYnkVV/Y418=
|
||||||
github.com/riverqueue/river/rivertype v0.47.0 h1:SzNavtLGR4nMT1QkrEYQ7n96OMatYsn/z3aJWhewmv0=
|
github.com/riverqueue/river/rivertype v0.47.0 h1:SzNavtLGR4nMT1QkrEYQ7n96OMatYsn/z3aJWhewmv0=
|
||||||
github.com/riverqueue/river/rivertype v0.47.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw=
|
github.com/riverqueue/river/rivertype v0.47.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
|
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||||
|
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
@@ -281,10 +373,18 @@ github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqo
|
|||||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||||
github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
|
github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
|
||||||
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
|
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
|
||||||
|
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||||
|
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
|
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
|
||||||
@@ -305,6 +405,8 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk
|
|||||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||||
|
go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE=
|
||||||
|
go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
@@ -314,6 +416,8 @@ gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q=
|
|||||||
gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM=
|
gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM=
|
||||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||||
@@ -336,6 +440,8 @@ golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
|||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||||
|
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
|
|||||||
855
headings_test.go
Normal file
855
headings_test.go
Normal file
@@ -0,0 +1,855 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/console"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
)
|
||||||
|
|
||||||
|
const journalAPIPrefix = "/_journal/api/v1"
|
||||||
|
|
||||||
|
// setH1ToH2 switches the Journal H1 setting on the settings row.
|
||||||
|
func setH1ToH2(t *testing.T, env *adminEnv, on bool) {
|
||||||
|
t.Helper()
|
||||||
|
if err := env.gdb.Exec(`UPDATE golem15_journal_settings SET content_h1_to_h2 = ? WHERE id = 1`, on).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishPost publishes a post with a past published_at.
|
||||||
|
func publishPost(t *testing.T, env *adminEnv, id uint) {
|
||||||
|
t.Helper()
|
||||||
|
if err := env.gdb.Exec(`UPDATE golem15_journal_posts SET published = TRUE, published_at = NOW() - INTERVAL '1 hour' WHERE id = ?`, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// showH1Flag is GET /_journal/api/v1/posts/{slug}'s content_h1_to_h2.
|
||||||
|
func showH1Flag(t *testing.T, env *adminEnv, slug string) any {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, journalAPIPrefix+"/posts/"+slug, "", nil)
|
||||||
|
var body struct {
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("show body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
flag, ok := body.Data["content_h1_to_h2"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("show answer has no content_h1_to_h2: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
return flag
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHeadingConversionTracer is the 33-02 tracer: an editor saves a post
|
||||||
|
// whose content holds an H1 through the admin and gets an H2 back with the
|
||||||
|
// h1_converted notice, and the public post answer reports the setting.
|
||||||
|
// With the setting off the same save keeps the H1, adds no notice, and the
|
||||||
|
// answer reports false.
|
||||||
|
func TestHeadingConversionTracer(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
token := env.admin(t,
|
||||||
|
controllers.PermissionAccessPosts,
|
||||||
|
controllers.PermissionAccessPublish,
|
||||||
|
controllers.PermissionAccessOtherPosts,
|
||||||
|
)
|
||||||
|
const submitted = "<h1>A</h1><p>x</p>"
|
||||||
|
|
||||||
|
save := func(t *testing.T, slug string) (uint, []adminNotice) {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
|
||||||
|
"title": map[string]any{"en": "Heading " + slug, "pl": "Nagłówek " + slug},
|
||||||
|
"slug": map[string]any{"en": slug, "pl": slug + "-pl"},
|
||||||
|
"content": map[string]any{"en": submitted, "pl": submitted},
|
||||||
|
})
|
||||||
|
return adminCreatedID(t, rec), adminNotices(t, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("setting on", func(t *testing.T) {
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
id, notices := save(t, "heading-on")
|
||||||
|
assertHostContent(t, env, id, "on", "<h2>A</h2><p>x</p>", "<h2>A</h2><p>x</p>")
|
||||||
|
pl := translatedAttrs(t, env, id, "pl")
|
||||||
|
if pl["content"] != "<h2>A</h2><p>x</p>" || pl["content_html"] != "<h2>A</h2><p>x</p>" {
|
||||||
|
t.Fatalf("pl content = %#v, content_html = %#v, want the H2", pl["content"], pl["content_html"])
|
||||||
|
}
|
||||||
|
for _, locale := range []string{"en", "pl"} {
|
||||||
|
found := false
|
||||||
|
for _, n := range notices {
|
||||||
|
if n.Field == "content" && n.Locale == locale &&
|
||||||
|
n.Message == "1 H1 heading was converted to H2, because the page shows the post title as the H1." {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("%s notices = %#v, want the h1_converted phrase with count 1", locale, notices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publishPost(t, env, id)
|
||||||
|
if flag := showH1Flag(t, env, "heading-on"); flag != true {
|
||||||
|
t.Fatalf("content_h1_to_h2 = %#v, want true", flag)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("setting off", func(t *testing.T) {
|
||||||
|
setH1ToH2(t, env, false)
|
||||||
|
t.Cleanup(func() { setH1ToH2(t, env, true) })
|
||||||
|
id, notices := save(t, "heading-off")
|
||||||
|
assertHostContent(t, env, id, "off", submitted, submitted)
|
||||||
|
for _, n := range notices {
|
||||||
|
if strings.Contains(n.Message, "H1") {
|
||||||
|
t.Fatalf("setting off: notice %#v", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if notices != nil {
|
||||||
|
t.Fatalf("setting off: clean save carries meta.notices %#v", notices)
|
||||||
|
}
|
||||||
|
publishPost(t, env, id)
|
||||||
|
if flag := showH1Flag(t, env, "heading-off"); flag != false {
|
||||||
|
t.Fatalf("content_h1_to_h2 = %#v, want false", flag)
|
||||||
|
}
|
||||||
|
// D-50: switching the setting never rewrites stored content.
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
var stored models.Post
|
||||||
|
if err := env.gdb.First(&stored, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.Content != submitted || stored.ContentHTML != submitted {
|
||||||
|
t.Fatalf("switching the setting on rewrote content: %q / %q", stored.Content, stored.ContentHTML)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdInput is a bonfire.Input of flags only.
|
||||||
|
type cmdInput map[string]string
|
||||||
|
|
||||||
|
func (in cmdInput) Args() []string { return nil }
|
||||||
|
func (in cmdInput) Argument(string) (string, bool) { return "", false }
|
||||||
|
func (in cmdInput) Flags(string) []string { return nil }
|
||||||
|
func (in cmdInput) Flag(name string) (string, bool) { v, ok := in[name]; return v, ok }
|
||||||
|
|
||||||
|
// apiNotices decodes a Journal API write answer's notices, nil when absent.
|
||||||
|
func apiNotices(t *testing.T, rec *httptest.ResponseRecorder) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var body map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("api body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
raw, ok := body["notices"]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
t.Fatalf("notices %s: %v", raw, err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiPostID is the id of a Journal API write answer's post.
|
||||||
|
func apiPostID(t *testing.T, rec *httptest.ResponseRecorder) uint {
|
||||||
|
t.Helper()
|
||||||
|
var body struct {
|
||||||
|
Data struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.ID == 0 {
|
||||||
|
t.Fatalf("api answer %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return body.Data.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func storedPost(t *testing.T, env *adminEnv, id uint) models.Post {
|
||||||
|
t.Helper()
|
||||||
|
var p models.Post
|
||||||
|
if err := env.gdb.First(&p, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
const postH1Phrase = "1 H1 heading was converted to H2, because the page shows the post title as the H1."
|
||||||
|
|
||||||
|
// TestHeadingConversion covers every other Journal rich-text write path
|
||||||
|
// (D-50, CONTEXT 2026-10-28 H1 conversion scope): the API (and so MCP)
|
||||||
|
// create and update with default and translated content, the markdown
|
||||||
|
// path, journal:import-posts, a category's pillar text through the admin,
|
||||||
|
// and the content_h1_to_h2 flag of GET /categories. With the setting off
|
||||||
|
// nothing converts and no notice appears.
|
||||||
|
func TestHeadingConversion(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
token := env.admin(t, controllers.PermissionAccessPosts)
|
||||||
|
posts := journalAPIPrefix + "/posts"
|
||||||
|
|
||||||
|
t.Run("API create converts default and translated content", func(t *testing.T) {
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{
|
||||||
|
"title": "API heading",
|
||||||
|
"content": `<h1 class="x">T</h1>`,
|
||||||
|
"translations": map[string]any{"pl": map[string]any{"title": "Nagłówek API", "content": `<h1 class="x">P</h1>`}},
|
||||||
|
})
|
||||||
|
id := apiPostID(t, rec)
|
||||||
|
p := storedPost(t, env, id)
|
||||||
|
if p.Content != `<h2 class="x">T</h2>` || !strings.Contains(p.ContentHTML, `<h2 class="x">T</h2>`) || strings.Contains(p.ContentHTML, "<h1") {
|
||||||
|
t.Fatalf("content = %q, content_html = %q, want the H2 with its class", p.Content, p.ContentHTML)
|
||||||
|
}
|
||||||
|
pl := translatedAttrs(t, env, id, "pl")
|
||||||
|
if pl["content"] != `<h2 class="x">P</h2>` || !strings.Contains(pl["content_html"].(string), `<h2 class="x">P</h2>`) {
|
||||||
|
t.Fatalf("pl = %#v, want the H2", pl)
|
||||||
|
}
|
||||||
|
notices := apiNotices(t, rec)
|
||||||
|
if len(notices) != 2 || notices[0]["message"] != postH1Phrase || notices[0]["count"] != float64(1) ||
|
||||||
|
notices[1]["locale"] != "pl" || notices[1]["count"] != float64(1) {
|
||||||
|
t.Fatalf("notices = %#v, want the default and the pl phrase with count 1", notices)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("API update converts markdown; an update without content converts nothing", func(t *testing.T) {
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
id := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{
|
||||||
|
"title": "Markdown heading", "content": "Body",
|
||||||
|
}))
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", posts, id), token, map[string]any{
|
||||||
|
"content": "# T\n\nText",
|
||||||
|
})
|
||||||
|
p := storedPost(t, env, id)
|
||||||
|
if p.Content != "# T\n\nText" || !strings.Contains(p.ContentHTML, "<h2") || strings.Contains(p.ContentHTML, "<h1") {
|
||||||
|
t.Fatalf("content = %q, content_html = %q, want markdown kept and an H2", p.Content, p.ContentHTML)
|
||||||
|
}
|
||||||
|
if notices := apiNotices(t, rec); len(notices) != 1 || notices[0]["message"] != postH1Phrase {
|
||||||
|
t.Fatalf("notices = %#v", notices)
|
||||||
|
}
|
||||||
|
if err := env.gdb.Exec(`UPDATE golem15_journal_posts SET content_html = '<h1>Old</h1>' WHERE id = ?`, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec = env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", posts, id), token, map[string]any{"title": "Renamed"})
|
||||||
|
if notices := apiNotices(t, rec); notices != nil {
|
||||||
|
t.Fatalf("title-only update notices = %#v", notices)
|
||||||
|
}
|
||||||
|
if got := storedPost(t, env, id).ContentHTML; got != "<h1>Old</h1>" {
|
||||||
|
t.Fatalf("title-only update rewrote content_html: %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("API with the setting off stores H1", func(t *testing.T) {
|
||||||
|
setH1ToH2(t, env, false)
|
||||||
|
t.Cleanup(func() { setH1ToH2(t, env, true) })
|
||||||
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{
|
||||||
|
"title": "Kept heading", "content": `<h1 class="x">T</h1>`,
|
||||||
|
})
|
||||||
|
p := storedPost(t, env, apiPostID(t, rec))
|
||||||
|
if p.Content != `<h1 class="x">T</h1>` || !strings.Contains(p.ContentHTML, `<h1 class="x">T</h1>`) {
|
||||||
|
t.Fatalf("content = %q, content_html = %q, want the H1 kept", p.Content, p.ContentHTML)
|
||||||
|
}
|
||||||
|
if notices := apiNotices(t, rec); notices != nil {
|
||||||
|
t.Fatalf("setting off: notices %#v", notices)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("journal:import-posts converts and prints the count", func(t *testing.T) {
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
dir := t.TempDir()
|
||||||
|
raw, err := json.Marshal(console.PostFile{
|
||||||
|
Slug: "imported-heading", Title: "Imported", Content: "<h1>I</h1><p>x</p>",
|
||||||
|
Translations: map[string]map[string]any{"pl": {"title": "Import", "content": "<h1>J</h1>"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "imported-heading.json"), raw, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
|
||||||
|
if err := console.ImportPosts(env.app).Run(t.Context(), cmdInput{"path": dir}, out); err != nil {
|
||||||
|
t.Fatalf("import: %v (%s)", err, buf.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "Converted 2 H1 headings to H2.") {
|
||||||
|
t.Fatalf("import output %q, want the converted count", buf.String())
|
||||||
|
}
|
||||||
|
var p models.Post
|
||||||
|
if err := env.gdb.Where("slug = ?", "imported-heading").Take(&p).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Content != "<h2>I</h2><p>x</p>" || strings.Contains(p.ContentHTML, "<h1") || !strings.Contains(p.ContentHTML, "<h2>I</h2>") {
|
||||||
|
t.Fatalf("imported content = %q, content_html = %q", p.Content, p.ContentHTML)
|
||||||
|
}
|
||||||
|
if pl := translatedAttrs(t, env, p.ID, "pl"); !strings.Contains(pl["content_html"].(string), "<h2>J</h2>") {
|
||||||
|
t.Fatalf("imported pl = %#v", pl)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("markdown with a fenced h1 sample keeps its source", func(t *testing.T) {
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
md := "# Sample\n\n```html\n<h1>Hello</h1>\n```\n\nInline `<h1>` tag."
|
||||||
|
id := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{"title": "Markdown sample", "content": md}))
|
||||||
|
p := storedPost(t, env, id)
|
||||||
|
if p.Content != md || strings.Contains(p.ContentHTML, "<h1") || !strings.Contains(p.ContentHTML, "<h1>Hello</h1>") {
|
||||||
|
t.Fatalf("API save: content = %q, content_html = %q", p.Content, p.ContentHTML)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
raw, err := json.Marshal(console.PostFile{Slug: "imported-markdown", Title: "Imported markdown", Content: md})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "imported-markdown.json"), raw, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := console.ImportPosts(env.app).Run(t.Context(), cmdInput{"path": dir}, bonfire.NewOutput(strings.NewReader(""), &buf, &buf)); err != nil {
|
||||||
|
t.Fatalf("import: %v (%s)", err, buf.String())
|
||||||
|
}
|
||||||
|
var imported models.Post
|
||||||
|
if err := env.gdb.Where("slug = ?", "imported-markdown").Take(&imported).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if imported.Content != md || strings.Contains(imported.ContentHTML, "<h1") || !strings.Contains(imported.ContentHTML, "<h2") {
|
||||||
|
t.Fatalf("import: content = %q, content_html = %q", imported.Content, imported.ContentHTML)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("markdown that starts with an HTML block keeps its h1 samples", func(t *testing.T) {
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
md := "<p class=\"lead\">Intro</p>\n\n# Title\n\n```html\n<h1>Hello</h1>\n```"
|
||||||
|
id := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{"title": "HTML lead", "content": md}))
|
||||||
|
p := storedPost(t, env, id)
|
||||||
|
if p.Content != md || strings.Contains(p.ContentHTML, "<h1") || !strings.Contains(p.ContentHTML, "<h1>Hello</h1>") || !strings.Contains(p.ContentHTML, "<h2") {
|
||||||
|
t.Fatalf("API save: content = %q, content_html = %q", p.Content, p.ContentHTML)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
raw, err := json.Marshal(console.PostFile{Slug: "imported-html-lead", Title: "Imported HTML lead", Content: md})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "imported-html-lead.json"), raw, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := console.ImportPosts(env.app).Run(t.Context(), cmdInput{"path": dir}, bonfire.NewOutput(strings.NewReader(""), &buf, &buf)); err != nil {
|
||||||
|
t.Fatalf("import: %v (%s)", err, buf.String())
|
||||||
|
}
|
||||||
|
var imported models.Post
|
||||||
|
if err := env.gdb.Where("slug = ?", "imported-html-lead").Take(&imported).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if imported.Content != md || strings.Contains(imported.ContentHTML, "<h1") {
|
||||||
|
t.Fatalf("import: content = %q, content_html = %q", imported.Content, imported.ContentHTML)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("category pillar text through the admin", func(t *testing.T) {
|
||||||
|
catToken := env.admin(t, controllers.PermissionAccessCategories)
|
||||||
|
save := func(t *testing.T, slug string) (*string, []adminNotice) {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), catToken, map[string]any{
|
||||||
|
"name": map[string]any{"en": "Pillar " + slug, "pl": "Filar " + slug},
|
||||||
|
"slug": map[string]any{"en": slug, "pl": slug + "-pl"},
|
||||||
|
"content": "<h1>C</h1><p>x</p>",
|
||||||
|
})
|
||||||
|
var cat models.Category
|
||||||
|
if err := env.gdb.First(&cat, adminCreatedID(t, rec)).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return cat.Content, adminNotices(t, rec)
|
||||||
|
}
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
content, notices := save(t, "pillar-on")
|
||||||
|
if content == nil || *content != "<h2>C</h2><p>x</p>" {
|
||||||
|
t.Fatalf("category content = %#v, want the H2", content)
|
||||||
|
}
|
||||||
|
if len(notices) != 1 || notices[0].Field != "content" ||
|
||||||
|
notices[0].Message != "1 H1 heading was converted to H2, because the page shows the category name as the H1." {
|
||||||
|
t.Fatalf("category notices = %#v", notices)
|
||||||
|
}
|
||||||
|
setH1ToH2(t, env, false)
|
||||||
|
t.Cleanup(func() { setH1ToH2(t, env, true) })
|
||||||
|
content, notices = save(t, "pillar-off")
|
||||||
|
if content == nil || *content != "<h1>C</h1><p>x</p>" || notices != nil {
|
||||||
|
t.Fatalf("setting off: content = %#v, notices = %#v", content, notices)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET /categories reports the setting on every row", func(t *testing.T) {
|
||||||
|
for _, on := range []bool{true, false} {
|
||||||
|
setH1ToH2(t, env, on)
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, journalAPIPrefix+"/categories?include_empty=1", "", nil)
|
||||||
|
var body struct {
|
||||||
|
Data []map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body.Data) == 0 {
|
||||||
|
t.Fatalf("categories %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
for _, row := range body.Data {
|
||||||
|
if row["content_h1_to_h2"] != on {
|
||||||
|
t.Fatalf("setting %v: row %#v", on, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const h1Refusal = "H1 to H2 conversion is turned off in the Journal settings (Headings and links). Turn it on first, or keep the H1 headings."
|
||||||
|
|
||||||
|
// seedStoredH1 stores, with the setting off, a post whose default and
|
||||||
|
// Polish content hold one H1 each and a category whose pillar text holds
|
||||||
|
// one H1, and switches the setting back on. It returns the post and the
|
||||||
|
// category id.
|
||||||
|
func seedStoredH1(t *testing.T, env *adminEnv, slug string) (uint, uint) {
|
||||||
|
t.Helper()
|
||||||
|
setH1ToH2(t, env, false)
|
||||||
|
token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessCategories)
|
||||||
|
post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", token, map[string]any{
|
||||||
|
"title": "Stored " + slug, "slug": slug, "content": "<h1>A</h1><p>x</p>",
|
||||||
|
"translations": map[string]any{"pl": map[string]any{"title": "Zapisany " + slug, "content": "<h1>B</h1>"}},
|
||||||
|
}))
|
||||||
|
cat := adminCreatedID(t, env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{
|
||||||
|
"name": map[string]any{"en": "Stored " + slug, "pl": "Zapisana " + slug},
|
||||||
|
"slug": map[string]any{"en": slug + "-cat", "pl": slug + "-kat"},
|
||||||
|
"content": "<h1>C</h1>",
|
||||||
|
}))
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
return post, cat
|
||||||
|
}
|
||||||
|
|
||||||
|
func categoryContent(t *testing.T, env *adminEnv, id uint) string {
|
||||||
|
t.Helper()
|
||||||
|
var c models.Category
|
||||||
|
if err := env.gdb.First(&c, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.Content == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *c.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFixHeadingsCommand: journal:fix-headings is a dry run by default,
|
||||||
|
// --apply converts default and translated post content and category pillar
|
||||||
|
// text and fires RecordSaved per changed record, a second run finds
|
||||||
|
// nothing, and with the setting off it refuses with exit code 1 (D-50,
|
||||||
|
// UI-SPEC C27 console copy, P33-54).
|
||||||
|
func TestFixHeadingsCommand(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
post, cat := seedStoredH1(t, env, "fix-cli")
|
||||||
|
run := func(t *testing.T, flags cmdInput) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
var buf strings.Builder
|
||||||
|
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
|
||||||
|
err := console.FixHeadings(env.app).Run(t.Context(), flags, out)
|
||||||
|
return buf.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
text, err := run(t, cmdInput{})
|
||||||
|
if err != nil || !strings.Contains(text, "Would convert 3 H1 headings in 1 posts and 1 categories. Run with --apply to save.") {
|
||||||
|
t.Fatalf("dry run = %q, %v", text, err)
|
||||||
|
}
|
||||||
|
if p := storedPost(t, env, post); p.ContentHTML != "<h1>A</h1><p>x</p>" || categoryContent(t, env, cat) != "<h1>C</h1>" {
|
||||||
|
t.Fatalf("dry run wrote: %q / %q", p.ContentHTML, categoryContent(t, env, cat))
|
||||||
|
}
|
||||||
|
|
||||||
|
records := listenRecords(t, env)
|
||||||
|
text, err = run(t, cmdInput{"apply": "true"})
|
||||||
|
if err != nil || !strings.Contains(text, "Converted 3 H1 headings in 1 posts and 1 categories.") {
|
||||||
|
t.Fatalf("apply = %q, %v", text, err)
|
||||||
|
}
|
||||||
|
p := storedPost(t, env, post)
|
||||||
|
if p.Content != "<h2>A</h2><p>x</p>" || p.ContentHTML != "<h2>A</h2><p>x</p>" {
|
||||||
|
t.Fatalf("applied post = %q / %q", p.Content, p.ContentHTML)
|
||||||
|
}
|
||||||
|
if pl := translatedAttrs(t, env, post, "pl"); pl["content"] != "<h2>B</h2>" || strings.Contains(pl["content_html"].(string), "<h1") {
|
||||||
|
t.Fatalf("applied pl = %#v", pl)
|
||||||
|
}
|
||||||
|
if got := categoryContent(t, env, cat); got != "<h2>C</h2>" {
|
||||||
|
t.Fatalf("applied category = %q", got)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, ev := range records.savedEvents() {
|
||||||
|
seen[ev.ev.Model] = true
|
||||||
|
if ev.ev.Model == p.MorphName() {
|
||||||
|
if c, ok := findChange(ev.ev.Changes, "content_html", "pl"); !ok || c.New == nil {
|
||||||
|
t.Fatalf("post event without the pl content_html change: %#v", ev.ev.Changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seen[p.MorphName()] || !seen[models.Category{}.MorphName()] {
|
||||||
|
t.Fatalf("RecordSaved models = %v, want the post and the category", seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
if text, err = run(t, cmdInput{"apply": "true"}); err != nil || !strings.Contains(text, "No H1 headings found in post or category content.") {
|
||||||
|
t.Fatalf("second run = %q, %v", text, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// L1 CR-02: a markdown post with a fenced h1 sample keeps its source;
|
||||||
|
// only its stored content_html is converted.
|
||||||
|
md := "# Sample\n\n```html\n<h1>Hello</h1>\n```\n"
|
||||||
|
rendered, err := classes.FormatHTML(md)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mdPost := models.Post{Title: "Markdown sample", Slug: "fix-cli-markdown", Content: md, ContentHTML: rendered}
|
||||||
|
if err := env.gdb.Create(&mdPost).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if text, err = run(t, cmdInput{"apply": "true"}); err != nil || !strings.Contains(text, "Converted 1 H1 headings in 1 posts and 0 categories.") {
|
||||||
|
t.Fatalf("markdown run = %q, %v", text, err)
|
||||||
|
}
|
||||||
|
if p := storedPost(t, env, mdPost.ID); p.Content != md || strings.Contains(p.ContentHTML, "<h1") || !strings.Contains(p.ContentHTML, "<h1>Hello</h1>") {
|
||||||
|
t.Fatalf("markdown post after --apply = %q / %q", p.Content, p.ContentHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
setH1ToH2(t, env, false)
|
||||||
|
t.Cleanup(func() { setH1ToH2(t, env, true) })
|
||||||
|
text, err = run(t, cmdInput{"apply": "true"})
|
||||||
|
if err == nil || !errors.Is(err, bonfire.ErrReported) || !strings.Contains(text, h1Refusal) {
|
||||||
|
t.Fatalf("setting off = %q, %v; want the refusal and exit code 1", text, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalSettingsTools: GET and PUT /_journal/api/v1/settings and POST
|
||||||
|
// /fix-headings, as the MCP tools call them, with
|
||||||
|
// golem15.journal.manage_settings, and 401/403 without (T-33-05).
|
||||||
|
func TestJournalSettingsTools(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
settings := journalAPIPrefix + "/settings"
|
||||||
|
manager := env.admin(t, controllers.PermissionManageSettings)
|
||||||
|
editor := env.admin(t, controllers.PermissionAccessPosts)
|
||||||
|
|
||||||
|
env.expect(t, http.StatusUnauthorized, http.MethodGet, settings, "", nil)
|
||||||
|
env.expect(t, http.StatusForbidden, http.MethodGet, settings, editor, nil)
|
||||||
|
env.expect(t, http.StatusForbidden, http.MethodPut, settings, editor, map[string]any{"content_h1_to_h2": false})
|
||||||
|
env.expect(t, http.StatusForbidden, http.MethodPost, journalAPIPrefix+"/fix-headings", editor, map[string]any{})
|
||||||
|
|
||||||
|
decode := func(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var body struct {
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data == nil {
|
||||||
|
t.Fatalf("settings body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return body.Data
|
||||||
|
}
|
||||||
|
got := decode(t, env.expect(t, http.StatusOK, http.MethodGet, settings, manager, nil))
|
||||||
|
if got["content_h1_to_h2"] != true || got["generic_anchors"] != nil || got["rss_enabled"] != true || got["rss_posts_per_feed"] != float64(20) {
|
||||||
|
t.Fatalf("settings = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = decode(t, env.expect(t, http.StatusOK, http.MethodPut, settings, manager, map[string]any{
|
||||||
|
"content_h1_to_h2": false, "generic_anchors": "click here\nhere",
|
||||||
|
}))
|
||||||
|
if got["content_h1_to_h2"] != false || got["generic_anchors"] != "click here\nhere" || got["rss_enabled"] != true {
|
||||||
|
t.Fatalf("updated settings = %#v", got)
|
||||||
|
}
|
||||||
|
var row models.Settings
|
||||||
|
if err := env.gdb.First(&row, 1).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if row.ContentH1ToH2 || row.GenericAnchors == nil || *row.GenericAnchors != "click here\nhere" {
|
||||||
|
t.Fatalf("stored settings = %+v", row)
|
||||||
|
}
|
||||||
|
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, settings, manager, map[string]any{"rss_posts_per_feed": 500})
|
||||||
|
if !strings.Contains(rec.Body.String(), "rss_posts_per_feed") {
|
||||||
|
t.Fatalf("invalid settings answer %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
for key, bad := range map[string]any{"rss_image_url": "not a url", "generic_anchors": 5} {
|
||||||
|
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, settings, manager, map[string]any{key: bad})
|
||||||
|
if !strings.Contains(rec.Body.String(), key) {
|
||||||
|
t.Fatalf("invalid %s answer %s", key, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post, _ := seedStoredH1(t, env, "fix-api")
|
||||||
|
fix := journalAPIPrefix + "/fix-headings"
|
||||||
|
setH1ToH2(t, env, false)
|
||||||
|
rec = env.expect(t, http.StatusConflict, http.MethodPost, fix, manager, map[string]any{})
|
||||||
|
if !strings.Contains(rec.Body.String(), "H1 to H2 conversion is turned off") {
|
||||||
|
t.Fatalf("refusal %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
answer := func(rec *httptest.ResponseRecorder) map[string]any {
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("fix body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
dry := answer(env.expect(t, http.StatusOK, http.MethodPost, fix, manager, map[string]any{}))
|
||||||
|
if dry["dry_run"] != true || dry["posts"] != float64(1) || dry["categories"] != float64(1) || dry["headings"] != float64(3) {
|
||||||
|
t.Fatalf("dry run answer = %#v", dry)
|
||||||
|
}
|
||||||
|
if storedPost(t, env, post).ContentHTML != "<h1>A</h1><p>x</p>" {
|
||||||
|
t.Fatal("dry run wrote the post")
|
||||||
|
}
|
||||||
|
applied := answer(env.expect(t, http.StatusOK, http.MethodPost, fix, manager, map[string]any{"dry_run": false}))
|
||||||
|
if applied["dry_run"] != false || applied["headings"] != float64(3) {
|
||||||
|
t.Fatalf("applied answer = %#v", applied)
|
||||||
|
}
|
||||||
|
if got := storedPost(t, env, post).ContentHTML; got != "<h2>A</h2><p>x</p>" {
|
||||||
|
t.Fatalf("applied post content_html = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetImageAlt: POST /_journal/api/v1/images/alt sets alt on the
|
||||||
|
// matching img of a post's content_html (default or translated), answers
|
||||||
|
// the per-change diff, writes nothing on a dry run, refuses the whole batch
|
||||||
|
// with 422 when one src is not in the post, escapes the alt through the
|
||||||
|
// tokenizer (T-33-04) and needs golem15.journal.access_posts (D-85, P33-05).
|
||||||
|
func TestSetImageAlt(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
editor := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts)
|
||||||
|
manager := env.admin(t, controllers.PermissionManageSettings)
|
||||||
|
const a, b = "https://cdn.example.test/a.jpg", "https://cdn.example.test/b.jpg"
|
||||||
|
body := `<p><img src="` + a + `"></p><p><img src="` + b + `" alt="old"></p>`
|
||||||
|
post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{
|
||||||
|
"title": "Images", "content": body,
|
||||||
|
"translations": map[string]any{"pl": map[string]any{"title": "Obrazy", "content": `<p><img src="` + a + `"></p>`}},
|
||||||
|
}))
|
||||||
|
path := journalAPIPrefix + "/images/alt"
|
||||||
|
const tricky = `Lion's "mane" <b>`
|
||||||
|
|
||||||
|
env.expect(t, http.StatusUnauthorized, http.MethodPost, path, "", map[string]any{"changes": []any{}})
|
||||||
|
env.expect(t, http.StatusForbidden, http.MethodPost, path, manager, map[string]any{"changes": []any{}})
|
||||||
|
|
||||||
|
decode := func(rec *httptest.ResponseRecorder) ([]map[string]any, any) {
|
||||||
|
var out struct {
|
||||||
|
Changes []map[string]any `json:"changes"`
|
||||||
|
DryRun any `json:"dry_run"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("alt body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return out.Changes, out.DryRun
|
||||||
|
}
|
||||||
|
|
||||||
|
changes, dry := decode(env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
|
||||||
|
"dry_run": true,
|
||||||
|
"changes": []any{map[string]any{"post_id": post, "src": a, "alt": tricky}},
|
||||||
|
}))
|
||||||
|
if dry != true || len(changes) != 1 || changes[0]["old_alt"] != nil || changes[0]["alt"] != tricky || changes[0]["changed"] != true {
|
||||||
|
t.Fatalf("dry run answer = %#v, %v", changes, dry)
|
||||||
|
}
|
||||||
|
if got := storedPost(t, env, post).ContentHTML; strings.Contains(got, "alt=\"Lion") {
|
||||||
|
t.Fatalf("dry run wrote: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, path, editor, map[string]any{
|
||||||
|
"changes": []any{
|
||||||
|
map[string]any{"post_id": post, "src": a, "alt": "kept out"},
|
||||||
|
map[string]any{"post_id": post, "src": "https://cdn.example.test/missing.jpg", "alt": "x"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !strings.Contains(rec.Body.String(), "changes.1.src") {
|
||||||
|
t.Fatalf("missing src answer %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := storedPost(t, env, post).ContentHTML; strings.Contains(got, "kept out") {
|
||||||
|
t.Fatalf("refused batch wrote: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
records := listenRecords(t, env)
|
||||||
|
changes, dry = decode(env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
|
||||||
|
"changes": []any{
|
||||||
|
map[string]any{"post_id": post, "src": a, "alt": tricky},
|
||||||
|
map[string]any{"post_id": post, "src": b, "alt": ""},
|
||||||
|
map[string]any{"post_id": post, "locale": "pl", "src": a, "alt": "Lew"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
if dry != false || len(changes) != 3 || changes[1]["old_alt"] != "old" || changes[2]["locale"] != "pl" {
|
||||||
|
t.Fatalf("applied answer = %#v, %v", changes, dry)
|
||||||
|
}
|
||||||
|
p := storedPost(t, env, post)
|
||||||
|
for _, html := range []string{p.ContentHTML, p.Content} {
|
||||||
|
if !strings.Contains(html, `alt="Lion's "mane" <b>"`) || strings.Contains(html, "<b>") || !strings.Contains(html, `alt=""`) {
|
||||||
|
t.Fatalf("applied default = %q", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pl := translatedAttrs(t, env, post, "pl")
|
||||||
|
if !strings.Contains(pl["content_html"].(string), `alt="Lew"`) {
|
||||||
|
t.Fatalf("applied pl = %#v", pl)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, ev := range records.savedEvents() {
|
||||||
|
if ev.ev.Model == p.MorphName() {
|
||||||
|
_, def := findChange(ev.ev.Changes, "content_html", "")
|
||||||
|
_, tr := findChange(ev.ev.Changes, "content_html", "pl")
|
||||||
|
found = def && tr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("no RecordSaved with the default and pl content_html: %#v", records.savedEvents())
|
||||||
|
}
|
||||||
|
|
||||||
|
// L1 WR-05: on a markdown post the alt lands in the markdown, outside
|
||||||
|
// code, and survives the next save of the same content.
|
||||||
|
sample := "```\n\n```\n"
|
||||||
|
md := "Intro\n\n\n\n" + sample
|
||||||
|
mdPost := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{"title": "Markdown images", "content": md}))
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
|
||||||
|
"changes": []any{map[string]any{"post_id": mdPost, "src": a, "alt": "Grzyb [leśny]"}},
|
||||||
|
})
|
||||||
|
stored := storedPost(t, env, mdPost)
|
||||||
|
if stored.Content != "Intro\n\n![Grzyb \\[leśny\\]]("+a+")\n\n"+sample || !strings.Contains(stored.ContentHTML, `alt="Grzyb [leśny]"`) {
|
||||||
|
t.Fatalf("markdown post after the alt = %q / %q", stored.Content, stored.ContentHTML)
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/posts/%d", journalAPIPrefix, mdPost), editor, map[string]any{"content": stored.Content})
|
||||||
|
if again := storedPost(t, env, mdPost); !strings.Contains(again.ContentHTML, `alt="Grzyb [leśny]"`) {
|
||||||
|
t.Fatalf("the next content save lost the alt: %q", again.ContentHTML)
|
||||||
|
}
|
||||||
|
// A reference-style image is not rewritten: 422, nothing written.
|
||||||
|
ref := "![old][r]\n\n[r]: " + a + "\n"
|
||||||
|
refPost := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{"title": "Reference image", "content": ref}))
|
||||||
|
before := storedPost(t, env, refPost)
|
||||||
|
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, path, editor, map[string]any{
|
||||||
|
"changes": []any{map[string]any{"post_id": refPost, "src": a, "alt": "new"}},
|
||||||
|
})
|
||||||
|
if !strings.Contains(rec.Body.String(), "changes.0.src") || !strings.Contains(rec.Body.String(), "not an inline image") {
|
||||||
|
t.Fatalf("reference-style answer %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if after := storedPost(t, env, refPost); after.Content != before.Content || after.ContentHTML != before.ContentHTML {
|
||||||
|
t.Fatalf("a refused markdown change wrote: %q / %q", after.Content, after.ContentHTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetImageAltBodyShapes pins the body rule of images/alt
|
||||||
|
// (33-REVIEW-GAPFIX WR-02): a locale whose content is blank (a translation
|
||||||
|
// that carries only content_html) gets the alt in content_html alone, and
|
||||||
|
// any other content is read as markdown, also when it starts with a tag.
|
||||||
|
func TestSetImageAltBodyShapes(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
editor := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts)
|
||||||
|
const a = "https://cdn.example.test/a.jpg"
|
||||||
|
path := journalAPIPrefix + "/images/alt"
|
||||||
|
decode := func(rec *httptest.ResponseRecorder) []map[string]any {
|
||||||
|
var out struct {
|
||||||
|
Changes []map[string]any `json:"changes"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("alt body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return out.Changes
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("a translation that holds only content_html", func(t *testing.T) {
|
||||||
|
post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{
|
||||||
|
"title": "HTML translation", "content": "Intro\n\n",
|
||||||
|
"translations": map[string]any{"pl": map[string]any{"title": "Obrazy", "content_html": `<p><img src="` + a + `"></p>`}},
|
||||||
|
}))
|
||||||
|
changes := decode(env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
|
||||||
|
"changes": []any{
|
||||||
|
map[string]any{"post_id": post, "src": a, "alt": "Grzyb"},
|
||||||
|
map[string]any{"post_id": post, "locale": "pl", "src": a, "alt": "Lew"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
if len(changes) != 2 || changes[1]["locale"] != "pl" || changes[1]["changed"] != true || changes[1]["old_alt"] != nil {
|
||||||
|
t.Fatalf("answer = %#v", changes)
|
||||||
|
}
|
||||||
|
pl := translatedAttrs(t, env, post, "pl")
|
||||||
|
if html, _ := pl["content_html"].(string); !strings.Contains(html, `alt="Lew"`) {
|
||||||
|
t.Fatalf("pl = %#v", pl)
|
||||||
|
}
|
||||||
|
if c, has := pl["content"]; has {
|
||||||
|
if s, _ := c.(string); strings.TrimSpace(s) != "" {
|
||||||
|
t.Fatalf("pl content was written: %#v", pl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := storedPost(t, env, post).Content; got != "Intro\n\n" {
|
||||||
|
t.Fatalf("default content = %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("markdown that starts with a tag", func(t *testing.T) {
|
||||||
|
md := "<p class=\"lead\">Intro</p>\n\n\n\n```html\n<img src=\"" + a + "\">\n```\n"
|
||||||
|
post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{"title": "Tag lead", "content": md}))
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
|
||||||
|
"changes": []any{map[string]any{"post_id": post, "src": a, "alt": "Grzyb"}},
|
||||||
|
})
|
||||||
|
want := "<p class=\"lead\">Intro</p>\n\n\n\n```html\n<img src=\"" + a + "\">\n```\n"
|
||||||
|
stored := storedPost(t, env, post)
|
||||||
|
if stored.Content != want || !strings.Contains(stored.ContentHTML, `alt="Grzyb"`) {
|
||||||
|
t.Fatalf("after the alt = %q / %q", stored.Content, stored.ContentHTML)
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/posts/%d", journalAPIPrefix, post), editor, map[string]any{"content": stored.Content})
|
||||||
|
if again := storedPost(t, env, post); !strings.Contains(again.ContentHTML, `alt="Grzyb"`) {
|
||||||
|
t.Fatalf("the next content save lost the alt: %q", again.ContentHTML)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("markdown that starts with a tag and holds the image by reference", func(t *testing.T) {
|
||||||
|
ref := "<p>Lead</p>\n\n![old][r]\n\n[r]: " + a + "\n"
|
||||||
|
post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{"title": "Tag lead reference", "content": ref}))
|
||||||
|
before := storedPost(t, env, post)
|
||||||
|
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, path, editor, map[string]any{
|
||||||
|
"changes": []any{map[string]any{"post_id": post, "src": a, "alt": "new"}},
|
||||||
|
})
|
||||||
|
if !strings.Contains(rec.Body.String(), "changes.0.src") || !strings.Contains(rec.Body.String(), "not an inline image") {
|
||||||
|
t.Fatalf("reference answer %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if after := storedPost(t, env, post); after.Content != before.Content || after.ContentHTML != before.ContentHTML {
|
||||||
|
t.Fatalf("a refused change wrote: %q / %q", after.Content, after.ContentHTML)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBlogEditorRequestOptions (J1, UI-SPEC C28): the post and category
|
||||||
|
// editor fields serve headingLevels and genericAnchors per form schema
|
||||||
|
// request, following the H1 setting and the settings' generic link texts.
|
||||||
|
func TestBlogEditorRequestOptions(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessCategories)
|
||||||
|
options := func(t *testing.T, path string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(path+"/schema/form"), token, nil)
|
||||||
|
var body struct {
|
||||||
|
Data struct {
|
||||||
|
Fields []map[string]any `json:"fields"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("schema %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
for _, f := range body.Data.Fields {
|
||||||
|
if f["name"] == "content" {
|
||||||
|
opts, _ := f["typeOptions"].(map[string]any)
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("schema has no content field: %s", rec.Body.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, path := range []string{adminPostsPath, adminCategoriesPath} {
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
opts := options(t, path)
|
||||||
|
anchors, _ := opts["genericAnchors"].([]any)
|
||||||
|
if fmt.Sprint(opts["headingLevels"]) != "[2 3 4]" || len(anchors) == 0 || anchors[0] != "click here" {
|
||||||
|
t.Fatalf("%s options with the setting on = %v", path, opts)
|
||||||
|
}
|
||||||
|
if opts["uploadFolder"] == nil {
|
||||||
|
t.Fatalf("%s lost its fields.yaml options: %v", path, opts)
|
||||||
|
}
|
||||||
|
setH1ToH2(t, env, false)
|
||||||
|
if err := env.gdb.Exec(`UPDATE golem15_journal_settings SET generic_anchors = ? WHERE id = 1`, "Tutaj\n\nmore info").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
opts = options(t, path)
|
||||||
|
if fmt.Sprint(opts["headingLevels"]) != "[1 2 3 4]" || fmt.Sprint(opts["genericAnchors"]) != "[tutaj more info]" {
|
||||||
|
t.Fatalf("%s options with the setting off = %v", path, opts)
|
||||||
|
}
|
||||||
|
if err := env.gdb.Exec(`UPDATE golem15_journal_settings SET generic_anchors = NULL WHERE id = 1`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
}
|
||||||
146
image_variants_cli_test.go
Normal file
146
image_variants_cli_test.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/console"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
_ "gocloud.dev/blob/fileblob"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImageVariantsCommandCLI pins journal:image-variants in the
|
||||||
|
// generated CLI, where no database and no bucket are published: the
|
||||||
|
// command opens database.dsn and storage.uploads.bucket_url itself and
|
||||||
|
// writes the missing variants; without a DSN it names the setting.
|
||||||
|
func TestImageVariantsCommandCLI(t *testing.T) {
|
||||||
|
if testShort() {
|
||||||
|
t.Skip("Postgres")
|
||||||
|
}
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
var name string
|
||||||
|
if err := env.gdb.Raw(`SELECT current_database()`).Scan(&name).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u, err := url.Parse(adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u.Path = "/" + name
|
||||||
|
|
||||||
|
post := models.Post{Title: "Cli", Slug: "cli", Content: "<p>x</p>", Published: true}
|
||||||
|
if err := env.gdb.Create(&post).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
disk := "cli123def456ghi789jkl0.jpg"
|
||||||
|
key := attach.BlobKey(disk)
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, filepath.Dir(key)), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, key), jpegOf(t, 900, 600), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
file := attach.File{DiskName: disk, FileName: "cli.jpg", ContentType: "image/jpeg", Field: "featured_images", AttachmentType: post.MorphName(), AttachmentID: strconv.FormatUint(uint64(post.ID), 10)}
|
||||||
|
if err := env.gdb.Create(&file).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
run := func(environ ...string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: append([]string{"SUMMER_ENV=development"}, environ...)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app := backpack.New(cfg)
|
||||||
|
var buf strings.Builder
|
||||||
|
root, err := bonfire.NewRootIO("summer", []bonfire.Command{console.ImageVariants(app)}, strings.NewReader(""), &buf, &buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return bonfire.Execute(context.Background(), root, []string{"journal:image-variants"}), buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if code, out := run(); code == 0 || !strings.Contains(out, "database is not configured (set SUMMER_DATABASE__DSN)") {
|
||||||
|
t.Fatalf("without a DSN: %d\n%s", code, out)
|
||||||
|
}
|
||||||
|
code, out := run("SUMMER_DATABASE__DSN="+u.String(), "SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://"+dir,
|
||||||
|
"SUMMER_APP__KEY="+base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("k"), 32)))
|
||||||
|
if code != 0 || !strings.Contains(out, "Checked 1 featured images, wrote 2 variants") {
|
||||||
|
t.Fatalf("journal:image-variants from the CLI: %d\n%s", code, out)
|
||||||
|
}
|
||||||
|
var variants []models.ImageVariant
|
||||||
|
if err := env.gdb.Where("file_id = ?", file.ID).Order("width ASC").Find(&variants).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(variants) != 2 || variants[0].Width != 480 || variants[1].Width != 800 {
|
||||||
|
t.Fatalf("variants = %+v, want 480 and 800 (1200 is wider than the original)", variants)
|
||||||
|
}
|
||||||
|
bucket, err := blob.OpenBucket(t.Context(), "file://"+dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer bucket.Close()
|
||||||
|
if ok, err := bucket.Exists(t.Context(), variants[1].WebPKey); err != nil || !ok {
|
||||||
|
t.Fatalf("the CLI's WebP %s is not in the bucket: %v", variants[1].WebPKey, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFixHeadingsCommandCLI pins L1 WR-01 (D-50): journal:fix-headings in
|
||||||
|
// the generated CLI, where no database is published: without a DSN it
|
||||||
|
// names the setting, with one it opens database.dsn and prints the dry run.
|
||||||
|
func TestFixHeadingsCommandCLI(t *testing.T) {
|
||||||
|
if testShort() {
|
||||||
|
t.Skip("Postgres")
|
||||||
|
}
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
var name string
|
||||||
|
if err := env.gdb.Raw(`SELECT current_database()`).Scan(&name).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u, err := url.Parse(adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u.Path = "/" + name
|
||||||
|
post := models.Post{Title: "Cli heading", Slug: "cli-heading", Content: "<h1>A</h1><p>x</p>", ContentHTML: "<h1>A</h1><p>x</p>"}
|
||||||
|
if err := env.gdb.Create(&post).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
run := func(environ ...string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: append([]string{"SUMMER_ENV=development"}, environ...)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app := backpack.New(cfg)
|
||||||
|
var buf strings.Builder
|
||||||
|
root, err := bonfire.NewRootIO("summer", []bonfire.Command{console.FixHeadings(app)}, strings.NewReader(""), &buf, &buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return bonfire.Execute(context.Background(), root, []string{"journal:fix-headings"}), buf.String()
|
||||||
|
}
|
||||||
|
if code, out := run(); code == 0 || !strings.Contains(out, "database is not configured (set SUMMER_DATABASE__DSN)") {
|
||||||
|
t.Fatalf("without a DSN: %d\n%s", code, out)
|
||||||
|
}
|
||||||
|
code, out := run("SUMMER_DATABASE__DSN="+u.String(), "SUMMER_APP__KEY="+base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("k"), 32)))
|
||||||
|
if code != 0 || !strings.Contains(out, "Would convert 1 H1 headings in 1 posts and 0 categories. Run with --apply to save.") {
|
||||||
|
t.Fatalf("journal:fix-headings from the CLI: %d\n%s", code, out)
|
||||||
|
}
|
||||||
|
if p := storedPost(t, env, post.ID); p.Content != "<h1>A</h1><p>x</p>" {
|
||||||
|
t.Fatalf("the dry run wrote %q", p.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
jobs.go
Normal file
42
jobs.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package journal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/conga"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ pact.HasJobs = (*Plugin)(nil)
|
||||||
|
|
||||||
|
// Jobs registers the image variants job (golem15.journal.image_variants,
|
||||||
|
// D-85): thumbnails and WebP copies of featured images, outside the upload
|
||||||
|
// request, three attempts.
|
||||||
|
func (p *Plugin) Jobs() []pact.Job {
|
||||||
|
return []pact.Job{conga.Job(p.imageVariants, conga.MaxAttempts(3))}
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageVariants runs one image variants job.
|
||||||
|
func (p *Plugin) imageVariants(ctx context.Context, args classes.ImageVariantsArgs) error {
|
||||||
|
if p.app == nil {
|
||||||
|
return errors.New("journal: app is not booted")
|
||||||
|
}
|
||||||
|
db, ok := p.app.Lookup[*gorm.DB]()
|
||||||
|
if !ok || db == nil {
|
||||||
|
return errors.New("journal: database is not configured")
|
||||||
|
}
|
||||||
|
bucket, ok := p.app.Lookup[*blob.Bucket]()
|
||||||
|
if !ok || bucket == nil {
|
||||||
|
return errors.New("journal: the uploads bucket is not configured")
|
||||||
|
}
|
||||||
|
res, err := classes.GenerateImageVariants(ctx, db, bucket, classes.ImageWidths(p.app.Config), args.FileIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pluginLog.Info("journal: image variants", "files", res.Files, "written", res.Written, "skipped", res.Skipped)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
344
journal_api_authors_test.go
Normal file
344
journal_api_authors_test.go
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
authorSecretHash = "$2y$10$authorSecretMarker"
|
||||||
|
authorPersistCode = "persist-marker"
|
||||||
|
authorAPIPrefix = "/_journal/api/v1/authors/"
|
||||||
|
authorNotFoundText = "Author not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
// insertBackendUser writes one backend_users row with private columns set to
|
||||||
|
// marker values, so a test can prove they never reach a public payload.
|
||||||
|
func insertBackendUser(t *testing.T, env *adminEnv, first, last, login, slug string, deleted bool) uint {
|
||||||
|
t.Helper()
|
||||||
|
var id uint
|
||||||
|
if err := env.gdb.Raw(`INSERT INTO backend_users
|
||||||
|
(first_name, last_name, login, email, password, persist_code, is_activated, golem15_bloghub_author_slug, deleted_at, created_at, updated_at)
|
||||||
|
VALUES (NULLIF(?, ''), NULLIF(?, ''), ?, ?, ?, ?, TRUE, NULLIF(?, ''), CASE WHEN ? THEN NOW() ELSE NULL END, NOW(), NOW())
|
||||||
|
RETURNING id`,
|
||||||
|
first, last, login, login+"@example.test", authorSecretHash, authorPersistCode, slug, deleted,
|
||||||
|
).Scan(&id).Error; err != nil || id == 0 {
|
||||||
|
t.Fatalf("insert backend user %s: id=%d err=%v", login, id, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertAuthorPost creates one post owned by userID. at is published_at.
|
||||||
|
func insertAuthorPost(t *testing.T, env *adminEnv, userID uint, slug string, published bool, at *time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
uid := userID
|
||||||
|
post := models.Post{
|
||||||
|
UserID: &uid,
|
||||||
|
Title: "Post " + slug,
|
||||||
|
Slug: slug,
|
||||||
|
Content: "body " + slug,
|
||||||
|
ContentHTML: "<p>body " + slug + "</p>",
|
||||||
|
Published: published,
|
||||||
|
PublishedAt: at,
|
||||||
|
}
|
||||||
|
if err := env.gdb.Create(&post).Error; err != nil {
|
||||||
|
t.Fatalf("insert post %s: %v", slug, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorProfileOK(t *testing.T, env *adminEnv, param string) (map[string]any, string) {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.call(t, http.MethodGet, authorAPIPrefix+param, "", nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET authors/%s = %d, want 200: %s", param, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data == nil {
|
||||||
|
t.Fatalf("author body %s: err=%v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(body.Data))
|
||||||
|
for k := range body.Data {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
slices.Sort(keys)
|
||||||
|
if want := []string{"avatar", "description", "first_name", "id", "last_name", "slug", "subtitle"}; !slices.Equal(keys, want) {
|
||||||
|
t.Fatalf("author keys = %v, want %v", keys, want)
|
||||||
|
}
|
||||||
|
return body.Data, rec.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectAuthorNotFound(t *testing.T, env *adminEnv, path string) {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.call(t, http.MethodGet, path, "", nil)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("GET %s = %d, want 404: %s", path, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
msg, body := phpErrorString(t, rec.Body.Bytes())
|
||||||
|
if msg != authorNotFoundText || len(body) != 1 {
|
||||||
|
t.Fatalf("GET %s body = %s, want only {\"error\":%q}", path, rec.Body.String(), authorNotFoundText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJournalAuthorProfile(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
past := now.Add(-time.Hour)
|
||||||
|
future := now.Add(time.Hour)
|
||||||
|
|
||||||
|
janeID := insertBackendUser(t, env, "Jane", "Doe", "jdoe", "jane-doe", false)
|
||||||
|
data, raw := authorProfileOK(t, env, "jane-doe")
|
||||||
|
if data["slug"] != "jane-doe" || data["first_name"] != "Jane" || data["last_name"] != "Doe" {
|
||||||
|
t.Fatalf("jane-doe profile = %v", data)
|
||||||
|
}
|
||||||
|
if id, _ := data["id"].(float64); uint(id) != janeID {
|
||||||
|
t.Fatalf("jane-doe id = %v, want %d", data["id"], janeID)
|
||||||
|
}
|
||||||
|
// No metadata and no avatar: empty strings and null.
|
||||||
|
if data["subtitle"] != "" || data["description"] != "" || data["avatar"] != nil {
|
||||||
|
t.Fatalf("jane-doe profile extras = %v, want empty subtitle and description and a null avatar", data)
|
||||||
|
}
|
||||||
|
for _, secret := range []string{"jdoe@example.test", "authorSecretMarker", authorPersistCode, `"jdoe"`} {
|
||||||
|
if strings.Contains(raw, secret) {
|
||||||
|
t.Fatalf("author body leaks %q: %s", secret, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slugged author's login resolves once they have a published post; the
|
||||||
|
// payload names the slug as the canonical identifier.
|
||||||
|
expectAuthorNotFound(t, env, authorAPIPrefix+"jdoe")
|
||||||
|
insertAuthorPost(t, env, janeID, "jane-first", true, &past)
|
||||||
|
data, raw = authorProfileOK(t, env, "jdoe")
|
||||||
|
if data["slug"] != "jane-doe" {
|
||||||
|
t.Fatalf("jdoe canonical slug = %v, want jane-doe", data["slug"])
|
||||||
|
}
|
||||||
|
if strings.Contains(raw, `"jdoe"`) {
|
||||||
|
t.Fatalf("login fallback body leaks login: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
ewaID := insertBackendUser(t, env, "", "", "ewa", "", false)
|
||||||
|
insertAuthorPost(t, env, ewaID, "ewa-first", true, &past)
|
||||||
|
data, _ = authorProfileOK(t, env, "ewa")
|
||||||
|
if data["slug"] != "ewa" || data["first_name"] != "" || data["last_name"] != "" {
|
||||||
|
t.Fatalf("ewa profile = %v", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enumeration guard: a login with only a draft and a scheduled post is 404.
|
||||||
|
carolID := insertBackendUser(t, env, "Carol", "Smith", "carol", "", false)
|
||||||
|
insertAuthorPost(t, env, carolID, "carol-draft", false, nil)
|
||||||
|
insertAuthorPost(t, env, carolID, "carol-future", true, &future)
|
||||||
|
expectAuthorNotFound(t, env, authorAPIPrefix+"carol")
|
||||||
|
|
||||||
|
// Soft-deleted users never resolve, by slug or by login.
|
||||||
|
goneID := insertBackendUser(t, env, "Gone", "Author", "gone", "gone-author", true)
|
||||||
|
insertAuthorPost(t, env, goneID, "gone-first", true, &past)
|
||||||
|
expectAuthorNotFound(t, env, authorAPIPrefix+"gone-author")
|
||||||
|
expectAuthorNotFound(t, env, authorAPIPrefix+"gone")
|
||||||
|
|
||||||
|
expectAuthorNotFound(t, env, authorAPIPrefix+"nobody")
|
||||||
|
expectAuthorNotFound(t, env, authorAPIPrefix+strings.Repeat("a", 300))
|
||||||
|
expectAuthorNotFound(t, env, authorAPIPrefix+"a%09b")
|
||||||
|
|
||||||
|
// subtitle and description come from backend_users.metadata, avatar is
|
||||||
|
// the public URL of the admin avatar (system_files, Backend\Models\User).
|
||||||
|
teamID := insertBackendUser(t, env, "Summer", "Team", "team", "team", false)
|
||||||
|
if err := env.gdb.Exec(`UPDATE backend_users SET metadata = ? WHERE id = ?`,
|
||||||
|
`{"subtitle":"Golem15","description":"We build SummerCMS.","preferences":{"x":1}}`, teamID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const avatarDisk = "0123456789abcdef01234567.webp"
|
||||||
|
if err := env.gdb.Exec(`INSERT INTO system_files (disk_name, file_name, file_size, content_type, field, attachment_id, attachment_type, is_public, sort_order, created_at, updated_at)
|
||||||
|
VALUES (?, 'team.webp', 10, 'image/webp', 'avatar', ?, 'Backend\Models\User', TRUE, 0, NOW(), NOW())`,
|
||||||
|
avatarDisk, strconv.FormatUint(uint64(teamID), 10)).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ = authorProfileOK(t, env, "team")
|
||||||
|
if data["subtitle"] != "Golem15" || data["description"] != "We build SummerCMS." {
|
||||||
|
t.Fatalf("team profile = %v, want subtitle Golem15 and the description", data)
|
||||||
|
}
|
||||||
|
if avatar, _ := data["avatar"].(string); !strings.HasSuffix(avatar, avatarDisk) {
|
||||||
|
t.Fatalf("team avatar = %v, want a URL ending in %s", data["avatar"], avatarDisk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a subtitle: the description stays an empty string and the
|
||||||
|
// avatar null.
|
||||||
|
subID := insertBackendUser(t, env, "Sub", "Title", "subonly", "sub-only", false)
|
||||||
|
if err := env.gdb.Exec(`UPDATE backend_users SET metadata = ? WHERE id = ?`, `{"subtitle":"Editor"}`, subID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ = authorProfileOK(t, env, "sub-only")
|
||||||
|
if data["subtitle"] != "Editor" || data["description"] != "" || data["avatar"] != nil {
|
||||||
|
t.Fatalf("sub-only profile = %v, want subtitle Editor, an empty description and a null avatar", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata that is empty, JSON null, not JSON or not strings never
|
||||||
|
// breaks the payload: empty strings and a null avatar.
|
||||||
|
for i, meta := range []string{"", "null", "{not json", `{"subtitle":7,"description":["x"]}`, `{"description":null}`} {
|
||||||
|
slug := "odd-meta-" + strconv.Itoa(i)
|
||||||
|
id := insertBackendUser(t, env, "Odd", "Meta", "oddmeta"+strconv.Itoa(i), slug, false)
|
||||||
|
if err := env.gdb.Exec(`UPDATE backend_users SET metadata = ? WHERE id = ?`, meta, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ = authorProfileOK(t, env, slug)
|
||||||
|
if data["subtitle"] != "" || data["description"] != "" || data["avatar"] != nil {
|
||||||
|
t.Fatalf("metadata %q: profile = %v, want empty subtitle and description and a null avatar", meta, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slug is an explicit opt-in: it resolves with no posts at all.
|
||||||
|
insertBackendUser(t, env, "Fresh", "Author", "fresh", "fresh-author", false)
|
||||||
|
data, _ = authorProfileOK(t, env, "fresh-author")
|
||||||
|
if data["slug"] != "fresh-author" || data["first_name"] != "Fresh" {
|
||||||
|
t.Fatalf("fresh-author profile = %v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type authorPostsPage struct {
|
||||||
|
Data []map[string]any `json:"data"`
|
||||||
|
Meta struct {
|
||||||
|
CurrentPage int `json:"current_page"`
|
||||||
|
LastPage int `json:"last_page"`
|
||||||
|
PerPage int `json:"per_page"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
} `json:"meta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p authorPostsPage) slugs() []string {
|
||||||
|
out := make([]string, 0, len(p.Data))
|
||||||
|
for _, row := range p.Data {
|
||||||
|
slug, _ := row["slug"].(string)
|
||||||
|
out = append(out, slug)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAuthorPosts(t *testing.T, env *adminEnv, path, token string) authorPostsPage {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.call(t, http.MethodGet, path, token, nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET %s = %d, want 200: %s", path, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var page authorPostsPage
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil {
|
||||||
|
t.Fatalf("GET %s body %s: %v", path, rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
if page.Data == nil {
|
||||||
|
t.Fatalf("GET %s: data is not an array: %s", path, rec.Body.String())
|
||||||
|
}
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(m map[string]any) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
slices.Sort(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectMeta(t *testing.T, label string, p authorPostsPage, current, last, perPage int, total int64) {
|
||||||
|
t.Helper()
|
||||||
|
m := p.Meta
|
||||||
|
if m.CurrentPage != current || m.LastPage != last || m.PerPage != perPage || m.Total != total {
|
||||||
|
t.Fatalf("%s meta = %+v, want {current_page:%d last_page:%d per_page:%d total:%d}", label, m, current, last, perPage, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJournalAuthorPosts(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
at := func(d time.Duration) *time.Time { v := now.Add(d); return &v }
|
||||||
|
|
||||||
|
janeID := insertBackendUser(t, env, "Jane", "Doe", "jdoe", "jane-doe", false)
|
||||||
|
insertAuthorPost(t, env, janeID, "a1", true, at(-3*time.Hour))
|
||||||
|
insertAuthorPost(t, env, janeID, "a2", true, at(-2*time.Hour))
|
||||||
|
insertAuthorPost(t, env, janeID, "a3", true, at(-time.Hour))
|
||||||
|
insertAuthorPost(t, env, janeID, "a-draft", false, at(-30*time.Minute))
|
||||||
|
insertAuthorPost(t, env, janeID, "a-future", true, at(time.Hour))
|
||||||
|
bobID := insertBackendUser(t, env, "Bob", "Brown", "bob", "", false)
|
||||||
|
insertAuthorPost(t, env, bobID, "b1", true, at(-90*time.Minute))
|
||||||
|
carolID := insertBackendUser(t, env, "Carol", "Smith", "carol", "", false)
|
||||||
|
insertAuthorPost(t, env, carolID, "carol-draft", false, nil)
|
||||||
|
insertBackendUser(t, env, "Fresh", "Author", "fresh", "fresh-author", false)
|
||||||
|
|
||||||
|
base := authorAPIPrefix + "jane-doe/posts"
|
||||||
|
page := getAuthorPosts(t, env, base, "")
|
||||||
|
if got := page.slugs(); !slices.Equal(got, []string{"a3", "a2", "a1"}) {
|
||||||
|
t.Fatalf("default order = %v, want [a3 a2 a1]", got)
|
||||||
|
}
|
||||||
|
expectMeta(t, "default", page, 1, 1, 9, 3)
|
||||||
|
|
||||||
|
// Each row has the GET /posts row shape.
|
||||||
|
all := getAuthorPosts(t, env, "/_journal/api/v1/posts", "")
|
||||||
|
var listRow map[string]any
|
||||||
|
for _, row := range all.Data {
|
||||||
|
if row["slug"] == "a3" {
|
||||||
|
listRow = row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if listRow == nil {
|
||||||
|
t.Fatalf("a3 missing from GET /posts: %v", all.slugs())
|
||||||
|
}
|
||||||
|
if got, want := sortedKeys(page.Data[0]), sortedKeys(listRow); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("author row keys = %v, want /posts row keys %v", got, want)
|
||||||
|
}
|
||||||
|
author, _ := page.Data[0]["author"].(map[string]any)
|
||||||
|
if author == nil || author["login"] != "jdoe" || author["slug"] != "jane-doe" {
|
||||||
|
t.Fatalf("row author = %v, want login jdoe and slug jane-doe", page.Data[0]["author"])
|
||||||
|
}
|
||||||
|
// Without an author slug, the row's slug is the login.
|
||||||
|
for _, row := range all.Data {
|
||||||
|
if row["slug"] == "b1" {
|
||||||
|
if a, _ := row["author"].(map[string]any); a == nil || a["slug"] != "bob" {
|
||||||
|
t.Fatalf("b1 author = %v, want slug bob", row["author"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page = getAuthorPosts(t, env, base+"?per_page=2&page=2", "")
|
||||||
|
if got := page.slugs(); !slices.Equal(got, []string{"a1"}) {
|
||||||
|
t.Fatalf("page 2 = %v, want [a1]", got)
|
||||||
|
}
|
||||||
|
expectMeta(t, "page 2", page, 2, 2, 2, 3)
|
||||||
|
|
||||||
|
page = getAuthorPosts(t, env, base+"?per_page=100", "")
|
||||||
|
expectMeta(t, "per_page=100", page, 1, 1, 30, 3)
|
||||||
|
|
||||||
|
page = getAuthorPosts(t, env, base+"?page=abc", "")
|
||||||
|
expectMeta(t, "page=abc", page, 1, 1, 9, 3)
|
||||||
|
|
||||||
|
page = getAuthorPosts(t, env, base+"?sort=published_at+asc", "")
|
||||||
|
if got := page.slugs(); !slices.Equal(got, []string{"a1", "a2", "a3"}) {
|
||||||
|
t.Fatalf("asc order = %v, want [a1 a2 a3]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An editor token is ignored: drafts and scheduled posts stay hidden.
|
||||||
|
token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts)
|
||||||
|
page = getAuthorPosts(t, env, base+"?published=0", token)
|
||||||
|
if got := page.slugs(); !slices.Equal(got, []string{"a3", "a2", "a1"}) {
|
||||||
|
t.Fatalf("editor list = %v, want published only [a3 a2 a1]", got)
|
||||||
|
}
|
||||||
|
expectMeta(t, "editor", page, 1, 1, 9, 3)
|
||||||
|
|
||||||
|
page = getAuthorPosts(t, env, authorAPIPrefix+"jdoe/posts", "")
|
||||||
|
expectMeta(t, "login", page, 1, 1, 9, 3)
|
||||||
|
|
||||||
|
page = getAuthorPosts(t, env, authorAPIPrefix+"fresh-author/posts", "")
|
||||||
|
if len(page.Data) != 0 {
|
||||||
|
t.Fatalf("fresh-author rows = %v, want none", page.slugs())
|
||||||
|
}
|
||||||
|
expectMeta(t, "fresh-author", page, 1, 1, 9, 0)
|
||||||
|
|
||||||
|
expectAuthorNotFound(t, env, authorAPIPrefix+"nobody/posts")
|
||||||
|
expectAuthorNotFound(t, env, authorAPIPrefix+"carol/posts")
|
||||||
|
}
|
||||||
272
journal_api_categories_test.go
Normal file
272
journal_api_categories_test.go
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// flattenCategoryRows walks the categories tree (data plus children) into one
|
||||||
|
// slice of rows.
|
||||||
|
func flattenCategoryRows(rows []map[string]any) []map[string]any {
|
||||||
|
var out []map[string]any
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, row)
|
||||||
|
if kids, ok := row["children"].([]any); ok {
|
||||||
|
nested := make([]map[string]any, 0, len(kids))
|
||||||
|
for _, k := range kids {
|
||||||
|
if m, ok := k.(map[string]any); ok {
|
||||||
|
nested = append(nested, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, flattenCategoryRows(nested)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func categoryRowsBySlug(t *testing.T, env *adminEnv, path string) map[string]map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, path, "", nil)
|
||||||
|
var body struct {
|
||||||
|
Data []map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("categories json: %s err=%v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
out := map[string]map[string]any{}
|
||||||
|
for _, row := range flattenCategoryRows(body.Data) {
|
||||||
|
slug, _ := row["slug"].(string)
|
||||||
|
out[slug] = row
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNullContent(t *testing.T, rows map[string]map[string]any, slug string) {
|
||||||
|
t.Helper()
|
||||||
|
row, ok := rows[slug]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("category %s not listed", slug)
|
||||||
|
}
|
||||||
|
v, present := row["content"]
|
||||||
|
if !present || v != nil {
|
||||||
|
t.Fatalf("%s content = %#v (present %v), want JSON null", slug, v, present)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJournalCategoryContentAPI(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
html := "<h2>Acme news</h2>\n<p>Pillar copy</p>"
|
||||||
|
left, right, depth := 3, 4, 0
|
||||||
|
news := models.Category{Name: "News", Slug: "news", Content: &html, NestLeft: &left, NestRight: &right, NestDepth: &depth}
|
||||||
|
if err := env.gdb.Create(&news).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
quiet := models.Category{Name: "Quiet", Slug: "quiet"}
|
||||||
|
if err := env.gdb.Create(&quiet).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
past := time.Now().UTC().Add(-time.Hour)
|
||||||
|
post := models.Post{Title: "Listed", Slug: "listed-content", Content: "x", Published: true, PublishedAt: &past}
|
||||||
|
if err := env.gdb.Create(&post).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := env.gdb.Create(&models.PostCategory{PostID: post.ID, CategoryID: news.ID}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
all := categoryRowsBySlug(t, env, "/_journal/api/v1/categories?include_empty=1")
|
||||||
|
if got, ok := all["news"]["content"].(string); !ok || got != html {
|
||||||
|
t.Fatalf("news content = %#v, want %q", all["news"]["content"], html)
|
||||||
|
}
|
||||||
|
assertNullContent(t, all, "quiet")
|
||||||
|
assertNullContent(t, all, "uncategorized")
|
||||||
|
|
||||||
|
listed := categoryRowsBySlug(t, env, "/_journal/api/v1/categories")
|
||||||
|
if got, ok := listed["news"]["content"].(string); !ok || got != html {
|
||||||
|
t.Fatalf("news content without include_empty = %#v, want %q", listed["news"]["content"], html)
|
||||||
|
}
|
||||||
|
if _, ok := listed["quiet"]; ok {
|
||||||
|
t.Fatal("empty category quiet listed without include_empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_journal/api/v1/posts", "", nil)
|
||||||
|
var posts struct {
|
||||||
|
Data []map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &posts); err != nil {
|
||||||
|
t.Fatalf("posts json: %s err=%v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, p := range posts.Data {
|
||||||
|
if p["slug"] != "listed-content" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
cats, ok := p["categories"].([]any)
|
||||||
|
if !ok || len(cats) == 0 {
|
||||||
|
t.Fatalf("post categories = %#v", p["categories"])
|
||||||
|
}
|
||||||
|
first, ok := cats[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("post category entry = %#v", cats[0])
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(first))
|
||||||
|
for k := range first {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
if want := []string{"id", "name", "slug"}; !reflect.DeepEqual(keys, want) {
|
||||||
|
t.Fatalf("post category keys = %v, want %v", keys, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("post listed-content missing from /posts: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminCategoriesPath = "/golem15/journal/categories"
|
||||||
|
|
||||||
|
func TestCategoriesAdminContent(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
token := env.admin(t, controllers.PermissionAccessCategories)
|
||||||
|
|
||||||
|
schema := adminDecode(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminCategoriesPath+"/schema/form"), token, nil))
|
||||||
|
var form struct {
|
||||||
|
Fields []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Size string `json:"size"`
|
||||||
|
} `json:"fields"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(schema.Data, &form); err != nil {
|
||||||
|
t.Fatalf("form schema %s: %v", schema.Data, err)
|
||||||
|
}
|
||||||
|
contentAt := -1
|
||||||
|
for i, f := range form.Fields {
|
||||||
|
if f.Name != "content" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
contentAt = i
|
||||||
|
if f.Type != "blogeditor" || f.Size != "huge" {
|
||||||
|
t.Fatalf("content field = %+v, want blogeditor size huge", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if contentAt < 1 || form.Fields[contentAt-1].Name != "description" {
|
||||||
|
t.Fatalf("content field must follow description: %s", schema.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
safe := "<h2>Acme</h2>\n<p>Pillar <a href=\"https://example.test/a\">copy</a></p>"
|
||||||
|
name := map[string]any{"en": "Acme news", "pl": "Wiadomości Acme"}
|
||||||
|
slug := map[string]any{"en": "acme-news", "pl": "wiadomosci-acme"}
|
||||||
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"slug": slug,
|
||||||
|
"content": safe,
|
||||||
|
})
|
||||||
|
if notices := adminNotices(t, rec); notices != nil {
|
||||||
|
t.Fatalf("safe create carries meta.notices: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(adminDecode(t, rec).Data, &created); err != nil || created.ID == 0 {
|
||||||
|
t.Fatalf("create data %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
storedContent := func(t *testing.T) *string {
|
||||||
|
t.Helper()
|
||||||
|
var cat models.Category
|
||||||
|
if err := env.gdb.First(&cat, created.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return cat.Content
|
||||||
|
}
|
||||||
|
if got := storedContent(t); got == nil || *got != safe {
|
||||||
|
t.Fatalf("stored content = %#v, want %q", got, safe)
|
||||||
|
}
|
||||||
|
rows := categoryRowsBySlug(t, env, "/_journal/api/v1/categories?include_empty=1")
|
||||||
|
if got, ok := rows["acme-news"]["content"].(string); !ok || got != safe {
|
||||||
|
t.Fatalf("API content = %#v, want %q", rows["acme-news"]["content"], safe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertContentStripped checks a save the blogeditor Decode stripped
|
||||||
|
// (D-10 amended, D-19): the status, and one meta.notices entry for
|
||||||
|
// content whose message names what was removed.
|
||||||
|
assertContentStripped := func(t *testing.T, rec *httptest.ResponseRecorder, status int, removed string) {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code != status {
|
||||||
|
t.Fatalf("status = %d, want %d: %s", rec.Code, status, rec.Body.String())
|
||||||
|
}
|
||||||
|
notices := adminNotices(t, rec)
|
||||||
|
if len(notices) != 1 || notices[0].Field != "content" || !strings.Contains(notices[0].Message, removed) {
|
||||||
|
t.Fatalf("meta.notices = %#v, want one content notice naming %s", notices, removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("unsafe create stripped", func(t *testing.T) {
|
||||||
|
rec := env.call(t, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{
|
||||||
|
"name": map[string]any{"en": "Unsafe", "pl": "Niebezpieczna"},
|
||||||
|
"slug": map[string]any{"en": "unsafe-acme", "pl": "niebezpieczna-acme"},
|
||||||
|
"content": "<p onclick=\"steal()\">x</p>",
|
||||||
|
})
|
||||||
|
assertContentStripped(t, rec, http.StatusCreated, "onclick")
|
||||||
|
var cat models.Category
|
||||||
|
if err := env.gdb.Where("slug = ?", "unsafe-acme").Take(&cat).Error; err != nil {
|
||||||
|
t.Fatalf("unsafe-acme row: %v", err)
|
||||||
|
}
|
||||||
|
if cat.Content == nil || *cat.Content != "<p>x</p>" {
|
||||||
|
t.Fatalf("stored content = %#v, want <p>x</p>", cat.Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
itemPath := adminAPI(adminCategoriesPath + "/" + strconv.FormatUint(uint64(created.ID), 10))
|
||||||
|
|
||||||
|
t.Run("unsafe update stripped", func(t *testing.T) {
|
||||||
|
rec := env.call(t, http.MethodPut, itemPath, token, map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"slug": slug,
|
||||||
|
"content": "<a href=\"javascript:alert(1)\">x</a>",
|
||||||
|
})
|
||||||
|
assertContentStripped(t, rec, http.StatusOK, "removed: a")
|
||||||
|
if got := storedContent(t); got == nil || *got != "x" {
|
||||||
|
t.Fatalf("stored content after stripped update = %#v, want x", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("blank update stores null", func(t *testing.T) {
|
||||||
|
rec := env.call(t, http.MethodPut, itemPath, token, map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"slug": slug,
|
||||||
|
"content": " ",
|
||||||
|
})
|
||||||
|
if rec.Code < 200 || rec.Code > 299 {
|
||||||
|
t.Fatalf("blank update = %d, want 2xx: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := storedContent(t); got != nil {
|
||||||
|
t.Fatalf("stored content = %q, want NULL", *got)
|
||||||
|
}
|
||||||
|
rows := categoryRowsBySlug(t, env, "/_journal/api/v1/categories?include_empty=1")
|
||||||
|
assertNullContent(t, rows, "acme-news")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("posts-only token forbidden", func(t *testing.T) {
|
||||||
|
postsOnly := env.admin(t, controllers.PermissionAccessPosts)
|
||||||
|
rec := env.call(t, http.MethodPost, adminAPI(adminCategoriesPath), postsOnly, map[string]any{
|
||||||
|
"name": map[string]any{"en": "Denied", "pl": "Odmowa"},
|
||||||
|
"slug": map[string]any{"en": "denied-acme", "pl": "odmowa-acme"},
|
||||||
|
"content": safe,
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("posts-only create = %d, want 403: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
282
journal_api_token_smoke_test.go
Normal file
282
journal_api_token_smoke_test.go
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
)
|
||||||
|
|
||||||
|
// probeTokenPlugin registers a non-Journal scope so expansion tests can mint a
|
||||||
|
// token that authenticates but fails the golem15.journal.* cover check.
|
||||||
|
type probeTokenPlugin struct{}
|
||||||
|
|
||||||
|
func init() { party.Register(&probeTokenPlugin{}) }
|
||||||
|
|
||||||
|
func (p *probeTokenPlugin) ID() string { return "acme.probe" }
|
||||||
|
func (p *probeTokenPlugin) Requires() []string { return nil }
|
||||||
|
func (p *probeTokenPlugin) Register(app *backpack.App) error { return nil }
|
||||||
|
func (p *probeTokenPlugin) Boot(app *backpack.App) error { return nil }
|
||||||
|
func (p *probeTokenPlugin) Permissions() []pact.Permission {
|
||||||
|
return []pact.Permission{{
|
||||||
|
Code: "acme.probe.access",
|
||||||
|
Label: "Probe access",
|
||||||
|
Roles: []string{"developer"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
func (p *probeTokenPlugin) TokenScopes() []pact.TokenScope {
|
||||||
|
return []pact.TokenScope{{
|
||||||
|
Name: "probe",
|
||||||
|
Label: "Probe",
|
||||||
|
Description: "Test scope that does not cover Journal",
|
||||||
|
Permissions: []string{"acme.probe.*"},
|
||||||
|
Methods: []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ pact.HasTokenScopes = (*probeTokenPlugin)(nil)
|
||||||
|
_ pact.HasPermissions = (*probeTokenPlugin)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *adminEnv) mintToken(t *testing.T, jwt string, scopes []string) (plain string, id uint) {
|
||||||
|
t.Helper()
|
||||||
|
create := e.call(t, http.MethodPost, adminAPI("/account/tokens"), jwt, map[string]any{
|
||||||
|
"name": "smoke-" + strings.Join(scopes, "-"),
|
||||||
|
"expires": "90d",
|
||||||
|
"scopes": scopes,
|
||||||
|
})
|
||||||
|
if create.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create token scopes=%v: %d %s", scopes, create.Code, create.Body.String())
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
Data cabana.AdminAPITokenCreated `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(created.Data.Token, cabana.APITokenPrefix) {
|
||||||
|
t.Fatalf("token shape %q", created.Data.Token)
|
||||||
|
}
|
||||||
|
return created.Data.Token, created.Data.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *adminEnv) developerJWT(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1))
|
||||||
|
login := "dev-" + stamp
|
||||||
|
password := "dev-secret-" + stamp
|
||||||
|
hash, err := bouncer.HashPassword(10, password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var roleID uint
|
||||||
|
if err := e.gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'developer'`).Scan(&roleID).Error; err != nil || roleID == 0 {
|
||||||
|
t.Fatalf("developer role: id=%d err=%v", roleID, err)
|
||||||
|
}
|
||||||
|
if err := e.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`,
|
||||||
|
login, login+"@example.test", hash, roleID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := e.call(t, http.MethodPost, adminAPI("/auth/login"), "", map[string]string{"login": login, "password": password})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("developer login: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Data struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.AccessToken == "" {
|
||||||
|
t.Fatalf("developer login body: %s err=%v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return body.Data.AccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPHPError(t *testing.T, rec *httptest.ResponseRecorder, status int, want string) {
|
||||||
|
t.Helper()
|
||||||
|
if rec.Code != status {
|
||||||
|
t.Fatalf("status = %d, want %d: %s", rec.Code, status, rec.Body.String())
|
||||||
|
}
|
||||||
|
got, _ := phpErrorString(t, rec.Body.Bytes())
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("error = %q, want %q; body=%s", got, want, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalTokenTracer mints a journal-scoped personal API token through the
|
||||||
|
// admin account API and creates a Journal post as that token's owner (16.3-03).
|
||||||
|
func TestJournalTokenTracer(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
jwt := env.admin(t, controllers.PermissionAccessPosts)
|
||||||
|
adminID := env.lastUserID(t)
|
||||||
|
|
||||||
|
create := env.call(t, http.MethodPost, adminAPI("/account/tokens"), jwt, map[string]any{
|
||||||
|
"name": "journal-agent",
|
||||||
|
"expires": "90d",
|
||||||
|
"scopes": []string{"journal"},
|
||||||
|
})
|
||||||
|
if create.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create token: %d %s", create.Code, create.Body.String())
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
Data cabana.AdminAPITokenCreated `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plain := created.Data.Token
|
||||||
|
if !strings.HasPrefix(plain, cabana.APITokenPrefix) || len(plain) != len(cabana.APITokenPrefix)+40 {
|
||||||
|
t.Fatalf("token shape %q", plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
postRec := env.call(t, http.MethodPost, "/_journal/api/v1/posts", plain, map[string]any{
|
||||||
|
"title": "Token-authored post",
|
||||||
|
"content": "Written with a journal scope token",
|
||||||
|
})
|
||||||
|
if postRec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("POST /posts with journal token: %d %s", postRec.Code, postRec.Body.String())
|
||||||
|
}
|
||||||
|
var postBody struct {
|
||||||
|
Data struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
UserID *uint `json:"user_id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(postRec.Body.Bytes(), &postBody); err != nil {
|
||||||
|
t.Fatalf("post body: %s err=%v", postRec.Body.String(), err)
|
||||||
|
}
|
||||||
|
if postBody.Data.ID == 0 {
|
||||||
|
t.Fatalf("missing post id: %s", postRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var row models.Post
|
||||||
|
if err := env.gdb.First(&row, postBody.Data.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if row.UserID == nil || *row.UserID != adminID {
|
||||||
|
t.Fatalf("stored user_id = %v, want %d", row.UserID, adminID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalTokenExpansion pins D-15 read/unrelated/revoked scopes, optional
|
||||||
|
// draft unlock, featured-image canEdit parity, and code-declared developer roles.
|
||||||
|
func TestJournalTokenExpansion(t *testing.T) {
|
||||||
|
env := bootJournal(t, []string{"golem15.translate", "golem15.journal", "acme.probe"})
|
||||||
|
editorJWT := env.admin(t, controllers.PermissionAccessPosts, "acme.probe.access")
|
||||||
|
editorID := env.lastUserID(t)
|
||||||
|
otherJWT := env.admin(t, controllers.PermissionAccessPosts)
|
||||||
|
otherID := env.lastUserID(t)
|
||||||
|
|
||||||
|
readTok, _ := env.mintToken(t, editorJWT, []string{"journal:read"})
|
||||||
|
journalTok, journalID := env.mintToken(t, editorJWT, []string{"journal"})
|
||||||
|
adminReadTok, _ := env.mintToken(t, editorJWT, []string{"admin:read"})
|
||||||
|
probeTok, _ := env.mintToken(t, editorJWT, []string{"probe"})
|
||||||
|
|
||||||
|
assertPHPError(t,
|
||||||
|
env.call(t, http.MethodPost, "/_journal/api/v1/posts", readTok, map[string]any{
|
||||||
|
"title": "Read scope write", "content": "no",
|
||||||
|
}),
|
||||||
|
http.StatusForbidden, "Insufficient permissions",
|
||||||
|
)
|
||||||
|
|
||||||
|
draft := models.Post{
|
||||||
|
Title: "Editor draft", Slug: "editor-token-draft", Content: "secret",
|
||||||
|
Published: false, UserID: &editorID,
|
||||||
|
}
|
||||||
|
if err := env.gdb.Create(&draft).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ownerDraft := env.call(t, http.MethodGet, "/_journal/api/v1/posts/editor-token-draft", readTok, nil)
|
||||||
|
if ownerDraft.Code != http.StatusOK {
|
||||||
|
t.Fatalf("journal:read draft show = %d, want 200: %s", ownerDraft.Code, ownerDraft.Body.String())
|
||||||
|
}
|
||||||
|
anonDraft := env.call(t, http.MethodGet, "/_journal/api/v1/posts/editor-token-draft", "", nil)
|
||||||
|
if anonDraft.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("anonymous draft show = %d, want 404: %s", anonDraft.Code, anonDraft.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPHPError(t,
|
||||||
|
env.call(t, http.MethodPost, "/_journal/api/v1/posts", adminReadTok, map[string]any{
|
||||||
|
"title": "Unrelated", "content": "no",
|
||||||
|
}),
|
||||||
|
http.StatusForbidden, "Insufficient permissions",
|
||||||
|
)
|
||||||
|
assertPHPError(t,
|
||||||
|
env.call(t, http.MethodPost, "/_journal/api/v1/posts", probeTok, map[string]any{
|
||||||
|
"title": "Probe write", "content": "no",
|
||||||
|
}),
|
||||||
|
http.StatusForbidden, "Insufficient permissions",
|
||||||
|
)
|
||||||
|
unrelatedGet := env.call(t, http.MethodGet, "/_journal/api/v1/posts/editor-token-draft", probeTok, nil)
|
||||||
|
if unrelatedGet.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("probe draft show = %d, want anonymous 404: %s", unrelatedGet.Code, unrelatedGet.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
ownPath := fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images", draft.ID)
|
||||||
|
up := env.callMultipart(t, http.MethodPost, ownPath, journalTok, nil, "file", "dot.png", png1x1)
|
||||||
|
if up.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("featured upload own post = %d: %s", up.Code, up.Body.String())
|
||||||
|
}
|
||||||
|
var upBody struct {
|
||||||
|
Data struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(up.Body.Bytes(), &upBody); err != nil || upBody.Data.ID == 0 {
|
||||||
|
t.Fatalf("featured upload body: %s err=%v", up.Body.String(), err)
|
||||||
|
}
|
||||||
|
delOwn := env.call(t, http.MethodDelete,
|
||||||
|
fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images/%d", draft.ID, upBody.Data.ID),
|
||||||
|
journalTok, nil)
|
||||||
|
if delOwn.Code != http.StatusOK {
|
||||||
|
t.Fatalf("featured delete own = %d: %s", delOwn.Code, delOwn.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
otherPost := models.Post{
|
||||||
|
Title: "Other author", Slug: "other-author-post", Content: "x",
|
||||||
|
Published: false, UserID: &otherID,
|
||||||
|
}
|
||||||
|
if err := env.gdb.Create(&otherPost).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
otherPath := fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images", otherPost.ID)
|
||||||
|
assertPHPError(t,
|
||||||
|
env.callMultipart(t, http.MethodPost, otherPath, journalTok, nil, "file", "dot.png", png1x1),
|
||||||
|
http.StatusForbidden, "You do not have permission to edit this post",
|
||||||
|
)
|
||||||
|
_ = otherJWT
|
||||||
|
|
||||||
|
revPath := adminAPI("/account/tokens/" + strconv.FormatUint(uint64(journalID), 10))
|
||||||
|
rev := env.call(t, http.MethodDelete, revPath, editorJWT, nil)
|
||||||
|
if rev.Code != http.StatusOK {
|
||||||
|
t.Fatalf("revoke: %d %s", rev.Code, rev.Body.String())
|
||||||
|
}
|
||||||
|
assertPHPError(t,
|
||||||
|
env.call(t, http.MethodPost, "/_journal/api/v1/posts", journalTok, map[string]any{
|
||||||
|
"title": "After revoke", "content": "no",
|
||||||
|
}),
|
||||||
|
http.StatusUnauthorized, "Authentication required",
|
||||||
|
)
|
||||||
|
|
||||||
|
devJWT := env.developerJWT(t)
|
||||||
|
created := env.call(t, http.MethodPost, "/_journal/api/v1/posts", devJWT, map[string]any{
|
||||||
|
"title": "Developer role grant", "content": "code-declared Roles",
|
||||||
|
})
|
||||||
|
if created.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("developer JWT create = %d: %s", created.Code, created.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
237
journal_api_tokens_test.go
Normal file
237
journal_api_tokens_test.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestJournalTokens pins SC-4 / D-15: every write route × token class with
|
||||||
|
// byte-identical PHP bodies, optional-auth reads, JWT unchanged, and publish
|
||||||
|
// permission still enforced under a journal token.
|
||||||
|
func TestJournalTokens(t *testing.T) {
|
||||||
|
env := bootJournal(t, []string{"golem15.translate", "golem15.journal", "acme.probe"})
|
||||||
|
jwt := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessPublish, "acme.probe.access")
|
||||||
|
limitedJWT := env.admin(t, controllers.PermissionAccessPosts)
|
||||||
|
|
||||||
|
journalTok, journalID := env.mintToken(t, jwt, []string{"journal"})
|
||||||
|
starTok, _ := env.mintToken(t, jwt, []string{"*"})
|
||||||
|
readTok, _ := env.mintToken(t, jwt, []string{"journal:read"})
|
||||||
|
adminReadTok, _ := env.mintToken(t, jwt, []string{"admin:read"})
|
||||||
|
probeTok, _ := env.mintToken(t, jwt, []string{"probe"})
|
||||||
|
malformed := "g15_notavalidtokenvaluexxxxxxxxxxxx"
|
||||||
|
|
||||||
|
createBody := map[string]any{"title": "Matrix post", "content": "hello"}
|
||||||
|
writeCases := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
status int
|
||||||
|
err string
|
||||||
|
}{
|
||||||
|
{"journal", journalTok, http.StatusCreated, ""},
|
||||||
|
{"star", starTok, http.StatusCreated, ""},
|
||||||
|
{"journal:read", readTok, http.StatusForbidden, "Insufficient permissions"},
|
||||||
|
{"admin:read", adminReadTok, http.StatusForbidden, "Insufficient permissions"},
|
||||||
|
{"probe", probeTok, http.StatusForbidden, "Insufficient permissions"},
|
||||||
|
{"malformed", malformed, http.StatusUnauthorized, "Authentication required"},
|
||||||
|
}
|
||||||
|
for _, tc := range writeCases {
|
||||||
|
t.Run("store/"+tc.name, func(t *testing.T) {
|
||||||
|
rec := env.call(t, http.MethodPost, "/_journal/api/v1/posts", tc.token, createBody)
|
||||||
|
if rec.Code != tc.status {
|
||||||
|
t.Fatalf("status = %d, want %d body=%s", rec.Code, tc.status, rec.Body.String())
|
||||||
|
}
|
||||||
|
if tc.err != "" {
|
||||||
|
assertPHPError(t, rec, tc.status, tc.err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed a post for update/destroy/featured/media under journal token.
|
||||||
|
seed := env.call(t, http.MethodPost, "/_journal/api/v1/posts", journalTok, map[string]any{
|
||||||
|
"title": "Seed", "content": "body",
|
||||||
|
})
|
||||||
|
if seed.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("seed: %d %s", seed.Code, seed.Body.String())
|
||||||
|
}
|
||||||
|
var seedBody struct {
|
||||||
|
Data struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(seed.Body.Bytes(), &seedBody); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
idPath := fmt.Sprintf("/_journal/api/v1/posts/%d", seedBody.Data.ID)
|
||||||
|
slugPath := "/_journal/api/v1/posts/" + seedBody.Data.Slug
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
status int
|
||||||
|
err string
|
||||||
|
}{
|
||||||
|
{"journal", journalTok, http.StatusOK, ""},
|
||||||
|
{"journal:read", readTok, http.StatusForbidden, "Insufficient permissions"},
|
||||||
|
{"probe", probeTok, http.StatusForbidden, "Insufficient permissions"},
|
||||||
|
{"malformed", malformed, http.StatusUnauthorized, "Authentication required"},
|
||||||
|
} {
|
||||||
|
t.Run("update/"+tc.name, func(t *testing.T) {
|
||||||
|
rec := env.call(t, http.MethodPut, idPath, tc.token, map[string]any{
|
||||||
|
"title": "Updated", "content": "body",
|
||||||
|
})
|
||||||
|
if rec.Code != tc.status {
|
||||||
|
t.Fatalf("status = %d, want %d body=%s", rec.Code, tc.status, rec.Body.String())
|
||||||
|
}
|
||||||
|
if tc.err != "" {
|
||||||
|
assertPHPError(t, rec, tc.status, tc.err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish permission still enforced under a journal token without access_publish.
|
||||||
|
limitedTok, _ := env.mintToken(t, limitedJWT, []string{"journal"})
|
||||||
|
pubDenied := env.call(t, http.MethodPost, "/_journal/api/v1/posts", limitedTok, map[string]any{
|
||||||
|
"title": "Pub", "content": "x", "published": true,
|
||||||
|
})
|
||||||
|
assertPHPError(t, pubDenied, http.StatusForbidden, "You do not have permission to publish posts")
|
||||||
|
|
||||||
|
featPath := fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images", seedBody.Data.ID)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
status int
|
||||||
|
err string
|
||||||
|
}{
|
||||||
|
{"journal", journalTok, http.StatusCreated, ""},
|
||||||
|
{"journal:read", readTok, http.StatusForbidden, "Insufficient permissions"},
|
||||||
|
{"malformed", malformed, http.StatusUnauthorized, "Authentication required"},
|
||||||
|
} {
|
||||||
|
t.Run("featured/"+tc.name, func(t *testing.T) {
|
||||||
|
rec := env.callMultipart(t, http.MethodPost, featPath, tc.token, nil, "file", "dot.png", png1x1)
|
||||||
|
if rec.Code != tc.status {
|
||||||
|
t.Fatalf("status = %d, want %d body=%s", rec.Code, tc.status, rec.Body.String())
|
||||||
|
}
|
||||||
|
if tc.err != "" {
|
||||||
|
assertPHPError(t, rec, tc.status, tc.err)
|
||||||
|
}
|
||||||
|
if tc.status == http.StatusCreated {
|
||||||
|
var up struct {
|
||||||
|
Data struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &up)
|
||||||
|
if up.Data.ID != 0 {
|
||||||
|
del := env.call(t, http.MethodDelete,
|
||||||
|
fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images/%d", seedBody.Data.ID, up.Data.ID),
|
||||||
|
journalTok, nil)
|
||||||
|
if del.Code != http.StatusOK {
|
||||||
|
t.Fatalf("featured delete: %d %s", del.Code, del.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaPath := "/_journal/api/v1/media/upload"
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
status int
|
||||||
|
err string
|
||||||
|
}{
|
||||||
|
{"journal", journalTok, http.StatusCreated, ""},
|
||||||
|
{"admin:read", adminReadTok, http.StatusForbidden, "Insufficient permissions"},
|
||||||
|
{"revoked", "", http.StatusUnauthorized, "Authentication required"}, // filled below
|
||||||
|
} {
|
||||||
|
token := tc.token
|
||||||
|
if tc.name == "revoked" {
|
||||||
|
rev := env.call(t, http.MethodDelete, adminAPI("/account/tokens/"+strconv.FormatUint(uint64(journalID), 10)), jwt, nil)
|
||||||
|
if rev.Code != http.StatusOK {
|
||||||
|
t.Fatalf("revoke journal token: %d %s", rev.Code, rev.Body.String())
|
||||||
|
}
|
||||||
|
token = journalTok
|
||||||
|
}
|
||||||
|
t.Run("media/"+tc.name, func(t *testing.T) {
|
||||||
|
if tc.name != "revoked" && tc.name != "admin:read" && tc.name != "journal" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec := env.callMultipart(t, http.MethodPost, mediaPath, token, nil, "file", "dot.png", png1x1)
|
||||||
|
// Media may be 201 or 200 depending on implementation; accept Created/OK for journal.
|
||||||
|
if tc.name == "journal" {
|
||||||
|
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("journal media: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rec.Code != tc.status {
|
||||||
|
t.Fatalf("status = %d, want %d body=%s", rec.Code, tc.status, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertPHPError(t, rec, tc.status, tc.err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-mint journal token after revoke for destroy + optional-auth reads.
|
||||||
|
journalTok, _ = env.mintToken(t, jwt, []string{"journal"})
|
||||||
|
readTok, _ = env.mintToken(t, jwt, []string{"journal:read"})
|
||||||
|
|
||||||
|
destroy := env.call(t, http.MethodDelete, idPath, journalTok, nil)
|
||||||
|
if destroy.Code != http.StatusOK {
|
||||||
|
t.Fatalf("destroy: %d %s", destroy.Code, destroy.Body.String())
|
||||||
|
}
|
||||||
|
assertPHPError(t,
|
||||||
|
env.call(t, http.MethodDelete, idPath, readTok, nil),
|
||||||
|
http.StatusForbidden, "Insufficient permissions",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Optional-auth Index/Show with each token class; JWT path unchanged.
|
||||||
|
pubRec := env.call(t, http.MethodPost, "/_journal/api/v1/posts", jwt, map[string]any{
|
||||||
|
"title": "Public", "content": "p", "published": true,
|
||||||
|
"published_at": "2020-01-01 00:00:00",
|
||||||
|
})
|
||||||
|
if pubRec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("publish seed: %d %s", pubRec.Code, pubRec.Body.String())
|
||||||
|
}
|
||||||
|
var pubBody struct {
|
||||||
|
Data struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(pubRec.Body.Bytes(), &pubBody); err != nil || pubBody.Data.Slug == "" {
|
||||||
|
t.Fatalf("publish slug: %s err=%v", pubRec.Body.String(), err)
|
||||||
|
}
|
||||||
|
showPath := "/_journal/api/v1/posts/" + pubBody.Data.Slug
|
||||||
|
for _, tok := range []string{"", journalTok, readTok, starTok, probeTok, jwt} {
|
||||||
|
rec := env.call(t, http.MethodGet, "/_journal/api/v1/posts", tok, nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("index token=%q: %d %s", trunc(tok), rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
show := env.call(t, http.MethodGet, showPath, tok, nil)
|
||||||
|
if show.Code != http.StatusOK {
|
||||||
|
t.Fatalf("show token=%q: %d %s", trunc(tok), show.Code, show.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jwtCreate := env.call(t, http.MethodPost, "/_journal/api/v1/posts", jwt, map[string]any{
|
||||||
|
"title": "JWT still works", "content": "yes",
|
||||||
|
})
|
||||||
|
if jwtCreate.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("jwt create: %d %s", jwtCreate.Code, jwtCreate.Body.String())
|
||||||
|
}
|
||||||
|
_ = slugPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func trunc(tok string) string {
|
||||||
|
if len(tok) > 12 {
|
||||||
|
return tok[:12] + "…"
|
||||||
|
}
|
||||||
|
if tok == "" {
|
||||||
|
return "<anon>"
|
||||||
|
}
|
||||||
|
return tok
|
||||||
|
}
|
||||||
272
journal_descriptor_fields_test.go
Normal file
272
journal_descriptor_fields_test.go
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedLocales enables en (default) and pl.
|
||||||
|
func seedLocales(t *testing.T, gdb *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
for _, row := range []struct {
|
||||||
|
code, name string
|
||||||
|
def bool
|
||||||
|
order int
|
||||||
|
}{{"en", "English", true, 1}, {"pl", "Polski", false, 2}} {
|
||||||
|
if err := gdb.Exec(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order)
|
||||||
|
SELECT ?, ?, ?, TRUE, ? WHERE NOT EXISTS (SELECT 1 FROM golem15_translate_locales WHERE code = ?)`,
|
||||||
|
row.code, row.name, row.def, row.order, row.code).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedAuthor inserts a backend user with an optional author slug.
|
||||||
|
func seedAuthor(t *testing.T, gdb *gorm.DB, login, first, last, slug string) uint {
|
||||||
|
t.Helper()
|
||||||
|
var userID uint
|
||||||
|
if err := gdb.Raw(`INSERT INTO backend_users (login, email, password, first_name, last_name, is_activated, is_superuser, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'x', ?, ?, TRUE, FALSE, NOW(), NOW()) RETURNING id`, login, login+"@example.test", first, last).Scan(&userID).Error; err != nil || userID == 0 {
|
||||||
|
t.Fatalf("backend user %s: %d %v", login, userID, err)
|
||||||
|
}
|
||||||
|
if slug != "" {
|
||||||
|
if err := gdb.Exec(`UPDATE backend_users SET golem15_bloghub_author_slug = ? WHERE id = ?`, slug, userID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return userID
|
||||||
|
}
|
||||||
|
|
||||||
|
func describe(t *testing.T, reg *signpost.Registry, key, recordID string) *pact.ContentRecord {
|
||||||
|
t.Helper()
|
||||||
|
rec, err := reg.Describe(context.Background(), key, recordID, "")
|
||||||
|
if err != nil || rec == nil {
|
||||||
|
t.Fatalf("Describe %s %s = %+v, %v", key, recordID, rec, err)
|
||||||
|
}
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalDescriptorFields (D-93, D-43, D-96, P33-06): posts describe
|
||||||
|
// their publication, categories in order, authors, featured and body
|
||||||
|
// images, YouTube embeds, heading outline, top heading level, kind and
|
||||||
|
// tools; categories their parent, pillar outline, top heading and tools;
|
||||||
|
// tags their kind. TopHeading follows the H1 setting.
|
||||||
|
func TestJournalDescriptorFields(t *testing.T) {
|
||||||
|
requirePostgres(t)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
files map[string]string
|
||||||
|
authorPages bool
|
||||||
|
}{
|
||||||
|
{"author pages on", map[string]string{}, true},
|
||||||
|
{"author pages off", map[string]string{"golem15.yaml": "journal:\n author_pages: false\n"}, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, gdb, reg := bootMenuItemTypes(t, tc.files)
|
||||||
|
seedLocales(t, gdb)
|
||||||
|
ctx := context.Background()
|
||||||
|
alpha := menuCategory(t, gdb, "Alpha", "alpha", nil, 1, 4)
|
||||||
|
beta := menuCategory(t, gdb, "Beta", "beta", nil, 5, 6)
|
||||||
|
child := menuCategory(t, gdb, "Child", "child", &alpha.ID, 2, 3)
|
||||||
|
pillar := "<h1>Pillar</h1><p>x</p><h2>Part</h2>"
|
||||||
|
if err := gdb.Model(&models.Category{}).Where("id = ?", alpha.ID).Update("content", pillar).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
author := seedAuthor(t, gdb, "ada", "Ada", "Lovelace", "ada-l")
|
||||||
|
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
|
||||||
|
body := `<h1>Top</h1><p>x</p><h2> Sub heading </h2>` +
|
||||||
|
`<p><img src="/a.jpg" alt="A" width="640" height="480"><img src="/d.jpg" alt=""></p>` +
|
||||||
|
`<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure><h3><a href="/x">Linked</a></h3>`
|
||||||
|
post := models.Post{Title: "Hello", Slug: "hello", Content: body, ContentHTML: body, Published: true, PublishedAt: &past, UserID: &author}
|
||||||
|
if err := gdb.Create(&post).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
attachCategory(t, gdb, post, beta)
|
||||||
|
attachCategory(t, gdb, post, alpha)
|
||||||
|
for field, value := range map[string]string{"slug": "witaj", "title": "Witaj", "content_html": `<h2>PL</h2><img src="/p.jpg" alt="P">`} {
|
||||||
|
if err := translate.SetTranslated(ctx, gdb, &post, field, "pl", value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cover := attach.File{DiskName: "cover.jpg", FileName: "cover.jpg", ContentType: "image/jpeg",
|
||||||
|
AttachmentType: post.MorphName(), AttachmentID: id(post.ID), Field: "featured_images"}
|
||||||
|
if err := gdb.Create(&cover).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tag := models.Tag{Name: "Mushrooms", Slug: "mushrooms"}
|
||||||
|
if err := gdb.Create(&tag).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
typ, ok := reg.ContentType(classes.MenuTypePost)
|
||||||
|
wantTools := map[string]string{
|
||||||
|
pact.ContentToolEdit: "journal_update_post",
|
||||||
|
pact.ContentToolHeadings: "journal_fix_headings",
|
||||||
|
pact.ContentToolImageAlt: "journal_set_image_alt",
|
||||||
|
}
|
||||||
|
if !ok || typ.Content.Kind != pact.ContentKindArticle || !reflect.DeepEqual(typ.Content.Tools, wantTools) {
|
||||||
|
t.Fatalf("post descriptor kind %q tools %v", typ.Content.Kind, typ.Content.Tools)
|
||||||
|
}
|
||||||
|
rec := describe(t, reg, classes.MenuTypePost, id(post.ID))
|
||||||
|
if rec.PublishedAt == nil || !rec.PublishedAt.Equal(past) {
|
||||||
|
t.Fatalf("PublishedAt = %v", rec.PublishedAt)
|
||||||
|
}
|
||||||
|
wantParents := []pact.ContentRef{{Type: classes.MenuTypeCategory, ID: id(alpha.ID)}, {Type: classes.MenuTypeCategory, ID: id(beta.ID)}}
|
||||||
|
if !reflect.DeepEqual(rec.Parents, wantParents) {
|
||||||
|
t.Fatalf("Parents = %+v, want %+v", rec.Parents, wantParents)
|
||||||
|
}
|
||||||
|
wantRef := pact.ContentRef{}
|
||||||
|
if tc.authorPages {
|
||||||
|
wantRef = pact.ContentRef{Type: classes.MenuTypeAuthor, ID: id(author)}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(rec.Authors, []pact.ContentPerson{{Name: "Ada Lovelace", Ref: wantRef}}) {
|
||||||
|
t.Fatalf("Authors = %+v", rec.Authors)
|
||||||
|
}
|
||||||
|
if rec.TopHeading != 2 {
|
||||||
|
t.Fatalf("TopHeading = %d, want 2 while the setting is on", rec.TopHeading)
|
||||||
|
}
|
||||||
|
en, pl := rec.Locales[0], rec.Locales[1]
|
||||||
|
wantImages := []pact.ContentImage{
|
||||||
|
{URL: cover.URL(), Alt: "Hello"},
|
||||||
|
{URL: "/a.jpg", Alt: "A", Width: 640, Height: 480},
|
||||||
|
{URL: "/d.jpg", Alt: "", Decorative: true},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(en.Images, wantImages) {
|
||||||
|
t.Fatalf("en Images = %+v, want %+v", en.Images, wantImages)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(pl.Images, []pact.ContentImage{{URL: cover.URL(), Alt: "Witaj"}, {URL: "/p.jpg", Alt: "P"}}) {
|
||||||
|
t.Fatalf("pl Images = %+v", pl.Images)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(en.Videos, []pact.ContentVideo{{Provider: "youtube", ID: "dQw4w9WgXcQ"}}) || len(pl.Videos) != 0 {
|
||||||
|
t.Fatalf("Videos = %+v / %+v", en.Videos, pl.Videos)
|
||||||
|
}
|
||||||
|
wantHeadings := []pact.ContentHeading{{Level: 1, Text: "Top"}, {Level: 2, Text: "Sub heading"}, {Level: 3, Text: "Linked"}}
|
||||||
|
if !reflect.DeepEqual(en.Headings, wantHeadings) || !reflect.DeepEqual(pl.Headings, []pact.ContentHeading{{Level: 2, Text: "PL"}}) {
|
||||||
|
t.Fatalf("Headings = %+v / %+v", en.Headings, pl.Headings)
|
||||||
|
}
|
||||||
|
|
||||||
|
catType, _ := reg.ContentType(classes.MenuTypeCategory)
|
||||||
|
if catType.Content.Kind != pact.ContentKindCollection || !reflect.DeepEqual(catType.Content.Tools, map[string]string{pact.ContentToolHeadings: "journal_fix_headings"}) {
|
||||||
|
t.Fatalf("category descriptor kind %q tools %v", catType.Content.Kind, catType.Content.Tools)
|
||||||
|
}
|
||||||
|
kid := describe(t, reg, classes.MenuTypeCategory, id(child.ID))
|
||||||
|
if !reflect.DeepEqual(kid.Parents, []pact.ContentRef{{Type: classes.MenuTypeCategory, ID: id(alpha.ID)}}) || len(kid.Locales[0].Headings) != 0 {
|
||||||
|
t.Fatalf("child category = %+v", kid)
|
||||||
|
}
|
||||||
|
top := describe(t, reg, classes.MenuTypeCategory, id(alpha.ID))
|
||||||
|
if len(top.Parents) != 0 || top.TopHeading != 2 ||
|
||||||
|
!reflect.DeepEqual(top.Locales[0].Headings, []pact.ContentHeading{{Level: 1, Text: "Pillar"}, {Level: 2, Text: "Part"}}) {
|
||||||
|
t.Fatalf("pillar category = %+v", top)
|
||||||
|
}
|
||||||
|
tagType, _ := reg.ContentType(classes.MenuTypeTag)
|
||||||
|
if tagType.Content.Kind != pact.ContentKindCollection {
|
||||||
|
t.Fatalf("tag kind %q", tagType.Content.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gdb.Exec(`UPDATE golem15_journal_settings SET content_h1_to_h2 = FALSE WHERE id = 1`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := describe(t, reg, classes.MenuTypePost, id(post.ID)).TopHeading; got != 0 {
|
||||||
|
t.Fatalf("post TopHeading with the setting off = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if got := describe(t, reg, classes.MenuTypeCategory, id(alpha.ID)).TopHeading; got != 0 {
|
||||||
|
t.Fatalf("category TopHeading with the setting off = %d, want 0", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalAuthorType (D-43, D-34, P33-07): journal-author lists the
|
||||||
|
// authors with a published post, describes them as profiles at
|
||||||
|
// /blog/author/:author (the author slug, else the login) per locale, matches
|
||||||
|
// exactly on the pattern, and with golem15.journal.author_pages false
|
||||||
|
// describes empty URLs and never matches.
|
||||||
|
func TestJournalAuthorType(t *testing.T) {
|
||||||
|
requirePostgres(t)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
files map[string]string
|
||||||
|
on bool
|
||||||
|
}{
|
||||||
|
{"default", map[string]string{}, true},
|
||||||
|
{"off", map[string]string{"golem15.yaml": "journal:\n author_pages: false\n"}, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, gdb, reg := bootMenuItemTypes(t, tc.files)
|
||||||
|
seedLocales(t, gdb)
|
||||||
|
ctx := context.Background()
|
||||||
|
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
|
||||||
|
ada := seedAuthor(t, gdb, "ada", "Ada", "Lovelace", "ada-l")
|
||||||
|
bob := seedAuthor(t, gdb, "bob", "Bob", "", "")
|
||||||
|
seedAuthor(t, gdb, "carl", "Carl", "Idle", "carl")
|
||||||
|
for _, p := range []models.Post{
|
||||||
|
{Title: "By Ada", Slug: "by-ada", Content: "x", Published: true, PublishedAt: &past, UserID: &ada},
|
||||||
|
{Title: "By Bob", Slug: "by-bob", Content: "x", Published: true, PublishedAt: &past, UserID: &bob},
|
||||||
|
{Title: "Carl draft", Slug: "carl-draft", Content: "x", Published: false, PublishedAt: &past},
|
||||||
|
} {
|
||||||
|
p := p
|
||||||
|
if err := gdb.Create(&p).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Ada's post is translated into pl, Bob's is not (P33-64).
|
||||||
|
if p.UserID != nil && *p.UserID == ada {
|
||||||
|
if err := translate.SetTranslated(ctx, gdb, &p, "title", "pl", "Od Ady"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typ, ok := reg.ContentType(classes.MenuTypeAuthor)
|
||||||
|
if !ok || typ.Content.Kind != pact.ContentKindProfile || typ.Content.Controller != "" || typ.Content.Model != "Backend\\Models\\User" {
|
||||||
|
t.Fatalf("journal-author descriptor = %+v", typ.Content)
|
||||||
|
}
|
||||||
|
page, err := reg.Records(ctx, classes.MenuTypeAuthor, "", 0, "")
|
||||||
|
if err != nil || len(page.Records) != 2 || page.Records[0].ID != id(ada) || page.Records[1].ID != id(bob) {
|
||||||
|
t.Fatalf("Records = %+v, %v; want Ada and Bob only", page, err)
|
||||||
|
}
|
||||||
|
rec := describe(t, reg, classes.MenuTypeAuthor, id(ada))
|
||||||
|
want := "en= pl="
|
||||||
|
if tc.on {
|
||||||
|
want = "en=/blog/author/ada-l pl=/pl/blog/author/ada-l"
|
||||||
|
}
|
||||||
|
if localeURLs(rec) != want || rec.Locales[0].Title != "Ada Lovelace" || !rec.Published {
|
||||||
|
t.Fatalf("Describe Ada = %+v; want URLs %s", rec, want)
|
||||||
|
}
|
||||||
|
if tc.on {
|
||||||
|
// Bob has no post with content in pl: no pl address.
|
||||||
|
if got := localeURLs(describe(t, reg, classes.MenuTypeAuthor, id(bob))); got != "en=/blog/author/bob pl=" {
|
||||||
|
t.Fatalf("login fallback URLs = %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/blog/author/ada-l", "/pl/blog/author/ada-l"} {
|
||||||
|
key, res, err := reg.MatchPath(ctx, path, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tc.on && (key != classes.MenuTypeAuthor || res == nil || !res.Exact || res.ID != id(ada)) {
|
||||||
|
t.Fatalf("MatchPath(%s) = %q %+v", path, key, res)
|
||||||
|
}
|
||||||
|
if !tc.on && res != nil && key == classes.MenuTypeAuthor {
|
||||||
|
t.Fatalf("MatchPath(%s) matched with author pages off: %+v", path, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key, res, err := reg.MatchPath(ctx, "/blog/author/carl", ""); err != nil || (res != nil && key == classes.MenuTypeAuthor) {
|
||||||
|
t.Fatalf("an author without a published post matched: %q %+v %v", key, res, err)
|
||||||
|
}
|
||||||
|
item, err := reg.Resolve(ctx, classes.MenuTypeAuthor, id(ada), "", false, "")
|
||||||
|
if err != nil || (item != nil) != tc.on || (tc.on && item.URL != "/blog/author/ada-l") {
|
||||||
|
t.Fatalf("Resolve = %+v, %v", item, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
239
journal_translation_html_test.go
Normal file
239
journal_translation_html_test.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/console"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// translatedXSS is a translated content_html carrying an event handler and a
|
||||||
|
// javascript URL. Stored verbatim it would reach anonymous readers through
|
||||||
|
// the public posts API.
|
||||||
|
const translatedXSS = `<p>Treść</p><img src="x" onerror="alert(1)"><a href="javascript:alert(1)">x</a>`
|
||||||
|
|
||||||
|
// translatedXSSClean is translatedXSS after the FormatHTML allow-list.
|
||||||
|
const translatedXSSClean = `<p>Treść</p><img src="x">x`
|
||||||
|
|
||||||
|
// assertNoXSS fails when html still holds the translatedXSS payload.
|
||||||
|
func assertNoXSS(t *testing.T, where, html string) {
|
||||||
|
t.Helper()
|
||||||
|
lower := strings.ToLower(html)
|
||||||
|
for _, bad := range []string{"onerror", "javascript:", "<script"} {
|
||||||
|
if strings.Contains(lower, bad) {
|
||||||
|
t.Fatalf("%s holds %q: %q", where, bad, html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicTranslations GETs a post anonymously and returns its translations
|
||||||
|
// and host content_html.
|
||||||
|
func publicTranslations(t *testing.T, env *adminEnv, slug string) (map[string]map[string]any, string) {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.call(t, http.MethodGet, "/_journal/api/v1/posts/"+slug, "", nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("anonymous show %s = %d: %s", slug, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Data struct {
|
||||||
|
ContentHTML string `json:"content_html"`
|
||||||
|
Translations json.RawMessage `json:"translations"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("show json: %s err=%v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
tr := map[string]map[string]any{}
|
||||||
|
if string(body.Data.Translations) != "[]" {
|
||||||
|
if err := json.Unmarshal(body.Data.Translations, &tr); err != nil {
|
||||||
|
t.Fatalf("translations json: %s err=%v", body.Data.Translations, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tr, body.Data.ContentHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalAPITranslatedContentHTML pins W-1 on the API write path: a
|
||||||
|
// translated content_html is stored sanitized, a translation with only
|
||||||
|
// content gets a derived content_html, and valid writes keep their status.
|
||||||
|
func TestJournalAPITranslatedContentHTML(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
tok := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessPublish)
|
||||||
|
past := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)
|
||||||
|
|
||||||
|
create := func(slug string, translations map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.call(t, http.MethodPost, "/_journal/api/v1/posts", tok, map[string]any{
|
||||||
|
"title": "Post " + slug,
|
||||||
|
"slug": slug,
|
||||||
|
"content": "Host **body**",
|
||||||
|
"published": true,
|
||||||
|
"published_at": past,
|
||||||
|
"translations": translations,
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create %s = %d, want 201: %s", slug, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertNoXSS(t, "create response", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("content_html only is sanitized", func(t *testing.T) {
|
||||||
|
create("tr-html-only", map[string]any{
|
||||||
|
"pl": map[string]any{"title": "Wpis", "content_html": translatedXSS},
|
||||||
|
})
|
||||||
|
tr, host := publicTranslations(t, env, "tr-html-only")
|
||||||
|
got, _ := tr["pl"]["content_html"].(string)
|
||||||
|
if got != translatedXSSClean {
|
||||||
|
t.Fatalf("pl content_html = %q, want %q", got, translatedXSSClean)
|
||||||
|
}
|
||||||
|
if host != "<p>Host <strong>body</strong></p>\n" {
|
||||||
|
t.Fatalf("host content_html = %q", host)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("content only derives content_html", func(t *testing.T) {
|
||||||
|
create("tr-content-only", map[string]any{
|
||||||
|
"pl": map[string]any{"title": "Wpis", "content": "Witaj **świecie**"},
|
||||||
|
})
|
||||||
|
tr, _ := publicTranslations(t, env, "tr-content-only")
|
||||||
|
if got, want := tr["pl"]["content_html"], "<p>Witaj <strong>świecie</strong></p>\n"; got != want {
|
||||||
|
t.Fatalf("pl content_html = %#v, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got := tr["pl"]["content"]; got != "Witaj **świecie**" {
|
||||||
|
t.Fatalf("pl content = %#v, stored as submitted expected", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("content wins over a supplied content_html", func(t *testing.T) {
|
||||||
|
create("tr-both", map[string]any{
|
||||||
|
"pl": map[string]any{"content": "Tekst", "content_html": translatedXSS},
|
||||||
|
})
|
||||||
|
tr, _ := publicTranslations(t, env, "tr-both")
|
||||||
|
if got, want := tr["pl"]["content_html"], "<p>Tekst</p>\n"; got != want {
|
||||||
|
t.Fatalf("pl content_html = %#v, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("default-locale translation cannot bypass the host column", func(t *testing.T) {
|
||||||
|
create("tr-default-locale", map[string]any{
|
||||||
|
"en": map[string]any{"content_html": translatedXSS},
|
||||||
|
})
|
||||||
|
_, host := publicTranslations(t, env, "tr-default-locale")
|
||||||
|
if host != translatedXSSClean {
|
||||||
|
t.Fatalf("host content_html = %q, want %q", host, translatedXSSClean)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("update sanitizes translated content_html", func(t *testing.T) {
|
||||||
|
var post models.Post
|
||||||
|
if err := env.gdb.Where("slug = ?", "tr-content-only").Take(&post).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := env.call(t, http.MethodPut, fmt.Sprintf("/_journal/api/v1/posts/%d", post.ID), tok, map[string]any{
|
||||||
|
"translations": map[string]any{
|
||||||
|
"pl": map[string]any{"content": "", "content_html": translatedXSS},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("update = %d, want 200: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
assertNoXSS(t, "update response", rec.Body.String())
|
||||||
|
tr, _ := publicTranslations(t, env, "tr-content-only")
|
||||||
|
if got := tr["pl"]["content_html"]; got != translatedXSSClean {
|
||||||
|
t.Fatalf("pl content_html after update = %#v, want %q", got, translatedXSSClean)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
list := env.call(t, http.MethodGet, "/_journal/api/v1/posts", "", nil)
|
||||||
|
if list.Code != http.StatusOK {
|
||||||
|
t.Fatalf("anonymous list = %d: %s", list.Code, list.Body.String())
|
||||||
|
}
|
||||||
|
assertNoXSS(t, "anonymous list", list.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalImportTranslatedContentHTML pins W-1 on journal:import-posts:
|
||||||
|
// a translated content_html in the import file is stored sanitized, and a
|
||||||
|
// translation with only content gets a derived content_html.
|
||||||
|
func TestJournalImportTranslatedContentHTML(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
published := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)
|
||||||
|
files := []console.PostFile{
|
||||||
|
{
|
||||||
|
Slug: "import-html-only",
|
||||||
|
Title: "Import HTML",
|
||||||
|
Content: "Host",
|
||||||
|
Published: true,
|
||||||
|
PublishedAt: &published,
|
||||||
|
Translations: map[string]map[string]any{
|
||||||
|
"pl": {"title": "Import HTML", "content_html": translatedXSS},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "import-content-only",
|
||||||
|
Title: "Import content",
|
||||||
|
Content: "Host",
|
||||||
|
Published: true,
|
||||||
|
PublishedAt: &published,
|
||||||
|
Translations: map[string]map[string]any{
|
||||||
|
"pl": {"title": "Import treści", "content": "Witaj **świecie**"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
raw, err := json.Marshal(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, f.Slug+".json"), raw, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats, err := console.ImportFromDir(context.Background(), env.gdb, dir, false, false, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import: %v", err)
|
||||||
|
}
|
||||||
|
if stats.Created != 2 || stats.Failed != 0 {
|
||||||
|
t.Fatalf("import stats = %+v, want 2 created", stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored := func(slug string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var post models.Post
|
||||||
|
if err := env.gdb.Where("slug = ?", slug).Take(&post).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var raw string
|
||||||
|
if err := env.gdb.Table("golem15_translate_attributes").
|
||||||
|
Select("attribute_data").
|
||||||
|
Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(post.ID), post.MorphName(), "pl").
|
||||||
|
Scan(&raw).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||||
|
t.Fatalf("attribute_data %q: %v", raw, err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := stored("import-html-only")["content_html"]; got != translatedXSSClean {
|
||||||
|
t.Fatalf("imported pl content_html = %#v, want %q", got, translatedXSSClean)
|
||||||
|
}
|
||||||
|
if got, want := stored("import-content-only")["content_html"], "<p>Witaj <strong>świecie</strong></p>\n"; got != want {
|
||||||
|
t.Fatalf("imported pl content_html = %#v, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
tr, _ := publicTranslations(t, env, "import-html-only")
|
||||||
|
if got := tr["pl"]["content_html"]; got != translatedXSSClean {
|
||||||
|
t.Fatalf("public pl content_html = %#v, want %q", got, translatedXSSClean)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,11 @@ journal:
|
|||||||
rss_image_url_comment: URL to an image/logo displayed in feed readers.
|
rss_image_url_comment: URL to an image/logo displayed in feed readers.
|
||||||
rss_posts_per_feed_label: Posts per feed
|
rss_posts_per_feed_label: Posts per feed
|
||||||
rss_posts_per_feed_comment: Default number of posts in the RSS feed. Can be overridden per component.
|
rss_posts_per_feed_comment: Default number of posts in the RSS feed. Can be overridden per component.
|
||||||
|
tab_headings: Headings and links
|
||||||
|
h1_to_h2_label: Convert H1 to H2 in post and category content
|
||||||
|
h1_to_h2_comment: 'Turn it off only when the page template does not show the post title or the category name as the H1. On: the editor offers H2–H4, and every H1 saved through the admin, the API, MCP or an import becomes an H2. Off: content keeps its H1 headings, and the site shows them unchanged.'
|
||||||
|
generic_anchors_label: Generic link texts
|
||||||
|
generic_anchors_comment: One per line. The editor warns when the whole text of a link is one of these.
|
||||||
posts:
|
posts:
|
||||||
list_title: Manage the journal posts
|
list_title: Manage the journal posts
|
||||||
filter_category: Category
|
filter_category: Category
|
||||||
@@ -110,6 +115,8 @@ category:
|
|||||||
name: Name
|
name: Name
|
||||||
name_placeholder: New category name
|
name_placeholder: New category name
|
||||||
description: Description
|
description: Description
|
||||||
|
content: "Content"
|
||||||
|
content_comment: "HTML shown on the category page above its post list. Unsafe HTML (scripts, frames, event handlers, javascript, vbscript and data URLs) is removed on save, and the form names what was removed."
|
||||||
slug: Slug
|
slug: Slug
|
||||||
slug_placeholder: new-category-slug
|
slug_placeholder: new-category-slug
|
||||||
posts: Posts
|
posts: Posts
|
||||||
@@ -129,3 +136,36 @@ tag:
|
|||||||
delete_confirm: Delete this tag?
|
delete_confirm: Delete this tag?
|
||||||
delete_success: Successfully deleted those tags.
|
delete_success: Successfully deleted those tags.
|
||||||
return_to_tags: Return to the journal tag list
|
return_to_tags: Return to the journal tag list
|
||||||
|
menuitem:
|
||||||
|
journal_category: Journal category
|
||||||
|
all_journal_categories: All journal categories
|
||||||
|
journal_post: Journal post
|
||||||
|
all_journal_posts: All journal posts
|
||||||
|
category_journal_posts: Journal category posts
|
||||||
|
journal_tag: Journal tag
|
||||||
|
journal_author: Journal author
|
||||||
|
scopes:
|
||||||
|
journal: Journal
|
||||||
|
journal_description: Create, edit, publish and delete posts as you, within your Journal permissions.
|
||||||
|
journal_read: Journal, read-only
|
||||||
|
journal_read_description: Read posts, including drafts you may see, without changing anything.
|
||||||
|
editor:
|
||||||
|
content_invalid: The content must be HTML text.
|
||||||
|
image_invalid: The image must be a JPEG, PNG, GIF or WebP file.
|
||||||
|
upload_failed: The image could not be uploaded.
|
||||||
|
removed_elements:
|
||||||
|
one: ":count element was removed: :names"
|
||||||
|
other: ":count elements were removed: :names"
|
||||||
|
removed_attributes:
|
||||||
|
one: ":count attribute was removed: :names"
|
||||||
|
other: ":count attributes were removed: :names"
|
||||||
|
removed_comments:
|
||||||
|
one: ":count HTML comment was removed"
|
||||||
|
other: ":count HTML comments were removed"
|
||||||
|
markup_changed: The HTML filter changed some markup.
|
||||||
|
h1_converted:
|
||||||
|
one: ":count H1 heading was converted to H2, because the page shows the post title as the H1."
|
||||||
|
other: ":count H1 headings were converted to H2, because the page shows the post title as the H1."
|
||||||
|
h1_converted_category:
|
||||||
|
one: ":count H1 heading was converted to H2, because the page shows the category name as the H1."
|
||||||
|
other: ":count H1 headings were converted to H2, because the page shows the category name as the H1."
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ journal:
|
|||||||
search_excerpt_weight_comment: Waga trafności dla dopasowań w zajawce posta (domyślnie 3).
|
search_excerpt_weight_comment: Waga trafności dla dopasowań w zajawce posta (domyślnie 3).
|
||||||
search_content_weight_label: Waga treści
|
search_content_weight_label: Waga treści
|
||||||
search_content_weight_comment: Waga trafności dla dopasowań w treści posta (domyślnie 1).
|
search_content_weight_comment: Waga trafności dla dopasowań w treści posta (domyślnie 1).
|
||||||
|
tab_headings: Nagłówki i linki
|
||||||
|
h1_to_h2_label: Zamieniaj H1 na H2 w treści wpisów i kategorii
|
||||||
|
h1_to_h2_comment: 'Wyłącz tylko wtedy, gdy szablon strony nie wyświetla tytułu wpisu ani nazwy kategorii jako H1. Włączone: edytor oferuje H2–H4, a każdy H1 zapisany przez panel, API, MCP lub import staje się H2. Wyłączone: treść zachowuje nagłówki H1, a witryna wyświetla je bez zmian.'
|
||||||
|
generic_anchors_label: Ogólnikowe teksty linków
|
||||||
|
generic_anchors_comment: Jeden w wierszu. Edytor ostrzega, gdy cały tekst linku jest jednym z nich.
|
||||||
posts:
|
posts:
|
||||||
list_title: Zarządzaj postami
|
list_title: Zarządzaj postami
|
||||||
filter_category: Kategoria
|
filter_category: Kategoria
|
||||||
@@ -74,6 +79,8 @@ category:
|
|||||||
name: Nazwa
|
name: Nazwa
|
||||||
name_placeholder: Nazwa nowej kategorii
|
name_placeholder: Nazwa nowej kategorii
|
||||||
description: Opis
|
description: Opis
|
||||||
|
content: "Treść"
|
||||||
|
content_comment: "Kod HTML wyświetlany na stronie kategorii nad listą wpisów. Niebezpieczny HTML (skrypty, ramki, atrybuty zdarzeń, adresy javascript, vbscript i data) jest usuwany przy zapisie, a formularz podaje, co usunięto."
|
||||||
slug: Alias
|
slug: Alias
|
||||||
slug_placeholder: alias-nowej-kategorii
|
slug_placeholder: alias-nowej-kategorii
|
||||||
posts: Posty
|
posts: Posty
|
||||||
@@ -81,3 +88,46 @@ category:
|
|||||||
delete_success: Kategorie zostały pomyślnie usunięte.
|
delete_success: Kategorie zostały pomyślnie usunięte.
|
||||||
return_to_categories: Wróć do listy kategorii
|
return_to_categories: Wróć do listy kategorii
|
||||||
reorder: Zmień kolejność kategorii
|
reorder: Zmień kolejność kategorii
|
||||||
|
menuitem:
|
||||||
|
journal_category: Kategoria (Journal)
|
||||||
|
all_journal_categories: Wszystkie kategorie (Journal)
|
||||||
|
journal_post: Wpis (Journal)
|
||||||
|
all_journal_posts: Wszystkie wpisy (Journal)
|
||||||
|
category_journal_posts: Wpisy kategorii (Journal)
|
||||||
|
journal_tag: Tag bloga
|
||||||
|
journal_author: Autor (Journal)
|
||||||
|
scopes:
|
||||||
|
journal: Journal
|
||||||
|
journal_description: Twórz, edytuj, publikuj i usuwaj posty jako Ty, w ramach swoich uprawnień Journal.
|
||||||
|
journal_read: Journal, tylko odczyt
|
||||||
|
journal_read_description: Czytaj posty, w tym szkice, które możesz zobaczyć, bez wprowadzania zmian.
|
||||||
|
editor:
|
||||||
|
content_invalid: Treść musi być tekstem HTML.
|
||||||
|
image_invalid: Obraz musi być plikiem JPEG, PNG, GIF lub WebP.
|
||||||
|
upload_failed: Nie udało się przesłać obrazu.
|
||||||
|
removed_elements:
|
||||||
|
one: "Usunięto :count element: :names"
|
||||||
|
few: "Usunięto :count elementy: :names"
|
||||||
|
many: "Usunięto :count elementów: :names"
|
||||||
|
other: "Usunięto :count elementu: :names"
|
||||||
|
removed_attributes:
|
||||||
|
one: "Usunięto :count atrybut: :names"
|
||||||
|
few: "Usunięto :count atrybuty: :names"
|
||||||
|
many: "Usunięto :count atrybutów: :names"
|
||||||
|
other: "Usunięto :count atrybutu: :names"
|
||||||
|
removed_comments:
|
||||||
|
one: "Usunięto :count komentarz HTML"
|
||||||
|
few: "Usunięto :count komentarze HTML"
|
||||||
|
many: "Usunięto :count komentarzy HTML"
|
||||||
|
other: "Usunięto :count komentarza HTML"
|
||||||
|
markup_changed: Filtr HTML zmienił część znaczników.
|
||||||
|
h1_converted:
|
||||||
|
one: "Zamieniono :count nagłówek H1 na H2, bo strona wyświetla tytuł wpisu jako H1."
|
||||||
|
few: "Zamieniono :count nagłówki H1 na H2, bo strona wyświetla tytuł wpisu jako H1."
|
||||||
|
many: "Zamieniono :count nagłówków H1 na H2, bo strona wyświetla tytuł wpisu jako H1."
|
||||||
|
other: "Zamieniono :count nagłówka H1 na H2, bo strona wyświetla tytuł wpisu jako H1."
|
||||||
|
h1_converted_category:
|
||||||
|
one: "Zamieniono :count nagłówek H1 na H2, bo strona wyświetla nazwę kategorii jako H1."
|
||||||
|
few: "Zamieniono :count nagłówki H1 na H2, bo strona wyświetla nazwę kategorii jako H1."
|
||||||
|
many: "Zamieniono :count nagłówków H1 na H2, bo strona wyświetla nazwę kategorii jako H1."
|
||||||
|
other: "Zamieniono :count nagłówka H1 na H2, bo strona wyświetla nazwę kategorii jako H1."
|
||||||
|
|||||||
7
logger.go
Normal file
7
logger.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package journal
|
||||||
|
|
||||||
|
import "git.golem15.com/golem15/summercms/modules/diary"
|
||||||
|
|
||||||
|
// pluginLog is the plugin's logger: its records carry component=journal, so
|
||||||
|
// config/log.yaml can raise or lower them (log.components.journal).
|
||||||
|
var pluginLog = diary.For("journal")
|
||||||
13
manifest_test.go
Normal file
13
manifest_test.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package journal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/kiosk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPluginManifest fails when plugin.yaml is missing or no longer matches
|
||||||
|
// what `summer plugin:manifest` derives from this repository.
|
||||||
|
func TestPluginManifest(t *testing.T) {
|
||||||
|
kiosk.CheckT(t, ".")
|
||||||
|
}
|
||||||
275
mcp.go
Normal file
275
mcp.go
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
package journal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ pact.HasMCP = (*Plugin)(nil)
|
||||||
|
|
||||||
|
// mcpGuard is the guard the Journal tools run as: the backend account linked
|
||||||
|
// to the MCP user (the routes resolve the backend principal themselves).
|
||||||
|
const mcpGuard = "backend"
|
||||||
|
|
||||||
|
// mcpAccessPosts is the permission every Journal tool and the prompt are
|
||||||
|
// listed with (the routes check it, and access_publish, themselves).
|
||||||
|
const mcpAccessPosts = "golem15.journal.access_posts"
|
||||||
|
|
||||||
|
// mcpListPostsInput is the query of GET /_journal/api/v1/posts.
|
||||||
|
type mcpListPostsInput struct {
|
||||||
|
Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"`
|
||||||
|
PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"posts per page"`
|
||||||
|
Search string `json:"search,omitempty" query:"search" jsonschema:"a phrase of at least three characters the title, slug, excerpt or content contains"`
|
||||||
|
Category string `json:"category,omitempty" query:"category" jsonschema:"a category slug or id"`
|
||||||
|
Tag string `json:"tag,omitempty" query:"tag" jsonschema:"a tag slug or id"`
|
||||||
|
Author string `json:"author,omitempty" query:"author" jsonschema:"the id of the administrator who wrote the posts"`
|
||||||
|
Published *bool `json:"published,omitempty" query:"published" jsonschema:"true for published posts only, false for drafts too (the default for an editor)"`
|
||||||
|
Sort string `json:"sort,omitempty" query:"sort" jsonschema:"the order: a column (published_at, created_at, updated_at, title or id) and optionally asc, for example title asc; newest published first by default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpShowPostInput names one post by slug.
|
||||||
|
type mcpShowPostInput struct {
|
||||||
|
Slug string `json:"slug" path:"slug" jsonschema:"the post slug"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpCreatePostInput is the body of POST /_journal/api/v1/posts.
|
||||||
|
type mcpCreatePostInput struct {
|
||||||
|
Title string `json:"title" jsonschema:"the post title"`
|
||||||
|
Content string `json:"content" jsonschema:"the post body (Markdown)"`
|
||||||
|
Slug string `json:"slug,omitempty" jsonschema:"the URL slug; derived from the title when omitted"`
|
||||||
|
Excerpt string `json:"excerpt,omitempty" jsonschema:"a short summary shown in lists"`
|
||||||
|
Published *bool `json:"published,omitempty" jsonschema:"publish at once (needs the publish permission); a draft when omitted"`
|
||||||
|
PublishedAt string `json:"published_at,omitempty" jsonschema:"the publication time (RFC 3339 or YYYY-MM-DD)"`
|
||||||
|
IsPinned *bool `json:"is_pinned,omitempty" jsonschema:"pin the post at the top of lists"`
|
||||||
|
Categories []uint `json:"categories,omitempty" jsonschema:"category ids"`
|
||||||
|
Tags []string `json:"tags,omitempty" jsonschema:"tag names; unknown tags are created"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpUpdatePostInput is PUT /_journal/api/v1/posts/{id}: only the fields sent
|
||||||
|
// are changed.
|
||||||
|
type mcpUpdatePostInput struct {
|
||||||
|
ID uint `json:"id" path:"id" jsonschema:"the post id"`
|
||||||
|
Title *string `json:"title,omitempty" jsonschema:"the post title"`
|
||||||
|
Content *string `json:"content,omitempty" jsonschema:"the post body (Markdown)"`
|
||||||
|
Slug *string `json:"slug,omitempty" jsonschema:"the URL slug"`
|
||||||
|
Excerpt *string `json:"excerpt,omitempty" jsonschema:"a short summary shown in lists"`
|
||||||
|
Published *bool `json:"published,omitempty" jsonschema:"published (needs the publish permission) or a draft"`
|
||||||
|
PublishedAt *string `json:"published_at,omitempty" jsonschema:"the publication time (RFC 3339 or YYYY-MM-DD)"`
|
||||||
|
IsPinned *bool `json:"is_pinned,omitempty" jsonschema:"pin the post at the top of lists"`
|
||||||
|
Categories *[]uint `json:"categories,omitempty" jsonschema:"category ids; replaces the post's categories"`
|
||||||
|
Tags *[]string `json:"tags,omitempty" jsonschema:"tag names; replaces the post's tags, unknown tags are created"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpDeletePostInput names one post by id.
|
||||||
|
type mcpDeletePostInput struct {
|
||||||
|
ID uint `json:"id" path:"id" jsonschema:"the post id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpManageSettings is the permission of the settings and heading tools.
|
||||||
|
const mcpManageSettings = "golem15.journal.manage_settings"
|
||||||
|
|
||||||
|
// mcpUpdateSettingsInput is PUT /_journal/api/v1/settings: only the fields
|
||||||
|
// sent are changed.
|
||||||
|
type mcpUpdateSettingsInput struct {
|
||||||
|
ContentH1ToH2 *bool `json:"content_h1_to_h2,omitempty" jsonschema:"convert H1 to H2 in post and category content on every save; the editor then offers H2 to H4 (switching it never rewrites stored content, journal_fix_headings does)"`
|
||||||
|
GenericAnchors *string `json:"generic_anchors,omitempty" jsonschema:"the link texts the editor warns about, one per line"`
|
||||||
|
ShowAllPosts *bool `json:"show_all_posts,omitempty" jsonschema:"show drafts to administrators on the site"`
|
||||||
|
UseRichEditor *bool `json:"use_rich_editor,omitempty" jsonschema:"use the rich editor for post content"`
|
||||||
|
SearchUseTypesense *bool `json:"search_use_typesense,omitempty" jsonschema:"search posts with the configured search engine"`
|
||||||
|
SearchTitleWeight *int `json:"search_title_weight,omitempty" jsonschema:"relevance weight of the title, 0 to 100"`
|
||||||
|
SearchExcerptWeight *int `json:"search_excerpt_weight,omitempty" jsonschema:"relevance weight of the excerpt, 0 to 100"`
|
||||||
|
SearchContentWeight *int `json:"search_content_weight,omitempty" jsonschema:"relevance weight of the content, 0 to 100"`
|
||||||
|
RSSEnabled *bool `json:"rss_enabled,omitempty" jsonschema:"serve the RSS feed"`
|
||||||
|
RSSIncludeContent *bool `json:"rss_include_content,omitempty" jsonschema:"include the full content in the feed"`
|
||||||
|
RSSTitle *string `json:"rss_title,omitempty" jsonschema:"the feed title"`
|
||||||
|
RSSDescription *string `json:"rss_description,omitempty" jsonschema:"the feed description"`
|
||||||
|
RSSLanguage *string `json:"rss_language,omitempty" jsonschema:"the feed language code, for example en-us"`
|
||||||
|
RSSCopyright *string `json:"rss_copyright,omitempty" jsonschema:"the feed copyright notice"`
|
||||||
|
RSSImageURL *string `json:"rss_image_url,omitempty" jsonschema:"the feed image URL"`
|
||||||
|
RSSPostsPerFeed *int `json:"rss_posts_per_feed,omitempty" jsonschema:"posts in the feed, 1 to 100"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpFixHeadingsInput is POST /_journal/api/v1/fix-headings.
|
||||||
|
type mcpFixHeadingsInput struct {
|
||||||
|
DryRun *bool `json:"dry_run,omitempty" jsonschema:"only count the H1 headings (the default); false converts and saves them"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpImageAltChange is one alt change of journal_set_image_alt.
|
||||||
|
type mcpImageAltChange struct {
|
||||||
|
PostID uint `json:"post_id" jsonschema:"the post id"`
|
||||||
|
Locale string `json:"locale,omitempty" jsonschema:"the content locale; the default locale when omitted"`
|
||||||
|
Src string `json:"src" jsonschema:"the src of the image in the post content, exactly as stored"`
|
||||||
|
Alt string `json:"alt" jsonschema:"the alt text; empty marks the image as decorative"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpSetImageAltInput is POST /_journal/api/v1/images/alt.
|
||||||
|
type mcpSetImageAltInput struct {
|
||||||
|
DryRun *bool `json:"dry_run,omitempty" jsonschema:"show the changes without saving them"`
|
||||||
|
Changes []mcpImageAltChange `json:"changes" jsonschema:"the alt changes; all are refused when one names an image that is not in its post"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP declares the Journal's MCP tools and prompt (D-15, D-16): the post,
|
||||||
|
// category and tag routes of /_journal/api/v1, run as the backend account
|
||||||
|
// linked to the MCP user and listed only when that account has
|
||||||
|
// golem15.journal.access_posts; the settings, fix_headings and
|
||||||
|
// set_image_alt tools of the content fixes (D-10, D-85) are listed with
|
||||||
|
// golem15.journal.manage_settings (set_image_alt with access_posts). The
|
||||||
|
// routes enforce their own permissions (publishing, other authors' posts)
|
||||||
|
// on every call. Media and featured-image uploads (multipart) are not
|
||||||
|
// tools.
|
||||||
|
func (p *Plugin) MCP() pact.MCP {
|
||||||
|
destructive := true
|
||||||
|
return pact.MCP{
|
||||||
|
Tools: []pact.MCPTool{
|
||||||
|
{
|
||||||
|
Name: "list_posts",
|
||||||
|
Title: "List posts",
|
||||||
|
Description: "Lists Journal posts, drafts included, newest first, with filters for a phrase, category, tag and author.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/_journal/api/v1/posts",
|
||||||
|
Input: mcpListPostsInput{},
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "show_post",
|
||||||
|
Title: "Show post",
|
||||||
|
Description: "Shows one Journal post by slug, a draft included.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/_journal/api/v1/posts/{slug}",
|
||||||
|
Input: mcpShowPostInput{},
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "list_categories",
|
||||||
|
Title: "List categories",
|
||||||
|
Description: "Lists the Journal categories with their ids and slugs.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/_journal/api/v1/categories",
|
||||||
|
Input: struct{}{},
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "list_tags",
|
||||||
|
Title: "List tags",
|
||||||
|
Description: "Lists the Journal tags with their ids and slugs.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/_journal/api/v1/tags",
|
||||||
|
Input: struct{}{},
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "create_post",
|
||||||
|
Title: "Create post",
|
||||||
|
Description: "Creates a Journal post; a draft unless published is true.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/_journal/api/v1/posts",
|
||||||
|
Input: mcpCreatePostInput{},
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "update_post",
|
||||||
|
Title: "Update post",
|
||||||
|
Description: "Changes the fields sent of one Journal post by id.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodPut,
|
||||||
|
Path: "/_journal/api/v1/posts/{id}",
|
||||||
|
Input: mcpUpdatePostInput{},
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "delete_post",
|
||||||
|
Title: "Delete post",
|
||||||
|
Description: "Deletes one Journal post by id.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Path: "/_journal/api/v1/posts/{id}",
|
||||||
|
Input: mcpDeletePostInput{},
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
Destructive: &destructive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "get_settings",
|
||||||
|
Title: "Get Journal settings",
|
||||||
|
Description: "Shows the Journal settings: the H1 to H2 conversion, the generic link texts, search and RSS.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/_journal/api/v1/settings",
|
||||||
|
Input: struct{}{},
|
||||||
|
Permissions: []string{mcpManageSettings},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "update_settings",
|
||||||
|
Title: "Update Journal settings",
|
||||||
|
Description: "Changes the Journal settings sent, such as the H1 to H2 conversion or the generic link texts.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodPut,
|
||||||
|
Path: "/_journal/api/v1/settings",
|
||||||
|
Input: mcpUpdateSettingsInput{},
|
||||||
|
Permissions: []string{mcpManageSettings},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fix_headings",
|
||||||
|
Title: "Fix stored H1 headings",
|
||||||
|
Description: "Converts H1 headings to H2 in stored post and category content, like journal:fix-headings; a dry run unless dry_run is false. Refused while the H1 setting is off.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/_journal/api/v1/fix-headings",
|
||||||
|
Input: mcpFixHeadingsInput{},
|
||||||
|
Permissions: []string{mcpManageSettings},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "set_image_alt",
|
||||||
|
Title: "Set image alt texts",
|
||||||
|
Description: "Sets the alt text of images in post content, several at once; an empty alt marks an image as decorative. In a markdown post the alt is written into the markdown source (an inline image , or a raw img tag outside code); an image the markdown does not hold that way (a reference-style image) is refused with changes.N.src: edit that post instead.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/_journal/api/v1/images/alt",
|
||||||
|
Input: mcpSetImageAltInput{},
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
Destructive: &destructive,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Prompts: []pact.MCPPrompt{{
|
||||||
|
Name: "draft_post",
|
||||||
|
Title: "Draft a post",
|
||||||
|
Description: "Drafts a Journal post on a topic and saves it as a draft.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
// It directs journal_create_post, so a read-only grant does
|
||||||
|
// not see it.
|
||||||
|
Writes: true,
|
||||||
|
Arguments: []pact.MCPPromptArgument{
|
||||||
|
{Name: "topic", Description: "what the post is about", Required: true},
|
||||||
|
{Name: "tone", Description: "the tone of voice, for example friendly or formal"},
|
||||||
|
},
|
||||||
|
Render: renderDraftPost,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDraftPost builds the draft_post prompt's one user message.
|
||||||
|
func renderDraftPost(_ context.Context, args map[string]string) ([]pact.MCPPromptMessage, error) {
|
||||||
|
topic := strings.TrimSpace(args["topic"])
|
||||||
|
if topic == "" {
|
||||||
|
return nil, fmt.Errorf("draft_post: topic is required")
|
||||||
|
}
|
||||||
|
tone := strings.TrimSpace(args["tone"])
|
||||||
|
if tone == "" {
|
||||||
|
tone = "clear and friendly"
|
||||||
|
}
|
||||||
|
text := "Write a Journal post about: " + topic + "\n\n" +
|
||||||
|
"Tone: " + tone + ".\n" +
|
||||||
|
"Style: a short, specific title; an excerpt of one or two sentences; the body in Markdown with short paragraphs and subheadings where they help; no filler.\n" +
|
||||||
|
"Before writing, call journal_list_categories and journal_list_tags and reuse fitting categories and tags.\n" +
|
||||||
|
"Then save it with journal_create_post with published set to false, so an editor reviews the draft before it goes live, and reply with the new post's slug."
|
||||||
|
return []pact.MCPPromptMessage{{Role: "user", Text: text}}, nil
|
||||||
|
}
|
||||||
194
mcp_prompt_writes_test.go
Normal file
194
mcp_prompt_writes_test.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
journal "git.golem15.com/golem15/sm-journal-plugin"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pier"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// servingHost is mountHost that also keeps the endpoint pier mounts, so
|
||||||
|
// the test can talk to it.
|
||||||
|
type servingHost struct {
|
||||||
|
mountHost
|
||||||
|
path string
|
||||||
|
handlers map[string]http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *servingHost) MountStream(method, path string, handler http.Handler, _ ...string) error {
|
||||||
|
h.handlers[method] = handler
|
||||||
|
h.path = path
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *servingHost) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if handler, ok := h.handlers[r.Method]; ok && r.URL.Path == h.path {
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptGrants verifies two OAuth grants of one linked user: read only and
|
||||||
|
// read+write, both with the backend scope and consented for the linked
|
||||||
|
// backend user 1 (pact.MCPGrant.BackendUserID).
|
||||||
|
type promptGrants struct{}
|
||||||
|
|
||||||
|
func (promptGrants) MCPVerifyToken(_ context.Context, raw string) (pact.MCPGrant, error) {
|
||||||
|
scopes := map[string][]string{"oas_at_reader": {"read", "backend"}, "oas_at_writer": {"read", "write", "backend"}}[raw]
|
||||||
|
if scopes == nil {
|
||||||
|
return pact.MCPGrant{}, pact.ErrMCPTokenInvalid
|
||||||
|
}
|
||||||
|
return pact.MCPGrant{UserID: 5, ClientID: "agent", TokenID: 9, Scopes: scopes, ExpiresAt: time.Now().Add(time.Hour), BackendUserID: 1}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (promptGrants) MCPResource() string { return "https://app.example.test/mcp" }
|
||||||
|
|
||||||
|
// linkedAdmin links frontend user 5 to backend user 1.
|
||||||
|
type linkedAdmin struct{}
|
||||||
|
|
||||||
|
func (linkedAdmin) LinkedBackendUser(context.Context, uint) (pact.LinkedBackendUser, bool, error) {
|
||||||
|
return pact.LinkedBackendUser{ID: 1, Login: "editor"}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowingBackend stands in for the backend guard provider (which needs the
|
||||||
|
// admin database): it allows the linked admin everything.
|
||||||
|
type allowingBackend struct{}
|
||||||
|
|
||||||
|
func (allowingBackend) MCPGuardName() string { return pier.BackendGuard }
|
||||||
|
func (allowingBackend) MCPTokenGuarded(mw []string) bool {
|
||||||
|
return slices.Contains(mw, "backend")
|
||||||
|
}
|
||||||
|
func (allowingBackend) MCPRequirements([]string) (pact.MCPRequirements, error) {
|
||||||
|
return pact.MCPRequirements{}, nil
|
||||||
|
}
|
||||||
|
func (allowingBackend) MCPAllows(context.Context, pact.MCPSubject, pact.MCPRequirements, []string) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
func (allowingBackend) MCPDispatch(_ context.Context, s pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
|
||||||
|
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID, Backend: true}, Guards: []string{pier.BackendGuard}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptsFor opens a stateful session with bearer and answers the prompt
|
||||||
|
// names prompts/list returns.
|
||||||
|
func promptsFor(t *testing.T, url, bearer string) []string {
|
||||||
|
t.Helper()
|
||||||
|
post := func(session, body string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||||
|
req.Header.Set("Mcp-Protocol-Version", "2025-11-25")
|
||||||
|
if session != "" {
|
||||||
|
req.Header.Set("Mcp-Session-Id", session)
|
||||||
|
}
|
||||||
|
res, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode >= 300 {
|
||||||
|
raw, _ := io.ReadAll(res.Body)
|
||||||
|
t.Fatalf("POST %s = %d %s", body, res.StatusCode, raw)
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(res.Body)
|
||||||
|
scanner.Buffer(make([]byte, 1<<20), 1<<20)
|
||||||
|
for scanner.Scan() {
|
||||||
|
if line := scanner.Text(); strings.HasPrefix(line, "data: ") {
|
||||||
|
return res.Header.Get("Mcp-Session-Id"), strings.TrimPrefix(line, "data: ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.Header.Get("Mcp-Session-Id"), ""
|
||||||
|
}
|
||||||
|
session, _ := post("", `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"journal-test","version":"1"}}}`)
|
||||||
|
if session == "" {
|
||||||
|
t.Fatal("initialize opened no session")
|
||||||
|
}
|
||||||
|
post(session, `{"jsonrpc":"2.0","method":"notifications/initialized"}`)
|
||||||
|
_, body := post(session, `{"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}`)
|
||||||
|
var out struct {
|
||||||
|
Result struct {
|
||||||
|
Prompts []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"prompts"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||||
|
t.Fatalf("prompts/list = %s (%v)", body, err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, p := range out.Result.Prompts {
|
||||||
|
names = append(names, p.Name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalPromptWrites pins plan 08's prompt ceiling for the blog plugin
|
||||||
|
// (C-IN-01): draft_post declares Writes, and through pier's catalog (the
|
||||||
|
// TestJournalMCP harness, served) a read-only grant's prompts/list omits
|
||||||
|
// journal_draft_post while a read and write grant lists it. Runs under
|
||||||
|
// -short.
|
||||||
|
func TestJournalPromptWrites(t *testing.T) {
|
||||||
|
decl := (&journal.Plugin{}).MCP()
|
||||||
|
t.Run("draft_post declares Writes", func(t *testing.T) {
|
||||||
|
i := slices.IndexFunc(decl.Prompts, func(p pact.MCPPrompt) bool { return p.Name == "draft_post" })
|
||||||
|
if i < 0 || !decl.Prompts[i].Writes {
|
||||||
|
t.Fatalf("draft_post = %+v, want Writes", decl.Prompts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("prompts/list follows the read/write ceiling", func(t *testing.T) {
|
||||||
|
app, plugins := journalApp(t)
|
||||||
|
router, err := surf.BuildRouter(app, plugins)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Publish[pact.MCPTokenVerifier](promptGrants{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Publish[pact.MCPLinkedBackendResolver](linkedAdmin{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
guards := pact.NewMCPGuards()
|
||||||
|
if err := guards.Register(allowingBackend{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Publish(guards); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Config.Set(pier.EnabledKey, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
host := &servingHost{mountHost: mountHost{r: router}, handlers: map[string]http.Handler{}}
|
||||||
|
if err := pier.Mount(host, app, plugins); err != nil {
|
||||||
|
t.Fatalf("pier.Mount: %v", err)
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(host)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
endpoint := srv.URL + host.path
|
||||||
|
|
||||||
|
if got := promptsFor(t, endpoint, "oas_at_reader"); slices.Contains(got, "journal_draft_post") {
|
||||||
|
t.Fatalf("a read-only grant lists %v", got)
|
||||||
|
}
|
||||||
|
if got := promptsFor(t, endpoint, "oas_at_writer"); !slices.Contains(got, "journal_draft_post") {
|
||||||
|
t.Fatalf("a read and write grant lists %v, want journal_draft_post", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
103
mcp_smoke_test.go
Normal file
103
mcp_smoke_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
journal "git.golem15.com/golem15/sm-journal-plugin"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestJournalMCPSmoke pins the Journal's MCP declaration (P-17, D-15, D-16):
|
||||||
|
// exactly the seven post, category and tag tools, the four settings and
|
||||||
|
// content-fix tools (33-02) and the draft_post prompt, each with Guard
|
||||||
|
// backend and golem15.journal.access_posts (manage_settings for the
|
||||||
|
// settings and fix_headings tools), delete and set_image_alt marked
|
||||||
|
// destructive, and every tool bound to a route the plugin registers (the
|
||||||
|
// same method and pattern in a router built from the booted plugins).
|
||||||
|
// Runs under -short (no database).
|
||||||
|
func TestJournalMCPSmoke(t *testing.T) {
|
||||||
|
var _ pact.HasMCP = (*journal.Plugin)(nil)
|
||||||
|
decl := (&journal.Plugin{}).MCP()
|
||||||
|
|
||||||
|
wantTools := []string{"list_posts", "show_post", "list_categories", "list_tags", "create_post", "update_post", "delete_post",
|
||||||
|
"get_settings", "update_settings", "fix_headings", "set_image_alt"}
|
||||||
|
// The settings and heading tools need manage_settings (33-02, D-10).
|
||||||
|
manageSettings := map[string]bool{"get_settings": true, "update_settings": true, "fix_headings": true}
|
||||||
|
destructive := map[string]bool{"delete_post": true, "set_image_alt": true}
|
||||||
|
var names []string
|
||||||
|
for _, tool := range decl.Tools {
|
||||||
|
names = append(names, tool.Name)
|
||||||
|
want := []string{"golem15.journal.access_posts"}
|
||||||
|
if manageSettings[tool.Name] {
|
||||||
|
want = []string{"golem15.journal.manage_settings"}
|
||||||
|
}
|
||||||
|
if tool.Guard != "backend" || !slices.Equal(tool.Permissions, want) {
|
||||||
|
t.Errorf("tool %s guard %q permissions %v, want backend and %v", tool.Name, tool.Guard, tool.Permissions, want)
|
||||||
|
}
|
||||||
|
if tool.Title == "" || tool.Description == "" || tool.Input == nil {
|
||||||
|
t.Errorf("tool %s lacks a title, description or input", tool.Name)
|
||||||
|
}
|
||||||
|
if (tool.Destructive != nil && *tool.Destructive) != destructive[tool.Name] {
|
||||||
|
t.Errorf("tool %s destructive = %v", tool.Name, tool.Destructive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !slices.Equal(names, wantTools) {
|
||||||
|
t.Fatalf("tools %v, want %v", names, wantTools)
|
||||||
|
}
|
||||||
|
if len(decl.Prompts) != 1 || decl.Prompts[0].Name != "draft_post" || decl.Prompts[0].Guard != "backend" ||
|
||||||
|
!slices.Equal(decl.Prompts[0].Permissions, []string{"golem15.journal.access_posts"}) {
|
||||||
|
t.Fatalf("prompts %+v, want draft_post (backend, access_posts)", decl.Prompts)
|
||||||
|
}
|
||||||
|
prompt := decl.Prompts[0]
|
||||||
|
if len(prompt.Arguments) != 2 || prompt.Arguments[0].Name != "topic" || !prompt.Arguments[0].Required || prompt.Arguments[1].Name != "tone" || prompt.Arguments[1].Required {
|
||||||
|
t.Fatalf("draft_post arguments %+v, want topic (required) and tone", prompt.Arguments)
|
||||||
|
}
|
||||||
|
msgs, err := prompt.Render(context.Background(), map[string]string{"topic": "Release notes", "tone": "formal"})
|
||||||
|
if err != nil || len(msgs) != 1 || msgs[0].Role != "user" ||
|
||||||
|
!strings.Contains(msgs[0].Text, "Release notes") || !strings.Contains(msgs[0].Text, "formal") ||
|
||||||
|
!strings.Contains(msgs[0].Text, "journal_create_post") || !strings.Contains(msgs[0].Text, "published set to false") {
|
||||||
|
t.Fatalf("draft_post render = %+v, %v", msgs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every tool's method and path is a route the plugin registers.
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: journal-mcp\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 8388608} {
|
||||||
|
if err := cfg.Set(key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app := backpack.New(cfg)
|
||||||
|
plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rt, err := surf.BuildRouter(app, plugins)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("surf.BuildRouter: %v", err)
|
||||||
|
}
|
||||||
|
routes := map[string]string{}
|
||||||
|
for _, r := range rt.Routes() {
|
||||||
|
routes[r.Method+" "+r.Pattern] = r.PluginID
|
||||||
|
}
|
||||||
|
for _, tool := range decl.Tools {
|
||||||
|
if owner, ok := routes[tool.Method+" "+tool.Path]; !ok || owner != "golem15.journal" {
|
||||||
|
t.Errorf("tool %s targets %s %s, which golem15.journal does not register (owner %q)", tool.Name, tool.Method, tool.Path, owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
161
mcp_test.go
Normal file
161
mcp_test.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
journal "git.golem15.com/golem15/sm-journal-plugin"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pier"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The JSON keys the post routes read (controllers/api/posts_helpers.go:
|
||||||
|
// body["..."] in the create and update validation, and the list query).
|
||||||
|
// A tool input field outside these would be sent and silently ignored.
|
||||||
|
var (
|
||||||
|
postBodyKeys = []string{"categories", "content", "excerpt", "is_pinned", "metadata", "published", "published_at", "slug", "sources", "tags", "title", "translations"}
|
||||||
|
postQueryKeys = []string{"author", "category", "include_empty", "page", "per_page", "pinned_first", "published", "search", "sort", "tag"}
|
||||||
|
// maintenanceBodyKeys are the body keys of the settings and content
|
||||||
|
// fix routes, by path.
|
||||||
|
maintenanceBodyKeys = map[string][]string{
|
||||||
|
"/_journal/api/v1/settings": models.Settings{}.Fillable(),
|
||||||
|
"/_journal/api/v1/fix-headings": {"dry_run"},
|
||||||
|
"/_journal/api/v1/images/alt": {"dry_run", "changes"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// mountHost adapts a built surf router to pier.Host for a compile-only mount.
|
||||||
|
type mountHost struct{ r *surf.Router }
|
||||||
|
|
||||||
|
func (h mountHost) Routes() []pier.RouteInfo {
|
||||||
|
var out []pier.RouteInfo
|
||||||
|
for _, ri := range h.r.Routes() {
|
||||||
|
out = append(out, pier.RouteInfo{Method: ri.Method, Pattern: ri.Pattern, PluginID: ri.PluginID, Middleware: ri.Middleware})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
func (h mountHost) Handler(method, pattern string) (http.Handler, bool) {
|
||||||
|
return h.r.Handler(method, pattern)
|
||||||
|
}
|
||||||
|
func (mountHost) MountStream(string, string, http.Handler, ...string) error { return nil }
|
||||||
|
func (mountHost) MountJSON(string, string, http.Handler) error { return nil }
|
||||||
|
func (h mountHost) Owner(method, path string) (string, bool) {
|
||||||
|
for _, ri := range h.r.Routes() {
|
||||||
|
if ri.Method == method && ri.Pattern == path {
|
||||||
|
return ri.PluginID, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalApp boots the translate and journal plugins on a config without a
|
||||||
|
// database.
|
||||||
|
func journalApp(t *testing.T) (*backpack.App, []party.Plugin) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: journal-mcp\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 8388608} {
|
||||||
|
if err := cfg.Set(key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app := backpack.New(cfg)
|
||||||
|
plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return app, plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalMCP (D-15, D-16, SC-2) extends TestJournalMCPSmoke: the
|
||||||
|
// declaration compiles through pier's own catalog against the routes the
|
||||||
|
// plugin registers (prefix journal_, the backend guard, path and query
|
||||||
|
// tags matching the patterns); every input field is a key the route reads
|
||||||
|
// (path segment, list query key or create/update body key); the draft
|
||||||
|
// prompt renders with and without its optional tone. The dispatch marker
|
||||||
|
// on the optional-principal list and show routes is pinned by
|
||||||
|
// controllers/api TestOptionalBackendPrincipalDispatch. Runs under -short.
|
||||||
|
func TestJournalMCP(t *testing.T) {
|
||||||
|
decl := (&journal.Plugin{}).MCP()
|
||||||
|
|
||||||
|
t.Run("compiles through pier against the plugin's routes", func(t *testing.T) {
|
||||||
|
app, plugins := journalApp(t)
|
||||||
|
router, err := surf.BuildRouter(app, plugins)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Config.Set(pier.EnabledKey, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pier.Mount(mountHost{r: router}, app, plugins); err != nil {
|
||||||
|
t.Fatalf("pier.Mount: %v", err)
|
||||||
|
}
|
||||||
|
p, ok := app.Lookup[*pier.Pier]()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("pier was not mounted")
|
||||||
|
}
|
||||||
|
want := []string{"journal_create_post", "journal_delete_post", "journal_fix_headings", "journal_get_settings", "journal_list_categories", "journal_list_posts", "journal_list_tags", "journal_set_image_alt", "journal_show_post", "journal_update_post", "journal_update_settings"}
|
||||||
|
if got := p.ToolNames(); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("compiled tools %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("input fields are keys the routes read", func(t *testing.T) {
|
||||||
|
for _, tool := range decl.Tools {
|
||||||
|
rt := reflect.TypeOf(tool.Input)
|
||||||
|
for rt.Kind() == reflect.Pointer {
|
||||||
|
rt = rt.Elem()
|
||||||
|
}
|
||||||
|
for i := 0; i < rt.NumField(); i++ {
|
||||||
|
f := rt.Field(i)
|
||||||
|
key, _, _ := strings.Cut(f.Tag.Get("json"), ",")
|
||||||
|
switch {
|
||||||
|
case f.Tag.Get("path") != "":
|
||||||
|
if !strings.Contains(tool.Path, "{"+f.Tag.Get("path")+"}") {
|
||||||
|
t.Errorf("%s.%s: path tag %q not in %s", tool.Name, f.Name, f.Tag.Get("path"), tool.Path)
|
||||||
|
}
|
||||||
|
case f.Tag.Get("query") != "":
|
||||||
|
if tool.Method != http.MethodGet || !slices.Contains(postQueryKeys, f.Tag.Get("query")) {
|
||||||
|
t.Errorf("%s.%s: query key %q is not one the list reads", tool.Name, f.Name, f.Tag.Get("query"))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
keys := postBodyKeys
|
||||||
|
if routeKeys, ok := maintenanceBodyKeys[tool.Path]; ok {
|
||||||
|
keys = routeKeys
|
||||||
|
}
|
||||||
|
if tool.Method == http.MethodGet || tool.Method == http.MethodDelete || !slices.Contains(keys, key) {
|
||||||
|
t.Errorf("%s.%s: body key %q is not one %s %s reads", tool.Name, f.Name, key, tool.Method, tool.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("draft prompt with and without tone", func(t *testing.T) {
|
||||||
|
prompt := decl.Prompts[0]
|
||||||
|
with, err := prompt.Render(context.Background(), map[string]string{"topic": "Spring release", "tone": "playful"})
|
||||||
|
if err != nil || len(with) != 1 || !strings.Contains(with[0].Text, "Spring release") || !strings.Contains(with[0].Text, "playful") {
|
||||||
|
t.Fatalf("with tone = %+v, %v", with, err)
|
||||||
|
}
|
||||||
|
without, err := prompt.Render(context.Background(), map[string]string{"topic": "Spring release"})
|
||||||
|
if err != nil || len(without) != 1 || !strings.Contains(without[0].Text, "Spring release") || strings.Contains(without[0].Text, "playful") {
|
||||||
|
t.Fatalf("without tone = %+v, %v", without, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
23
menu_item_types.go
Normal file
23
menu_item_types.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package journal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MenuItemTypes registers the Journal menu item types under their PHP keys
|
||||||
|
// (classes.MenuItemTypes) with a lazy accessor of the booted app, so menus
|
||||||
|
// and sitemaps can address Journal posts and categories through the
|
||||||
|
// framework's menu item type registry; posts, categories, tags and author
|
||||||
|
// pages (journal-author) carry content descriptors.
|
||||||
|
func (p *Plugin) MenuItemTypes() []pact.MenuItemType {
|
||||||
|
return classes.MenuItemTypes(func() *backpack.App {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.app
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ pact.HasMenuItemTypes = (*Plugin)(nil)
|
||||||
281
menu_item_types_smoke_test.go
Normal file
281
menu_item_types_smoke_test.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||||
|
"gocloud.dev/blob/memblob"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bootMenuItemTypes boots translate and Journal on a fresh database with
|
||||||
|
// extra config files (name to YAML) and returns the app, the database and
|
||||||
|
// the signpost registry.
|
||||||
|
func bootMenuItemTypes(t *testing.T, files map[string]string) (*backpack.App, *gorm.DB, *signpost.Registry) {
|
||||||
|
t.Helper()
|
||||||
|
sqlDB := dedicatedDB(t, "journal_menu_"+strconv.FormatInt(time.Now().UnixNano(), 10))
|
||||||
|
gdb, err := lagoon.Use(t.Context(), sqlDB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
files["app.yaml"] = "name: journal-menu\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"
|
||||||
|
for name, body := range files {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg, err := compass.Open(compass.Options{
|
||||||
|
Dir: dir,
|
||||||
|
Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app := backpack.New(cfg)
|
||||||
|
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bucket := memblob.OpenBucket(nil)
|
||||||
|
t.Cleanup(func() { _ = bucket.Close() })
|
||||||
|
if err := attach.Publish(app, bucket); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := lagoon.Migrate(gdb, plugins); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg, ok := app.Lookup[*signpost.Registry]()
|
||||||
|
if !ok || reg == nil {
|
||||||
|
t.Fatal("signpost registry is not published")
|
||||||
|
}
|
||||||
|
return app, gdb, reg
|
||||||
|
}
|
||||||
|
|
||||||
|
func menuPost(t *testing.T, gdb *gorm.DB, title, slug string, published bool, at *time.Time) models.Post {
|
||||||
|
t.Helper()
|
||||||
|
p := models.Post{Title: title, Slug: slug, Content: "x", Published: published, PublishedAt: at}
|
||||||
|
if err := gdb.Create(&p).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func altString(alts []pact.MenuItemAlternate) string {
|
||||||
|
parts := make([]string, 0, len(alts))
|
||||||
|
for _, a := range alts {
|
||||||
|
parts = append(parts, a.Locale+"="+a.URL)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func menuCategory(t *testing.T, gdb *gorm.DB, name, slug string, parent *uint, left, right int) models.Category {
|
||||||
|
t.Helper()
|
||||||
|
c := models.Category{Name: name, Slug: slug, ParentID: parent, NestLeft: &left, NestRight: &right}
|
||||||
|
if err := gdb.Create(&c).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachCategory(t *testing.T, gdb *gorm.DB, post models.Post, cat models.Category) {
|
||||||
|
t.Helper()
|
||||||
|
if err := gdb.Exec(`INSERT INTO golem15_journal_posts_categories (post_id, category_id) VALUES (?, ?)`, post.ID, cat.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itemURLs(items []pact.ResolvedMenuItem) string {
|
||||||
|
parts := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
parts = append(parts, it.Title+"="+it.URL)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func id(n uint) string { return strconv.FormatUint(uint64(n), 10) }
|
||||||
|
|
||||||
|
// TestMenuItemTypesSmoke boots Journal with translate, checks the five
|
||||||
|
// registered types and resolves them through the signpost registry with host
|
||||||
|
// pattern overrides of the record types, item overrides and ordered
|
||||||
|
// alternates.
|
||||||
|
func TestMenuItemTypesSmoke(t *testing.T) {
|
||||||
|
requirePostgres(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, gdb, reg := bootMenuItemTypes(t, map[string]string{
|
||||||
|
"menu_items.yaml": "patterns:\n journal-category: /:slug\n journal-post: /p/:slug\n",
|
||||||
|
})
|
||||||
|
|
||||||
|
wantFrom := map[string]string{
|
||||||
|
classes.MenuTypeCategory: "",
|
||||||
|
classes.MenuTypeAllCategories: classes.MenuTypeCategory,
|
||||||
|
classes.MenuTypePost: "",
|
||||||
|
classes.MenuTypeAllPosts: classes.MenuTypePost,
|
||||||
|
classes.MenuTypeCategoryPosts: classes.MenuTypePost,
|
||||||
|
}
|
||||||
|
for key, from := range wantFrom {
|
||||||
|
typ, ok := reg.Type(key)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s is not registered", key)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(typ.DefaultPattern, "/") || typ.PatternFrom != from || typ.Resolve == nil {
|
||||||
|
t.Fatalf("%s type = %+v", key, typ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if typ, _ := reg.Type(classes.MenuTypeAllPosts); typ.References != nil {
|
||||||
|
t.Fatal("all-journal-posts must take no reference")
|
||||||
|
}
|
||||||
|
|
||||||
|
// pl sorts before the default en, so the first alternate (the item
|
||||||
|
// URL, PHP reset()) is the prefixed pl URL.
|
||||||
|
if err := gdb.Exec(`UPDATE golem15_translate_locales SET sort_order = CASE code WHEN 'pl' THEN 1 WHEN 'en' THEN 2 ELSE sort_order END`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The migrations seed a default category; the fixture owns the tree.
|
||||||
|
if err := gdb.Exec(`DELETE FROM golem15_journal_categories`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
beta := menuCategory(t, gdb, "Beta", "beta", nil, 1, 4)
|
||||||
|
alpha := menuCategory(t, gdb, "Alpha", "alpha", &beta.ID, 2, 3)
|
||||||
|
if err := translate.SetTranslated(ctx, gdb, &alpha, "slug", "pl", "alfa"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
|
||||||
|
post := menuPost(t, gdb, "Hello", "hello", true, &past)
|
||||||
|
if err := translate.SetTranslated(ctx, gdb, &post, "slug", "pl", "witaj"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
attachCategory(t, gdb, post, alpha)
|
||||||
|
zed := menuPost(t, gdb, "Zed", "zed", true, &past)
|
||||||
|
attachCategory(t, gdb, zed, beta)
|
||||||
|
draft := menuPost(t, gdb, "Another", "another", false, nil)
|
||||||
|
attachCategory(t, gdb, draft, beta)
|
||||||
|
|
||||||
|
// journal-post: references by title, host override, item override.
|
||||||
|
refs, err := signpost.ReferenceOptions(ctx, reg, classes.MenuTypePost)
|
||||||
|
if err != nil || !refs.Applies || len(refs.Options) != 3 || refs.Options[0].Label != "Another" || refs.Options[1].Label != "Hello" {
|
||||||
|
t.Fatalf("post references = %+v err=%v", refs, err)
|
||||||
|
}
|
||||||
|
got, err := reg.Resolve(ctx, classes.MenuTypePost, id(post.ID), "", false, "")
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatalf("resolve journal-post: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
if want := "pl=/pl/p/witaj en=/p/hello"; altString(got.Alternates) != want {
|
||||||
|
t.Fatalf("alternates = %q, want %q", altString(got.Alternates), want)
|
||||||
|
}
|
||||||
|
if got.URL != "/pl/p/witaj" || got.Title != "Hello" || got.MTime == nil {
|
||||||
|
t.Fatalf("resolved = %+v", got)
|
||||||
|
}
|
||||||
|
got, err = reg.Resolve(ctx, classes.MenuTypePost, id(post.ID), "/:category/:year/:month/:day/:slug", false, "pl")
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatalf("resolve with item pattern: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
if want := "pl=/alfa/2026/03/04/witaj en=/en/alpha/2026/03/04/hello"; altString(got.Alternates) != want {
|
||||||
|
t.Fatalf("alternates = %q, want %q", altString(got.Alternates), want)
|
||||||
|
}
|
||||||
|
if got, err := reg.Resolve(ctx, classes.MenuTypePost, "999999", "", false, ""); err != nil || got != nil {
|
||||||
|
t.Fatalf("unknown post = %+v %v", got, err)
|
||||||
|
}
|
||||||
|
// A draft is offered as a reference but resolves to nothing (WR-04).
|
||||||
|
if got, err := reg.Resolve(ctx, classes.MenuTypePost, id(draft.ID), "", false, ""); err != nil || got != nil {
|
||||||
|
t.Fatalf("draft post = %+v %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// journal-category: references are the tree; nesting returns the
|
||||||
|
// subtree, each child once with its first-locale URL.
|
||||||
|
refs, err = signpost.ReferenceOptions(ctx, reg, classes.MenuTypeCategory)
|
||||||
|
if err != nil || len(refs.Options) != 2 || refs.Options[0].Label != "Beta" || refs.Options[1].Label != "Alpha" || refs.Options[1].Depth != 1 {
|
||||||
|
t.Fatalf("category references = %+v err=%v", refs, err)
|
||||||
|
}
|
||||||
|
got, err = reg.Resolve(ctx, classes.MenuTypeCategory, id(beta.ID), "", true, "")
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatalf("resolve journal-category: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
if got.URL != "/pl/beta" || len(got.Items) != 1 || got.Items[0].URL != "/pl/alfa" || got.Items[0].Title != "Alpha" {
|
||||||
|
t.Fatalf("nested category = %+v", got)
|
||||||
|
}
|
||||||
|
if want := "pl=/pl/alfa en=/alpha"; altString(got.Items[0].Alternates) != want {
|
||||||
|
t.Fatalf("child alternates = %q, want %q", altString(got.Items[0].Alternates), want)
|
||||||
|
}
|
||||||
|
got, err = reg.Resolve(ctx, classes.MenuTypeCategory, id(beta.ID), "", false, "")
|
||||||
|
if err != nil || got == nil || len(got.Items) != 0 {
|
||||||
|
t.Fatalf("category without nesting = %+v %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// all-journal-categories: no URL, items by name, the host override of
|
||||||
|
// journal-category reaches them.
|
||||||
|
got, err = reg.Resolve(ctx, classes.MenuTypeAllCategories, "", "", false, "")
|
||||||
|
if err != nil || got == nil || got.URL != "" {
|
||||||
|
t.Fatalf("resolve all-journal-categories: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
if want := "Alpha=/pl/alfa Beta=/pl/beta"; itemURLs(got.Items) != want {
|
||||||
|
t.Fatalf("all categories = %q, want %q", itemURLs(got.Items), want)
|
||||||
|
}
|
||||||
|
if want := "pl=/pl/alfa en=/alpha"; altString(got.Items[0].Alternates) != want {
|
||||||
|
t.Fatalf("all categories alternates = %q, want %q", altString(got.Items[0].Alternates), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// all-journal-posts: published only, by title, following the host
|
||||||
|
// override of journal-post; an item override reaches the children.
|
||||||
|
got, err = reg.Resolve(ctx, classes.MenuTypeAllPosts, "", "", false, "")
|
||||||
|
if err != nil || got == nil || got.URL != "" {
|
||||||
|
t.Fatalf("resolve all-journal-posts: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
if want := "Hello=/pl/p/witaj Zed=/pl/p/zed"; itemURLs(got.Items) != want {
|
||||||
|
t.Fatalf("all posts = %q, want %q", itemURLs(got.Items), want)
|
||||||
|
}
|
||||||
|
if want := "pl=/pl/p/zed en=/p/zed"; altString(got.Items[1].Alternates) != want {
|
||||||
|
t.Fatalf("all posts alternates = %q, want %q", altString(got.Items[1].Alternates), want)
|
||||||
|
}
|
||||||
|
got, err = reg.Resolve(ctx, classes.MenuTypeAllPosts, "", "/x/:slug", false, "")
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatalf("resolve all-journal-posts with item pattern: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
if want := "pl=/pl/x/witaj en=/x/hello"; altString(got.Items[0].Alternates) != want {
|
||||||
|
t.Fatalf("item override alternates = %q, want %q", altString(got.Items[0].Alternates), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// category-journal-posts: the category and its descendants, published
|
||||||
|
// only, by title.
|
||||||
|
got, err = reg.Resolve(ctx, classes.MenuTypeCategoryPosts, id(beta.ID), "", false, "")
|
||||||
|
if err != nil || got == nil || got.URL != "" {
|
||||||
|
t.Fatalf("resolve category-journal-posts: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
if want := "Hello=/pl/p/witaj Zed=/pl/p/zed"; itemURLs(got.Items) != want {
|
||||||
|
t.Fatalf("category posts = %q, want %q", itemURLs(got.Items), want)
|
||||||
|
}
|
||||||
|
got, err = reg.Resolve(ctx, classes.MenuTypeCategoryPosts, id(alpha.ID), "", false, "")
|
||||||
|
if err != nil || got == nil || itemURLs(got.Items) != "Hello=/pl/p/witaj" {
|
||||||
|
t.Fatalf("child category posts = %+v %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without an enabled locale the single alternate is x-default.
|
||||||
|
if err := gdb.Exec(`UPDATE golem15_translate_locales SET is_enabled = FALSE`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err = reg.Resolve(ctx, classes.MenuTypePost, id(post.ID), "", false, "")
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatalf("resolve without locales: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
if want := "x-default=/p/hello"; altString(got.Alternates) != want || got.URL != "/p/hello" {
|
||||||
|
t.Fatalf("without locales = %q %q", altString(got.Alternates), got.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user