Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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/
|
||||
248
README.md
248
README.md
@@ -18,14 +18,15 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later
|
||||
|
||||
## 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.
|
||||
- **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`.
|
||||
- **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 four rules for the Blog Editor's own output (Phase 16.2): `data-embed` matching `^youtube$` and `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` (the embed placeholder), `alt` with any text on `img`, and `start` matching `^[0-9]+$` on `ol`. 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). 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`.
|
||||
- **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`) with the framework's menu item type registry, so menus and sitemaps can point at posts and categories. See [menu item types](#menu-item-types).
|
||||
- **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.
|
||||
- **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`).
|
||||
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`.
|
||||
- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal.
|
||||
- **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`.
|
||||
- **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.** Eight gormigrate steps create the six journal tables, the author-slug column and the category `content` column. AutoMigrate is not used. Table names are `golem15_journal_*` only.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -76,18 +77,92 @@ GET /_journal/api/v1/posts/hello-world
|
||||
GET /_journal/api/v1/categories
|
||||
GET /_journal/api/v1/tags
|
||||
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 and no slug.
|
||||
|
||||
```json
|
||||
{"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe"}}
|
||||
```
|
||||
|
||||
The author object has exactly these four 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`. Missing names 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"}}],"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
|
||||
POST /_journal/api/v1/posts
|
||||
Authorization: Bearer <backend-access-token>
|
||||
Authorization: Bearer <backend-access-token-or-g15-token>
|
||||
Content-Type: application/json
|
||||
|
||||
{"title":"Hello","content":"A blog post"}
|
||||
```
|
||||
|
||||
### 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. 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:
|
||||
|
||||
```go
|
||||
@@ -98,6 +173,115 @@ func (Post) TranslatableIndexes() []string { return []string{"slug"} }
|
||||
func (Post) MorphName() string { return `Golem15\Journal\Models\Post` }
|
||||
```
|
||||
|
||||
### 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, 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.
|
||||
- **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; stored links keep their own `target` and `rel`, which the server strips on an edited save.
|
||||
- **Images.** An image chosen with the file picker (JPEG, PNG, GIF, WebP) or dropped on the editor is handed 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 a URL the editor asks for the alt text (required) and an optional caption and inserts `<img src alt>`, or `<figure>` with `<img>` and `<figcaption>` when a caption is given. An error, or no answer within 60 seconds, is shown in the status line 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 table with a header row, links, 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 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`. 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`.
|
||||
|
||||
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. Its name is lowercased and every character outside `a-z`, `0-9`, `.`, `_` and `-` becomes `-`, so the inserted `src` needs no escaping. The public upload route keeps its own naming, 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. |
|
||||
|
||||
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 pattern takes `:id` and `:slug`. 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.
|
||||
|
||||
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`.
|
||||
|
||||
## 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. With the two keys below set, an admin save of a Journal post or category asks the host site to drop its page caches, so the edit shows on the next page view.
|
||||
|
||||
| Key | Environment variable | Value |
|
||||
|-----|----------------------|-------|
|
||||
| `golem15.journal.site_cache.purge_url` | `SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL` | The host site's purge route, for example `http://127.0.0.1:3000/api/__journal__/cache/purge`. |
|
||||
| `golem15.journal.site_cache.purge_token` | `SUMMER_GOLEM15__JOURNAL__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. Boot fails, 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, Boot logs the URL's host and path once.
|
||||
|
||||
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.
|
||||
- **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 (`journal: site cache purge failed`) and the save response is unchanged; a successful one logs `journal: 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. Tags, the posts import action and the public Journal API writes do not purge.
|
||||
- **Receiver.** The host's Nuxt app built on vue-journal-layer provides the route (`POST /api/__journal__/cache/purge`, token in `NUXT_JOURNAL_CACHE_PURGE_TOKEN`). Generate one token on the server and put the same value in both environments.
|
||||
|
||||
## API reference
|
||||
|
||||
| Identifier | Description |
|
||||
@@ -108,7 +292,10 @@ func (Post) MorphName() string { return `Golem15\Journal\Models\Post
|
||||
| `journal.Plugin.Migrations` | gormigrate steps 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.Routes` | Public GETs and backend-Bearer writes under `/_journal/api/v1`. |
|
||||
| `journal.Plugin.Routes` | Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`. |
|
||||
| `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 five Journal menu item types (`pact.HasMenuItemTypes`). |
|
||||
| `journal.Plugin.Buckets` | Limiter buckets `journal-public-api` and `journal-api` (Max 120). |
|
||||
| `models.Post` | Post row; `TableName` is `golem15_journal_posts`. |
|
||||
| `models.Post.MorphName` | PHP class string `Golem15\Journal\Models\Post`. |
|
||||
@@ -118,14 +305,37 @@ func (Post) MorphName() string { return `Golem15\Journal\Models\Post
|
||||
| `models.Post.ShouldBeSearchable` | False when unpublished or `search_use_typesense` is off. |
|
||||
| `models.Post.ToSearchableArray` | Typesense document built after a committed save. |
|
||||
| `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.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.Fillable` | `name`, `slug`, `description`. |
|
||||
| `models.Settings` | Singleton settings row; `TableName` is `golem15_journal_settings`. |
|
||||
| `models.All` | Models 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, Load with the markdown fallback, list text and the image upload. |
|
||||
| `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` | The type keys `journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts` and `category-journal-posts`. |
|
||||
| `classes.DefaultPostPattern`, `classes.DefaultCategoryPattern` | The default patterns `/blog/:slug` and `/blog/category/:slug`. |
|
||||
| `classes.LooksLikeHTML` | Whether stored content is HTML: its first non-space character is `<`. |
|
||||
| `classes.StoreMedia` | Stores an uploaded image under `/journal/<folder>/<name>` 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` | Sends the host site cache purge request; a nil purger does nothing. |
|
||||
| `classes.NewSiteCachePurger` | Builds the purger from the purge URL and token and validates them; 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` | Registers a purge to run after the surrounding save transaction commits. |
|
||||
| `classes.SiteCachePurgeURLKey`, `classes.SiteCachePurgeTokenKey` | The config keys `golem15.journal.site_cache.purge_url` and `purge_token`. |
|
||||
| `classes.SiteCachePurgeMinTokenLength`, `classes.SiteCachePurgeTimeout` | The shortest accepted token (32) and the request timeout (2 s). |
|
||||
|
||||
## CLI commands
|
||||
|
||||
@@ -144,9 +354,10 @@ Both commands write JSON files (one per post slug), matching the PHP `journal:ex
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: backpack, beachcomber, bonfire, cabana, lagoon/attach, pact, party, surf.
|
||||
- SummerCMS modules: backpack, beachcomber, bonfire, cabana, lagoon/attach, pact, party, surf; signpost in tests.
|
||||
- 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
|
||||
|
||||
@@ -155,3 +366,16 @@ go test ./...
|
||||
```
|
||||
|
||||
Postgres integration smokes start Docker unless `-short`. Missing Docker is a failure, not a skip, unless `-short`.
|
||||
|
||||
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.
|
||||
|
||||
`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 in `classes` (`TestSiteCachePurgeConfig`, `TestSiteCachePurgeRequest`) and Postgres tests through the admin API (`TestCategoriesAdminPurgesSiteCache`, `TestPostsAdminPurgesSiteCache`) 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"
|
||||
)
|
||||
|
||||
// adminFS holds the admin YAML and the nav SVG file by file: the controllers
|
||||
// directory also holds Go sources that are not admin assets.
|
||||
// adminFS holds the admin YAML, the nav SVG and the committed Blog Editor
|
||||
// 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/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 models/settings/fields.yaml
|
||||
//go:embed assets/images/journal-icon.svg
|
||||
//go:embed assets/dist/blogeditor.js assets/dist/blogeditor.css
|
||||
var adminFS embed.FS
|
||||
|
||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -67,13 +68,11 @@ func TestMain(m *testing.M) {
|
||||
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 {
|
||||
for _, a := range os.Args {
|
||||
if a == "-test.short" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
flag.Parse()
|
||||
return testing.Short()
|
||||
}
|
||||
|
||||
func startAdminPostgres(ctx context.Context) error {
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
)
|
||||
|
||||
// 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 {
|
||||
return []pact.NavigationItem{
|
||||
{
|
||||
@@ -17,13 +19,6 @@ func (*Plugin) Navigation() []pact.NavigationItem {
|
||||
Order: 300,
|
||||
Controller: controllers.PostsControllerID,
|
||||
SideMenu: []pact.NavigationItem{
|
||||
{
|
||||
Code: "new_post",
|
||||
Label: "golem15.journal::lang.posts.new_post",
|
||||
Icon: "plus",
|
||||
Permissions: []string{controllers.PermissionAccessPosts},
|
||||
Controller: controllers.PostsControllerID,
|
||||
},
|
||||
{
|
||||
Code: "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
420
assets/src/blogeditor/blogeditor.css
Normal file
420
assets/src/blogeditor/blogeditor.css
Normal file
@@ -0,0 +1,420 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
665
assets/src/blogeditor/element.ts
Normal file
665
assets/src/blogeditor/element.ts
Normal file
@@ -0,0 +1,665 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { parseEmbedUrl } from './embed'
|
||||
import { EDIT_HTML_EVENT, isPolicyUrl, preservationExtensions } from './extensions'
|
||||
import { format, labelsFor, type Labels } from './labels'
|
||||
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'
|
||||
|
||||
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
|
||||
#embedForm?: InlineForm
|
||||
#imageForm?: InlineForm
|
||||
#showingSource = false
|
||||
#pending = new Map<string, { name: string; timer: ReturnType<typeof setTimeout> }>()
|
||||
#ready: { url: string; name: string }[] = []
|
||||
/** 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()
|
||||
}
|
||||
|
||||
/** 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.#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),
|
||||
})
|
||||
|
||||
this.#linkForm = this.#form('link', labels.linkUrl, [
|
||||
['apply', labels.linkApply, () => this.#applyLink()],
|
||||
['remove', labels.linkRemove, () => this.#removeLink()],
|
||||
['cancel', labels.cancel, () => this.#close(this.#linkForm)],
|
||||
])
|
||||
this.#embedForm = this.#form('embed', labels.embedUrl, [
|
||||
['insert', labels.embedInsert, () => this.#insertEmbed()],
|
||||
['cancel', labels.cancel, () => this.#close(this.#embedForm)],
|
||||
])
|
||||
this.#imageForm = this.#imageFormElement()
|
||||
|
||||
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
|
||||
|
||||
this.append(
|
||||
this.#toolbar.element,
|
||||
this.#linkForm.root,
|
||||
this.#embedForm.root,
|
||||
this.#imageForm.root,
|
||||
panes,
|
||||
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),
|
||||
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()
|
||||
for (const file of files) this.#upload(file)
|
||||
return true
|
||||
},
|
||||
},
|
||||
onUpdate: ({ transaction, appendedTransactions }) => {
|
||||
if (!transaction.docChanged && !appendedTransactions.some((tr) => tr.docChanged)) return
|
||||
this.#mode = 'wysiwyg'
|
||||
this.#emitChange()
|
||||
this.#schedulePreview()
|
||||
},
|
||||
onTransaction: () => this.#toolbar?.refresh(),
|
||||
})
|
||||
this.#blocks = captureBlocks(initial, this.#editor.schema)
|
||||
this.#syncAttributes()
|
||||
this.showSource(false)
|
||||
if (this.#previewOn) this.showPreview(true)
|
||||
}
|
||||
|
||||
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.#ready = []
|
||||
// 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
|
||||
#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 {
|
||||
if (!this.#editor?.isEditable) return
|
||||
const href = this.#editor.getAttributes('link').href
|
||||
this.#open(this.#linkForm, typeof href === 'string' ? href : '')
|
||||
}
|
||||
|
||||
#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
|
||||
}
|
||||
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 } }] })
|
||||
.unsetMark('link')
|
||||
.run()
|
||||
} else {
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).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) -------------------------------------------------------------
|
||||
|
||||
#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)
|
||||
for (const file of images) this.#upload(file)
|
||||
try {
|
||||
input.value = ''
|
||||
} catch {
|
||||
// A test double may not allow resetting the value.
|
||||
}
|
||||
}
|
||||
|
||||
#upload(file: File): 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, 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('')
|
||||
this.#ready.push({ url, name: pending.name })
|
||||
this.#showImageForm()
|
||||
}
|
||||
|
||||
#imageFormElement(): InlineForm {
|
||||
const labels = this.#labels
|
||||
const root = document.createElement('div')
|
||||
root.className = 'blogeditor__form'
|
||||
root.dataset.form = 'image'
|
||||
root.hidden = true
|
||||
const field = (name: string, text: string) => {
|
||||
const label = document.createElement('label')
|
||||
const caption = document.createElement('span')
|
||||
caption.textContent = text
|
||||
const input = document.createElement('input')
|
||||
input.type = 'text'
|
||||
input.name = name
|
||||
input.autocomplete = 'off'
|
||||
label.append(caption, input)
|
||||
return label
|
||||
}
|
||||
root.append(field('alt', labels.altText), field('caption', labels.caption))
|
||||
root.append(this.#action('insert', labels.insertImage, () => this.#insertImage()))
|
||||
root.append(this.#action('cancel', labels.cancel, () => this.#nextImage()))
|
||||
const error = document.createElement('p')
|
||||
error.className = 'blogeditor__error'
|
||||
error.dataset.error = ''
|
||||
error.setAttribute('aria-live', 'polite')
|
||||
root.append(error)
|
||||
return { root, error }
|
||||
}
|
||||
|
||||
#showImageForm(): void {
|
||||
const form = this.#imageForm
|
||||
if (!form || !form.root.hidden || this.#ready.length === 0) return
|
||||
for (const input of Array.from(form.root.querySelectorAll('input'))) input.value = ''
|
||||
form.error.textContent = ''
|
||||
form.root.hidden = false
|
||||
form.root.querySelector<HTMLInputElement>('input[name="alt"]')?.focus()
|
||||
}
|
||||
|
||||
#insertImage(): void {
|
||||
const editor = this.#editor
|
||||
const form = this.#imageForm
|
||||
const ready = this.#ready[0]
|
||||
if (!editor || !form || !ready) return
|
||||
const alt = this.#inputOf(form, 'alt')
|
||||
const caption = this.#inputOf(form, 'caption')
|
||||
if (!alt) {
|
||||
form.error.textContent = this.#labels.altRequired
|
||||
return
|
||||
}
|
||||
if (caption) editor.chain().focus().setFigure({ src: ready.url, alt, caption }).run()
|
||||
else editor.chain().focus().setImage({ src: ready.url, alt }).run()
|
||||
this.#nextImage()
|
||||
}
|
||||
|
||||
#nextImage(): void {
|
||||
this.#ready.shift()
|
||||
if (this.#imageForm) this.#close(this.#imageForm)
|
||||
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
|
||||
}
|
||||
561
assets/src/blogeditor/extensions.ts
Normal file
561
assets/src/blogeditor/extensions.ts
Normal file
@@ -0,0 +1,561 @@
|
||||
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?.())
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function preservationExtensions(labels: Labels = labelsFor('en')): AnyExtension[] {
|
||||
return [
|
||||
StarterKit.configure({
|
||||
trailingNode: false,
|
||||
link: {
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { target: null, rel: null, class: null },
|
||||
isAllowedUri: (url) => isPolicyUrl(url),
|
||||
},
|
||||
}),
|
||||
Image.configure({ inline: true, allowBase64: false }),
|
||||
PlainTable,
|
||||
TableRow,
|
||||
PlainTableHeader,
|
||||
PlainTableCell,
|
||||
Figure,
|
||||
FigureImage,
|
||||
Figcaption,
|
||||
Embed.configure({ labels }),
|
||||
RawHtml.configure({ labels }),
|
||||
Span,
|
||||
HtmlAttributes,
|
||||
]
|
||||
}
|
||||
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 }
|
||||
160
assets/src/blogeditor/labels.ts
Normal file
160
assets/src/blogeditor/labels.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/** Every label, prompt and error the Blog Editor shows. */
|
||||
export interface Labels {
|
||||
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
|
||||
}
|
||||
|
||||
const en: Labels = {
|
||||
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.',
|
||||
insertImage: 'Insert image',
|
||||
rawHtmlCard: 'HTML block',
|
||||
editAsHtml: 'Edit as HTML',
|
||||
embedCard: 'Embedded video',
|
||||
preview: 'Preview',
|
||||
previewFrame: 'Post preview',
|
||||
}
|
||||
|
||||
const pl: Labels = {
|
||||
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.',
|
||||
insertImage: 'Wstaw obraz',
|
||||
rawHtmlCard: 'Blok HTML',
|
||||
editAsHtml: 'Edytuj jako HTML',
|
||||
embedCard: 'Osadzone wideo',
|
||||
preview: 'Podgląd',
|
||||
previewFrame: 'Podgląd wpisu',
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
181
assets/src/blogeditor/toolbar.ts
Normal file
181
assets/src/blogeditor/toolbar.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { Labels } 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
|
||||
}
|
||||
|
||||
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: keyof Labels
|
||||
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')
|
||||
|
||||
const GROUPS: ButtonSpec[][] = [
|
||||
[
|
||||
{ command: 'paragraph', glyph: 'P', label: 'paragraph', run: (e) => e.chain().focus().setParagraph().run(), active: (e) => e.isActive('paragraph') },
|
||||
{ command: 'heading2', glyph: 'H2', label: 'heading2', run: (e) => e.chain().focus().toggleHeading({ level: 2 }).run(), active: (e) => e.isActive('heading', { level: 2 }) },
|
||||
{ command: 'heading3', glyph: 'H3', label: 'heading3', run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(), active: (e) => e.isActive('heading', { level: 3 }) },
|
||||
{ command: 'heading4', glyph: 'H4', label: 'heading4', run: (e) => e.chain().focus().toggleHeading({ level: 4 }).run(), active: (e) => e.isActive('heading', { level: 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 headings 2 to 4, 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 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/ok-adka.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/ok-adka.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())
|
||||
}
|
||||
})
|
||||
}
|
||||
426
classes/blog_editor.go
Normal file
426
classes/blog_editor.go
Normal file
@@ -0,0 +1,426 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gocloud.dev/blob"
|
||||
xhtml "golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
// 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)
|
||||
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: decodeBlogHTML,
|
||||
Load: loadBlogHTML,
|
||||
ListValue: blogListValue,
|
||||
Upload: upload,
|
||||
},
|
||||
{
|
||||
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: decodeBlogHTML,
|
||||
Load: loadBlogHTML,
|
||||
ListValue: blogListValue,
|
||||
Upload: upload,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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: the base name,
|
||||
// lowercased, every character outside a-z, 0-9, '.', '_' and '-' turned
|
||||
// into '-' with runs collapsed and no leading or trailing '-' or '.', and
|
||||
// the extension kept. An empty name becomes "image". The result needs no
|
||||
// escaping in an src attribute.
|
||||
func safeUploadName(filename string) string {
|
||||
base := path.Base(strings.ReplaceAll(filename, `\`, "/"))
|
||||
ext := strings.ToLower(path.Ext(base))
|
||||
if !blogUploadExtPattern.MatchString(ext) {
|
||||
ext = ""
|
||||
}
|
||||
stem := strings.ToLower(strings.TrimSuffix(base, path.Ext(base)))
|
||||
if ext == "" {
|
||||
stem = strings.ToLower(base)
|
||||
}
|
||||
var b strings.Builder
|
||||
dash := false
|
||||
for _, r := range stem {
|
||||
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-'
|
||||
if !ok || r == '-' {
|
||||
if !dash {
|
||||
b.WriteByte('-')
|
||||
dash = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
dash = false
|
||||
}
|
||||
stem = strings.Trim(b.String(), "-.")
|
||||
if stem == "" {
|
||||
stem = "image"
|
||||
}
|
||||
return stem + ext
|
||||
}
|
||||
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": "zdj-cie-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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
305
classes/editor_policy_test.go
Normal file
305
classes/editor_policy_test.go
Normal file
@@ -0,0 +1,305 @@
|
||||
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 four editor-output additions
|
||||
// (figure data-embed and data-id, img alt, ol start) 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", "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>`},
|
||||
}
|
||||
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,505 @@ package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
xhtml "golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var (
|
||||
markdownEngine = goldmark.New(
|
||||
goldmark.WithExtensions(extension.Footnote, extension.Table),
|
||||
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`)
|
||||
markdownEventHandler = regexp.MustCompile(`(?i)\son[a-z]+\s*=`)
|
||||
markdownDangerousURL = regexp.MustCompile(`(?i)(?:javascript|vbscript|data):`)
|
||||
// htmlPolicy is the allow-list for stored journal HTML.
|
||||
htmlPolicy = newHTMLPolicy()
|
||||
)
|
||||
|
||||
// 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")
|
||||
return p
|
||||
}
|
||||
|
||||
// FormatHTML converts journal post markdown to stored HTML using goldmark
|
||||
// footnote, table, and attribute extensions, then the same rejectUnsafe gate
|
||||
// as cabana.RenderMarkdown. It does not call cabana.RenderMarkdown.
|
||||
// footnote, table, and attribute extensions. Raw HTML in the source passes
|
||||
// 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) {
|
||||
var buf bytes.Buffer
|
||||
if err := markdownEngine.Convert([]byte(strings.TrimSpace(src)), &buf); err != nil {
|
||||
return "", fmt.Errorf("journal: markdown: %w", err)
|
||||
}
|
||||
html := buf.String()
|
||||
if err := rejectUnsafe(html); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return html, nil
|
||||
return SanitizeHTML(buf.String()), nil
|
||||
}
|
||||
|
||||
func rejectUnsafe(html string) error {
|
||||
if markdownUnsafeTag.MatchString(html) {
|
||||
return fmt.Errorf("journal: rendered HTML contains raw unsafe tags")
|
||||
// SanitizeHTML filters doc, which is HTML and not markdown, through the
|
||||
// FormatHTML allow-list and strips what the policy does not allow. HTML the
|
||||
// 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) {
|
||||
return fmt.Errorf("journal: rendered HTML contains event handlers")
|
||||
if len(names) >= maxRemovalNames {
|
||||
return names, true
|
||||
}
|
||||
if markdownDangerousURL.MatchString(html) {
|
||||
return fmt.Errorf("journal: rendered HTML contains a dangerous URL scheme")
|
||||
return append(names, name), cut
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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 (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
xhtml "golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// TestFormatHTMLRejectsUnsafeHTML substitutes JOURNAL-003/004 Twig XSS
|
||||
// templates (Phase 16). Script, iframe, event handlers, and javascript /
|
||||
// vbscript / data schemes must fail the reject gate. Footnotes and tables
|
||||
// from goldmark extensions still pass.
|
||||
func TestFormatHTMLRejectsUnsafeHTML(t *testing.T) {
|
||||
// unsafePayloads are the stored-XSS inputs FormatHTML and CheckHTML must
|
||||
// neutralize: the JOURNAL-003/004 Twig XSS templates (Phase 16) and the
|
||||
// regex-gate bypasses from the Phase 16.1 code review (CR-01).
|
||||
var unsafePayloads = []struct {
|
||||
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")
|
||||
if err != nil || !strings.Contains(html, "<h1>") {
|
||||
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 {
|
||||
t.Fatalf("table markdown: %v", err)
|
||||
}
|
||||
if !strings.Contains(table, "<table") {
|
||||
t.Fatalf("table did not render: %s", table)
|
||||
if !strings.Contains(table, "<table") || !strings.Contains(table, "text-align: right") {
|
||||
t.Fatalf("table or its alignment did not render: %s", table)
|
||||
}
|
||||
|
||||
fn, err := FormatHTML("See footnote.[^1]\n\n[^1]: note")
|
||||
if err != nil {
|
||||
t.Fatalf("footnote markdown: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(fn), "footnote") && !strings.Contains(fn, "<sup") {
|
||||
t.Fatalf("footnote did not render: %s", fn)
|
||||
for _, want := range []string{`<sup id="fnref:1">`, `href="#fn:1"`, `role="doc-noteref"`, `role="doc-endnotes"`, `role="doc-backlink"`} {
|
||||
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 {
|
||||
name string
|
||||
src string
|
||||
needle string
|
||||
name, src, want string
|
||||
}{
|
||||
{"script", "<script>alert(1)</script>", "<script"},
|
||||
{"iframe", `<iframe src="https://evil.test"></iframe>`, "<iframe"},
|
||||
{"event", `<img src=x onerror="alert(1)">`, "onerror"},
|
||||
{"javascript", "[x](javascript:alert(1))", "javascript:"},
|
||||
{"vbscript", "[x](vbscript:msgbox(1))", "vbscript:"},
|
||||
{"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)", "data:"},
|
||||
{"duplicate href", `<p><a href="https://x" href="javascript:alert(1)">x</a></p>`, `<p><a href="https://x">x</a></p>`},
|
||||
{"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>`},
|
||||
{"duplicate src", `<img src=ok src="javascript:alert(1)">`, `<img src="ok">`},
|
||||
{"duplicate style", `<p style="color: red" style="position: fixed">x</p>`, `<p style="color: red">x</p>`},
|
||||
{"duplicate class", `<p class="a" class="b">x</p>`, `<p class="a">x</p>`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := FormatHTML(tc.src)
|
||||
if err == nil && strings.Contains(strings.ToLower(out), tc.needle) {
|
||||
t.Fatalf("unsafe HTML survived: %s", out)
|
||||
if err != nil {
|
||||
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) {
|
||||
t.Fatalf("FormatHTML accepted %s: %s", tc.name, out)
|
||||
if out != tc.want {
|
||||
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`
|
||||
// from reporting [no tests to run] in this package.
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
224
classes/media_store.go
Normal file
224
classes/media_store.go
Normal file
@@ -0,0 +1,224 @@
|
||||
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's base is kept; 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."}
|
||||
}
|
||||
name := path.Base(strings.ReplaceAll(filename, `\`, "/"))
|
||||
switch strings.ToLower(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)
|
||||
}
|
||||
256
classes/media_store_test.go
Normal file
256
classes/media_store_test.go
Normal file
@@ -0,0 +1,256 @@
|
||||
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"},
|
||||
{"", "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)
|
||||
}
|
||||
})
|
||||
}
|
||||
765
classes/menu_item_types.go
Normal file
765
classes/menu_item_types.go
Normal file
@@ -0,0 +1,765 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The Journal menu item type keys, the PHP keys of Plugin.php
|
||||
// extendWinterPagesPlugin (D-04).
|
||||
const (
|
||||
MenuTypeCategory = "journal-category"
|
||||
MenuTypeAllCategories = "all-journal-categories"
|
||||
MenuTypePost = "journal-post"
|
||||
MenuTypeAllPosts = "all-journal-posts"
|
||||
MenuTypeCategoryPosts = "category-journal-posts"
|
||||
)
|
||||
|
||||
// The phrase keys of the menu item type labels.
|
||||
const (
|
||||
menuLabelCategory = "golem15.journal::lang.menuitem.journal_category"
|
||||
menuLabelAllCategories = "golem15.journal::lang.menuitem.all_journal_categories"
|
||||
menuLabelPost = "golem15.journal::lang.menuitem.journal_post"
|
||||
menuLabelAllPosts = "golem15.journal::lang.menuitem.all_journal_posts"
|
||||
menuLabelCategoryPosts = "golem15.journal::lang.menuitem.category_journal_posts"
|
||||
)
|
||||
|
||||
// The default URL patterns of the menu item types. A host overrides them
|
||||
// through the registry's menu_items.patterns config, an item through its own
|
||||
// URL pattern. The list types take the pattern of the record type they list
|
||||
// and name it in PatternFrom, so a host that overrides journal-post and
|
||||
// journal-category changes the list types' children too.
|
||||
const (
|
||||
DefaultPostPattern = "/blog/:slug"
|
||||
DefaultCategoryPattern = "/blog/category/:slug"
|
||||
)
|
||||
|
||||
// xDefaultLocale is the single alternate's locale when translate has no
|
||||
// enabled locale (PHP buildAlternateLinks).
|
||||
const xDefaultLocale = "x-default"
|
||||
|
||||
// The placeholders a URL pattern understands, in the order PHP
|
||||
// getMenuTypeInfo lists them.
|
||||
var (
|
||||
postPlaceholders = []string{":slug", ":category", ":id", ":year", ":month", ":day"}
|
||||
categoryPlaceholders = []string{":slug", ":id"}
|
||||
)
|
||||
|
||||
// MenuItemTypes returns the Journal menu item types, the typed successors
|
||||
// of the PHP pages.menuitem handlers of Post and Category (headless branch):
|
||||
//
|
||||
// - journal-category: one category picked from the category tree; it
|
||||
// resolves to the category's URL and, with nesting, to its subtree as
|
||||
// sub-items, each with its URL in the first locale only.
|
||||
// - all-journal-categories: a list without a URL of its own whose items are
|
||||
// every category ordered by name.
|
||||
// - journal-post: one post picked by id (References lists every post by
|
||||
// title, drafts included, so a menu can be wired before the post goes
|
||||
// live); it resolves to the post's URL only while the post is published
|
||||
// with a past publish date, else to nothing. PHP resolved any post, but
|
||||
// its result now reaches the anonymous menu API and sitemap.xml, so a
|
||||
// draft's title and slug would leak.
|
||||
// - all-journal-posts: a list whose items are the published posts ordered
|
||||
// by title.
|
||||
// - category-journal-posts: a list whose items are the published posts of
|
||||
// the picked category and its descendants, ordered by title.
|
||||
//
|
||||
// A list type builds its items from the pattern it receives, which the
|
||||
// registry layers from the item's override, the host override of the list
|
||||
// key, the host override of its PatternFrom record type and the default.
|
||||
// Published means published, with a publish date in the past. Ties in every
|
||||
// order break by id.
|
||||
//
|
||||
// URLs are built from the layered pattern (MenuItemResolve Pattern) by
|
||||
// ordered literal replacement of the placeholders, as PHP
|
||||
// buildUrlFromPattern did: categories fill :id and :slug; posts fill :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).
|
||||
// Alternates hold one URL per enabled translate locale in sort_order (ties by
|
||||
// id): the unprefixed locale (MenuItemResolve.UnprefixedLocale, else
|
||||
// translate's default locale) has no prefix, every other locale the prefix
|
||||
// /code, and slugs are translated per locale with the default value as
|
||||
// fallback. Without an enabled locale the only alternate is x-default. An
|
||||
// item's URL is its first alternate's URL, as PHP took reset($alternateLinks).
|
||||
//
|
||||
// app is asked for the booted application at request time; its *gorm.DB is
|
||||
// the database the types read.
|
||||
func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType {
|
||||
m := menuTypes{app: app}
|
||||
return []pact.MenuItemType{
|
||||
{
|
||||
Key: MenuTypeCategory,
|
||||
Label: menuLabelCategory,
|
||||
DefaultPattern: DefaultCategoryPattern,
|
||||
Placeholders: append([]string(nil), categoryPlaceholders...),
|
||||
Nesting: true,
|
||||
DynamicItems: true,
|
||||
References: m.categoryReferences,
|
||||
Resolve: m.resolveCategory,
|
||||
},
|
||||
{
|
||||
Key: MenuTypeAllCategories,
|
||||
Label: menuLabelAllCategories,
|
||||
DefaultPattern: DefaultCategoryPattern,
|
||||
PatternFrom: MenuTypeCategory,
|
||||
Placeholders: append([]string(nil), categoryPlaceholders...),
|
||||
DynamicItems: true,
|
||||
Resolve: m.resolveAllCategories,
|
||||
},
|
||||
{
|
||||
Key: MenuTypePost,
|
||||
Label: menuLabelPost,
|
||||
DefaultPattern: DefaultPostPattern,
|
||||
Placeholders: append([]string(nil), postPlaceholders...),
|
||||
References: m.postReferences,
|
||||
Resolve: m.resolvePost,
|
||||
},
|
||||
{
|
||||
Key: MenuTypeAllPosts,
|
||||
Label: menuLabelAllPosts,
|
||||
DefaultPattern: DefaultPostPattern,
|
||||
PatternFrom: MenuTypePost,
|
||||
Placeholders: append([]string(nil), postPlaceholders...),
|
||||
DynamicItems: true,
|
||||
Resolve: m.resolveAllPosts,
|
||||
},
|
||||
{
|
||||
Key: MenuTypeCategoryPosts,
|
||||
Label: menuLabelCategoryPosts,
|
||||
DefaultPattern: DefaultPostPattern,
|
||||
PatternFrom: MenuTypePost,
|
||||
Placeholders: append([]string(nil), postPlaceholders...),
|
||||
DynamicItems: true,
|
||||
References: m.categoryReferences,
|
||||
Resolve: m.resolveCategoryPosts,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// menuTypes carries the lazy app accessor the type closures share.
|
||||
type menuTypes struct {
|
||||
app func() *backpack.App
|
||||
}
|
||||
|
||||
func (m menuTypes) db(ctx context.Context) (*gorm.DB, error) {
|
||||
if m.app == nil {
|
||||
return nil, errors.New("golem15.journal: menu item types: app is not available")
|
||||
}
|
||||
app := m.app()
|
||||
if app == nil {
|
||||
return nil, errors.New("golem15.journal: menu item types: app is not booted")
|
||||
}
|
||||
db, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || db == nil {
|
||||
return nil, errors.New("golem15.journal: menu item types: database is not configured")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return db.WithContext(ctx), nil
|
||||
}
|
||||
|
||||
// postReferences lists every post ordered by title (PHP getMenuTypeInfo),
|
||||
// ties by id.
|
||||
func (m menuTypes) postReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
|
||||
db, err := m.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var posts []models.Post
|
||||
if err := db.Select("id", "title").Order("title ASC, id ASC").Find(&posts).Error; err != nil {
|
||||
return nil, fmt.Errorf("golem15.journal: menu item post references: %w", err)
|
||||
}
|
||||
refs := make([]pact.MenuItemReference, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
refs = append(refs, pact.MenuItemReference{Value: strconv.FormatUint(uint64(p.ID), 10), Label: p.Title})
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// resolvePost resolves one journal-post item. An unknown or missing
|
||||
// reference resolves to nothing, as PHP returned null, and so does a post
|
||||
// that is not published (publishedPosts).
|
||||
func (m menuTypes) resolvePost(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
id, ok := menuReferenceID(in.Reference)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
db, err := m.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var posts []models.Post
|
||||
if err := publishedPosts(db).Where("id = ?", id).Limit(1).Find(&posts).Error; err != nil {
|
||||
return nil, fmt.Errorf("golem15.journal: menu item post %d: %w", id, err)
|
||||
}
|
||||
if len(posts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
items, err := resolvePostItems(ctx, db, posts, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &items[0], nil
|
||||
}
|
||||
|
||||
// resolvePostItems resolves posts to one item each, in the given order.
|
||||
func resolvePostItems(ctx context.Context, db *gorm.DB, posts []models.Post, in pact.MenuItemResolve) ([]pact.ResolvedMenuItem, error) {
|
||||
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]uint, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
postTr, err := loadMenuTranslations(ctx, db, models.Post{}.MorphName(), ids, locales)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firsts, err := firstPostCategories(ctx, db, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catIDs := []uint{}
|
||||
for _, c := range firsts {
|
||||
catIDs = append(catIDs, c.ID)
|
||||
}
|
||||
catTr, err := loadMenuTranslations(ctx, db, models.Category{}.MorphName(), catIDs, locales)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]pact.ResolvedMenuItem, 0, len(posts))
|
||||
for i := range posts {
|
||||
p := &posts[i]
|
||||
cat, hasCat := firsts[p.ID]
|
||||
params := func(locale string) [][2]string {
|
||||
slug := p.Slug
|
||||
catSlug := ""
|
||||
if hasCat {
|
||||
catSlug = cat.Slug
|
||||
}
|
||||
if locale != "" {
|
||||
slug = postTr.value(locale, p.ID, "slug", slug)
|
||||
if hasCat {
|
||||
catSlug = catTr.value(locale, cat.ID, "slug", catSlug)
|
||||
}
|
||||
}
|
||||
return postURLParams(p, slug, catSlug)
|
||||
}
|
||||
title := func(locale string) string {
|
||||
if locale == "" {
|
||||
return p.Title
|
||||
}
|
||||
return postTr.value(locale, p.ID, "title", p.Title)
|
||||
}
|
||||
out = append(out, resolvedMenuItem(in.Pattern, locales, p.Title, p.UpdatedAt, params, title))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveAllPosts resolves all-journal-posts: no URL, the published posts
|
||||
// ordered by title as items.
|
||||
func (m menuTypes) resolveAllPosts(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
db, err := m.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var posts []models.Post
|
||||
if err := publishedPosts(db).Order("title ASC, id ASC").Find(&posts).Error; err != nil {
|
||||
return nil, fmt.Errorf("golem15.journal: menu item posts: %w", err)
|
||||
}
|
||||
items, err := resolvePostItems(ctx, db, posts, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pact.ResolvedMenuItem{Items: items}, nil
|
||||
}
|
||||
|
||||
// resolveCategoryPosts resolves category-journal-posts: no URL, the
|
||||
// published posts of the referenced category and its descendants ordered by
|
||||
// title as items. An unknown category resolves to nothing.
|
||||
func (m menuTypes) resolveCategoryPosts(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
id, ok := menuReferenceID(in.Reference)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
db, err := m.db(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, fmt.Errorf("golem15.journal: menu item category %d: %w", id, err)
|
||||
}
|
||||
if len(cats) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ids, err := categoryAndDescendantIDs(db, cats[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var posts []models.Post
|
||||
err = publishedPosts(db).
|
||||
Where("id IN (SELECT post_id FROM golem15_journal_posts_categories WHERE category_id IN ?)", ids).
|
||||
Order("title ASC, id ASC").
|
||||
Find(&posts).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("golem15.journal: menu item category posts: %w", err)
|
||||
}
|
||||
items, err := resolvePostItems(ctx, db, posts, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pact.ResolvedMenuItem{Items: items}, nil
|
||||
}
|
||||
|
||||
// publishedPosts restricts db to published posts: published, with a publish
|
||||
// date in the past (PHP scopeIsPublished, the public API's rule).
|
||||
func publishedPosts(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("published = ? AND published_at IS NOT NULL AND published_at < ?", true, time.Now())
|
||||
}
|
||||
|
||||
// categoryAndDescendantIDs is the category's id and the ids of every
|
||||
// category inside its nested-set range (PHP getAllChildrenAndSelf).
|
||||
func categoryAndDescendantIDs(db *gorm.DB, c models.Category) ([]uint, error) {
|
||||
ids := []uint{c.ID}
|
||||
if c.NestLeft == nil || c.NestRight == nil {
|
||||
return ids, nil
|
||||
}
|
||||
var kids []uint
|
||||
if err := db.Model(&models.Category{}).
|
||||
Where("nest_left > ? AND nest_right < ?", *c.NestLeft, *c.NestRight).
|
||||
Order("nest_left ASC, id ASC").
|
||||
Pluck("id", &kids).Error; err != nil {
|
||||
return nil, fmt.Errorf("golem15.journal: menu item category descendants: %w", err)
|
||||
}
|
||||
return append(ids, kids...), nil
|
||||
}
|
||||
|
||||
// categoryTree is the categories nested by parent_id in nested-set order
|
||||
// (PHP getNested): a category whose parent is not among them is a root.
|
||||
type categoryTree struct {
|
||||
cats []models.Category
|
||||
index map[uint]int
|
||||
roots []int
|
||||
children map[uint][]int
|
||||
}
|
||||
|
||||
// loadCategoryTree reads every category ordered by nest_left, then name and
|
||||
// id.
|
||||
func loadCategoryTree(ctx context.Context, db *gorm.DB) (categoryTree, error) {
|
||||
var cats []models.Category
|
||||
if err := db.WithContext(ctx).Order("nest_left ASC NULLS LAST, name ASC, id ASC").Find(&cats).Error; err != nil {
|
||||
return categoryTree{}, fmt.Errorf("golem15.journal: menu item categories: %w", err)
|
||||
}
|
||||
t := categoryTree{cats: cats, index: map[uint]int{}, children: map[uint][]int{}}
|
||||
for i, c := range cats {
|
||||
t.index[c.ID] = i
|
||||
}
|
||||
for i, c := range cats {
|
||||
if c.ParentID != nil && *c.ParentID != 0 && *c.ParentID != c.ID {
|
||||
if _, ok := t.index[*c.ParentID]; ok {
|
||||
t.children[*c.ParentID] = append(t.children[*c.ParentID], i)
|
||||
continue
|
||||
}
|
||||
}
|
||||
t.roots = append(t.roots, i)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t categoryTree) ids() []uint {
|
||||
out := make([]uint, 0, len(t.cats))
|
||||
for _, c := range t.cats {
|
||||
out = append(out, c.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// references is the tree as menu item references: value id, label name.
|
||||
func (t categoryTree) references(nodes []int) []pact.MenuItemReference {
|
||||
out := make([]pact.MenuItemReference, 0, len(nodes))
|
||||
for _, i := range nodes {
|
||||
c := t.cats[i]
|
||||
out = append(out, pact.MenuItemReference{
|
||||
Value: strconv.FormatUint(uint64(c.ID), 10),
|
||||
Label: c.Name,
|
||||
Items: t.references(t.children[c.ID]),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// categoryReferences lists the category tree (PHP listSubCategoryOptions).
|
||||
func (m menuTypes) categoryReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
|
||||
db, err := m.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := loadCategoryTree(ctx, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t.references(t.roots), nil
|
||||
}
|
||||
|
||||
// categoryItems resolves categories with their pattern URLs and
|
||||
// alternates.
|
||||
type categoryItems struct {
|
||||
pattern string
|
||||
locales menuLocales
|
||||
tr menuTranslations
|
||||
}
|
||||
|
||||
func newCategoryItems(ctx context.Context, db *gorm.DB, in pact.MenuItemResolve, ids []uint) (categoryItems, error) {
|
||||
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
|
||||
if err != nil {
|
||||
return categoryItems{}, err
|
||||
}
|
||||
tr, err := loadMenuTranslations(ctx, db, models.Category{}.MorphName(), ids, locales)
|
||||
if err != nil {
|
||||
return categoryItems{}, err
|
||||
}
|
||||
return categoryItems{pattern: in.Pattern, locales: locales, tr: tr}, nil
|
||||
}
|
||||
|
||||
func (ci categoryItems) item(c models.Category) pact.ResolvedMenuItem {
|
||||
params := func(locale string) [][2]string {
|
||||
slug := c.Slug
|
||||
if locale != "" {
|
||||
slug = ci.tr.value(locale, c.ID, "slug", slug)
|
||||
}
|
||||
return [][2]string{{"id", strconv.FormatUint(uint64(c.ID), 10)}, {"slug", slug}}
|
||||
}
|
||||
title := func(locale string) string {
|
||||
if locale == "" {
|
||||
return c.Name
|
||||
}
|
||||
return ci.tr.value(locale, c.ID, "name", c.Name)
|
||||
}
|
||||
return resolvedMenuItem(ci.pattern, ci.locales, c.Name, c.UpdatedAt, params, title)
|
||||
}
|
||||
|
||||
// subtree resolves the children of id, each with its own children.
|
||||
func (ci categoryItems) subtree(t categoryTree, id uint) []pact.ResolvedMenuItem {
|
||||
nodes := t.children[id]
|
||||
out := make([]pact.ResolvedMenuItem, 0, len(nodes))
|
||||
for _, i := range nodes {
|
||||
c := t.cats[i]
|
||||
item := ci.item(c)
|
||||
if kids := ci.subtree(t, c.ID); len(kids) > 0 {
|
||||
item.Items = kids
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveCategory resolves journal-category: the category's URL, and with
|
||||
// nesting its subtree as items. An unknown category resolves to nothing.
|
||||
func (m menuTypes) resolveCategory(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
id, ok := menuReferenceID(in.Reference)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
db, err := m.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := loadCategoryTree(ctx, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i, ok := t.index[id]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
ci, err := newCategoryItems(ctx, db, in, t.ids())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := ci.item(t.cats[i])
|
||||
if in.Nesting {
|
||||
item.Items = ci.subtree(t, id)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// resolveAllCategories resolves all-journal-categories: no URL, every
|
||||
// category ordered by name as items.
|
||||
func (m menuTypes) resolveAllCategories(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
db, err := m.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cats []models.Category
|
||||
if err := db.Order("name ASC, id ASC").Find(&cats).Error; err != nil {
|
||||
return nil, fmt.Errorf("golem15.journal: menu item categories: %w", err)
|
||||
}
|
||||
ids := make([]uint, 0, len(cats))
|
||||
for _, c := range cats {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
ci, err := newCategoryItems(ctx, db, in, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]pact.ResolvedMenuItem, 0, len(cats))
|
||||
for _, c := range cats {
|
||||
items = append(items, ci.item(c))
|
||||
}
|
||||
return &pact.ResolvedMenuItem{Items: items}, nil
|
||||
}
|
||||
|
||||
// postURLParams are a post's placeholder values in PHP
|
||||
// getHeadlessUrlParams order: id, slug, category, then year, month and day
|
||||
// for a published post with a publish date.
|
||||
func postURLParams(p *models.Post, slug, categorySlug string) [][2]string {
|
||||
params := [][2]string{
|
||||
{"id", strconv.FormatUint(uint64(p.ID), 10)},
|
||||
{"slug", slug},
|
||||
{"category", categorySlug},
|
||||
}
|
||||
if p.Published && p.PublishedAt != nil {
|
||||
at := p.PublishedAt.UTC()
|
||||
params = append(params,
|
||||
[2]string{"year", fmt.Sprintf("%04d", at.Year())},
|
||||
[2]string{"month", fmt.Sprintf("%02d", int(at.Month()))},
|
||||
[2]string{"day", fmt.Sprintf("%02d", at.Day())},
|
||||
)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// firstPostCategories maps each post id to its first category by name (the
|
||||
// PHP categories relation's order), ties by id.
|
||||
func firstPostCategories(ctx context.Context, db *gorm.DB, postIDs []uint) (map[uint]models.Category, error) {
|
||||
out := map[uint]models.Category{}
|
||||
if len(postIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []struct {
|
||||
PostID uint
|
||||
models.Category
|
||||
}
|
||||
err := db.WithContext(ctx).
|
||||
Table("golem15_journal_categories AS c").
|
||||
Select("pc.post_id AS post_id, c.*").
|
||||
Joins("JOIN golem15_journal_posts_categories AS pc ON pc.category_id = c.id").
|
||||
Where("pc.post_id IN ?", postIDs).
|
||||
Order("c.name ASC, c.id ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("golem15.journal: menu item post categories: %w", err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if _, seen := out[r.PostID]; !seen {
|
||||
out[r.PostID] = r.Category
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolvedMenuItem builds one resolved item: its alternates from pattern
|
||||
// per locale, its URL the first alternate's URL (else the pattern filled
|
||||
// with the default values), its title and mtime.
|
||||
func resolvedMenuItem(pattern string, locales menuLocales, title string, mtime *time.Time, params func(locale string) [][2]string, titleIn func(locale string) string) pact.ResolvedMenuItem {
|
||||
alternates := menuAlternates(pattern, locales, params, titleIn)
|
||||
url := ""
|
||||
if len(alternates) > 0 {
|
||||
url = alternates[0].URL
|
||||
}
|
||||
if url == "" {
|
||||
url = fillMenuPattern(pattern, params(""))
|
||||
}
|
||||
item := pact.ResolvedMenuItem{
|
||||
Title: title,
|
||||
URL: url,
|
||||
Alternates: alternates,
|
||||
}
|
||||
if mtime != nil {
|
||||
t := *mtime
|
||||
item.MTime = &t
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// menuAlternates is PHP buildAlternateLinks: x-default alone without an
|
||||
// enabled locale, else one URL per enabled locale in order, prefixed with
|
||||
// /code except for the unprefixed locale.
|
||||
func menuAlternates(pattern string, locales menuLocales, params func(locale string) [][2]string, title func(locale string) string) []pact.MenuItemAlternate {
|
||||
if len(locales.codes) == 0 {
|
||||
return []pact.MenuItemAlternate{{
|
||||
Locale: xDefaultLocale,
|
||||
URL: fillMenuPattern(pattern, params("")),
|
||||
Title: title(""),
|
||||
}}
|
||||
}
|
||||
out := make([]pact.MenuItemAlternate, 0, len(locales.codes))
|
||||
for _, code := range locales.codes {
|
||||
prefix := ""
|
||||
if code != locales.unprefixed {
|
||||
prefix = "/" + code
|
||||
}
|
||||
out = append(out, pact.MenuItemAlternate{
|
||||
Locale: code,
|
||||
URL: prefix + fillMenuPattern(pattern, params(code)),
|
||||
Title: title(code),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fillMenuPattern replaces each ":key" in pattern with its value, in params
|
||||
// order, as PHP buildUrlFromPattern's str_replace loop did.
|
||||
func fillMenuPattern(pattern string, params [][2]string) string {
|
||||
url := pattern
|
||||
for _, kv := range params {
|
||||
url = strings.ReplaceAll(url, ":"+kv[0], kv[1])
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// menuReferenceID parses an item reference as a record id.
|
||||
func menuReferenceID(ref string) (uint, bool) {
|
||||
n, err := strconv.ParseUint(strings.TrimSpace(ref), 10, 32)
|
||||
if err != nil || n == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return uint(n), true
|
||||
}
|
||||
|
||||
// menuLocales is the enabled translate locales in sort_order (ties by id),
|
||||
// the default locale whose values live on the host columns, and the locale
|
||||
// whose URLs carry no prefix.
|
||||
type menuLocales struct {
|
||||
codes []string
|
||||
defaultCode string
|
||||
unprefixed string
|
||||
}
|
||||
|
||||
// loadMenuLocales reads the enabled locales. unprefixed names the
|
||||
// unprefixed locale; "" means translate's default locale.
|
||||
func loadMenuLocales(ctx context.Context, db *gorm.DB, unprefixed string) (menuLocales, error) {
|
||||
var rows []translatemodels.Locale
|
||||
err := db.WithContext(ctx).
|
||||
Where("is_enabled = ?", true).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return menuLocales{}, fmt.Errorf("golem15.journal: menu item locales: %w", err)
|
||||
}
|
||||
out := menuLocales{unprefixed: strings.TrimSpace(unprefixed)}
|
||||
for _, l := range rows {
|
||||
out.codes = append(out.codes, l.Code)
|
||||
if l.IsDefault && out.defaultCode == "" {
|
||||
out.defaultCode = l.Code
|
||||
}
|
||||
}
|
||||
if out.unprefixed == "" {
|
||||
out.unprefixed = out.defaultCode
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// menuTranslations holds the translated attributes of a set of records:
|
||||
// locale, then record id, then field.
|
||||
type menuTranslations struct {
|
||||
defaultCode string
|
||||
data map[string]map[uint]map[string]any
|
||||
}
|
||||
|
||||
// value is the record's field in locale with PHP's fallback: the default
|
||||
// locale and a missing or falsy translation read the host value.
|
||||
func (t menuTranslations) value(locale string, id uint, field, host string) string {
|
||||
if locale == "" || locale == t.defaultCode {
|
||||
return host
|
||||
}
|
||||
v, ok := t.data[locale][id][field]
|
||||
if !ok || !phpTruthy(v) {
|
||||
return host
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
|
||||
// loadMenuTranslations reads the translate attribute rows of the records
|
||||
// ids of morph in every non-default enabled locale with one query.
|
||||
func loadMenuTranslations(ctx context.Context, db *gorm.DB, morph string, ids []uint, locales menuLocales) (menuTranslations, error) {
|
||||
out := menuTranslations{defaultCode: locales.defaultCode, data: map[string]map[uint]map[string]any{}}
|
||||
var codes []string
|
||||
for _, c := range locales.codes {
|
||||
if c != locales.defaultCode {
|
||||
codes = append(codes, c)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 || len(codes) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
keys := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
keys = append(keys, strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
var rows []translatemodels.Attribute
|
||||
err := db.WithContext(ctx).
|
||||
Where("model_type = ? AND locale IN ? AND model_id IN ?", morph, codes, keys).
|
||||
Order("id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("golem15.journal: menu item translations: %w", err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.ModelID == nil || r.AttributeData == nil || strings.TrimSpace(*r.AttributeData) == "" {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(*r.ModelID), 10, 32)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal([]byte(*r.AttributeData), &fields); err != nil {
|
||||
continue
|
||||
}
|
||||
if out.data[r.Locale] == nil {
|
||||
out.data[r.Locale] = map[uint]map[string]any{}
|
||||
}
|
||||
if _, seen := out.data[r.Locale][uint(id)]; !seen {
|
||||
out.data[r.Locale][uint(id)] = fields
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// phpTruthy is PHP's boolean cast of a decoded JSON value, the test PHP
|
||||
// hasTranslation applied before it used a translation.
|
||||
func phpTruthy(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case string:
|
||||
return t != "" && t != "0"
|
||||
case bool:
|
||||
return t
|
||||
case float64:
|
||||
return t != 0
|
||||
case []any:
|
||||
return len(t) > 0
|
||||
case map[string]any:
|
||||
return len(t) > 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
244
classes/menu_item_types_test.go
Normal file
244
classes/menu_item_types_test.go
Normal file
@@ -0,0 +1,244 @@
|
||||
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 five
|
||||
// registered types, 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 five 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",
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
184
classes/site_cache.go
Normal file
184
classes/site_cache.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// SiteCachePurgeURLKey is the config key of the host site's cache purge
|
||||
// URL (env SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL).
|
||||
SiteCachePurgeURLKey = "golem15.journal.site_cache.purge_url"
|
||||
// SiteCachePurgeTokenKey is the config key of the bearer token the purge
|
||||
// request carries (env SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN).
|
||||
SiteCachePurgeTokenKey = "golem15.journal.site_cache.purge_token"
|
||||
|
||||
// SiteCachePurgeTimeout bounds one purge request.
|
||||
SiteCachePurgeTimeout = 2 * time.Second
|
||||
|
||||
// siteCacheAnswerLimit is how much of the purge answer is read.
|
||||
siteCacheAnswerLimit = 4 << 10
|
||||
)
|
||||
|
||||
// SiteCachePurger asks the host site to drop its page caches after an admin
|
||||
// save of a Journal post or category. A nil *SiteCachePurger is disabled
|
||||
// and does nothing.
|
||||
type SiteCachePurger struct {
|
||||
url string
|
||||
endpoint string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// SiteCachePurgeMinTokenLength is the shortest purge token accepted, the
|
||||
// same minimum the host site's purge route applies.
|
||||
const SiteCachePurgeMinTokenLength = 32
|
||||
|
||||
// NewSiteCachePurger returns the purger for purgeURL and token. With both
|
||||
// empty the purge is disabled and it returns nil, nil. Otherwise both must
|
||||
// be set, the URL must be http or https with a host and without user info
|
||||
// or a fragment, an http URL must point at a loopback host (127.0.0.0/8,
|
||||
// ::1 or localhost) so the bearer token never crosses the network in
|
||||
// clear, and the token must hold at least SiteCachePurgeMinTokenLength
|
||||
// characters. The error names the offending key and never the token.
|
||||
func NewSiteCachePurger(purgeURL, token string) (*SiteCachePurger, error) {
|
||||
purgeURL = strings.TrimSpace(purgeURL)
|
||||
if purgeURL == "" && token == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if purgeURL == "" {
|
||||
return nil, fmt.Errorf("%s is set but %s is not; set both or neither", SiteCachePurgeTokenKey, SiteCachePurgeURLKey)
|
||||
}
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("%s is set but %s is not; set both or neither", SiteCachePurgeURLKey, SiteCachePurgeTokenKey)
|
||||
}
|
||||
u, err := url.Parse(purgeURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Hostname() == "" {
|
||||
return nil, fmt.Errorf("%s must be an http or https URL with a host", SiteCachePurgeURLKey)
|
||||
}
|
||||
if u.User != nil {
|
||||
return nil, fmt.Errorf("%s must not carry user info", SiteCachePurgeURLKey)
|
||||
}
|
||||
if u.Fragment != "" || strings.Contains(purgeURL, "#") {
|
||||
return nil, fmt.Errorf("%s must not carry a fragment", SiteCachePurgeURLKey)
|
||||
}
|
||||
if u.Scheme == "http" && !loopbackHost(u.Hostname()) {
|
||||
return nil, fmt.Errorf("%s: an http URL must point at a loopback host (127.0.0.0/8, ::1 or localhost); use https for any other host", SiteCachePurgeURLKey)
|
||||
}
|
||||
if len(token) < SiteCachePurgeMinTokenLength {
|
||||
return nil, fmt.Errorf("%s must hold at least %d characters", SiteCachePurgeTokenKey, SiteCachePurgeMinTokenLength)
|
||||
}
|
||||
return &SiteCachePurger{url: u.String(), endpoint: u.Host + u.Path, token: token, client: &http.Client{}}, nil
|
||||
}
|
||||
|
||||
// Endpoint returns the purge URL's host and path, without the scheme, user
|
||||
// info or query, for log lines.
|
||||
func (p *SiteCachePurger) Endpoint() string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return p.endpoint
|
||||
}
|
||||
|
||||
func loopbackHost(host string) bool {
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
type siteCachePurgeBody struct {
|
||||
Kind string `json:"kind"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
// Purge sends the purge request for one saved entity: a POST of
|
||||
// {"kind":kind,"slug":slug} with the bearer token, bounded by
|
||||
// SiteCachePurgeTimeout and detached from ctx's cancellation. It logs the
|
||||
// outcome and returns nothing: a purge never fails a save. The token is
|
||||
// never logged.
|
||||
func (p *SiteCachePurger) Purge(ctx context.Context, kind, slug string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), SiteCachePurgeTimeout)
|
||||
defer cancel()
|
||||
log := slog.Default()
|
||||
|
||||
raw, err := json.Marshal(siteCachePurgeBody{Kind: kind, Slug: slug})
|
||||
if err != nil {
|
||||
log.Warn("journal: site cache purge failed", "kind", kind, "slug", slug, "error", err.Error())
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.url, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
log.Warn("journal: site cache purge failed", "kind", kind, "slug", slug, "error", "invalid request")
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+p.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
res, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
log.Warn("journal: site cache purge failed", "kind", kind, "slug", slug, "error", purgeTransportError(err))
|
||||
return
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(res.Body, siteCacheAnswerLimit))
|
||||
if res.StatusCode < 200 || res.StatusCode > 299 {
|
||||
log.Warn("journal: site cache purge failed", "kind", kind, "slug", slug, "status", res.StatusCode)
|
||||
return
|
||||
}
|
||||
attrs := []any{"kind", kind, "slug", slug, "status", res.StatusCode}
|
||||
var answer struct {
|
||||
Purged *int `json:"purged"`
|
||||
}
|
||||
if json.Unmarshal(body, &answer) == nil && answer.Purged != nil {
|
||||
attrs = append(attrs, "purged", *answer.Purged)
|
||||
}
|
||||
log.Info("journal: site cache purged", attrs...)
|
||||
}
|
||||
|
||||
// purgeTransportError describes a failed request without the request URL
|
||||
// or headers.
|
||||
func purgeTransportError(err error) string {
|
||||
var uerr *url.Error
|
||||
if errors.As(err, &uerr) {
|
||||
err = uerr.Err
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "timeout"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// PurgeSiteCacheAfterCommit registers p.Purge(kind, slug) to run after the
|
||||
// transaction db belongs to commits, through lagoon.AfterCommit: a failed
|
||||
// save never purges, and a concurrent page view cannot re-cache the old
|
||||
// content between the purge and the commit. A nil p does nothing.
|
||||
func PurgeSiteCacheAfterCommit(ctx context.Context, db *gorm.DB, p *SiteCachePurger, kind, slug string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
lagoon.AfterCommit(ctx, db, func(ctx context.Context, _ *gorm.DB) {
|
||||
p.Purge(ctx, kind, slug)
|
||||
})
|
||||
}
|
||||
293
classes/site_cache_test.go
Normal file
293
classes/site_cache_test.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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="journal: 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("an answer without purged logs the status only", func(t *testing.T) {
|
||||
logs := captureLogs(t)
|
||||
srv, _ := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
newTestPurger(t, srv).Purge(context.Background(), "post", "hello")
|
||||
lines := logs.lines()
|
||||
if len(lines) != 1 || !strings.Contains(lines[0], "level=INFO") || !strings.Contains(lines[0], "status=204") || strings.Contains(lines[0], "purged=") {
|
||||
t.Fatalf("log = %q", lines)
|
||||
}
|
||||
})
|
||||
|
||||
for _, status := range []int{http.StatusInternalServerError, http.StatusUnauthorized} {
|
||||
t.Run("a "+http.StatusText(status)+" answer logs one warning", func(t *testing.T) {
|
||||
logs := captureLogs(t)
|
||||
srv, hits := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(status) })
|
||||
newTestPurger(t, srv).Purge(context.Background(), "post", "hello")
|
||||
if len(hits()) != 1 {
|
||||
t.Fatalf("%d requests, want 1", len(hits()))
|
||||
}
|
||||
lines := logs.lines()
|
||||
if len(lines) != 1 || !strings.Contains(lines[0], "level=WARN") || !strings.Contains(lines[0], `msg="journal: site cache purge failed"`) ||
|
||||
!strings.Contains(lines[0], "kind=post") || !strings.Contains(lines[0], "slug=hello") {
|
||||
t.Fatalf("log = %q", lines)
|
||||
}
|
||||
if !strings.Contains(lines[0], "status="+strconv.Itoa(status)) {
|
||||
t.Fatalf("log %q lacks status=%d", lines[0], status)
|
||||
}
|
||||
assertNoToken(t, logs)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a slow receiver is abandoned after the timeout", func(t *testing.T) {
|
||||
logs := captureLogs(t)
|
||||
release := make(chan struct{})
|
||||
srv, _ := purgeReceiver(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-release:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
})
|
||||
defer close(release)
|
||||
start := time.Now()
|
||||
newTestPurger(t, srv).Purge(context.Background(), "post", "slow")
|
||||
elapsed := time.Since(start)
|
||||
if elapsed < SiteCachePurgeTimeout-200*time.Millisecond || elapsed > SiteCachePurgeTimeout+1500*time.Millisecond {
|
||||
t.Fatalf("Purge returned after %s, want about %s", elapsed, SiteCachePurgeTimeout)
|
||||
}
|
||||
lines := logs.lines()
|
||||
if len(lines) != 1 || !strings.Contains(lines[0], "level=WARN") || !strings.Contains(lines[0], "error=timeout") {
|
||||
t.Fatalf("log = %q", lines)
|
||||
}
|
||||
assertNoToken(t, logs)
|
||||
})
|
||||
|
||||
t.Run("an unreachable receiver logs one warning without the URL's token", func(t *testing.T) {
|
||||
logs := captureLogs(t)
|
||||
srv, _ := purgeReceiver(t, func(http.ResponseWriter, *http.Request) {})
|
||||
p := newTestPurger(t, srv)
|
||||
srv.Close()
|
||||
p.Purge(context.Background(), "category", "gone")
|
||||
lines := logs.lines()
|
||||
if len(lines) != 1 || !strings.Contains(lines[0], "level=WARN") || !strings.Contains(lines[0], "kind=category") {
|
||||
t.Fatalf("log = %q", lines)
|
||||
}
|
||||
assertNoToken(t, logs)
|
||||
})
|
||||
|
||||
t.Run("a cancelled parent context still sends", func(t *testing.T) {
|
||||
captureLogs(t)
|
||||
srv, hits := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"purged":1}`)) })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
newTestPurger(t, srv).Purge(ctx, "post", "after-cancel")
|
||||
if got := hits(); len(got) != 1 || got[0].body["slug"] != "after-cancel" {
|
||||
t.Fatalf("requests = %#v, want one", got)
|
||||
}
|
||||
})
|
||||
|
||||
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 runs at once", func(t *testing.T) {
|
||||
captureLogs(t)
|
||||
srv, hits := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"purged":2}`)) })
|
||||
PurgeSiteCacheAfterCommit(context.Background(), nil, newTestPurger(t, srv), "category", "now")
|
||||
if got := hits(); len(got) != 1 || got[0].body["kind"] != "category" || got[0].body["slug"] != "now" {
|
||||
t.Fatalf("requests = %#v, want one", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -312,6 +312,11 @@ func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, trans
|
||||
}
|
||||
fields := []string{"title", "slug", "content", "content_html", "excerpt", "metadata"}
|
||||
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 err
|
||||
}
|
||||
for _, field := range fields {
|
||||
val, ok := attrs[field]
|
||||
if !ok || val == nil {
|
||||
|
||||
@@ -27,13 +27,21 @@ func init() {
|
||||
var _ pact.FilterOptions = postsAdminController{}
|
||||
var _ pact.FormBeforeCreate = postsAdminController{}
|
||||
var _ pact.FormBeforeUpdate = postsAdminController{}
|
||||
var _ pact.FormAfterCreate = postsAdminController{}
|
||||
var _ pact.FormAfterUpdate = postsAdminController{}
|
||||
var _ pact.FormBeforeDelete = postsAdminController{}
|
||||
var _ pact.FormAfterDelete = postsAdminController{}
|
||||
var _ pact.HasAdminActions = postsAdminController{}
|
||||
var _ cabana.FieldRelationProvider = postsAdminController{}
|
||||
|
||||
var _ pact.AdminController = categoriesAdminController{}
|
||||
var _ pact.AdminPermissioned = 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 _ pact.AdminController = tagsAdminController{}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -28,7 +27,7 @@ const (
|
||||
permAccessOtherPosts = "golem15.journal.access_other_posts"
|
||||
permAccessPublish = "golem15.journal.access_publish"
|
||||
|
||||
backendJWTBlacklistTable = "backend_jwt_blacklist"
|
||||
journalCover = "golem15.journal.*"
|
||||
)
|
||||
|
||||
func writeAPIError(w http.ResponseWriter, status int, message string) {
|
||||
@@ -55,55 +54,40 @@ func hasBearer(r *http.Request) bool {
|
||||
return ok && strings.TrimSpace(token) != ""
|
||||
}
|
||||
|
||||
func newBackendGuard(app *backpack.App, write func(http.ResponseWriter, error)) bouncer.Guard {
|
||||
secret := ""
|
||||
if app != nil && app.Config != nil {
|
||||
secret = strings.TrimSpace(app.Config.String("admin.jwt.secret"))
|
||||
// resolveError maps cabana.ResolveBackend errors to the PHP Journal API bodies.
|
||||
func resolveError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, cabana.ErrTokenScope) {
|
||||
writeAPIError(w, http.StatusForbidden, errInsufficientPerms)
|
||||
return
|
||||
}
|
||||
var gdb *gorm.DB
|
||||
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)
|
||||
writeAuthRequired(w, err)
|
||||
}
|
||||
|
||||
// optionalBackendPrincipal verifies a backend JWT only when Authorization
|
||||
// Bearer is present. Failure is anonymous: public GET must not 401.
|
||||
// optionalBackendPrincipal verifies a backend JWT or personal API token only
|
||||
// when Authorization Bearer is present. Failure is anonymous: public GET must
|
||||
// not 401. Uses cabana.ResolveBackend with cover golem15.journal.*.
|
||||
func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request {
|
||||
if r == nil || !hasBearer(r) {
|
||||
return r
|
||||
}
|
||||
pr, err := newBackendGuard(app, nil).Authenticate(r)
|
||||
if err != nil || pr == nil {
|
||||
out, _, err := cabana.ResolveBackend(app, r, journalCover)
|
||||
if err != nil || out == nil {
|
||||
return r
|
||||
}
|
||||
return r.WithContext(bouncer.WithUser(r.Context(), pr))
|
||||
return out
|
||||
}
|
||||
|
||||
// requireBackendPrincipal authenticates a backend-audience JWT and writes the
|
||||
// PHP {error:"Authentication required"} body when the token is missing or
|
||||
// invalid (D-15). It does not use cabana's admin unauthenticated envelope.
|
||||
// requireBackendPrincipal authenticates a backend JWT or personal API token
|
||||
// and writes the PHP {error:"Authentication required"} or
|
||||
// {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) {
|
||||
pr, err := newBackendGuard(app, writeAuthRequired).Authenticate(r)
|
||||
out, pr, err := cabana.ResolveBackend(app, r, journalCover)
|
||||
if err != nil || pr == nil {
|
||||
writeAuthRequired(w, err)
|
||||
resolveError(w, err)
|
||||
return r, nil, false
|
||||
}
|
||||
r = r.WithContext(bouncer.WithUser(r.Context(), pr))
|
||||
return r, pr, true
|
||||
return out, pr, true
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
166
controllers/api/authors.go
Normal file
166
controllers/api/authors.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"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/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.
|
||||
authorColumns = "id, COALESCE(first_name, '') AS first_name, COALESCE(last_name, '') AS last_name, " +
|
||||
"login, COALESCE(golem15_bloghub_author_slug, '') AS author_slug"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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 and last_name.
|
||||
func authorProfile(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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
_, row, ok := authorOrRespond(app, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": authorProfile(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)
|
||||
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", "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", "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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,20 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"errors"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"gocloud.dev/blob"
|
||||
"gocloud.dev/gcerrors"
|
||||
)
|
||||
|
||||
const mediaMaxBytes = 10240 << 10
|
||||
|
||||
var mediaFolderPattern = regexp.MustCompile(`^[A-Za-z0-9_\-/]*$`)
|
||||
|
||||
// UploadMedia is POST /_journal/api/v1/media/upload (JOURNAL-006).
|
||||
// UploadMedia is POST /_journal/api/v1/media/upload (JOURNAL-006). The
|
||||
// route authenticates, checks permissions, the folder and the form, then
|
||||
// stores the file with classes.StoreMedia, which the Blog Editor's image
|
||||
// upload shares.
|
||||
func UploadMedia(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
r, pr, ok := requireBackendPrincipal(app, w, r)
|
||||
@@ -29,7 +25,7 @@ func UploadMedia(app *backpack.App) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
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."}})
|
||||
return
|
||||
}
|
||||
@@ -39,109 +35,29 @@ func UploadMedia(app *backpack.App) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
name := path.Base(strings.ReplaceAll(header.Filename, `\`, "/"))
|
||||
ext := strings.ToLower(strings.TrimPrefix(path.Ext(name), "."))
|
||||
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))
|
||||
bucket, _ := app.Lookup[*blob.Bucket]()
|
||||
stored, err := classes.StoreMedia(r.Context(), bucket, folder, header.Filename, file)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
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")
|
||||
writeMediaStoreError(w, err)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusCreated, map[string]any{
|
||||
"data": map[string]any{
|
||||
"url": attach.PublicURL(strings.TrimPrefix(keyPath, "/")),
|
||||
"path": keyPath,
|
||||
"url": stored.URL,
|
||||
"path": stored.Path,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// writeMediaStoreError answers a StoreMedia failure as the route always
|
||||
// has: a classes.MediaError is a 422 on its field with its message, any
|
||||
// other error the opaque 500.
|
||||
func writeMediaStoreError(w http.ResponseWriter, err error) {
|
||||
var refused *classes.MediaError
|
||||
if errors.As(err, &refused) && refused != nil {
|
||||
writeValidation(w, map[string][]string{refused.Field: {refused.Message}})
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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:])
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
}
|
||||
|
||||
@@ -1,36 +1,47 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||
)
|
||||
|
||||
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)
|
||||
// TestWriteMediaStoreError pins the media upload route's error bodies (D-09)
|
||||
// after the storage moved to classes.StoreMedia: a MediaError is the same
|
||||
// 422 body writeValidation always wrote, anything else the opaque 500.
|
||||
func TestWriteMediaStoreError(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{"size", &classes.MediaError{Field: "file", Message: "The file may not be greater than 10240 kilobytes."}, http.StatusUnprocessableEntity,
|
||||
`{"error":"Validation failed","errors":{"file":["The file may not be greater than 10240 kilobytes."]}}`},
|
||||
{"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")
|
||||
}
|
||||
|
||||
@@ -195,6 +195,10 @@ func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Catego
|
||||
if c.Description != nil {
|
||||
desc = *c.Description
|
||||
}
|
||||
var content any
|
||||
if c.Content != nil {
|
||||
content = *c.Content
|
||||
}
|
||||
var parent any
|
||||
if c.ParentID != nil {
|
||||
parent = *c.ParentID
|
||||
@@ -208,6 +212,7 @@ func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Catego
|
||||
"name": c.Name,
|
||||
"slug": c.Slug,
|
||||
"description": desc,
|
||||
"content": content,
|
||||
"parent_id": parent,
|
||||
"nest_depth": depth,
|
||||
"post_count": postCount,
|
||||
@@ -759,12 +764,11 @@ func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw a
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if content := asString(attrs["content"]); strings.TrimSpace(content) != "" && strings.TrimSpace(asString(attrs["content_html"])) == "" {
|
||||
html, err := classes.FormatHTML(content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attrs["content_html"] = html
|
||||
// Every translated content_html goes through the host column's
|
||||
// policy: derived from content, or sanitized when only content_html
|
||||
// is supplied. Winter stored a supplied content_html verbatim.
|
||||
if err := classes.FormatTranslation(attrs); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, field := range fields {
|
||||
if val, has := attrs[field]; has && val != nil {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"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/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 (
|
||||
_ pact.AdminController = categoriesAdminController{}
|
||||
_ pact.AdminPermissioned = categoriesAdminController{}
|
||||
_ pact.AdminRecordSource = categoriesAdminController{}
|
||||
_ pact.FormBeforeCreate = categoriesAdminController{}
|
||||
_ pact.FormBeforeUpdate = categoriesAdminController{}
|
||||
_ pact.FormAfterCreate = categoriesAdminController{}
|
||||
_ pact.FormAfterUpdate = categoriesAdminController{}
|
||||
_ pact.FormAfterDelete = categoriesAdminController{}
|
||||
_ cabana.FieldRelationProvider = categoriesAdminController{}
|
||||
)
|
||||
|
||||
@@ -210,13 +210,100 @@ func preparePostWrite(ctx context.Context, post *models.Post) error {
|
||||
"published_at": []string{"golem15.journal::lang.post.published_validation"},
|
||||
}}
|
||||
}
|
||||
html, err := classes.FormatHTML(post.Content)
|
||||
if err != nil {
|
||||
return &cabana.ValidationError{Details: map[string]any{
|
||||
"content": []string{err.Error()},
|
||||
}}
|
||||
// D-02: the Blog Editor's Decode already stored the policy's output in
|
||||
// content; content_html gets the same HTML, without goldmark. Content the
|
||||
// save does not write (untouched, D-03) leaves content_html as it is.
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
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/zdj-cie-1.png") {
|
||||
t.Fatalf("URL = %q", got.URL)
|
||||
}
|
||||
if !bytes.Equal(stored(t, "journal/posts/zdj-cie-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
|
||||
}
|
||||
33
controllers/site_cache.go
Normal file
33
controllers/site_cache.go
Normal file
@@ -0,0 +1,33 @@
|
||||
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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// siteCachePurger returns the published *classes.SiteCachePurger 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
|
||||
}
|
||||
purger, ok := a.Lookup[*classes.SiteCachePurger]()
|
||||
if !ok || purger == nil {
|
||||
return nil, nil
|
||||
}
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
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)
|
||||
9
go.mod
9
go.mod
@@ -5,13 +5,16 @@ go 1.27.0
|
||||
require (
|
||||
git.golem15.com/golem15/sm-translate-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.1.23
|
||||
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/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/testcontainers/testcontainers-go v0.44.0
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
|
||||
github.com/yuin/goldmark v1.8.6
|
||||
gocloud.dev v0.46.0
|
||||
golang.org/x/net v0.58.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
@@ -19,6 +22,7 @@ require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
@@ -41,10 +45,10 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.4 // 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/google/uuid v1.6.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/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -106,7 +110,6 @@ require (
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/image v0.46.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/term v0.46.0 // indirect
|
||||
|
||||
6
go.sum
6
go.sum
@@ -72,6 +72,8 @@ 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/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
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/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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -153,6 +155,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/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/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/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
|
||||
@@ -195,6 +199,8 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
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/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/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
|
||||
285
journal_api_authors_test.go
Normal file
285
journal_api_authors_test.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package journal_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"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{"first_name", "id", "last_name", "slug"}; !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)
|
||||
}
|
||||
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")
|
||||
|
||||
// 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" {
|
||||
t.Fatalf("row author = %v, want login jdoe", page.Data[0]["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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,8 @@ category:
|
||||
name: Name
|
||||
name_placeholder: New category name
|
||||
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_placeholder: new-category-slug
|
||||
posts: Posts
|
||||
@@ -129,3 +131,28 @@ tag:
|
||||
delete_confirm: Delete this tag?
|
||||
delete_success: Successfully deleted those tags.
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -74,6 +74,8 @@ category:
|
||||
name: Nazwa
|
||||
name_placeholder: Nazwa nowej kategorii
|
||||
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_placeholder: alias-nowej-kategorii
|
||||
posts: Posty
|
||||
@@ -81,3 +83,34 @@ category:
|
||||
delete_success: Kategorie zostały pomyślnie usunięte.
|
||||
return_to_categories: Wróć do listy 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)
|
||||
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.
|
||||
|
||||
22
menu_item_types.go
Normal file
22
menu_item_types.go
Normal file
@@ -0,0 +1,22 @@
|
||||
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.
|
||||
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)
|
||||
}
|
||||
}
|
||||
249
menu_item_types_test.go
Normal file
249
menu_item_types_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// TestMenuItemTypes pins D-04 on Postgres: each type's pattern layers
|
||||
// (a host override of only one record type reaches only its list types; an
|
||||
// item override reaches the list children and the nested categories), the
|
||||
// references, nesting with the subtree at any depth, list order with ties by
|
||||
// id, the published filters, descendant categories, alternates in sort_order
|
||||
// with ties by id, translated titles with PHP's fallback, date placeholders
|
||||
// only for published posts, and unknown references.
|
||||
func TestMenuItemTypes(t *testing.T) {
|
||||
requirePostgres(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("a host override of journal-post reaches only the post list types", func(t *testing.T) {
|
||||
_, gdb, reg := bootMenuItemTypes(t, map[string]string{"menu_items.yaml": "patterns:\n journal-post: /p/:slug\n"})
|
||||
seedMenuTree(t, gdb)
|
||||
for key, want := range map[string]string{
|
||||
classes.MenuTypePost: "/p/:slug",
|
||||
classes.MenuTypeAllPosts: "/p/:slug",
|
||||
classes.MenuTypeCategoryPosts: "/p/:slug",
|
||||
classes.MenuTypeCategory: classes.DefaultCategoryPattern,
|
||||
classes.MenuTypeAllCategories: classes.DefaultCategoryPattern,
|
||||
} {
|
||||
if got := reg.Pattern(key, ""); got != want {
|
||||
t.Fatalf("Pattern(%s) = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
got := resolve(t, reg, classes.MenuTypeAllCategories, "", "", false)
|
||||
if itemURLs(got.Items) != "Alpha=/blog/category/alpha Beta=/blog/category/beta Gamma=/blog/category/gamma" {
|
||||
t.Fatalf("categories under the default pattern = %s", itemURLs(got.Items))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a host override of journal-category reaches only the category list type", func(t *testing.T) {
|
||||
_, gdb, reg := bootMenuItemTypes(t, map[string]string{"menu_items.yaml": "patterns:\n journal-category: /:slug\n"})
|
||||
seed := seedMenuTree(t, gdb)
|
||||
got := resolve(t, reg, classes.MenuTypeAllCategories, "", "", false)
|
||||
if itemURLs(got.Items) != "Alpha=/alpha Beta=/beta Gamma=/gamma" {
|
||||
t.Fatalf("categories = %s", itemURLs(got.Items))
|
||||
}
|
||||
got = resolve(t, reg, classes.MenuTypeAllPosts, "", "", false)
|
||||
if !strings.HasPrefix(itemURLs(got.Items), "Apple=/blog/apple") {
|
||||
t.Fatalf("posts keep their default = %s", itemURLs(got.Items))
|
||||
}
|
||||
// An item override of the list item reaches its children.
|
||||
got = resolve(t, reg, classes.MenuTypeAllCategories, "", "/topics/:id-:slug", false)
|
||||
if want := "Alpha=/topics/" + id(seed.alpha.ID) + "-alpha"; !strings.HasPrefix(itemURLs(got.Items), want) {
|
||||
t.Fatalf("item override = %s, want prefix %s", itemURLs(got.Items), want)
|
||||
}
|
||||
// The list key's own host override wins over its record type's.
|
||||
_, gdb2, reg2 := bootMenuItemTypes(t, map[string]string{"menu_items.yaml": "patterns:\n journal-category: /:slug\n all-journal-categories: /all/:slug\n"})
|
||||
seedMenuTree(t, gdb2)
|
||||
got = resolve(t, reg2, classes.MenuTypeAllCategories, "", "", false)
|
||||
if !strings.HasPrefix(itemURLs(got.Items), "Alpha=/all/alpha") {
|
||||
t.Fatalf("list key override = %s", itemURLs(got.Items))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("references, nesting, order, published filters and descendants", func(t *testing.T) {
|
||||
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
||||
seed := seedMenuTree(t, gdb)
|
||||
|
||||
refs, err := signpost.ReferenceOptions(ctx, reg, classes.MenuTypePost)
|
||||
if err != nil || !refs.Applies {
|
||||
t.Fatalf("post references = %+v %v", refs, err)
|
||||
}
|
||||
var labels []string
|
||||
for _, opt := range refs.Options {
|
||||
labels = append(labels, opt.Label)
|
||||
}
|
||||
// Every post by title, drafts and future posts included, ties by id.
|
||||
if strings.Join(labels, ",") != "Apple,Draft,Future,Same,Same,Zebra" || refs.Options[3].Value != id(seed.same1.ID) {
|
||||
t.Fatalf("post references = %v (%+v)", labels, refs.Options)
|
||||
}
|
||||
refs, err = signpost.ReferenceOptions(ctx, reg, classes.MenuTypeCategory)
|
||||
if err != nil || len(refs.Options) != 3 || refs.Options[0].Label != "Beta" || refs.Options[1].Label != "Alpha" || refs.Options[1].Depth != 1 || refs.Options[2].Label != "Gamma" || refs.Options[2].Depth != 2 {
|
||||
t.Fatalf("category references = %+v %v", refs, err)
|
||||
}
|
||||
if same, err := signpost.ReferenceOptions(ctx, reg, classes.MenuTypeCategoryPosts); err != nil || len(same.Options) != 3 {
|
||||
t.Fatalf("category-journal-posts references = %+v %v", same, err)
|
||||
}
|
||||
|
||||
// journal-post resolves published posts only (WR-04): a draft or a
|
||||
// future post resolves to nothing.
|
||||
for _, p := range []models.Post{seed.draft, seed.future} {
|
||||
if got, err := reg.Resolve(ctx, classes.MenuTypePost, id(p.ID), "", false, ""); err != nil || got != nil {
|
||||
t.Fatalf("unpublished post %s = %+v %v", p.Slug, got, err)
|
||||
}
|
||||
}
|
||||
if got := resolve(t, reg, classes.MenuTypePost, id(seed.zebra.ID), "", false); got.URL != "/blog/zebra" {
|
||||
t.Fatalf("published post = %+v", got)
|
||||
}
|
||||
// Nesting returns the subtree at every depth; without nesting no items.
|
||||
got := resolve(t, reg, classes.MenuTypeCategory, id(seed.beta.ID), "", true)
|
||||
if got.URL != "/blog/category/beta" || len(got.Items) != 1 || got.Items[0].Title != "Alpha" || len(got.Items[0].Items) != 1 || got.Items[0].Items[0].URL != "/blog/category/gamma" {
|
||||
t.Fatalf("nested = %+v", got)
|
||||
}
|
||||
got = resolve(t, reg, classes.MenuTypeCategory, id(seed.beta.ID), "/c/:slug", true)
|
||||
if got.URL != "/c/beta" || got.Items[0].URL != "/c/alpha" || got.Items[0].Items[0].URL != "/c/gamma" {
|
||||
t.Fatalf("nested with an item override = %+v", got)
|
||||
}
|
||||
if got := resolve(t, reg, classes.MenuTypeCategory, id(seed.gamma.ID), "", true); len(got.Items) != 0 {
|
||||
t.Fatalf("leaf nesting = %+v", got)
|
||||
}
|
||||
if got := resolve(t, reg, classes.MenuTypeCategory, id(seed.beta.ID), "", false); len(got.Items) != 0 {
|
||||
t.Fatalf("no nesting = %+v", got)
|
||||
}
|
||||
|
||||
// all-journal-posts: published with a past date only, by title, ties by id.
|
||||
got = resolve(t, reg, classes.MenuTypeAllPosts, "", "/:id", false)
|
||||
if want := "Apple=/" + id(seed.apple.ID) + " Same=/" + id(seed.same1.ID) + " Same=/" + id(seed.same2.ID) + " Zebra=/" + id(seed.zebra.ID); itemURLs(got.Items) != want {
|
||||
t.Fatalf("all posts = %s, want %s", itemURLs(got.Items), want)
|
||||
}
|
||||
// category-journal-posts: the category and every descendant.
|
||||
if got := resolve(t, reg, classes.MenuTypeCategoryPosts, id(seed.beta.ID), "", false); itemURLs(got.Items) != "Apple=/blog/apple Same=/blog/same-1 Zebra=/blog/zebra" {
|
||||
t.Fatalf("beta posts = %s", itemURLs(got.Items))
|
||||
}
|
||||
if got := resolve(t, reg, classes.MenuTypeCategoryPosts, id(seed.alpha.ID), "", false); itemURLs(got.Items) != "Apple=/blog/apple Same=/blog/same-1" {
|
||||
t.Fatalf("alpha posts = %s", itemURLs(got.Items))
|
||||
}
|
||||
if got := resolve(t, reg, classes.MenuTypeCategoryPosts, id(seed.gamma.ID), "", false); itemURLs(got.Items) != "Apple=/blog/apple" {
|
||||
t.Fatalf("gamma posts = %s", itemURLs(got.Items))
|
||||
}
|
||||
|
||||
// :category is the first category by name; date parts only when published.
|
||||
got = resolve(t, reg, classes.MenuTypePost, id(seed.apple.ID), "/:category/:year/:month/:day/:slug", false)
|
||||
if got.URL != "/alpha/2026/03/04/apple" {
|
||||
t.Fatalf("published post url = %q", got.URL)
|
||||
}
|
||||
|
||||
// Unknown or malformed references resolve to nothing.
|
||||
for _, key := range []string{classes.MenuTypePost, classes.MenuTypeCategory, classes.MenuTypeCategoryPosts} {
|
||||
for _, ref := range []string{"999999", "abc", ""} {
|
||||
if got, err := reg.Resolve(ctx, key, ref, "", true, ""); err != nil || got != nil {
|
||||
t.Fatalf("%s %q = %+v %v", key, ref, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alternates in sort order with ties by id, translated titles with fallback", func(t *testing.T) {
|
||||
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
||||
seed := seedMenuTree(t, gdb)
|
||||
if err := gdb.Exec(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order) VALUES ('de', 'Deutsch', FALSE, TRUE, 5), ('cs', 'Čeština', FALSE, TRUE, 5)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.Exec(`UPDATE golem15_translate_locales SET sort_order = CASE code WHEN 'en' THEN 1 WHEN 'pl' THEN 5 ELSE sort_order END`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var order []string
|
||||
if err := gdb.Raw(`SELECT code FROM golem15_translate_locales WHERE is_enabled ORDER BY sort_order, id`).Scan(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := translate.SetTranslated(ctx, gdb, &seed.apple, "slug", "pl", "jablko"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := translate.SetTranslated(ctx, gdb, &seed.apple, "title", "pl", "Jabłko"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := translate.SetTranslated(ctx, gdb, &seed.apple, "slug", "de", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := resolve(t, reg, classes.MenuTypePost, id(seed.apple.ID), "", false)
|
||||
var parts []string
|
||||
for _, alt := range got.Alternates {
|
||||
parts = append(parts, alt.Locale+"="+alt.URL+"|"+alt.Title)
|
||||
}
|
||||
want := []string{}
|
||||
for _, code := range order {
|
||||
switch code {
|
||||
case "en":
|
||||
want = append(want, "en=/blog/apple|Apple")
|
||||
case "pl":
|
||||
want = append(want, "pl=/pl/blog/jablko|Jabłko")
|
||||
default:
|
||||
want = append(want, code+"=/"+code+"/blog/apple|Apple")
|
||||
}
|
||||
}
|
||||
if strings.Join(parts, " ") != strings.Join(want, " ") || got.URL != "/blog/apple" || got.Title != "Apple" {
|
||||
t.Fatalf("alternates = %s\nwant %s (url %q)", strings.Join(parts, " "), strings.Join(want, " "), got.URL)
|
||||
}
|
||||
// The unprefixed locale may be another one.
|
||||
got = resolve(t, reg, classes.MenuTypePost, id(seed.apple.ID), "", false, "pl")
|
||||
if got.Alternates[0].URL != "/en/blog/apple" || !strings.Contains(altString(got.Alternates), "pl=/blog/jablko") {
|
||||
t.Fatalf("unprefixed pl = %s", altString(got.Alternates))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// menuSeed is the fixture of TestMenuItemTypes: categories Beta > Alpha >
|
||||
// Gamma, posts in them, a draft, a future post and two posts titled Same.
|
||||
type menuSeed struct {
|
||||
beta, alpha, gamma models.Category
|
||||
apple, zebra, draft, future, same1, same2 models.Post
|
||||
}
|
||||
|
||||
func seedMenuTree(t *testing.T, gdb *gorm.DB) menuSeed {
|
||||
t.Helper()
|
||||
if err := gdb.Exec(`DELETE FROM golem15_journal_categories`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var s menuSeed
|
||||
s.beta = menuCategory(t, gdb, "Beta", "beta", nil, 1, 6)
|
||||
s.alpha = menuCategory(t, gdb, "Alpha", "alpha", &s.beta.ID, 2, 5)
|
||||
s.gamma = menuCategory(t, gdb, "Gamma", "gamma", &s.alpha.ID, 3, 4)
|
||||
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
|
||||
future := time.Now().Add(240 * time.Hour)
|
||||
s.zebra = menuPost(t, gdb, "Zebra", "zebra", true, &past)
|
||||
s.apple = menuPost(t, gdb, "Apple", "apple", true, &past)
|
||||
s.draft = menuPost(t, gdb, "Draft", "draft", false, &past)
|
||||
s.future = menuPost(t, gdb, "Future", "future", true, &future)
|
||||
s.same1 = menuPost(t, gdb, "Same", "same-1", true, &past)
|
||||
s.same2 = menuPost(t, gdb, "Same", "same-2", true, &past)
|
||||
attachCategory(t, gdb, s.apple, s.gamma)
|
||||
attachCategory(t, gdb, s.apple, s.alpha)
|
||||
attachCategory(t, gdb, s.zebra, s.beta)
|
||||
attachCategory(t, gdb, s.draft, s.beta)
|
||||
attachCategory(t, gdb, s.future, s.beta)
|
||||
attachCategory(t, gdb, s.same1, s.alpha)
|
||||
return s
|
||||
}
|
||||
|
||||
// resolve resolves one item and fails when it resolves to nothing.
|
||||
func resolve(t *testing.T, reg *signpost.Registry, key, ref, pattern string, nesting bool, unprefixed ...string) *pact.ResolvedMenuItem {
|
||||
t.Helper()
|
||||
locale := ""
|
||||
if len(unprefixed) > 0 {
|
||||
locale = unprefixed[0]
|
||||
}
|
||||
got, err := reg.Resolve(context.Background(), key, ref, pattern, nesting, locale)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("Resolve(%s, %q, %q) = %+v, %v", key, ref, pattern, got, err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
@@ -9,6 +9,7 @@ type Category struct {
|
||||
Slug string `gorm:"column:slug"`
|
||||
Code *string `gorm:"column:code"`
|
||||
Description *string `gorm:"column:description"`
|
||||
Content *string `gorm:"column:content"` // HTML body (D-21): stored and served as is, not translatable
|
||||
ParentID *uint `gorm:"column:parent_id"`
|
||||
NestLeft *int `gorm:"column:nest_left"`
|
||||
NestRight *int `gorm:"column:nest_right"`
|
||||
@@ -28,7 +29,7 @@ func (Category) TranslatableIndexes() []string { return []string{"slug"} }
|
||||
|
||||
// Fillable is JOURNAL-002: nest_* stay out of mass assignment.
|
||||
func (Category) Fillable() []string {
|
||||
return []string{"name", "slug", "code", "description", "parent_id"}
|
||||
return []string{"name", "slug", "code", "description", "content", "parent_id"}
|
||||
}
|
||||
|
||||
func (Category) Rules() map[string]string {
|
||||
|
||||
@@ -22,3 +22,10 @@ fields:
|
||||
type: mltextarea
|
||||
size: large
|
||||
span: full
|
||||
content:
|
||||
label: golem15.journal::lang.category.content
|
||||
comment: golem15.journal::lang.category.content_comment
|
||||
type: blogeditor
|
||||
uploadFolder: categories
|
||||
size: huge
|
||||
span: full
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestFillable(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("category_excludes_nest", func(t *testing.T) {
|
||||
if got := (models.Category{}).Fillable(); !reflect.DeepEqual(got, []string{"name", "slug", "code", "description", "parent_id"}) {
|
||||
if got := (models.Category{}).Fillable(); !reflect.DeepEqual(got, []string{"name", "slug", "code", "description", "content", "parent_id"}) {
|
||||
t.Fatalf("Category.Fillable = %#v", got)
|
||||
}
|
||||
left := 3
|
||||
@@ -48,6 +48,7 @@ func TestFillable(t *testing.T) {
|
||||
"name": "Essays",
|
||||
"slug": "essays",
|
||||
"code": "ess",
|
||||
"content": "<p>Essay hub</p>",
|
||||
"nest_left": 9,
|
||||
"nest_right": 10,
|
||||
"nest_depth": 2,
|
||||
@@ -58,6 +59,9 @@ func TestFillable(t *testing.T) {
|
||||
if cat.Name != "Essays" || cat.Slug != "essays" {
|
||||
t.Fatalf("category fill = %+v", cat)
|
||||
}
|
||||
if cat.Content == nil || *cat.Content != "<p>Essay hub</p>" {
|
||||
t.Fatalf("category content fill = %#v", cat.Content)
|
||||
}
|
||||
if cat.ID != 0 {
|
||||
t.Fatalf("id was mass-assigned: %d", cat.ID)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ fields:
|
||||
type: slug
|
||||
content:
|
||||
label: golem15.journal::lang.post.content
|
||||
type: mlmarkdown
|
||||
type: mlblogeditor
|
||||
htmlColumn: content_html
|
||||
uploadFolder: posts
|
||||
size: huge
|
||||
tab: golem15.journal::lang.post.tab_edit
|
||||
required: true
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
@@ -45,13 +46,11 @@ func TestMain(m *testing.M) {
|
||||
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 {
|
||||
for _, a := range os.Args {
|
||||
if a == "-test.short" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
flag.Parse()
|
||||
return testing.Short()
|
||||
}
|
||||
|
||||
func startJournalPostgres(ctx context.Context) error {
|
||||
|
||||
2340
package-lock.json
generated
Normal file
2340
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "golem15-journal-blogeditor",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.6"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/extension-image": "3.31.4",
|
||||
"@tiptap/extension-table": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4",
|
||||
"@tiptap/starter-kit": "3.31.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"happy-dom": "20.11.6",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.3.5",
|
||||
"vitest": "3.2.7"
|
||||
}
|
||||
}
|
||||
31
plugin.go
31
plugin.go
@@ -2,11 +2,14 @@ package journal
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/console"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/updates"
|
||||
@@ -48,9 +51,37 @@ func (p *Plugin) Register(app *backpack.App) error {
|
||||
|
||||
func (p *Plugin) Boot(app *backpack.App) error {
|
||||
p.app = app
|
||||
if err := p.wireSiteCache(app); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.wireSearch(app)
|
||||
}
|
||||
|
||||
// wireSiteCache builds the site cache purger from
|
||||
// golem15.journal.site_cache.purge_url and purge_token and publishes it, so
|
||||
// admin saves of posts and categories purge the host site's page caches.
|
||||
// With neither key set nothing is published.
|
||||
func (p *Plugin) wireSiteCache(app *backpack.App) error {
|
||||
if app == nil || app.Config == nil {
|
||||
return nil
|
||||
}
|
||||
purger, err := classes.NewSiteCachePurger(
|
||||
app.Config.String(classes.SiteCachePurgeURLKey),
|
||||
app.Config.String(classes.SiteCachePurgeTokenKey),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("golem15.journal: site cache: %w", err)
|
||||
}
|
||||
if purger == nil {
|
||||
return nil
|
||||
}
|
||||
if err := app.Publish(purger); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Default().Info("journal: site cache purge enabled", "endpoint", purger.Endpoint())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) LangFS() fs.FS { return langFS }
|
||||
|
||||
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
|
||||
|
||||
@@ -98,6 +98,16 @@ func TestJournalSVGEmbed(t *testing.T) {
|
||||
t.Fatalf("AdminFS missing journal-icon.svg: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
bundle, err := fs.ReadFile(fsys, "assets/dist/blogeditor.js")
|
||||
if err != nil {
|
||||
t.Fatalf("AdminFS missing the Blog Editor bundle: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(bundle), "golem15-journal-blogeditor") {
|
||||
t.Fatal("blogeditor.js does not define golem15-journal-blogeditor")
|
||||
}
|
||||
if _, err := fs.Stat(fsys, "assets/dist/blogeditor.css"); err != nil {
|
||||
t.Fatalf("AdminFS missing blogeditor.css: %v", err)
|
||||
}
|
||||
filter, err := fs.ReadFile(fsys, "controllers/posts/config_filter.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -109,8 +119,8 @@ func TestJournalSVGEmbed(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(fields), "type: mlmarkdown") {
|
||||
t.Fatal("post fields.yaml content is not mlmarkdown")
|
||||
if !strings.Contains(string(fields), "type: mlblogeditor") || !strings.Contains(string(fields), "htmlColumn: content_html") {
|
||||
t.Fatal("post fields.yaml content is not mlblogeditor with htmlColumn: content_html")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -16,8 +17,8 @@ func TestPostsFormCompiles(t *testing.T) {
|
||||
token := env.admin(t, controllers.PermissionAccessPosts)
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminPostsPath+"/schema/form"), token, nil)
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "mlmarkdown") {
|
||||
t.Fatalf("form schema missing mlmarkdown: %s", body)
|
||||
if !strings.Contains(body, `"type":"mlblogeditor"`) {
|
||||
t.Fatalf("form schema missing mlblogeditor: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "mltext") {
|
||||
t.Fatalf("form schema missing mltext: %s", body)
|
||||
@@ -40,9 +41,12 @@ func TestPostsAdminCreateSmoke(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), privileged, map[string]any{
|
||||
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
|
||||
"slug": map[string]any{"en": "hello-admin", "pl": "witaj-admin"},
|
||||
"content": map[string]any{"en": "Hello **world**", "pl": "Witaj **świecie**"},
|
||||
"content": map[string]any{"en": "<p>Hello <strong>world</strong></p>", "pl": "<p>Witaj <strong>świecie</strong></p>"},
|
||||
"excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"},
|
||||
})
|
||||
if notices := adminNotices(t, rec); notices != nil {
|
||||
t.Fatalf("clean create carries meta.notices: %s", rec.Body.String())
|
||||
}
|
||||
var created struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
@@ -60,14 +64,9 @@ func TestPostsAdminCreateSmoke(t *testing.T) {
|
||||
if stored.Slug != "hello-admin" {
|
||||
t.Fatalf("host slug = %q, want hello-admin", stored.Slug)
|
||||
}
|
||||
if stored.ContentHTML == "" {
|
||||
t.Fatal("content_html is empty")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(stored.ContentHTML), "<script") {
|
||||
t.Fatalf("content_html contains script: %s", stored.ContentHTML)
|
||||
}
|
||||
if !strings.Contains(stored.ContentHTML, "<strong>") && !strings.Contains(stored.ContentHTML, "<p>") {
|
||||
t.Fatalf("content_html missing markup: %s", stored.ContentHTML)
|
||||
// D-02: the admin path stores the same HTML in both columns.
|
||||
if stored.Content != "<p>Hello <strong>world</strong></p>" || stored.ContentHTML != stored.Content {
|
||||
t.Fatalf("content = %q, content_html = %q, want both the submitted HTML", stored.Content, stored.ContentHTML)
|
||||
}
|
||||
|
||||
var plCount int64
|
||||
@@ -103,3 +102,351 @@ func TestPostsAdminCreateSmoke(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPostsAdminResaveKeepsHTML is the acceptance test of the todo
|
||||
// journal-imported-html-admin-resave (Phase 16.2 D-03, D-07) on the real
|
||||
// admin API: the administrator opens a post and saves it without touching
|
||||
// the editor. The test reads the form values the API serves with GET and
|
||||
// PUTs them back unchanged, as the admin SPA does.
|
||||
//
|
||||
// - An HTML post keeps content and content_html byte for byte, gains no
|
||||
// translated content for its untouched empty Polish value, and the
|
||||
// response has no meta.notices key.
|
||||
// - A markdown post (content markdown, content_html rendered at import, as
|
||||
// Płytarium-era imports are) is served as content_html (D-07) and keeps
|
||||
// its markdown content and its content_html byte for byte in both
|
||||
// locales, even though its content_html holds markup the policy would
|
||||
// strip: an untouched value is never decoded.
|
||||
// - An edited save of clean HTML in both locales stores equal content and
|
||||
// content_html per locale and reports nothing.
|
||||
//
|
||||
// The harness's default locale is en (host columns); pl is the translation.
|
||||
func TestPostsAdminResaveKeepsHTML(t *testing.T) {
|
||||
env := newPostsEnv(t)
|
||||
token := env.admin(t,
|
||||
controllers.PermissionAccessPosts,
|
||||
controllers.PermissionAccessPublish,
|
||||
controllers.PermissionAccessOtherPosts,
|
||||
)
|
||||
|
||||
t.Run("HTML post", func(t *testing.T) {
|
||||
const content = "<h2>Wstęp</h2>\n" +
|
||||
"<p>Grzyby rosną w lesie.</p>\n" +
|
||||
"<p>Druga <b>część</b> z <a href=\"https://example.test/zrodlo\">linkiem</a>.</p>\n" +
|
||||
"<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure>\n"
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
|
||||
"title": map[string]any{"en": "Imported", "pl": "Zaimportowany"},
|
||||
"slug": map[string]any{"en": "imported-html", "pl": "zaimportowany-html"},
|
||||
"content": map[string]any{"en": content},
|
||||
"excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"},
|
||||
})
|
||||
if notices := adminNotices(t, rec); notices != nil {
|
||||
t.Fatalf("clean create carries meta.notices: %s", rec.Body.String())
|
||||
}
|
||||
id := adminCreatedID(t, rec)
|
||||
assertHostContent(t, env, id, "create", content, content)
|
||||
before := translatedAttrs(t, env, id, "pl")
|
||||
rowsBefore := translatedRows(t, env, id)
|
||||
|
||||
served := servedForm(t, env, token, id)
|
||||
if got := servedLocales(t, served, "content"); got["en"] != content || got["pl"] != "" {
|
||||
t.Fatalf("served content = %#v, want en as stored and an empty pl", got)
|
||||
}
|
||||
put := env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, servedBody(served, "title", "slug", "content", "excerpt"))
|
||||
if _, ok := adminMeta(t, put)["notices"]; ok {
|
||||
t.Fatalf("untouched save carries meta.notices: %s", put.Body.String())
|
||||
}
|
||||
assertHostContent(t, env, id, "untouched re-save", content, content)
|
||||
after := translatedAttrs(t, env, id, "pl")
|
||||
for _, key := range []string{"content", "content_html"} {
|
||||
old, had := before[key]
|
||||
now, has := after[key]
|
||||
if had != has || old != now {
|
||||
t.Fatalf("untouched re-save changed pl %s: before %#v (%v), after %#v (%v)", key, old, had, now, has)
|
||||
}
|
||||
if has {
|
||||
t.Fatalf("pl %s written for an untouched empty value: %#v", key, now)
|
||||
}
|
||||
}
|
||||
if rows := translatedRows(t, env, id); rows != rowsBefore {
|
||||
t.Fatalf("untouched re-save added translation rows: %d -> %d", rowsBefore, rows)
|
||||
}
|
||||
|
||||
const editedEN = "<p>Edited <em>English</em> text.</p>\n<ol start=\"3\"><li>three</li></ol>\n"
|
||||
const editedPL = "<p>Poprawiony <strong>polski</strong> tekst.</p>\n<p><img src=\"https://cdn.example.test/a.jpg\" alt=\"grzyb\"></p>\n"
|
||||
editBody := servedBody(served, "title", "slug", "excerpt")
|
||||
editBody["content"] = map[string]any{"en": editedEN, "pl": editedPL}
|
||||
edit := env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, editBody)
|
||||
if _, ok := adminMeta(t, edit)["notices"]; ok {
|
||||
t.Fatalf("clean edited save carries meta.notices: %s", edit.Body.String())
|
||||
}
|
||||
assertHostContent(t, env, id, "edited save", editedEN, editedEN)
|
||||
pl := translatedAttrs(t, env, id, "pl")
|
||||
if pl["content"] != editedPL || pl["content_html"] != pl["content"] {
|
||||
t.Fatalf("pl content = %#v, content_html = %#v, want both %q", pl["content"], pl["content_html"], editedPL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("markdown post", func(t *testing.T) {
|
||||
const markdownEN = "## Heading\n\nText with *italics* and **bold**.\n\n- one\n- two\n"
|
||||
const markdownPL = "## Nagłówek\n\nTekst z *kursywą* i **pogrubieniem**.\n\n- jeden\n- dwa\n"
|
||||
// The content_html an import stored: rendered markdown plus the raw
|
||||
// author footer and its style block, which the policy would strip.
|
||||
const footer = "<style>.author-footer{color:#555}</style>\n<footer class=\"author-footer\" itemscope=\"\" itemtype=\"https://schema.org/Person\"><p>Autor</p></footer>\n"
|
||||
const renderedEN = "<h2>Heading</h2>\n<p>Text with <em>italics</em> and <strong>bold</strong>.</p>\n<ul>\n<li>one</li>\n<li>two</li>\n</ul>\n" + footer
|
||||
const renderedPL = "<h2>Nagłówek</h2>\n<p>Tekst z <em>kursywą</em> i <strong>pogrubieniem</strong>.</p>\n<ul>\n<li>jeden</li>\n<li>dwa</li>\n</ul>\n" + footer
|
||||
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
|
||||
"title": map[string]any{"en": "Markdown", "pl": "Markdown PL"},
|
||||
"slug": map[string]any{"en": "imported-markdown", "pl": "zaimportowany-markdown"},
|
||||
"content": map[string]any{"en": markdownEN, "pl": markdownPL},
|
||||
})
|
||||
id := adminCreatedID(t, rec)
|
||||
assertHostContent(t, env, id, "markdown create", markdownEN, markdownEN)
|
||||
// What an import leaves behind: content_html rendered from markdown.
|
||||
if err := env.gdb.Model(&models.Post{}).Where("id = ?", id).Update("content_html", renderedEN).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setTranslatedAttr(t, env, id, "pl", "content_html", renderedPL)
|
||||
|
||||
served := servedForm(t, env, token, id)
|
||||
if got := servedLocales(t, served, "content"); got["en"] != renderedEN || got["pl"] != renderedPL {
|
||||
t.Fatalf("served content = %#v, want each locale's content_html (D-07)", got)
|
||||
}
|
||||
put := env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, servedBody(served, "title", "slug", "content", "excerpt"))
|
||||
if _, ok := adminMeta(t, put)["notices"]; ok {
|
||||
t.Fatalf("untouched markdown save carries meta.notices: %s", put.Body.String())
|
||||
}
|
||||
assertHostContent(t, env, id, "untouched markdown re-save", markdownEN, renderedEN)
|
||||
pl := translatedAttrs(t, env, id, "pl")
|
||||
if pl["content"] != markdownPL || pl["content_html"] != renderedPL {
|
||||
t.Fatalf("pl content = %#v, content_html = %#v, want the markdown and the imported HTML unchanged", pl["content"], pl["content_html"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// adminCreatedID returns the id of a create response's record.
|
||||
func adminCreatedID(t *testing.T, rec *httptest.ResponseRecorder) uint {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
return created.ID
|
||||
}
|
||||
|
||||
// adminMeta returns a response's meta object by key.
|
||||
func adminMeta(t *testing.T, rec *httptest.ResponseRecorder) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Meta map[string]json.RawMessage `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("admin body %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return body.Meta
|
||||
}
|
||||
|
||||
// assertHostContent compares the host columns byte for byte (no trimming).
|
||||
func assertHostContent(t *testing.T, env *adminEnv, id uint, stage, content, contentHTML string) {
|
||||
t.Helper()
|
||||
var stored models.Post
|
||||
if err := env.gdb.First(&stored, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(stored.ContentHTML, "raw HTML omitted") {
|
||||
t.Fatalf("%s: content_html holds the goldmark placeholder: %q", stage, stored.ContentHTML)
|
||||
}
|
||||
if stored.Content != content {
|
||||
t.Fatalf("%s: content = %q, want %q", stage, stored.Content, content)
|
||||
}
|
||||
if stored.ContentHTML != contentHTML {
|
||||
t.Fatalf("%s: content_html = %q, want %q", stage, stored.ContentHTML, contentHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// servedForm returns the record data the admin API serves the form with.
|
||||
func servedForm(t *testing.T, env *adminEnv, token string, id uint) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, nil)
|
||||
var data map[string]json.RawMessage
|
||||
if err := json.Unmarshal(adminDecode(t, rec).Data, &data); err != nil {
|
||||
t.Fatalf("show data %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// servedLocales decodes one served locale map.
|
||||
func servedLocales(t *testing.T, served map[string]json.RawMessage, field string) map[string]string {
|
||||
t.Helper()
|
||||
var out map[string]string
|
||||
if err := json.Unmarshal(served[field], &out); err != nil {
|
||||
t.Fatalf("served %s %s: %v", field, served[field], err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// servedBody is a save body of the named served fields, unchanged.
|
||||
func servedBody(served map[string]json.RawMessage, fields ...string) map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, field := range fields {
|
||||
if raw, ok := served[field]; ok {
|
||||
out[field] = raw
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// translatedAttrs returns a post's attribute_data in locale, empty when it
|
||||
// has no row.
|
||||
func translatedAttrs(t *testing.T, env *adminEnv, id uint, locale string) map[string]any {
|
||||
t.Helper()
|
||||
var raw *string
|
||||
if err := env.gdb.Table("golem15_translate_attributes").
|
||||
Select("attribute_data").
|
||||
Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(id), models.Post{}.MorphName(), locale).
|
||||
Limit(1).
|
||||
Scan(&raw).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]any{}
|
||||
if raw == nil || *raw == "" {
|
||||
return out
|
||||
}
|
||||
if err := json.Unmarshal([]byte(*raw), &out); err != nil {
|
||||
t.Fatalf("attribute_data %q: %v", *raw, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// translatedRows counts a post's translation rows.
|
||||
func translatedRows(t *testing.T, env *adminEnv, id uint) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := env.gdb.Table("golem15_translate_attributes").
|
||||
Where("model_id = ? AND model_type = ?", fmt.Sprint(id), models.Post{}.MorphName()).
|
||||
Count(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// setTranslatedAttr sets one key of a post's attribute_data in locale, as an
|
||||
// import does.
|
||||
func setTranslatedAttr(t *testing.T, env *adminEnv, id uint, locale, key, value string) {
|
||||
t.Helper()
|
||||
attrs := translatedAttrs(t, env, id, locale)
|
||||
attrs[key] = value
|
||||
raw, err := json.Marshal(attrs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := env.gdb.Table("golem15_translate_attributes").
|
||||
Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(id), models.Post{}.MorphName(), locale).
|
||||
Update("attribute_data", string(raw))
|
||||
if res.Error != nil || res.RowsAffected != 1 {
|
||||
t.Fatalf("set %s %s: rows=%d err=%v", locale, key, res.RowsAffected, res.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostsAdminSaveSanitizesHTML pins CR-01, WR-01 and Phase 16.2 D-02 and
|
||||
// D-10 on the admin write path: unsafe markup in content is stripped, as
|
||||
// Winter's Html::clean strips it, instead of refusing the save; content and
|
||||
// content_html both hold the sanitized HTML for the host locale (en) and the
|
||||
// translation (pl); a "Data:" label is ordinary text; and the response's
|
||||
// meta.notices names what was removed for each locale.
|
||||
func TestPostsAdminSaveSanitizesHTML(t *testing.T) {
|
||||
env := newPostsEnv(t)
|
||||
token := env.admin(t,
|
||||
controllers.PermissionAccessPosts,
|
||||
controllers.PermissionAccessPublish,
|
||||
controllers.PermissionAccessOtherPosts,
|
||||
)
|
||||
const content = "<p>Data: 12 maja 2024</p>\n" +
|
||||
"<p><img src=\"https://cdn.example.test/a.jpg\" alt=\"a\"onerror=\"alert(1)\"></p>\n" +
|
||||
"<p><a href=\"javascript:alert(1)\">x</a></p>\n" +
|
||||
"<base href=\"https://evil.test/\">"
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
|
||||
"title": map[string]any{"en": "Sanitized", "pl": "Oczyszczony"},
|
||||
"slug": map[string]any{"en": "sanitized-html", "pl": "oczyszczony-html"},
|
||||
"content": map[string]any{"en": content, "pl": content},
|
||||
})
|
||||
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)
|
||||
}
|
||||
var stored models.Post
|
||||
if err := env.gdb.First(&stored, created.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const want = "<p>Data: 12 maja 2024</p>\n" +
|
||||
"<p><img src=\"https://cdn.example.test/a.jpg\" alt=\"a\"></p>\n" +
|
||||
"<p>x</p>\n"
|
||||
if stored.Content != want || stored.ContentHTML != want {
|
||||
t.Fatalf("host content = %q, content_html = %q, want both %q", stored.Content, stored.ContentHTML, want)
|
||||
}
|
||||
var raw string
|
||||
if err := env.gdb.Table("golem15_translate_attributes").
|
||||
Select("attribute_data").
|
||||
Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(stored.ID), stored.MorphName(), "pl").
|
||||
Scan(&raw).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pl map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &pl); err != nil {
|
||||
t.Fatalf("attribute_data %q: %v", raw, err)
|
||||
}
|
||||
if pl["content"] != want || pl["content_html"] != want {
|
||||
t.Fatalf("pl content = %#v, content_html = %#v, want both %q", pl["content"], pl["content_html"], want)
|
||||
}
|
||||
|
||||
notices := adminNotices(t, rec)
|
||||
for _, locale := range []string{"en", "pl"} {
|
||||
var elements, attributes bool
|
||||
for _, n := range notices {
|
||||
if n.Field != "content" || n.Locale != locale {
|
||||
continue
|
||||
}
|
||||
elements = elements || strings.Contains(n.Message, "2 elements were removed: a, base")
|
||||
attributes = attributes || strings.Contains(n.Message, "1 attribute was removed: onerror")
|
||||
}
|
||||
if !elements || !attributes {
|
||||
t.Fatalf("%s notices = %#v, want removed elements a, base and attribute onerror", locale, notices)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// adminNotice is one meta.notices entry of an admin save response.
|
||||
type adminNotice struct {
|
||||
Field string `json:"field"`
|
||||
Locale string `json:"locale"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// adminNotices returns a save response's meta.notices, nil when the key is
|
||||
// absent.
|
||||
func adminNotices(t *testing.T, rec *httptest.ResponseRecorder) []adminNotice {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Meta map[string]json.RawMessage `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("admin body %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
raw, ok := body.Meta["notices"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var notices []adminNotice
|
||||
if err := json.Unmarshal(raw, ¬ices); err != nil {
|
||||
t.Fatalf("meta.notices %s: %v", raw, err)
|
||||
}
|
||||
if notices == nil {
|
||||
notices = []adminNotice{}
|
||||
}
|
||||
return notices
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
|
||||
// Routes mounts public GETs and backend-Bearer writes under /_journal/api/v1
|
||||
// (D-14). The public group must not attach cabana middleware name "backend"
|
||||
// (anonymous callers would 401 — pitfall 4).
|
||||
// (anonymous callers would 401 — pitfall 4). The public author routes resolve
|
||||
// {author} in the handler rather than with Where, so every miss is the JSON
|
||||
// 404 {"error":"Author not found"}.
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Group("/_journal/api/v1", surf.Use("throttle:journal-public-api"), func(g pact.Router) {
|
||||
g.Get("/posts", apicontrollers.Index(p.app))
|
||||
@@ -17,6 +19,8 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
g.Get("/categories", apicontrollers.Categories(p.app))
|
||||
g.Get("/tags", apicontrollers.Tags(p.app))
|
||||
g.Get("/rss", apicontrollers.RSS(p.app))
|
||||
g.Get("/authors/{author}", apicontrollers.AuthorShow(p.app))
|
||||
g.Get("/authors/{author}/posts", apicontrollers.AuthorPosts(p.app))
|
||||
})
|
||||
r.Group("/_journal/api/v1", surf.Use("throttle:journal-api"), func(g pact.Router) {
|
||||
g.Post("/media/upload", apicontrollers.UploadMedia(p.app))
|
||||
|
||||
26
scripts/check-dist.sh
Executable file
26
scripts/check-dist.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Drift gate for the committed Blog Editor bundle (D-16). Rebuilds the element
|
||||
# from the committed lockfile into a temporary directory and fails when the
|
||||
# result differs from assets/dist, the files admin.go embeds. After changing
|
||||
# assets/src, rebuild with `npm run build` and commit assets/dist.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ ! -d node_modules ]]; then
|
||||
npm ci
|
||||
fi
|
||||
|
||||
npm run typecheck
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
node_modules/.bin/vite build --outDir "$TMP/dist" --emptyOutDir --logLevel warn
|
||||
|
||||
if ! diff -r "$TMP/dist" assets/dist; then
|
||||
echo "check-dist: assets/dist is stale; run npm run build and commit assets/dist" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "check-dist: assets/dist matches a fresh build"
|
||||
331
site_cache_admin_test.go
Normal file
331
site_cache_admin_test.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package journal_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
)
|
||||
|
||||
// purgeTestToken is a 40-character purge token for the receiver.
|
||||
const purgeTestToken = "purge-test-token-0123456789abcdefghijklm"
|
||||
|
||||
const purgeTestPath = "/api/__journal__/cache/purge"
|
||||
|
||||
// purgeRequest is one request the purge receiver got.
|
||||
type purgeRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
Authorization string
|
||||
ContentType string
|
||||
Body map[string]string
|
||||
// Seen is what the receiver's probe read from the database while the
|
||||
// request was in flight.
|
||||
Seen string
|
||||
}
|
||||
|
||||
// purgeReceiver stands in for the host site's purge route on 127.0.0.1.
|
||||
type purgeReceiver struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
got []purgeRequest
|
||||
status int
|
||||
probe func() string
|
||||
}
|
||||
|
||||
func newPurgeReceiver(t *testing.T) *purgeReceiver {
|
||||
t.Helper()
|
||||
r := &purgeReceiver{status: http.StatusOK}
|
||||
r.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
raw, _ := io.ReadAll(req.Body)
|
||||
var body map[string]string
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
r.mu.Lock()
|
||||
probe, status := r.probe, r.status
|
||||
r.mu.Unlock()
|
||||
seen := ""
|
||||
if probe != nil {
|
||||
seen = probe()
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.got = append(r.got, purgeRequest{
|
||||
Method: req.Method,
|
||||
Path: req.URL.Path,
|
||||
Authorization: req.Header.Get("Authorization"),
|
||||
ContentType: req.Header.Get("Content-Type"),
|
||||
Body: body,
|
||||
Seen: seen,
|
||||
})
|
||||
r.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if status == http.StatusOK {
|
||||
_, _ = w.Write([]byte(`{"purged":3}`))
|
||||
}
|
||||
}))
|
||||
t.Cleanup(r.srv.Close)
|
||||
return r
|
||||
}
|
||||
|
||||
// env returns the extra environment that points the Journal plugin at r.
|
||||
func (r *purgeReceiver) env() []string {
|
||||
return []string{
|
||||
"SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL=" + r.srv.URL + purgeTestPath,
|
||||
"SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN=" + purgeTestToken,
|
||||
}
|
||||
}
|
||||
|
||||
// take returns the requests received so far and forgets them.
|
||||
func (r *purgeReceiver) take() []purgeRequest {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
got := r.got
|
||||
r.got = nil
|
||||
return got
|
||||
}
|
||||
|
||||
func (r *purgeReceiver) setStatus(status int) {
|
||||
r.mu.Lock()
|
||||
r.status = status
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *purgeReceiver) setProbe(probe func() string) {
|
||||
r.mu.Lock()
|
||||
r.probe = probe
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// expectOnePurge asserts exactly one contract request for kind and slug was
|
||||
// received since the last take, and returns it.
|
||||
func (r *purgeReceiver) expectOnePurge(t *testing.T, stage, kind, slug string) purgeRequest {
|
||||
t.Helper()
|
||||
got := r.take()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("%s: %d purge requests, want 1: %+v", stage, len(got), got)
|
||||
}
|
||||
req := got[0]
|
||||
if req.Method != http.MethodPost || req.Path != purgeTestPath {
|
||||
t.Fatalf("%s: purge request %s %s, want POST %s", stage, req.Method, req.Path, purgeTestPath)
|
||||
}
|
||||
if req.Authorization != "Bearer "+purgeTestToken {
|
||||
t.Fatalf("%s: Authorization = %q, want the bearer token", stage, req.Authorization)
|
||||
}
|
||||
if !strings.HasPrefix(req.ContentType, "application/json") {
|
||||
t.Fatalf("%s: Content-Type = %q, want application/json", stage, req.ContentType)
|
||||
}
|
||||
if req.Body["kind"] != kind || req.Body["slug"] != slug || len(req.Body) != 2 {
|
||||
t.Fatalf("%s: purge body = %#v, want kind %q and slug %q", stage, req.Body, kind, slug)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// TestCategoriesAdminPurgesSiteCache proves every admin write of a category
|
||||
// sends one authorized purge request with kind category after the save
|
||||
// transaction commits, and a failed save sends none (G-16.2-2, D-19).
|
||||
func TestCategoriesAdminPurgesSiteCache(t *testing.T) {
|
||||
recv := newPurgeReceiver(t)
|
||||
env := newPostsEnv(t, recv.env()...)
|
||||
token := env.admin(t, controllers.PermissionAccessCategories)
|
||||
|
||||
// The probe reads the committed row through the pool while the purge
|
||||
// request is in flight: "<content>" or "missing".
|
||||
recv.setProbe(func() string {
|
||||
var cat models.Category
|
||||
if err := env.gdb.Where("slug = ?", "purge-news").Limit(1).Find(&cat).Error; err != nil {
|
||||
return "error: " + err.Error()
|
||||
}
|
||||
if cat.ID == 0 {
|
||||
return "missing"
|
||||
}
|
||||
if cat.Content == nil {
|
||||
return ""
|
||||
}
|
||||
return *cat.Content
|
||||
})
|
||||
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{
|
||||
"name": map[string]any{"en": "Purge news", "pl": "Wiadomości"},
|
||||
"slug": map[string]any{"en": "purge-news", "pl": "wiadomosci"},
|
||||
"content": "<p>Before</p>\n",
|
||||
})
|
||||
id := adminCreatedID(t, rec)
|
||||
itemPath := adminAPI(fmt.Sprintf("%s/%d", adminCategoriesPath, id))
|
||||
if req := recv.expectOnePurge(t, "create", "category", "purge-news"); req.Seen != "<p>Before</p>\n" {
|
||||
t.Fatalf("create: the purge saw %q, want the committed row", req.Seen)
|
||||
}
|
||||
|
||||
served := func(t *testing.T) map[string]json.RawMessage {
|
||||
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.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
t.Run("update", func(t *testing.T) {
|
||||
const edited = "<p>After the edit</p>\n"
|
||||
body := servedBody(served(t), "name", "slug")
|
||||
body["content"] = edited
|
||||
env.expect(t, http.StatusOK, http.MethodPut, itemPath, token, body)
|
||||
if req := recv.expectOnePurge(t, "update", "category", "purge-news"); req.Seen != edited {
|
||||
t.Fatalf("the purge saw content %q, want the committed %q (purge before commit)", req.Seen, edited)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a failed update sends none", func(t *testing.T) {
|
||||
body := servedBody(served(t), "slug")
|
||||
body["name"] = map[string]any{"en": "", "pl": ""}
|
||||
body["content"] = "<p>Refused</p>\n"
|
||||
if rec := env.call(t, http.MethodPut, itemPath, token, body); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("update without a name = %d, want 422: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := recv.take(); len(got) != 0 {
|
||||
t.Fatalf("a failed update sent %d purge requests: %+v", len(got), got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, itemPath, token, nil)
|
||||
if req := recv.expectOnePurge(t, "delete", "category", "purge-news"); req.Seen != "missing" {
|
||||
t.Fatalf("the purge saw %q, want the row already deleted", req.Seen)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPostsAdminPurgesSiteCache proves every admin write of a post sends one
|
||||
// authorized purge request with kind post after the commit, that an
|
||||
// untouched save still purges and keeps both content columns byte for byte
|
||||
// (D-03), that a refused, failing or unreachable purge never changes the
|
||||
// save response, and that a failed save sends none.
|
||||
func TestPostsAdminPurgesSiteCache(t *testing.T) {
|
||||
recv := newPurgeReceiver(t)
|
||||
env := newPostsEnv(t, recv.env()...)
|
||||
token := env.admin(t, controllers.PermissionAccessPosts)
|
||||
|
||||
recv.setProbe(func() string {
|
||||
var post models.Post
|
||||
if err := env.gdb.Where("slug = ?", "purge-post").Limit(1).Find(&post).Error; err != nil {
|
||||
return "error: " + err.Error()
|
||||
}
|
||||
if post.ID == 0 {
|
||||
return "missing"
|
||||
}
|
||||
return post.ContentHTML
|
||||
})
|
||||
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
|
||||
"title": map[string]any{"en": "Purge post", "pl": "Wpis"},
|
||||
"slug": map[string]any{"en": "purge-post", "pl": "wpis"},
|
||||
"content": map[string]any{"en": "<p>Before</p>"},
|
||||
"excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"},
|
||||
})
|
||||
id := adminCreatedID(t, rec)
|
||||
itemPath := adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id))
|
||||
if req := recv.expectOnePurge(t, "create", "post", "purge-post"); req.Seen != "<p>Before</p>" {
|
||||
t.Fatalf("create: the purge saw %q, want the committed row", req.Seen)
|
||||
}
|
||||
|
||||
put := func(t *testing.T, content string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body := servedBody(servedForm(t, env, token, id), "title", "slug", "excerpt")
|
||||
body["content"] = map[string]any{"en": content}
|
||||
return env.call(t, http.MethodPut, itemPath, token, body)
|
||||
}
|
||||
stored := func(t *testing.T) models.Post {
|
||||
t.Helper()
|
||||
var post models.Post
|
||||
if err := env.gdb.First(&post, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return post
|
||||
}
|
||||
|
||||
t.Run("update", func(t *testing.T) {
|
||||
const edited = "<p>After the edit</p>"
|
||||
if rec := put(t, edited); rec.Code != http.StatusOK {
|
||||
t.Fatalf("update = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if req := recv.expectOnePurge(t, "update", "post", "purge-post"); req.Seen != edited {
|
||||
t.Fatalf("the purge saw content_html %q, want the committed %q (purge before commit)", req.Seen, edited)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an untouched update purges and keeps both columns", func(t *testing.T) {
|
||||
const imported = "\n<!-- imported -->\n<p onclick=\"x()\">Kept as stored</p>\n"
|
||||
const importedHTML = "<p>Rendered elsewhere</p>\n"
|
||||
if err := env.gdb.Model(&models.Post{}).Where("id = ?", id).Updates(map[string]any{"content": imported, "content_html": importedHTML}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
served := servedForm(t, env, token, id)
|
||||
env.expect(t, http.StatusOK, http.MethodPut, itemPath, token, servedBody(served, "title", "slug", "excerpt", "content"))
|
||||
recv.expectOnePurge(t, "untouched update", "post", "purge-post")
|
||||
assertHostContent(t, env, id, "untouched update", imported, importedHTML)
|
||||
})
|
||||
|
||||
t.Run("a failed save sends none", func(t *testing.T) {
|
||||
// The admin lacks access_publish: FormBeforeUpdate refuses inside
|
||||
// the save transaction.
|
||||
body := servedBody(servedForm(t, env, token, id), "title", "slug", "excerpt")
|
||||
body["published"] = true
|
||||
if rec := env.call(t, http.MethodPut, itemPath, token, body); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("publish without access_publish = %d, want 403: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := recv.take(); len(got) != 0 {
|
||||
t.Fatalf("a failed save sent %d purge requests: %+v", len(got), got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a failing purge never changes the save", func(t *testing.T) {
|
||||
recv.setStatus(http.StatusInternalServerError)
|
||||
defer recv.setStatus(http.StatusOK)
|
||||
const edited = "<p>Saved while the site answers 500</p>"
|
||||
if rec := put(t, edited); rec.Code != http.StatusOK {
|
||||
t.Fatalf("update with a failing purge = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
recv.expectOnePurge(t, "failing purge", "post", "purge-post")
|
||||
if got := stored(t); got.Content != edited || got.ContentHTML != edited {
|
||||
t.Fatalf("stored = %q / %q, want %q", got.Content, got.ContentHTML, edited)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
spare := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
|
||||
"title": map[string]any{"en": "Spare"},
|
||||
"slug": map[string]any{"en": "spare-post"},
|
||||
"content": map[string]any{"en": "<p>Spare</p>"},
|
||||
})
|
||||
spareID := adminCreatedID(t, spare)
|
||||
recv.expectOnePurge(t, "spare create", "post", "spare-post")
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, spareID)), token, nil)
|
||||
recv.expectOnePurge(t, "delete", "post", "spare-post")
|
||||
var n int64
|
||||
if err := env.gdb.Model(&models.Post{}).Where("id = ?", spareID).Count(&n).Error; err != nil || n != 0 {
|
||||
t.Fatalf("spare post rows = %d (%v), want 0", n, err)
|
||||
}
|
||||
})
|
||||
|
||||
// Last: closing the receiver ends its use for this env.
|
||||
t.Run("an unreachable site never changes the save", func(t *testing.T) {
|
||||
recv.srv.Close()
|
||||
const edited = "<p>Saved while nothing listens</p>"
|
||||
if rec := put(t, edited); rec.Code != http.StatusOK {
|
||||
t.Fatalf("update with no receiver = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := stored(t); got.Content != edited || got.ContentHTML != edited {
|
||||
t.Fatalf("stored = %q / %q, want %q", got.Content, got.ContentHTML, edited)
|
||||
}
|
||||
if got := recv.take(); len(got) != 0 {
|
||||
t.Fatalf("a closed receiver recorded %d requests", len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
210
tests/blogeditor/corpus.test.ts
Normal file
210
tests/blogeditor/corpus.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { parseEmbedUrl } from '../../assets/src/blogeditor/embed'
|
||||
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
|
||||
import { mount, type } from './helpers'
|
||||
|
||||
// The editor's own output (D-05, D-10 amended). Every entry is built through
|
||||
// editor commands and typing only, never by inserting HTML, so it is what
|
||||
// the toolbar and input rules produce. The committed file is read in place
|
||||
// by the Go policy test (classes/editor_policy_test.go) and copied into the
|
||||
// layer, so both sanitizer policies are tested against the real output.
|
||||
// Entry names are stable once committed. UPDATE_CORPUS=1 rewrites the file.
|
||||
const CORPUS = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'editor-corpus.json')
|
||||
|
||||
interface Entry {
|
||||
name: string
|
||||
html: string
|
||||
}
|
||||
|
||||
/** startBlock puts the cursor in an empty paragraph at the end of the document. */
|
||||
function startBlock(editor: Editor): void {
|
||||
const { doc } = editor.state
|
||||
const last = doc.lastChild
|
||||
if (last && last.type.name === 'paragraph' && last.content.size === 0) {
|
||||
editor.commands.setTextSelection(doc.content.size - 1)
|
||||
return
|
||||
}
|
||||
editor.commands.insertContentAt(doc.content.size, { type: 'paragraph' })
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size - 1)
|
||||
}
|
||||
|
||||
/** link types text and links it through setLink, then leaves the link. */
|
||||
function link(editor: Editor, text: string, href: string): void {
|
||||
const from = editor.state.selection.from
|
||||
type(editor, text)
|
||||
const to = editor.state.selection.from
|
||||
expect(editor.chain().setTextSelection({ from, to }).setLink({ href }).run(), href).toBe(true)
|
||||
editor.chain().setTextSelection(to).unsetMark('link').run()
|
||||
}
|
||||
|
||||
function mark(editor: Editor, toggle: () => boolean, text: string): void {
|
||||
expect(toggle()).toBe(true)
|
||||
type(editor, text)
|
||||
expect(toggle()).toBe(true)
|
||||
}
|
||||
|
||||
const IMAGE_SRC = '/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg'
|
||||
|
||||
const builders: Record<string, (editor: Editor) => void> = {
|
||||
'embed-youtube': (editor) => {
|
||||
startBlock(editor)
|
||||
const embed = parseEmbedUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')!
|
||||
expect(editor.commands.setEmbed(embed)).toBe(true)
|
||||
},
|
||||
'image-without-caption': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.setImage({ src: IMAGE_SRC, alt: 'Turkey Tail: czy to "grzyb odporności"?' })).toBe(true)
|
||||
},
|
||||
'image-with-caption': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(
|
||||
editor.commands.setFigure({ src: IMAGE_SRC, alt: 'Owocniki wrośniaka', caption: 'Owocniki wrośniaka różnobarwnego.' }),
|
||||
).toBe(true)
|
||||
},
|
||||
'table-header-row': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })).toBe(true)
|
||||
const cells = ['Forma', 'Dawka', 'Uwagi', 'Proszek', '1-2 g', 'z posiłkiem', 'Kapsułki', '2 x 500 mg', 'rano']
|
||||
cells.forEach((text, i) => {
|
||||
if (i > 0) expect(editor.commands.goToNextCell()).toBe(true)
|
||||
type(editor, text)
|
||||
})
|
||||
},
|
||||
links: (editor) => {
|
||||
startBlock(editor)
|
||||
type(editor, 'Źródła: ')
|
||||
link(editor, 'strona', 'http://example.test/')
|
||||
type(editor, ', ')
|
||||
link(editor, 'badania', 'https://example.test/badania?rok=2026#wyniki')
|
||||
type(editor, ', ')
|
||||
link(editor, 'redakcja', 'mailto:redakcja@example.test')
|
||||
type(editor, ' i ')
|
||||
link(editor, 'inny wpis', '/blog/beta-glukany')
|
||||
type(editor, '.')
|
||||
},
|
||||
headings: (editor) => {
|
||||
for (const level of [2, 3, 4] as const) {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.setHeading({ level })).toBe(true)
|
||||
type(editor, `Nagłówek ${level}`)
|
||||
}
|
||||
},
|
||||
'bullet-list': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.toggleBulletList()).toBe(true)
|
||||
type(editor, 'Beta-glukany')
|
||||
expect(editor.commands.splitListItem('listItem')).toBe(true)
|
||||
type(editor, 'Antyoksydanty')
|
||||
},
|
||||
'ordered-list': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.toggleOrderedList()).toBe(true)
|
||||
type(editor, 'Pierwszy krok')
|
||||
expect(editor.commands.splitListItem('listItem')).toBe(true)
|
||||
type(editor, 'Drugi krok')
|
||||
},
|
||||
// The blockquote sits between the two ordered lists, so the combined
|
||||
// document keeps them as two lists instead of joining them.
|
||||
blockquote: (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.toggleBlockquote()).toBe(true)
|
||||
type(editor, 'Grzyby funkcjonalne wspierają organizm.')
|
||||
},
|
||||
'ordered-list-start-3': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.toggleOrderedList()).toBe(true)
|
||||
expect(editor.commands.updateAttributes('orderedList', { start: 3 })).toBe(true)
|
||||
type(editor, 'Trzeci krok')
|
||||
expect(editor.commands.splitListItem('listItem')).toBe(true)
|
||||
type(editor, 'Czwarty krok')
|
||||
},
|
||||
'inline-marks': (editor) => {
|
||||
startBlock(editor)
|
||||
type(editor, 'Tekst ')
|
||||
mark(editor, () => editor.commands.toggleBold(), 'pogrubiony')
|
||||
type(editor, ', ')
|
||||
mark(editor, () => editor.commands.toggleItalic(), 'kursywa')
|
||||
type(editor, ', ')
|
||||
mark(editor, () => editor.commands.toggleStrike(), 'przekreślony')
|
||||
type(editor, ', ')
|
||||
mark(editor, () => editor.commands.toggleUnderline(), 'podkreślony')
|
||||
type(editor, ' i ')
|
||||
mark(editor, () => editor.commands.toggleCode(), 'kod')
|
||||
type(editor, '.')
|
||||
},
|
||||
'code-block': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.toggleCodeBlock()).toBe(true)
|
||||
type(editor, 'dawka = 2 * 500 // mg')
|
||||
},
|
||||
'horizontal-rule': (editor) => {
|
||||
startBlock(editor)
|
||||
type(editor, 'Przed linią')
|
||||
expect(editor.commands.setHorizontalRule()).toBe(true)
|
||||
type(editor, 'Po linii')
|
||||
},
|
||||
'hard-break': (editor) => {
|
||||
startBlock(editor)
|
||||
type(editor, 'Pierwsza linia')
|
||||
expect(editor.commands.setHardBreak()).toBe(true)
|
||||
type(editor, 'Druga linia')
|
||||
},
|
||||
}
|
||||
|
||||
function build(steps: ((editor: Editor) => void)[]): string {
|
||||
const { el, editor } = mount('')
|
||||
for (const step of steps) step(editor)
|
||||
const html = el.value
|
||||
el.remove()
|
||||
return html
|
||||
}
|
||||
|
||||
function corpus(): Entry[] {
|
||||
const entries: Entry[] = Object.entries(builders).map(([name, step]) => ({ name, html: build([step]) }))
|
||||
entries.push({ name: 'combined', html: build(Object.values(builders)) })
|
||||
return entries
|
||||
}
|
||||
|
||||
describe('editor corpus (D-05)', () => {
|
||||
const entries = corpus()
|
||||
|
||||
afterAll(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('matches the committed editor-corpus.json', () => {
|
||||
const text = `${JSON.stringify(entries, null, 2)}\n`
|
||||
if (process.env.UPDATE_CORPUS === '1') {
|
||||
writeFileSync(CORPUS, text)
|
||||
}
|
||||
expect(readFileSync(CORPUS, 'utf8')).toBe(text)
|
||||
})
|
||||
|
||||
it('holds no iframe, handler, target, rel or style and only policy URLs', () => {
|
||||
expect(entries.length).toBeGreaterThanOrEqual(15)
|
||||
const doc = document.implementation.createHTMLDocument('')
|
||||
for (const { name, html } of entries) {
|
||||
expect(html, name).not.toMatch(/<iframe|target=|rel=| on[a-z]+=|style=/i)
|
||||
expect(html, name).not.toBe('')
|
||||
expect(html, name).not.toMatch(/<p><\/p>$/)
|
||||
const host = doc.createElement('div')
|
||||
host.innerHTML = html
|
||||
for (const el of Array.from(host.querySelectorAll('[href], [src]'))) {
|
||||
const url = el.getAttribute('href') ?? el.getAttribute('src') ?? ''
|
||||
expect(isPolicyUrl(url), `${name}: ${url}`).toBe(true)
|
||||
}
|
||||
for (const a of Array.from(host.querySelectorAll('a'))) {
|
||||
expect(a.getAttributeNames(), name).toEqual(['href'])
|
||||
}
|
||||
}
|
||||
const html = entries.map((e) => e.html).join('\n')
|
||||
expect(html).toContain('<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>')
|
||||
expect(html).toContain('<figcaption>')
|
||||
expect(html).toContain('<table>')
|
||||
expect(html).toContain('start="3"')
|
||||
})
|
||||
})
|
||||
110
tests/blogeditor/element.test.ts
Normal file
110
tests/blogeditor/element.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// The imported-post resave contract at the element layer (Phase 16.2 D-03,
|
||||
// D-07; todo journal-imported-html-admin-resave): a value the administrator
|
||||
// does not touch comes back as the exact served string, even after the
|
||||
// Source tab and the preview were opened and closed, and the element
|
||||
// announces nothing.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import fixture from './fixtures/imported-post.html?raw'
|
||||
import { button, click, mount } from './helpers'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
// What the server serves for a markdown post (D-07): its content_html, as
|
||||
// goldmark rendered it at import, with the raw author footer and a trailing
|
||||
// newline.
|
||||
const markdownContentHTML =
|
||||
'<h2>Nagłówek</h2>\n' +
|
||||
'<p>Tekst z <em>kursywą</em> i <strong>pogrubieniem</strong>.</p>\n' +
|
||||
'<ul>\n<li>jeden</li>\n<li>dwa</li>\n</ul>\n' +
|
||||
'<blockquote>\n<p>Cytat</p>\n</blockquote>\n' +
|
||||
'<p><a href="https://example.test/zrodlo">źródło</a></p>\n' +
|
||||
'<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person"><p>Autor</p></footer>\n'
|
||||
|
||||
const samples: [string, string][] = [
|
||||
['the imported HTML post', fixture],
|
||||
['a markdown post served as content_html', markdownContentHTML],
|
||||
['HTML with leading white space and a comment', '\n <!-- imported -->\n<p>a b</p>\n'],
|
||||
['an empty value', ''],
|
||||
]
|
||||
|
||||
function tab(el: HTMLElement, name: 'wysiwyg' | 'source'): HTMLButtonElement {
|
||||
const found = el.querySelector<HTMLButtonElement>(`button[data-tab="${name}"]`)
|
||||
if (!found) throw new Error(`no ${name} tab`)
|
||||
return found
|
||||
}
|
||||
|
||||
describe('untouched value (D-03)', () => {
|
||||
it.each(samples)('returns %s unchanged after a Source tab round trip', (_name, served) => {
|
||||
const { el, changes } = mount(served)
|
||||
expect(el.value).toBe(served)
|
||||
|
||||
el.showSource(true)
|
||||
expect(el.querySelector('textarea')!.value).toBe(served)
|
||||
expect(el.value).toBe(served)
|
||||
el.showSource(false)
|
||||
|
||||
expect(el.value).toBe(served)
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it.each(samples)('returns %s unchanged after the tab buttons are clicked', (_name, served) => {
|
||||
const { el, changes } = mount(served)
|
||||
click(tab(el, 'source'))
|
||||
expect(tab(el, 'source').getAttribute('aria-selected')).toBe('true')
|
||||
click(tab(el, 'wysiwyg'))
|
||||
click(tab(el, 'source'))
|
||||
click(tab(el, 'wysiwyg'))
|
||||
expect(tab(el, 'wysiwyg').getAttribute('aria-selected')).toBe('true')
|
||||
expect(el.value).toBe(served)
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it.each(samples)('returns %s unchanged when the value is set after connect and echoed', (_name, served) => {
|
||||
const { el, changes } = mount()
|
||||
el.value = served
|
||||
el.showSource(true)
|
||||
el.showSource(false)
|
||||
// The SPA writes the form value back only when it differs; an echo of
|
||||
// the same string must change nothing.
|
||||
el.value = el.value
|
||||
expect(el.value).toBe(served)
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('returns the imported post unchanged after the preview is opened and closed', () => {
|
||||
vi.useFakeTimers()
|
||||
const { el, changes } = mount(fixture)
|
||||
click(button(el, 'preview'))
|
||||
vi.advanceTimersByTime(1000)
|
||||
click(button(el, 'preview'))
|
||||
el.showSource(true)
|
||||
el.showSource(false)
|
||||
expect(el.value).toBe(fixture)
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('returns the imported post unchanged after a detach and a new attach', () => {
|
||||
const { el, changes } = mount(fixture)
|
||||
el.remove()
|
||||
expect(el.value).toBe(fixture)
|
||||
document.body.append(el)
|
||||
expect(el.value).toBe(fixture)
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('reports an edit in the Source tab once and keeps the edited text exactly', () => {
|
||||
const { el, changes } = mount(fixture)
|
||||
el.showSource(true)
|
||||
const textarea = el.querySelector('textarea')!
|
||||
const edited = fixture.replace('</h2>', ' (edytowane)</h2>')
|
||||
expect(edited).not.toBe(fixture)
|
||||
textarea.value = edited
|
||||
textarea.dispatchEvent(new Event('input'))
|
||||
el.showSource(false)
|
||||
expect(el.value).toBe(edited)
|
||||
expect(changes).toEqual([edited])
|
||||
})
|
||||
})
|
||||
276
tests/blogeditor/embed.test.ts
Normal file
276
tests/blogeditor/embed.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
// Embeds, the URL rule, uploads and labels of the Blog Editor (Phase 16.2
|
||||
// D-04, D-05, D-13, D-18).
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EMBED_PROVIDERS, isEmbedProvider, parseEmbedUrl } from '../../assets/src/blogeditor/embed'
|
||||
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
|
||||
import { format, labelsFor } from '../../assets/src/blogeditor/labels'
|
||||
import { button, click, fill, form, mount, status } from './helpers'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
const ID = 'dQw4w9WgXcQ'
|
||||
const PLACEHOLDER = `<figure data-embed="youtube" data-id="${ID}"></figure>`
|
||||
|
||||
describe('parseEmbedUrl', () => {
|
||||
it.each([
|
||||
[`https://www.youtube.com/watch?v=${ID}`],
|
||||
[`https://youtube.com/watch?v=${ID}`],
|
||||
[`https://m.youtube.com/watch?v=${ID}`],
|
||||
[`https://music.youtube.com/watch?v=${ID}`],
|
||||
[`http://www.youtube.com/watch?v=${ID}`],
|
||||
[`https://WWW.YOUTUBE.COM/watch?v=${ID}`],
|
||||
[`https://www.youtube.com/watch?v=${ID}&t=42s&list=PL123`],
|
||||
[`https://www.youtube.com/watch?feature=share&v=${ID}`],
|
||||
[`https://youtu.be/${ID}`],
|
||||
[`https://youtu.be/${ID}?t=10`],
|
||||
[`https://www.youtube.com/shorts/${ID}`],
|
||||
[`https://www.youtube.com/shorts/${ID}/`],
|
||||
[`https://www.youtube.com/embed/${ID}`],
|
||||
[`https://www.youtube-nocookie.com/embed/${ID}?rel=0`],
|
||||
[` https://youtu.be/${ID} `],
|
||||
])('accepts %s', (url) => {
|
||||
expect(parseEmbedUrl(url)).toEqual({ provider: 'youtube', id: ID })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a 10-character id', 'https://youtu.be/dQw4w9WgXc'],
|
||||
['a 12-character id', 'https://youtu.be/dQw4w9WgXcQQ'],
|
||||
['an id with a dot', 'https://www.youtube.com/watch?v=dQw4w9WgX.Q'],
|
||||
['no id', 'https://www.youtube.com/watch'],
|
||||
['a channel page', 'https://www.youtube.com/@channel'],
|
||||
['an extra path segment', `https://www.youtube.com/embed/${ID}/extra`],
|
||||
['another provider', `https://vimeo.com/${ID}`],
|
||||
['a lookalike host', `https://www.youtube.com.evil.test/watch?v=${ID}`],
|
||||
['a suffix host', `https://notyoutube.com/watch?v=${ID}`],
|
||||
['a subdomain of youtu.be', `https://x.youtu.be/${ID}`],
|
||||
['javascript:', `javascript:alert('${ID}')`],
|
||||
['ftp:', `ftp://youtu.be/${ID}`],
|
||||
['data:', `data:text/html,${ID}`],
|
||||
['a relative URL', `/watch?v=${ID}`],
|
||||
['text', 'not a url'],
|
||||
['empty', ''],
|
||||
])('refuses %s', (_name, url) => {
|
||||
expect(parseEmbedUrl(url)).toBeNull()
|
||||
})
|
||||
|
||||
it('has YouTube as the only provider, with an 11-character id pattern', () => {
|
||||
expect(Object.keys(EMBED_PROVIDERS)).toEqual(['youtube'])
|
||||
expect(EMBED_PROVIDERS.youtube.idPattern.test(ID)).toBe(true)
|
||||
expect(EMBED_PROVIDERS.youtube.idPattern.test(`${ID}\n`)).toBe(false)
|
||||
expect(isEmbedProvider('youtube')).toBe(true)
|
||||
expect(isEmbedProvider('vimeo')).toBe(false)
|
||||
expect(isEmbedProvider('toString')).toBe(false)
|
||||
expect(isEmbedProvider('__proto__')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('embed node', () => {
|
||||
it('round-trips the exact placeholder between other blocks', () => {
|
||||
const html = `<p>Film:</p>${PLACEHOLDER}<p>Koniec</p>`
|
||||
const { el, editor } = mount(html)
|
||||
const node = editor.state.doc.child(1)
|
||||
expect(node.type.name).toBe('embed')
|
||||
expect(node.attrs).toMatchObject({ provider: 'youtube', id: ID })
|
||||
expect(el.value).toBe(html)
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
expect(el.value).toBe(`<p>XFilm:</p>${PLACEHOLDER}<p>Koniec</p>`)
|
||||
})
|
||||
|
||||
it('shows a card and never an iframe', () => {
|
||||
const { el } = mount(PLACEHOLDER)
|
||||
expect(el.querySelector('iframe')).toBeNull()
|
||||
expect(el.querySelector('.blogeditor__embed')!.textContent).toBe(`${labelsFor("en").embedCard}: YouTube ${ID}`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['another provider', `<figure data-embed="vimeo" data-id="${ID}"></figure>`],
|
||||
['a short id', '<figure data-embed="youtube" data-id="abc"></figure>'],
|
||||
['an extra attribute', `<figure data-embed="youtube" data-id="${ID}" class="x"></figure>`],
|
||||
['content', `<figure data-embed="youtube" data-id="${ID}">x</figure>`],
|
||||
['no id', '<figure data-embed="youtube"></figure>'],
|
||||
])('keeps a placeholder with %s as raw HTML, byte for byte', (_name, html) => {
|
||||
const { el, editor } = mount(html)
|
||||
expect(editor.state.doc.child(0).type.name).toBe('rawHtml')
|
||||
expect(el.value).toBe(html)
|
||||
})
|
||||
|
||||
it('serializes the attributes in placeholder order whatever the stored order', () => {
|
||||
const { el, editor } = mount(`<p>a</p><figure data-id="${ID}" data-embed="youtube"></figure>`)
|
||||
expect(editor.state.doc.child(1).type.name).toBe('embed')
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
// The untouched embed block keeps its stored bytes.
|
||||
expect(el.value).toBe(`<p>Xa</p><figure data-id="${ID}" data-embed="youtube"></figure>`)
|
||||
})
|
||||
|
||||
it('setEmbed refuses an unknown provider or a bad id', () => {
|
||||
const { el, editor } = mount('')
|
||||
expect(editor.commands.setEmbed({ provider: 'vimeo', id: ID })).toBe(false)
|
||||
expect(editor.commands.setEmbed({ provider: 'youtube', id: 'short' })).toBe(false)
|
||||
expect(el.value).toBe('')
|
||||
expect(editor.commands.setEmbed({ provider: 'youtube', id: ID })).toBe(true)
|
||||
expect(el.value).toBe(PLACEHOLDER)
|
||||
})
|
||||
|
||||
it('inserts from the toolbar, keeps the form open on an error and closes on cancel', () => {
|
||||
const { el, changes } = mount('<p>a</p>')
|
||||
click(button(el, 'embed'))
|
||||
const embedForm = form(el, 'embed')!
|
||||
fill(embedForm.querySelector('input'), 'javascript:alert(1)')
|
||||
click(embedForm.querySelector('[data-action="insert"]'))
|
||||
expect(form(el, 'embed')).not.toBeNull()
|
||||
expect(el.value).toBe('<p>a</p>')
|
||||
expect(changes).toEqual([])
|
||||
click(embedForm.querySelector('[data-action="cancel"]'))
|
||||
expect(form(el, 'embed')).toBeNull()
|
||||
|
||||
click(button(el, 'embed'))
|
||||
fill(form(el, 'embed')!.querySelector('input'), `https://www.youtube.com/shorts/${ID}`)
|
||||
click(form(el, 'embed')!.querySelector('[data-action="insert"]'))
|
||||
expect(el.value).toContain(PLACEHOLDER)
|
||||
expect(changes).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPolicyUrl', () => {
|
||||
it.each(['http://a.test', 'https://a.test/x?y#z', 'HTTPS://A.TEST', 'mailto:a@b.test', '/rel', 'rel/path', '../up', '#fragment', '?q=1', '', 'https://a.test/javascript:x'])(
|
||||
'accepts %j',
|
||||
(url) => {
|
||||
expect(isPolicyUrl(url)).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'JavaScript:alert(1)',
|
||||
' javascript:alert(1)',
|
||||
'java\tscript:alert(1)',
|
||||
'java\nscript:alert(1)',
|
||||
'java\rscript:alert(1)',
|
||||
'javascript\u0000:alert(1)',
|
||||
'vbscript:msgbox(1)',
|
||||
'data:text/html,x',
|
||||
'tel:123',
|
||||
'ftp://a.test',
|
||||
'file:///etc/passwd',
|
||||
])('refuses %j', (url) => {
|
||||
expect(isPolicyUrl(url)).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses null and undefined', () => {
|
||||
expect(isPolicyUrl(null)).toBe(false)
|
||||
expect(isPolicyUrl(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload flow', () => {
|
||||
const png = () => new File([new Uint8Array([137, 80, 78, 71])], 'grzyb.png', { type: 'image/png' })
|
||||
|
||||
function pick(el: HTMLElement, files: File[]): void {
|
||||
const input = el.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||
Object.defineProperty(input, 'files', { value: files, configurable: true })
|
||||
input.dispatchEvent(new Event('change'))
|
||||
}
|
||||
|
||||
function answer(el: HTMLElement, detail: Record<string, string>): void {
|
||||
el.dispatchEvent(new CustomEvent('summer-upload-result', { detail }))
|
||||
}
|
||||
|
||||
it('ignores an answer with an id it did not send', () => {
|
||||
const { el, uploads, changes } = mount('<p>a</p>')
|
||||
pick(el, [png()])
|
||||
answer(el, { id: 'not-ours', url: '/x.png' })
|
||||
answer(el, { id: 'not-ours', error: 'nope' })
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
expect(status(el)).toBe('Uploading grzyb.png…')
|
||||
answer(el, { id: uploads[0]!.id, url: '/ours.png' })
|
||||
expect(form(el, 'image')).not.toBeNull()
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('times out after 60 seconds and ignores the late answer', () => {
|
||||
vi.useFakeTimers()
|
||||
const { el, uploads } = mount('')
|
||||
pick(el, [png()])
|
||||
vi.advanceTimersByTime(59_999)
|
||||
expect(status(el)).toBe('Uploading grzyb.png…')
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(status(el)).toBe('The upload of grzyb.png took too long.')
|
||||
answer(el, { id: uploads[0]!.id, url: '/late.png' })
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
expect(el.value).toBe('')
|
||||
})
|
||||
|
||||
it('answers each of several uploads by its own id', () => {
|
||||
const { el, uploads } = mount('')
|
||||
pick(el, [png(), new File([new Uint8Array([255, 216, 255])], 'b.jpg', { type: 'image/jpeg' })])
|
||||
expect(uploads).toHaveLength(2)
|
||||
expect(uploads[0]!.id).not.toBe(uploads[1]!.id)
|
||||
answer(el, { id: uploads[1]!.id, error: 'Too big' })
|
||||
expect(status(el)).toBe('Upload failed: Too big')
|
||||
})
|
||||
|
||||
it('refuses an uploaded URL outside the policy', () => {
|
||||
const { el, uploads } = mount('')
|
||||
pick(el, [png()])
|
||||
answer(el, { id: uploads[0]!.id, url: 'javascript:alert(1)' })
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
expect(status(el)).toMatch(/^Upload failed/)
|
||||
expect(el.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('labels', () => {
|
||||
it('has Polish labels, an English fallback and every key in both', () => {
|
||||
const en = labelsFor('en')
|
||||
const pl = labelsFor('pl')
|
||||
expect(Object.keys(pl).sort()).toEqual(Object.keys(en).sort())
|
||||
for (const key of Object.keys(en) as (keyof typeof en)[]) {
|
||||
expect(en[key], key).not.toBe('')
|
||||
expect(pl[key], key).not.toBe('')
|
||||
}
|
||||
expect(pl.tabSource).not.toBe(en.tabSource)
|
||||
expect(labelsFor('pl-PL')).toBe(pl)
|
||||
expect(labelsFor('PL')).toBe(pl)
|
||||
expect(labelsFor('de')).toBe(en)
|
||||
expect(labelsFor('')).toBe(en)
|
||||
expect(labelsFor(null)).toBe(en)
|
||||
expect(labelsFor(undefined)).toBe(en)
|
||||
})
|
||||
|
||||
it('follows the ui-locale attribute of the element', () => {
|
||||
const { el } = mount('<p>a</p>', { 'ui-locale': 'pl' })
|
||||
expect(el.querySelector('button[data-tab="source"]')!.textContent).toBe(labelsFor('pl').tabSource)
|
||||
const { el: other } = mount('<p>a</p>', { 'ui-locale': 'fr' })
|
||||
expect(other.querySelector('button[data-tab="source"]')!.textContent).toBe(labelsFor('en').tabSource)
|
||||
})
|
||||
|
||||
it('format fills known placeholders and leaves unknown ones', () => {
|
||||
expect(format('Uploading {name}…', { name: 'a.png' })).toBe('Uploading a.png…')
|
||||
expect(format('{a} and {b}', { a: '1' })).toBe('1 and {b}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('disabled and invalid', () => {
|
||||
it('disables the formatting buttons and the source while disabled, and marks the editor invalid', () => {
|
||||
const { el } = mount('<p>a</p>', { disabled: '', invalid: '' })
|
||||
expect(el.editor!.isEditable).toBe(false)
|
||||
expect(button(el, 'bold').disabled).toBe(true)
|
||||
expect(el.querySelector('textarea')!.readOnly).toBe(true)
|
||||
expect(el.editor!.view.dom.getAttribute('aria-invalid')).toBe('true')
|
||||
// The preview toggle stays usable: it never edits.
|
||||
expect(button(el, 'preview').disabled).toBe(false)
|
||||
el.removeAttribute('disabled')
|
||||
expect(button(el, 'bold').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('emits nothing for clicks on a disabled editor', () => {
|
||||
const { el, changes } = mount('<p>a</p>', { disabled: '' })
|
||||
click(button(el, 'bold'))
|
||||
click(button(el, 'embed'))
|
||||
expect(changes).toEqual([])
|
||||
expect(el.value).toBe('<p>a</p>')
|
||||
})
|
||||
})
|
||||
62
tests/blogeditor/fixtures/editor-corpus.json
Normal file
62
tests/blogeditor/fixtures/editor-corpus.json
Normal file
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"name": "embed-youtube",
|
||||
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure>"
|
||||
},
|
||||
{
|
||||
"name": "image-without-caption",
|
||||
"html": "<p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to "grzyb odporności"?\"></p>"
|
||||
},
|
||||
{
|
||||
"name": "image-with-caption",
|
||||
"html": "<figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure>"
|
||||
},
|
||||
{
|
||||
"name": "table-header-row",
|
||||
"html": "<table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table>"
|
||||
},
|
||||
{
|
||||
"name": "links",
|
||||
"html": "<p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p>"
|
||||
},
|
||||
{
|
||||
"name": "headings",
|
||||
"html": "<h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4>"
|
||||
},
|
||||
{
|
||||
"name": "bullet-list",
|
||||
"html": "<ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul>"
|
||||
},
|
||||
{
|
||||
"name": "ordered-list",
|
||||
"html": "<ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol>"
|
||||
},
|
||||
{
|
||||
"name": "blockquote",
|
||||
"html": "<blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote>"
|
||||
},
|
||||
{
|
||||
"name": "ordered-list-start-3",
|
||||
"html": "<ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol>"
|
||||
},
|
||||
{
|
||||
"name": "inline-marks",
|
||||
"html": "<p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p>"
|
||||
},
|
||||
{
|
||||
"name": "code-block",
|
||||
"html": "<pre><code>dawka = 2 * 500 // mg</code></pre>"
|
||||
},
|
||||
{
|
||||
"name": "horizontal-rule",
|
||||
"html": "<p>Przed linią</p><hr><p>Po linii</p>"
|
||||
},
|
||||
{
|
||||
"name": "hard-break",
|
||||
"html": "<p>Pierwsza linia<br>Druga linia</p>"
|
||||
},
|
||||
{
|
||||
"name": "combined",
|
||||
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to "grzyb odporności"?\"></p><figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure><table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table><p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p><h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4><ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul><ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol><blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote><ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol><p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p><pre><code>dawka = 2 * 500 // mg</code></pre><p>Przed linią</p><hr><p>Po linii</p><p>Pierwsza linia<br>Druga linia</p>"
|
||||
}
|
||||
]
|
||||
48
tests/blogeditor/fixtures/imported-post.html
Normal file
48
tests/blogeditor/fixtures/imported-post.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<h2>Czym jest ekstrakt z Turkey Tail?</h2>
|
||||
<p>Turkey Tail (<em>Trametes versicolor</em>, wrośniak różnobarwny) to jeden z najlepiej przebadanych grzybów funkcjonalnych. Ekstrakt powstaje z <strong>owocników</strong> poddanych ekstrakcji gorącą wodą, która uwalnia beta-glukany.</p>
|
||||
<h3>Najważniejsze związki aktywne</h3>
|
||||
<ul>
|
||||
<li><strong>PSK</strong> (polisacharyd K, krestin)</li>
|
||||
<li><strong>PSP</strong> (polisacharopeptyd)</li>
|
||||
<li>Beta-glukany i antyoksydanty fenolowe</li>
|
||||
</ul>
|
||||
<h3>Jak stosować?</h3>
|
||||
<ol>
|
||||
<li>Zacznij od mniejszej porcji przez pierwszy tydzień.</li>
|
||||
<li>Przyjmuj ekstrakt z posiłkiem.</li>
|
||||
<li>Obserwuj reakcję organizmu i skonsultuj się z lekarzem.</li>
|
||||
</ol>
|
||||
<blockquote>
|
||||
<p>Grzyby funkcjonalne wspierają organizm, ale nie zastępują leczenia.</p>
|
||||
</blockquote>
|
||||
<p>Więcej przeczytasz w <a href="/blog/beta-glukany">artykule o beta-glukanach</a> & <a href="https://example.test/badania">przeglądzie badań</a>.</p>
|
||||
<p>Data: 12.03 – aktualizacja opisu dawkowania.</p>
|
||||
|
||||
<footer class="author-footer" itemscope itemtype="https://schema.org/Person">
|
||||
<div class="author-info">
|
||||
<img src="https://cdn.example.test/autor.png" alt="Autor" class="author-photo" itemprop="image">
|
||||
<div class="author-text">
|
||||
<div style="font-size: 1.5em; font-weight: bold;" itemprop="name">Jan Kowalski</div>
|
||||
<span style="color: #138c74;" itemprop="jobTitle">Ekspert od grzybów funkcjonalnych</span>
|
||||
<p itemprop="description">Od lat zajmuje się mykologią i suplementacją.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<style>
|
||||
.author-footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd; }
|
||||
.author-info { display: flex; align-items: center; gap: 1rem; }
|
||||
.author-photo { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; }
|
||||
</style>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Forma</th><th>Dawka</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Proszek</td><td>1–2 g</td></tr>
|
||||
<tr><td>Kapsułki</td><td>2 × 500 mg</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<figure><img src="/storage/app/uploads/public/journal/2026/turkey-tail.jpg" alt="Owocniki Turkey Tail na pniu"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure>
|
||||
<p>Zobacz też <a href="https://example.test/sklep" target="_blank" rel="noopener">sklep z ekstraktami</a>.</p>
|
||||
<p>Producent nazywa go "grzybem odporności", a badacze "wrośniakiem".</p>
|
||||
78
tests/blogeditor/helpers.ts
Normal file
78
tests/blogeditor/helpers.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import '../../assets/src/blogeditor/index'
|
||||
import { BlogEditorElement, TAG } from '../../assets/src/blogeditor/index'
|
||||
|
||||
export interface Mounted {
|
||||
el: BlogEditorElement
|
||||
editor: Editor
|
||||
changes: string[]
|
||||
uploads: { id: string; file: File }[]
|
||||
}
|
||||
|
||||
/** mount creates a connected editor element, optionally with a value. */
|
||||
export function mount(value?: string, attributes: Record<string, string> = {}): Mounted {
|
||||
const el = document.createElement(TAG) as BlogEditorElement
|
||||
for (const [name, val] of Object.entries(attributes)) el.setAttribute(name, val)
|
||||
const changes: string[] = []
|
||||
const uploads: { id: string; file: File }[] = []
|
||||
el.addEventListener('summer-change', (e) => changes.push((e as CustomEvent<{ value: string }>).detail.value))
|
||||
el.addEventListener('summer-upload', (e) => uploads.push((e as CustomEvent<{ id: string; file: File }>).detail))
|
||||
if (value !== undefined) el.value = value
|
||||
document.body.append(el)
|
||||
return { el, editor: el.editor!, changes, uploads }
|
||||
}
|
||||
|
||||
/** type inserts text at the selection, as typing does. */
|
||||
export function type(editor: Editor, text: string): void {
|
||||
editor.view.dispatch(editor.state.tr.insertText(text))
|
||||
}
|
||||
|
||||
/** insertAtBlock types text at the start of the top-level textblock index. */
|
||||
export function insertAtBlock(editor: Editor, index: number, text: string): void {
|
||||
let pos = -1
|
||||
editor.state.doc.forEach((node, offset, i) => {
|
||||
if (i === index) pos = node.isTextblock ? offset + 1 : -1
|
||||
})
|
||||
if (pos < 0) throw new Error(`block ${index} is not a textblock`)
|
||||
editor.view.dispatch(editor.state.tr.insertText(text, pos))
|
||||
}
|
||||
|
||||
/** insertAtEveryTextblock types text at the start of every textblock. */
|
||||
export function insertAtEveryTextblock(editor: Editor, text: string): void {
|
||||
const positions: number[] = []
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.isTextblock) positions.push(pos + 1)
|
||||
return true
|
||||
})
|
||||
let tr = editor.state.tr
|
||||
for (const pos of positions.reverse()) tr = tr.insertText(text, pos)
|
||||
editor.view.dispatch(tr)
|
||||
}
|
||||
|
||||
/** button returns the toolbar button for a command. */
|
||||
export function button(el: HTMLElement, command: string): HTMLButtonElement {
|
||||
const found = el.querySelector<HTMLButtonElement>(`button[data-command="${command}"]`)
|
||||
if (!found) throw new Error(`no toolbar button ${command}`)
|
||||
return found
|
||||
}
|
||||
|
||||
/** form returns the inline form of a kind, or null when it is closed. */
|
||||
export function form(el: HTMLElement, kind: string): HTMLElement | null {
|
||||
const found = el.querySelector<HTMLElement>(`[data-form="${kind}"]`)
|
||||
return found && !found.hidden ? found : null
|
||||
}
|
||||
|
||||
export function click(el: Element | null | undefined): void {
|
||||
if (!el) throw new Error('nothing to click')
|
||||
;(el as HTMLElement).click()
|
||||
}
|
||||
|
||||
export function fill(input: Element | null | undefined, value: string): void {
|
||||
if (!input) throw new Error('no input')
|
||||
;(input as HTMLInputElement).value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
export function status(el: HTMLElement): string {
|
||||
return el.querySelector('[role="status"]')?.textContent ?? ''
|
||||
}
|
||||
323
tests/blogeditor/preview.test.ts
Normal file
323
tests/blogeditor/preview.test.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import fixture from './fixtures/imported-post.html?raw'
|
||||
import { serializeDocument } from '../../assets/src/blogeditor/serializer'
|
||||
import { PREVIEW_CSP, PREVIEW_DEBOUNCE_MS, PREVIEW_DEFAULTS, safeCssValue } from '../../assets/src/blogeditor/preview'
|
||||
import type { BlogEditorElement } from '../../assets/src/blogeditor/index'
|
||||
import { button, click, mount } from './helpers'
|
||||
|
||||
// Wrap the real serializer so the tests can tell whether the preview path
|
||||
// ever serializes an untouched document.
|
||||
vi.mock('../../assets/src/blogeditor/serializer', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../assets/src/blogeditor/serializer')>()
|
||||
return { ...actual, serializeDocument: vi.fn(actual.serializeDocument) }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.mocked(serializeDocument).mockClear()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
function pane(el: BlogEditorElement): HTMLElement {
|
||||
const found = el.querySelector<HTMLElement>('.blogeditor__preview')
|
||||
if (!found) throw new Error('no preview pane')
|
||||
return found
|
||||
}
|
||||
|
||||
function frame(el: BlogEditorElement): HTMLIFrameElement {
|
||||
const found = el.querySelector<HTMLIFrameElement>('.blogeditor__preview iframe')
|
||||
if (!found) throw new Error('no preview frame')
|
||||
return found
|
||||
}
|
||||
|
||||
function srcdoc(el: BlogEditorElement): string {
|
||||
return frame(el).getAttribute('srcdoc') ?? ''
|
||||
}
|
||||
|
||||
describe('blog editor preview: toggle', () => {
|
||||
it('is off by default and toggles the split pane with aria-pressed', () => {
|
||||
const { el } = mount('<p>Hello</p>')
|
||||
const toggle = button(el, 'preview')
|
||||
expect(toggle.textContent).toBe('Preview')
|
||||
expect(toggle.getAttribute('aria-label')).toBe('Preview')
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('false')
|
||||
expect(pane(el).hidden).toBe(true)
|
||||
|
||||
click(toggle)
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('true')
|
||||
expect(pane(el).hidden).toBe(false)
|
||||
expect(el.querySelector('.blogeditor__panes')!.classList.contains('blogeditor__panes--split')).toBe(true)
|
||||
// Rendered right away, with no timer advanced.
|
||||
expect(srcdoc(el)).toContain('<p>Hello</p>')
|
||||
|
||||
click(toggle)
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('false')
|
||||
expect(pane(el).hidden).toBe(true)
|
||||
expect(el.querySelector('.blogeditor__panes')!.classList.contains('blogeditor__panes--split')).toBe(false)
|
||||
})
|
||||
|
||||
it('is labelled through the ui-locale', () => {
|
||||
const { el: pl } = mount('<p>x</p>', { 'ui-locale': 'pl' })
|
||||
expect(button(pl, 'preview').textContent).toBe('Podgląd')
|
||||
expect(button(pl, 'preview').getAttribute('aria-label')).toBe('Podgląd')
|
||||
click(button(pl, 'preview'))
|
||||
expect(frame(pl).title).toBe('Podgląd wpisu')
|
||||
|
||||
const { el: en } = mount('<p>x</p>', { 'ui-locale': 'en' })
|
||||
click(button(en, 'preview'))
|
||||
expect(frame(en).title).toBe('Post preview')
|
||||
})
|
||||
|
||||
it('creates no iframe until the preview is first switched on', () => {
|
||||
const { el } = mount('<p>x</p>')
|
||||
expect(el.querySelector('iframe')).toBeNull()
|
||||
click(button(el, 'preview'))
|
||||
expect(el.querySelectorAll('iframe')).toHaveLength(1)
|
||||
click(button(el, 'preview'))
|
||||
click(button(el, 'preview'))
|
||||
expect(el.querySelectorAll('iframe')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps its state per element', () => {
|
||||
const { el: first } = mount('<p>one</p>')
|
||||
const { el: second } = mount('<p>two</p>')
|
||||
click(button(first, 'preview'))
|
||||
expect(button(first, 'preview').getAttribute('aria-pressed')).toBe('true')
|
||||
expect(button(second, 'preview').getAttribute('aria-pressed')).toBe('false')
|
||||
expect(pane(second).hidden).toBe(true)
|
||||
})
|
||||
|
||||
it('stays usable in the Source tab and when disabled', () => {
|
||||
const { el: source } = mount('<p>s</p>')
|
||||
source.showSource(true)
|
||||
expect(button(source, 'preview').disabled).toBe(false)
|
||||
click(button(source, 'preview'))
|
||||
expect(button(source, 'preview').getAttribute('aria-pressed')).toBe('true')
|
||||
expect(pane(source).hidden).toBe(false)
|
||||
|
||||
const { el: disabled } = mount('<p>d</p>', { disabled: '' })
|
||||
expect(button(disabled, 'preview').disabled).toBe(false)
|
||||
click(button(disabled, 'preview'))
|
||||
expect(button(disabled, 'preview').getAttribute('aria-pressed')).toBe('true')
|
||||
expect(pane(disabled).hidden).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blog editor preview: sandbox', () => {
|
||||
it('uses an empty sandbox and only srcdoc', () => {
|
||||
const { el } = mount('<p>x</p>')
|
||||
click(button(el, 'preview'))
|
||||
const f = frame(el)
|
||||
expect(f.getAttribute('sandbox')).toBe('')
|
||||
const sandbox = f.getAttribute('sandbox') ?? ''
|
||||
expect(sandbox).not.toMatch(/allow-scripts/)
|
||||
expect(sandbox).not.toMatch(/allow-same-origin/)
|
||||
expect(f.hasAttribute('src')).toBe(false)
|
||||
expect(f.hasAttribute('allow')).toBe(false)
|
||||
expect(f.getAttribute('referrerpolicy')).toBe('no-referrer')
|
||||
const doc = srcdoc(el)
|
||||
expect(doc).toContain(`<meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}">`)
|
||||
expect(doc).toContain('<meta name="referrer" content="no-referrer">')
|
||||
// The head starts with the charset, then the CSP, then the referrer meta.
|
||||
expect(doc.indexOf('<meta charset="utf-8">')).toBeLessThan(doc.indexOf('Content-Security-Policy'))
|
||||
expect(doc.indexOf('Content-Security-Policy')).toBeLessThan(doc.indexOf('name="referrer"'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('blog editor preview: value contract', () => {
|
||||
it('never touches an untouched imported post', () => {
|
||||
const { el, editor, changes } = mount(fixture)
|
||||
const before = editor.state.doc
|
||||
const toggle = button(el, 'preview')
|
||||
click(toggle)
|
||||
click(toggle)
|
||||
click(toggle)
|
||||
expect(changes).toEqual([])
|
||||
expect(el.value).toBe(fixture)
|
||||
expect(editor.state.doc).toBe(before)
|
||||
expect(serializeDocument).not.toHaveBeenCalled()
|
||||
expect(srcdoc(el)).toContain('Jan Kowalski')
|
||||
|
||||
el.value = fixture
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('reads a touched document through the value getter without an event', () => {
|
||||
const { el, editor, changes } = mount('<p>a</p>')
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
const toggle = button(el, 'preview')
|
||||
click(toggle)
|
||||
expect(changes).toHaveLength(1)
|
||||
expect(srcdoc(el)).toContain('<p>Xa</p>')
|
||||
expect(serializeDocument).toHaveBeenCalled()
|
||||
|
||||
click(toggle)
|
||||
click(toggle)
|
||||
expect(changes).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
/** Switches fake timers on (after mount, before the toggle) and the preview on. */
|
||||
function previewWithTimers(el: BlogEditorElement): void {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
click(button(el, 'preview'))
|
||||
}
|
||||
|
||||
describe('blog editor preview: live updates', () => {
|
||||
it('re-renders a WYSIWYG edit after the debounce', () => {
|
||||
const { el, editor } = mount('<p>a</p>')
|
||||
previewWithTimers(el)
|
||||
expect(srcdoc(el)).toContain('<p>a</p>')
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
vi.advanceTimersByTime(PREVIEW_DEBOUNCE_MS - 1)
|
||||
expect(srcdoc(el)).toContain('<p>a</p>')
|
||||
expect(srcdoc(el)).not.toContain('<p>Xa</p>')
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(srcdoc(el)).toContain('<p>Xa</p>')
|
||||
})
|
||||
|
||||
it('restarts the debounce on every change', () => {
|
||||
const { el, editor } = mount('<p>a</p>')
|
||||
previewWithTimers(el)
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
vi.advanceTimersByTime(200)
|
||||
editor.view.dispatch(editor.state.tr.insertText('Y', 1))
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(srcdoc(el)).not.toContain('Xa')
|
||||
expect(srcdoc(el)).not.toContain('YXa')
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(srcdoc(el)).toContain('<p>YXa</p>')
|
||||
})
|
||||
|
||||
it('re-renders Source tab input without an extra event', () => {
|
||||
const { el, changes } = mount('<p>a</p>')
|
||||
previewWithTimers(el)
|
||||
el.showSource(true)
|
||||
const textarea = el.querySelector('textarea')!
|
||||
textarea.value = '<h2>From source</h2>'
|
||||
textarea.dispatchEvent(new Event('input'))
|
||||
vi.advanceTimersByTime(PREVIEW_DEBOUNCE_MS)
|
||||
expect(srcdoc(el)).toContain('<h2>From source</h2>')
|
||||
expect(changes).toEqual(['<h2>From source</h2>'])
|
||||
})
|
||||
|
||||
it('re-renders an external value without an event', () => {
|
||||
const { el, changes } = mount('<p>a</p>')
|
||||
previewWithTimers(el)
|
||||
el.value = '<p>New</p>'
|
||||
vi.advanceTimersByTime(PREVIEW_DEBOUNCE_MS)
|
||||
expect(srcdoc(el)).toContain('<p>New</p>')
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('schedules nothing while it is off', () => {
|
||||
const { el, editor } = mount('<p>a</p>')
|
||||
previewWithTimers(el)
|
||||
click(button(el, 'preview'))
|
||||
const last = srcdoc(el)
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(srcdoc(el)).toBe(last)
|
||||
})
|
||||
|
||||
it('drops a pending render when the element is detached', () => {
|
||||
const { el, editor } = mount('<p>a</p>')
|
||||
previewWithTimers(el)
|
||||
const last = srcdoc(el)
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
el.remove()
|
||||
expect(() => vi.advanceTimersByTime(1000)).not.toThrow()
|
||||
expect(srcdoc(el)).toBe(last)
|
||||
})
|
||||
|
||||
it('keeps an untouched post exact after renders', () => {
|
||||
const { el, changes } = mount(fixture)
|
||||
previewWithTimers(el)
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(changes).toEqual([])
|
||||
expect(el.value).toBe(fixture)
|
||||
expect(serializeDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('blog editor preview: content', () => {
|
||||
const placeholder = '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>'
|
||||
|
||||
it('shows a YouTube placeholder as a thumbnail card, never a player', () => {
|
||||
const { el } = mount(placeholder)
|
||||
click(button(el, 'preview'))
|
||||
const doc = srcdoc(el)
|
||||
expect(doc).toContain('https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg')
|
||||
expect(doc).toContain('referrerpolicy="no-referrer"')
|
||||
expect(doc).toContain('Embedded video: YouTube dQw4w9WgXcQ')
|
||||
expect(doc).not.toMatch(/<iframe/i)
|
||||
expect(el.value).toBe(placeholder)
|
||||
|
||||
const { el: pl } = mount(placeholder, { 'ui-locale': 'pl' })
|
||||
click(button(pl, 'preview'))
|
||||
expect(srcdoc(pl)).toContain('Osadzone wideo: YouTube dQw4w9WgXcQ')
|
||||
})
|
||||
|
||||
it('requests no thumbnail for an invalid placeholder', () => {
|
||||
const { el } = mount('<figure data-embed="youtube" data-id="short"></figure>')
|
||||
click(button(el, 'preview'))
|
||||
expect(srcdoc(el)).not.toContain('img.youtube.com')
|
||||
})
|
||||
|
||||
it('drops active content from the preview copy only', () => {
|
||||
const value =
|
||||
'<p>a</p><script>alert(1)</script><iframe src="https://example.test/"></iframe>' +
|
||||
'<img src="/x.png" onerror="alert(1)"><meta http-equiv="refresh" content="0;url=https://example.test/">'
|
||||
const { el, changes } = mount(value)
|
||||
click(button(el, 'preview'))
|
||||
const doc = srcdoc(el)
|
||||
expect(doc).not.toMatch(/<script/i)
|
||||
expect(doc).not.toMatch(/<iframe/i)
|
||||
expect(doc).not.toMatch(/onerror/i)
|
||||
expect(doc).not.toMatch(/http-equiv="refresh"/i)
|
||||
expect(doc).toContain('<p>a</p>')
|
||||
expect(doc).toContain('src="/x.png"')
|
||||
expect(el.value).toBe(value)
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('resolves colours from the host or uses the defaults', () => {
|
||||
const { el } = mount('<p>a</p>')
|
||||
el.style.setProperty('--c-text', 'rgb(1, 2, 3)')
|
||||
click(button(el, 'preview'))
|
||||
expect(srcdoc(el)).toContain('rgb(1, 2, 3)')
|
||||
expect(srcdoc(el)).not.toContain('var(--c-')
|
||||
|
||||
const { el: plain } = mount('<p>b</p>')
|
||||
click(button(plain, 'preview'))
|
||||
expect(srcdoc(plain)).toContain(PREVIEW_DEFAULTS.colors.text)
|
||||
expect(srcdoc(plain)).not.toContain('var(--c-')
|
||||
})
|
||||
|
||||
it('sets the document language and the post-body typography', () => {
|
||||
const { el } = mount('<p>a</p>', { locale: 'pl' })
|
||||
click(button(el, 'preview'))
|
||||
expect(srcdoc(el)).toContain('<html lang="pl">')
|
||||
|
||||
const { el: odd } = mount('<p>a</p>', { locale: 'pl"><x' })
|
||||
click(button(odd, 'preview'))
|
||||
const doc = srcdoc(odd)
|
||||
expect(doc).toContain('<html>')
|
||||
expect(doc).not.toContain('lang=')
|
||||
expect(doc.match(/<style/g)).toHaveLength(1)
|
||||
for (const rule of ['.post-body', 'blockquote', 'figcaption', '.embed-card']) expect(doc).toContain(rule)
|
||||
expect(doc).toContain('<article class="post-body">')
|
||||
})
|
||||
})
|
||||
|
||||
describe('safeCssValue', () => {
|
||||
it('falls back for anything that could leave the declaration', () => {
|
||||
for (const bad of ['', ' ', 'red}', 'a<b', 'red; color: blue', 'x'.repeat(201), 'red\\', 'red /* x */', 'a>b', 'a{b'])
|
||||
expect(safeCssValue(bad, 'fallback')).toBe('fallback')
|
||||
})
|
||||
|
||||
it('returns a safe value trimmed', () => {
|
||||
expect(safeCssValue(' rgb(1, 2, 3) ', 'fallback')).toBe('rgb(1, 2, 3)')
|
||||
expect(safeCssValue('"DM Sans", sans-serif', 'fallback')).toBe('"DM Sans", sans-serif')
|
||||
})
|
||||
})
|
||||
261
tests/blogeditor/schema.test.ts
Normal file
261
tests/blogeditor/schema.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
// The Blog Editor's preservation schema (Phase 16.2 D-03, D-04): what the
|
||||
// schema models, what becomes a raw HTML card, which attributes survive an
|
||||
// edit, and how the stored form of links, tables and figures looks when a
|
||||
// block is serialized from the model.
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { RAW_HTML_TAGS } from '../../assets/src/blogeditor/extensions'
|
||||
import { serializeDocument } from '../../assets/src/blogeditor/serializer'
|
||||
import { insertAtBlock, mount } from './helpers'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
/** types lists the top-level node types of the editor's document. */
|
||||
function types(html: string): string[] {
|
||||
const { editor } = mount(html)
|
||||
const out: string[] = []
|
||||
editor.state.doc.forEach((node) => out.push(node.type.name))
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* shape describes markup for comparison: element names, attributes sorted by
|
||||
* name (a style without its final semicolon) and text. An edited block may
|
||||
* list attributes in another order (the model renders modelled ones after
|
||||
* the captured ones) and the DOM normalizes a style declaration; names and
|
||||
* values must stay the same.
|
||||
*/
|
||||
function shape(html: string): string {
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = html
|
||||
const walk = (node: Node): string => {
|
||||
if (node.nodeType === 3) return JSON.stringify(node.nodeValue)
|
||||
if (node.nodeType !== 1) return ''
|
||||
const el = node as Element
|
||||
const attrs = Array.from(el.attributes)
|
||||
.map((a) => `${a.name}=${JSON.stringify(a.name === 'style' ? a.value.trim().replace(/;$/, '') : a.value)}`)
|
||||
.sort()
|
||||
return `<${el.nodeName.toLowerCase()} ${attrs.join(' ')}>${Array.from(el.childNodes).map(walk).join('')}</>`
|
||||
}
|
||||
return Array.from(template.content.childNodes).map(walk).join('')
|
||||
}
|
||||
|
||||
/** model serializes the whole document from the model, without block reuse. */
|
||||
function model(html: string): string {
|
||||
const { editor } = mount(html)
|
||||
return serializeDocument(editor, null)
|
||||
}
|
||||
|
||||
const rawSamples: Record<(typeof RAW_HTML_TAGS)[number], string> = {
|
||||
footer: '<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person"><p>Autor</p></footer>',
|
||||
section: '<section class="s"><p>Sekcja</p></section>',
|
||||
aside: '<aside>Na marginesie</aside>',
|
||||
header: '<header><h2>Nagłówek</h2></header>',
|
||||
nav: '<nav><a href="/a">a</a></nav>',
|
||||
details: '<details><summary>Więcej</summary>Treść</details>',
|
||||
style: '<style>.x { color: red; }</style>',
|
||||
div: '<div class="box" data-x="1">Pudełko</div>',
|
||||
article: '<article><p>Artykuł</p></article>',
|
||||
main: '<main><p>Główna</p></main>',
|
||||
address: '<address>ul. Leśna 1</address>',
|
||||
dl: '<dl><dt>Termin</dt><dd>Opis</dd></dl>',
|
||||
iframe: '<iframe title="video"></iframe>',
|
||||
video: '<video controls=""></video>',
|
||||
audio: '<audio controls=""></audio>',
|
||||
picture: '<picture><source srcset="/a.webp"></picture>',
|
||||
object: '<object data="/x.bin"></object>',
|
||||
embed: '<embed src="/x.bin">',
|
||||
form: '<form action="/x"><input name="q"></form>',
|
||||
svg: '<svg viewBox="0 0 1 1"><circle r="1"></circle></svg>',
|
||||
math: '<math><mi>x</mi></math>',
|
||||
script: '<script>window.__schemaPwned = 1</script>',
|
||||
noscript: '<noscript>Bez skryptów</noscript>',
|
||||
}
|
||||
|
||||
describe('raw HTML blocks', () => {
|
||||
it('has a sample for every raw HTML tag rule', () => {
|
||||
expect(Object.keys(rawSamples).sort()).toEqual([...RAW_HTML_TAGS].sort())
|
||||
})
|
||||
|
||||
it.each(Object.entries(rawSamples))('keeps <%s> as one raw HTML card through an edit elsewhere', (_tag, block) => {
|
||||
const html = `<p>a</p>${block}`
|
||||
const { el, editor } = mount(html)
|
||||
expect(editor.state.doc.childCount).toBe(2)
|
||||
expect(editor.state.doc.child(1).type.name).toBe('rawHtml')
|
||||
insertAtBlock(editor, 0, 'X')
|
||||
expect(el.value).toBe(`<p>Xa</p>${block}`)
|
||||
expect((window as unknown as { __schemaPwned?: number }).__schemaPwned).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows the card source as escaped text, never as live markup', () => {
|
||||
const payload = '<div><img src="x" onerror="window.__schemaPwned = 2"><b>bold</b></div>'
|
||||
const { el } = mount(payload)
|
||||
const source = el.querySelector('.blogeditor__raw-source')!
|
||||
expect(source.textContent).toBe(payload)
|
||||
expect(source.innerHTML).toContain('<img')
|
||||
expect(el.querySelector('.ProseMirror img, .ProseMirror b')).toBeNull()
|
||||
expect((window as unknown as { __schemaPwned?: number }).__schemaPwned).toBeUndefined()
|
||||
})
|
||||
|
||||
it('emits a raw block verbatim when the document is serialized from the model', () => {
|
||||
expect(model(rawSamples.footer)).toBe(rawSamples.footer)
|
||||
})
|
||||
})
|
||||
|
||||
describe('figures', () => {
|
||||
it('models a figure holding only an image', () => {
|
||||
expect(types('<figure><img src="/a.jpg" alt="a"></figure>')).toEqual(['figure'])
|
||||
expect(model('<figure><img src="/a.jpg" alt="a"></figure>')).toBe('<figure><img src="/a.jpg" alt="a"></figure>')
|
||||
})
|
||||
|
||||
it('models a figure with an image and a caption', () => {
|
||||
const html = '<figure><img src="/a.jpg" alt="a"><figcaption>Podpis <em>zdjęcia</em></figcaption></figure>'
|
||||
const { editor } = mount(html)
|
||||
const figure = editor.state.doc.child(0)
|
||||
expect(figure.type.name).toBe('figure')
|
||||
expect(figure.child(0).type.name).toBe('figureImage')
|
||||
expect(figure.child(1).type.name).toBe('figcaption')
|
||||
expect(serializeDocument(editor, null)).toBe(html)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['other content', '<figure><p>tekst</p></figure>'],
|
||||
['a quote with a caption', '<figure class="quote"><blockquote>Cytat</blockquote><figcaption>Autor</figcaption></figure>'],
|
||||
['two images', '<figure><img src="/a.jpg"><img src="/b.jpg"></figure>'],
|
||||
['loose text', '<figure>tekst<img src="/a.jpg"></figure>'],
|
||||
['a caption first', '<figure><figcaption>Najpierw</figcaption><img src="/a.jpg"></figure>'],
|
||||
['a data URL image', '<figure><img src="data:image/png;base64,AAAA"></figure>'],
|
||||
['an image without src', '<figure><img alt="x"></figure>'],
|
||||
['nothing', '<figure></figure>'],
|
||||
])('keeps a figure with %s as raw HTML', (_name, html) => {
|
||||
expect(types(html)).toEqual(['rawHtml'])
|
||||
})
|
||||
|
||||
it('makes an uncaptioned inline image a paragraph image without base64', () => {
|
||||
expect(model('<p><img src="/a.jpg" alt="a"></p>')).toBe('<p><img src="/a.jpg" alt="a"></p>')
|
||||
expect(model('<p>a<img src="data:image/png;base64,AAAA">b</p>')).toBe('<p>ab</p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('marks and global attributes', () => {
|
||||
it('keeps a span with kept attributes as a mark and drops a bare span', () => {
|
||||
expect(shape(model('<p>a <span class="hl" title="t" data-x="1">b</span> c</p>'))).toBe(shape('<p>a <span class="hl" title="t" data-x="1">b</span> c</p>'))
|
||||
expect(model('<p>a <span>b</span> c</p>')).toBe('<p>a b c</p>')
|
||||
})
|
||||
|
||||
it('drops a span whose attributes are all view-unsafe', () => {
|
||||
expect(model('<p>a <span style="color: red" id="s">b</span> c</p>')).toBe('<p>a b c</p>')
|
||||
expect(model('<p>a <span class="hl" style="color: red">b</span> c</p>')).toBe('<p>a <span class="hl">b</span> c</p>')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['paragraph', '<p class="lead" data-x="1">a</p>'],
|
||||
['heading', '<h2 class="h" title="Intro">a</h2>'],
|
||||
['blockquote', '<blockquote class="q"><p>a</p></blockquote>'],
|
||||
['bullet list and item', '<ul class="list"><li class="item"><p>a</p></li></ul>'],
|
||||
['ordered list with start', '<ol start="3" class="steps"><li><p>a</p></li></ol>'],
|
||||
['code block', '<pre class="code"><code>a</code></pre>'],
|
||||
['horizontal rule', '<hr class="sep">'],
|
||||
['image', '<p><img src="/a.jpg" alt="a" class="photo" data-id="7"></p>'],
|
||||
['figcaption', '<figure><img src="/a.jpg" alt="a"><figcaption class="cap">c</figcaption></figure>'],
|
||||
['link', '<p><a href="/a" class="more" data-id="7">a</a></p>'],
|
||||
['table, row and cells', '<table class="t" width="100%"><tbody><tr class="r"><td class="c" data-x="1" width="50" height="20">a</td></tr></tbody></table>'],
|
||||
])('keeps unmodelled attributes on a %s', (_name, html) => {
|
||||
expect(shape(model(html))).toBe(shape(html))
|
||||
})
|
||||
|
||||
// WR-05: ProseMirror renders captured attributes into the live admin DOM,
|
||||
// so only view-safe ones are kept once a block is serialized from the model.
|
||||
it.each([
|
||||
['id', '<h2 id="intro" class="h">a</h2>', '<h2 class="h">a</h2>'],
|
||||
['name', '<p><img src="/a.jpg" alt="a" name="querySelector"></p>', '<p><img src="/a.jpg" alt="a"></p>'],
|
||||
['style', '<p class="x" style="position:fixed;inset:0;z-index:99999">a</p>', '<p class="x">a</p>'],
|
||||
['srcset and loading', '<p><img src="/a.jpg" alt="a" srcset="https://evil.test/a.jpg 2x" loading="lazy"></p>', '<p><img src="/a.jpg" alt="a"></p>'],
|
||||
['background', '<table background="https://evil.test/bg.png"><tbody><tr><td>a</td></tr></tbody></table>', '<table><tbody><tr><td>a</td></tr></tbody></table>'],
|
||||
['form, contenteditable and popover', '<p form="f" contenteditable="true" popover="auto" class="k">a</p>', '<p class="k">a</p>'],
|
||||
['itemprop, dir and lang', '<p itemprop="description" dir="rtl" lang="pl">a</p>', '<p>a</p>'],
|
||||
['a cell style', '<table><tbody><tr><td style="text-align: right" id="c">a</td></tr></tbody></table>', '<table><tbody><tr><td>a</td></tr></tbody></table>'],
|
||||
])('drops the view-unsafe attribute %s from a block serialized from the model', (_name, html, want) => {
|
||||
expect(shape(model(html))).toBe(shape(want))
|
||||
})
|
||||
|
||||
it('never renders a view-unsafe stored attribute into the live editor DOM', () => {
|
||||
const html =
|
||||
'<h2 id="intro">a</h2><p name="querySelector" style="position:fixed" class="k" data-x="1">b <span style="color:red" id="s" class="hl">c</span></p>' +
|
||||
'<p><img src="/a.jpg" alt="a" name="img" srcset="https://evil.test/a.jpg 2x" class="photo"></p>' +
|
||||
'<table background="https://evil.test/bg.png"><tbody><tr><td style="color:red" id="c">d</td></tr></tbody></table>'
|
||||
const { el } = mount(html)
|
||||
const view = el.querySelector('.ProseMirror')!
|
||||
for (const attribute of ['id', 'name', 'srcset', 'background']) {
|
||||
expect(Array.from(view.querySelectorAll(`[${attribute}]`)).map((node) => node.outerHTML), attribute).toEqual([])
|
||||
}
|
||||
// The table node view sets its own min-width style; no stored style
|
||||
// reaches the view.
|
||||
const styles = Array.from(view.querySelectorAll('[style]')).map((node) => node.getAttribute('style'))
|
||||
expect(styles.filter((style) => /fixed|red/.test(style ?? ''))).toEqual([])
|
||||
expect(view.querySelector('p.k[data-x="1"]')).not.toBeNull()
|
||||
expect(view.querySelector('span.hl')).not.toBeNull()
|
||||
expect(view.querySelector('img.photo')).not.toBeNull()
|
||||
// Untouched, the element still returns the stored bytes (D-03).
|
||||
expect(el.value).toBe(html)
|
||||
})
|
||||
|
||||
it('never keeps an event handler attribute', () => {
|
||||
const out = model('<p class="a" onclick="x()" ONMOUSEOVER="y()">a</p><p><img src="/a.jpg" alt="a" onerror="z()"></p>')
|
||||
expect(out).toBe('<p class="a">a</p><p><img src="/a.jpg" alt="a"></p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('links', () => {
|
||||
it('adds no target, rel or class and keeps stored ones', () => {
|
||||
expect(model('<p><a href="https://example.test/">a</a></p>')).toBe('<p><a href="https://example.test/">a</a></p>')
|
||||
const stored = model('<p><a href="https://example.test/" target="_blank" rel="noopener">a</a></p>')
|
||||
expect(stored).toContain('target="_blank"')
|
||||
expect(stored).toContain('rel="noopener"')
|
||||
})
|
||||
|
||||
it.each(['javascript:alert(1)', 'JAVASCRIPT:alert(1)', 'java\tscript:alert(1)', 'vbscript:x', 'data:text/html,x', 'tel:123'])(
|
||||
'drops the link but keeps the text for %s',
|
||||
(href) => {
|
||||
const out = model(`<p><a href="${href}">tekst</a></p>`)
|
||||
expect(out).toBe('<p>tekst</p>')
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('tables', () => {
|
||||
it('renders without colgroup or a min-width style, header row in thead, bare cell text', () => {
|
||||
const out = model('<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>')
|
||||
expect(out).toBe('<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>')
|
||||
expect(out).not.toContain('colgroup')
|
||||
expect(out).not.toContain('min-width')
|
||||
})
|
||||
|
||||
it('keeps a paragraph with attributes inside a cell', () => {
|
||||
expect(model('<table><tbody><tr><td><p class="x">1</p></td></tr></tbody></table>')).toBe('<table><tbody><tr><td><p class="x">1</p></td></tr></tbody></table>')
|
||||
})
|
||||
|
||||
it('keeps colspan and rowspan', () => {
|
||||
const html = '<table><tbody><tr><td colspan="2">a</td></tr><tr><td rowspan="2">b</td><td>c</td></tr><tr><td>d</td></tr></tbody></table>'
|
||||
const out = model(html)
|
||||
expect(out).toContain('colspan="2"')
|
||||
expect(out).toContain('rowspan="2"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('document shape', () => {
|
||||
it('adds no trailing paragraph after a final block', () => {
|
||||
for (const html of ['<h2>Tytuł</h2>', '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>', '<table><tbody><tr><td>a</td></tr></tbody></table>', rawSamples.footer]) {
|
||||
const { el, editor } = mount(html)
|
||||
expect(editor.state.doc.lastChild!.type.name).not.toBe('paragraph')
|
||||
expect(el.value).toBe(html)
|
||||
}
|
||||
})
|
||||
|
||||
it('serializes an edited heading level and list without wrappers it did not have', () => {
|
||||
const { el, editor } = mount('<h3>Tytuł</h3>')
|
||||
insertAtBlock(editor, 0, 'Nowy ')
|
||||
expect(el.value).toBe('<h3>Nowy Tytuł</h3>')
|
||||
})
|
||||
})
|
||||
155
tests/blogeditor/serializer.test.ts
Normal file
155
tests/blogeditor/serializer.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// Top-level block reuse (Phase 16.2 D-03): after an edit, every top-level
|
||||
// block the edit did not touch is emitted as its original source bytes,
|
||||
// with the white space and comments between blocks, whatever the edit was
|
||||
// (one changed block, a deletion, an insertion).
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getSchema } from '@tiptap/core'
|
||||
import { preservationExtensions } from '../../assets/src/blogeditor/extensions'
|
||||
import { captureBlocks } from '../../assets/src/blogeditor/serializer'
|
||||
import { insertAtBlock, mount } from './helpers'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
// Source formatting the schema serializer would not reproduce: upper-case
|
||||
// tags, single quotes, unquoted and reordered attributes, entities,
|
||||
// self-closing void tags and odd white space.
|
||||
const blocks = [
|
||||
"<P CLASS='lead'>Pierwszy akapit & więcej</P>",
|
||||
'<h2 id=intro >Wstęp</h2>',
|
||||
'<p>Drugi <a title="t" href="/x">link</a></p>',
|
||||
'<ul>\n <li>jeden</li>\n <li>dwa</li>\n</ul>',
|
||||
'<p>Trzeci<br/>wiersz</p>',
|
||||
'<footer class="author-footer"><p>Autor</p></footer>',
|
||||
]
|
||||
const separators = ['\n\n', '\n<!-- komentarz -->\n', '\r\n', '\n \n', '\n']
|
||||
const source = blocks.map((block, i) => block + (separators[i] ?? '')).join('') + '\n<!-- end -->\n'
|
||||
|
||||
/** topLevel returns the editor document's top-level node types. */
|
||||
function topLevel(el: ReturnType<typeof mount>['editor']): string[] {
|
||||
const out: string[] = []
|
||||
el.state.doc.forEach((node) => out.push(node.type.name))
|
||||
return out
|
||||
}
|
||||
|
||||
describe('block reuse', () => {
|
||||
it('returns the source unchanged before any edit', () => {
|
||||
const { el, editor } = mount(source)
|
||||
expect(topLevel(editor)).toEqual(['paragraph', 'heading', 'paragraph', 'bulletList', 'paragraph', 'rawHtml'])
|
||||
expect(el.value).toBe(source)
|
||||
})
|
||||
|
||||
it('keeps every other block, separator and comment byte for byte after one edit', () => {
|
||||
const { el, editor } = mount(source)
|
||||
insertAtBlock(editor, 2, 'X')
|
||||
const value = el.value
|
||||
expect(value).not.toBe(source)
|
||||
for (const [i, block] of blocks.entries()) {
|
||||
if (i === 2) continue
|
||||
expect(value).toContain(block)
|
||||
}
|
||||
for (const separator of separators) expect(value).toContain(separator)
|
||||
expect(value).toContain('<p>XDrugi')
|
||||
expect(value.endsWith('\n<!-- end -->\n')).toBe(true)
|
||||
// Only the edited block differs from the source.
|
||||
expect(value.replace(/<p>XDrugi[^]*?<\/p>/, blocks[2]!)).toBe(source)
|
||||
})
|
||||
|
||||
it('keeps the remaining blocks after a deletion', () => {
|
||||
const { el, editor } = mount(source)
|
||||
let from = 0
|
||||
let to = 0
|
||||
editor.state.doc.forEach((node, offset, index) => {
|
||||
if (index === 1) {
|
||||
from = offset
|
||||
to = offset + node.nodeSize
|
||||
}
|
||||
})
|
||||
editor.view.dispatch(editor.state.tr.delete(from, to))
|
||||
const value = el.value
|
||||
expect(value).not.toContain('Wstęp')
|
||||
for (const [i, block] of blocks.entries()) {
|
||||
if (i !== 1) expect(value).toContain(block)
|
||||
}
|
||||
expect(value.startsWith(blocks[0]!)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps every block around an insertion and serializes only the new one', () => {
|
||||
const { el, editor } = mount(source)
|
||||
let at = 0
|
||||
editor.state.doc.forEach((_node, offset, index) => {
|
||||
if (index === 3) at = offset
|
||||
})
|
||||
const paragraph = editor.schema.nodes.paragraph!.create(null, editor.schema.text('Nowy'))
|
||||
editor.view.dispatch(editor.state.tr.insert(at, paragraph))
|
||||
const value = el.value
|
||||
for (const block of blocks) expect(value).toContain(block)
|
||||
const inserted = value.indexOf('<p>Nowy</p>')
|
||||
expect(inserted).toBeGreaterThan(value.indexOf(blocks[2]!))
|
||||
expect(inserted).toBeLessThan(value.indexOf(blocks[3]!))
|
||||
})
|
||||
|
||||
it('keeps reused blocks after several edits and an undo returns the exact source', () => {
|
||||
const { el, editor } = mount(source)
|
||||
insertAtBlock(editor, 0, 'A')
|
||||
insertAtBlock(editor, 4, 'B')
|
||||
const value = el.value
|
||||
for (const i of [1, 2, 3, 5]) expect(value).toContain(blocks[i]!)
|
||||
editor.commands.undo()
|
||||
editor.commands.undo()
|
||||
expect(el.value).toBe(source)
|
||||
})
|
||||
|
||||
it('serializes every block when the document is replaced entirely', () => {
|
||||
const { el, editor } = mount(source)
|
||||
editor.commands.setContent('<p>Inny</p>')
|
||||
expect(el.value).toBe('<p>Inny</p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureBlocks', () => {
|
||||
const schema = getSchema(preservationExtensions())
|
||||
|
||||
it('records each block with the text before it and the trailing text', () => {
|
||||
const captured = captureBlocks('\n<p>a</p>\n<!-- c -->\n<h2>b</h2> ', schema)!
|
||||
expect(captured.blocks.map((b) => [b.before, b.html])).toEqual([
|
||||
['\n', '<p>a</p>'],
|
||||
['\n<!-- c -->\n', '<h2>b</h2>'],
|
||||
])
|
||||
expect(captured.trailing).toBe(' ')
|
||||
expect(captured.blocks.map((b) => b.nodes.map((n) => n.type.name))).toEqual([['paragraph'], ['heading']])
|
||||
})
|
||||
|
||||
it('splits raw text elements and nested elements by source position', () => {
|
||||
const html = '<style>p > a { x: "</p>" }</style><div><div>a</div><p>b</p></div><p>c<!-- </p> --></p>'
|
||||
const captured = captureBlocks(html, schema)!
|
||||
expect(captured.blocks.map((b) => b.html)).toEqual(['<style>p > a { x: "</p>" }</style>', '<div><div>a</div><p>b</p></div>', '<p>c<!-- </p> --></p>'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an unclosed comment', '<p>a</p><!-- open'],
|
||||
['an unclosed element', '<p>a</p><div><p>b'],
|
||||
['an unclosed tag', '<p>a</p><p class="x'],
|
||||
])('gives up on %s, and the element still returns the untouched source', (_name, html) => {
|
||||
expect(captureBlocks(html, schema)).toBeNull()
|
||||
const { el } = mount(html)
|
||||
expect(el.value).toBe(html)
|
||||
})
|
||||
|
||||
it('gives up on implied end tags it cannot split by source position', () => {
|
||||
// The second <p> closes the first only in the HTML parser's context.
|
||||
expect(captureBlocks('<p>a<p>b', schema)).toBeNull()
|
||||
const { el, editor } = mount('<p>a<p>b')
|
||||
expect(el.value).toBe('<p>a<p>b')
|
||||
// An edit serializes the whole document from the model.
|
||||
insertAtBlock(editor, 0, 'X')
|
||||
expect(el.value).toBe('<p>Xa</p><p>b</p>')
|
||||
})
|
||||
|
||||
it('handles an empty value', () => {
|
||||
const captured = captureBlocks('', schema)!
|
||||
expect(captured.blocks).toEqual([])
|
||||
expect(captured.trailing).toBe('')
|
||||
})
|
||||
})
|
||||
544
tests/blogeditor/smoke.test.ts
Normal file
544
tests/blogeditor/smoke.test.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import fixture from './fixtures/imported-post.html?raw'
|
||||
import { BlogEditorElement, TAG } from '../../assets/src/blogeditor/index'
|
||||
import { uploadRequestId } from '../../assets/src/blogeditor/element'
|
||||
import { parseEmbedUrl } from '../../assets/src/blogeditor/embed'
|
||||
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
|
||||
import { button, click, fill, form, insertAtBlock, insertAtEveryTextblock, mount, status } from './helpers'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
describe('blog editor element (tracer)', () => {
|
||||
it('defines the custom element tag once', () => {
|
||||
expect(customElements.get(TAG)).toBe(BlogEditorElement)
|
||||
expect(document.createElement(TAG)).toBeInstanceOf(BlogEditorElement)
|
||||
})
|
||||
|
||||
it('returns the imported post byte for byte without an edit', () => {
|
||||
const { el, changes } = mount(fixture)
|
||||
expect(el.editor).toBeDefined()
|
||||
expect(el.value).toBe(fixture)
|
||||
|
||||
// Setting the value after connect is untouched too.
|
||||
const { el: late, changes: lateChanges } = mount()
|
||||
late.value = fixture
|
||||
expect(late.value).toBe(fixture)
|
||||
expect(changes).toEqual([])
|
||||
expect(lateChanges).toEqual([])
|
||||
})
|
||||
|
||||
it('emits no summer-change on set or on an echo set', () => {
|
||||
const { el, changes } = mount(fixture)
|
||||
el.value = fixture
|
||||
el.value = el.value
|
||||
expect(changes).toEqual([])
|
||||
expect(el.value).toBe(fixture)
|
||||
})
|
||||
|
||||
it('emits exactly one summer-change per user edit with the element value', () => {
|
||||
const { el, editor, changes } = mount('<p>Hello</p>')
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
expect(changes).toHaveLength(1)
|
||||
expect(changes[0]).toBe(el.value)
|
||||
expect(el.value).toBe('<p>XHello</p>')
|
||||
|
||||
// The SPA echoes the emitted value back: no reset, no event.
|
||||
el.value = changes[0]!
|
||||
expect(changes).toHaveLength(1)
|
||||
expect(el.value).toBe('<p>XHello</p>')
|
||||
})
|
||||
|
||||
it('shows the exact value in the source tab', () => {
|
||||
const { el, changes } = mount(fixture)
|
||||
el.showSource(true)
|
||||
const textarea = el.querySelector('textarea')!
|
||||
expect(textarea.hidden).toBe(false)
|
||||
expect(textarea.value).toBe(fixture)
|
||||
|
||||
textarea.value = '<p>Edited in source</p>\n<footer>kept</footer>'
|
||||
textarea.dispatchEvent(new Event('input'))
|
||||
expect(changes).toEqual(['<p>Edited in source</p>\n<footer>kept</footer>'])
|
||||
expect(el.value).toBe('<p>Edited in source</p>\n<footer>kept</footer>')
|
||||
|
||||
// Back to WYSIWYG: re-parsed, but the value stays the exact text.
|
||||
el.showSource(false)
|
||||
expect(el.value).toBe('<p>Edited in source</p>\n<footer>kept</footer>')
|
||||
expect(changes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps an edited value after it is detached and attached again', () => {
|
||||
const { el, editor, changes } = mount('<p>a</p>')
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
el.remove()
|
||||
expect(el.value).toBe('<p>Xa</p>')
|
||||
document.body.append(el)
|
||||
expect(el.value).toBe('<p>Xa</p>')
|
||||
expect(el.editor!.state.doc.textContent).toBe('Xa')
|
||||
expect(changes).toEqual(['<p>Xa</p>'])
|
||||
})
|
||||
|
||||
it('a new external value replaces the content without an event', () => {
|
||||
const { el, changes } = mount('<p>One</p>')
|
||||
el.value = '<p>Two</p>'
|
||||
expect(el.value).toBe('<p>Two</p>')
|
||||
expect(el.editor!.state.doc.textContent).toBe('Two')
|
||||
expect(changes).toEqual([])
|
||||
// The replaced value is not in the undo history.
|
||||
expect(el.editor!.can().undo()).toBe(false)
|
||||
})
|
||||
|
||||
it('disabled makes the editor and the source read-only; invalid sets aria-invalid', () => {
|
||||
const { el } = mount('<p>x</p>')
|
||||
el.setAttribute('disabled', '')
|
||||
el.setAttribute('invalid', '')
|
||||
expect(el.editor!.isEditable).toBe(false)
|
||||
expect(el.querySelector('textarea')!.readOnly).toBe(true)
|
||||
expect(el.editor!.view.dom.getAttribute('aria-invalid')).toBe('true')
|
||||
el.removeAttribute('disabled')
|
||||
el.removeAttribute('invalid')
|
||||
expect(el.editor!.isEditable).toBe(true)
|
||||
expect(el.editor!.view.dom.hasAttribute('aria-invalid')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('preservation schema', () => {
|
||||
const rawBlocks = [
|
||||
'<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person"><p>Autor</p></footer>',
|
||||
'<section class="s"><p>Sekcja</p></section>',
|
||||
'<aside>Na marginesie</aside>',
|
||||
'<header><h2>Nagłówek</h2></header>',
|
||||
'<nav><a href="/a">a</a></nav>',
|
||||
'<details><summary>Więcej</summary>Treść</details>',
|
||||
'<style>.x { color: red; }</style>',
|
||||
'<div class="box" data-x="1">Pudełko</div>',
|
||||
'<figure class="quote"><blockquote>Cytat</blockquote><figcaption>Autor</figcaption></figure>',
|
||||
]
|
||||
|
||||
it('turns unknown block markup into raw HTML cards that keep it through an edit', () => {
|
||||
const html = `<p>Start</p>\n${rawBlocks.join('\n')}\n`
|
||||
const { el, editor } = mount(html)
|
||||
const types: string[] = []
|
||||
editor.state.doc.forEach((node) => types.push(node.type.name))
|
||||
expect(types).toEqual(['paragraph', ...rawBlocks.map(() => 'rawHtml')])
|
||||
|
||||
const cards = Array.from(el.querySelectorAll('.blogeditor__raw-source')).map((n) => n.textContent)
|
||||
expect(cards).toEqual(rawBlocks)
|
||||
|
||||
insertAtBlock(editor, 0, 'X')
|
||||
expect(el.value).toBe(`<p>XStart</p>\n${rawBlocks.join('\n')}\n`)
|
||||
})
|
||||
|
||||
it('renders a raw HTML card as text, never as live markup', () => {
|
||||
const payload = '<div><img src="x" onerror="window.__blogeditorPwned = 1"><script>window.__blogeditorPwned = 2</script></div>'
|
||||
const { el, editor } = mount(`<p>a</p>${payload}`)
|
||||
expect(editor.state.doc.child(1).type.name).toBe('rawHtml')
|
||||
expect(el.querySelector('.ProseMirror img')).toBeNull()
|
||||
expect(el.querySelector('.ProseMirror script')).toBeNull()
|
||||
expect(el.querySelector('.blogeditor__raw-source')!.textContent).toBe(payload)
|
||||
expect((window as unknown as { __blogeditorPwned?: number }).__blogeditorPwned).toBeUndefined()
|
||||
|
||||
// Handler attributes on modelled nodes never reach the editor DOM.
|
||||
const inline = mount('<p><img src="/a.png" alt="a" onerror="window.__blogeditorPwned = 3" onload="x()"></p>')
|
||||
const img = inline.el.querySelector('.ProseMirror img:not(.ProseMirror-separator)')!
|
||||
expect(img.getAttribute('src')).toBe('/a.png')
|
||||
expect(img.getAttributeNames().filter((name) => name.startsWith('on'))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps view-safe unmodelled attributes on edited blocks and spans as marks', () => {
|
||||
const html =
|
||||
'<p class="lead" data-x="1" itemprop="description">Tekst <span style="color: red;" class="s">czerwony</span></p>' +
|
||||
'<h2 id="h" class="c">Tytuł</h2>' +
|
||||
'<ul class="u"><li data-i="1"><p>Punkt</p></li></ul>' +
|
||||
'<ol start="3" class="o"><li><p>Trzy</p></li></ol>' +
|
||||
'<blockquote class="q"><p>Cytat</p></blockquote>' +
|
||||
'<p><a href="/x" class="lnk" data-t="1">link</a> <img src="/a.png" alt="A" class="photo" data-id="7"></p>' +
|
||||
'<table class="t"><tbody><tr><td class="c" style="text-align: right;">1</td></tr></tbody></table>'
|
||||
const { el, editor } = mount(html)
|
||||
insertAtEveryTextblock(editor, 'X')
|
||||
// WR-05: itemprop, style and id are not view-safe, so an edited block
|
||||
// drops them; class and data-* survive.
|
||||
expect(el.value).toBe(
|
||||
'<p class="lead" data-x="1">XTekst <span class="s">czerwony</span></p>' +
|
||||
'<h2 class="c">XTytuł</h2>' +
|
||||
'<ul class="u"><li data-i="1"><p>XPunkt</p></li></ul>' +
|
||||
'<ol class="o" start="3"><li><p>XTrzy</p></li></ol>' +
|
||||
'<blockquote class="q"><p>XCytat</p></blockquote>' +
|
||||
'<p><a class="lnk" data-t="1" href="/x">Xlink</a> <img class="photo" data-id="7" src="/a.png" alt="A"></p>' +
|
||||
'<table class="t"><tbody><tr><td class="c">X1</td></tr></tbody></table>',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps every stored attribute of a block the edit does not touch', () => {
|
||||
const kept = '<p id="keep" style="color: red;" itemprop="x">Bez zmian</p>'
|
||||
const html = `<p>Edytowany</p>\n${kept}\n`
|
||||
const { el, editor } = mount(html)
|
||||
expect(el.value).toBe(html)
|
||||
insertAtBlock(editor, 0, 'X')
|
||||
expect(el.value).toBe(`<p>XEdytowany</p>\n${kept}\n`)
|
||||
})
|
||||
|
||||
it('leaves every other top-level block byte-identical after editing one paragraph', () => {
|
||||
const { el, editor, changes } = mount(fixture)
|
||||
insertAtBlock(editor, 1, 'X')
|
||||
const expected = fixture.replace('<p>Turkey Tail', '<p>XTurkey Tail')
|
||||
expect(expected).not.toBe(fixture)
|
||||
expect(el.value).toBe(expected)
|
||||
expect(changes).toEqual([expected])
|
||||
|
||||
// Undoing the edit gives back the exact original bytes.
|
||||
expect(editor.commands.undo()).toBe(true)
|
||||
expect(el.value).toBe(fixture)
|
||||
})
|
||||
|
||||
it('serializes tables without colgroup or min-width, with thead and bare cell text', () => {
|
||||
const html = '<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>'
|
||||
const { el, editor } = mount(html)
|
||||
insertAtEveryTextblock(editor, 'X')
|
||||
expect(el.value).toBe(
|
||||
'<table><thead><tr><th>XA</th><th>XB</th></tr></thead><tbody><tr><td>X1</td><td>X2</td></tr></tbody></table>',
|
||||
)
|
||||
|
||||
const fresh = mount('<p>t</p>')
|
||||
fresh.editor.commands.setTextSelection(1)
|
||||
expect(fresh.editor.commands.insertTable({ rows: 2, cols: 2, withHeaderRow: true })).toBe(true)
|
||||
expect(fresh.el.value).not.toMatch(/colgroup|min-width|style=/)
|
||||
expect(fresh.el.value).toContain('<thead><tr><th></th><th></th></tr></thead><tbody><tr><td></td><td></td></tr></tbody>')
|
||||
expect(fresh.el.value).not.toMatch(/<p><\/p>$/)
|
||||
})
|
||||
|
||||
it('keeps stored link target and rel, adds none and leaves no trailing paragraph', () => {
|
||||
// Text typed at the start of a link joins the link (links are inclusive).
|
||||
// Edited links keep their attributes; their order may change.
|
||||
const { el, editor } = mount('<p><a href="https://x.test/" target="_blank" rel="noopener">x</a> y</p><hr>')
|
||||
insertAtBlock(editor, 0, 'Z')
|
||||
expect(el.value).toBe('<p><a target="_blank" rel="noopener" href="https://x.test/">Zx</a> y</p><hr>')
|
||||
|
||||
const fresh = mount('<p>link</p>')
|
||||
fresh.editor.commands.setTextSelection({ from: 1, to: 5 })
|
||||
expect(fresh.editor.commands.setLink({ href: 'https://example.test/' })).toBe(true)
|
||||
expect(fresh.el.value).toBe('<p><a href="https://example.test/">link</a></p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('embeds', () => {
|
||||
it('parses supported YouTube URLs to the 11-character id', () => {
|
||||
const id = 'dQw4w9WgXcQ'
|
||||
for (const url of [
|
||||
`https://www.youtube.com/watch?v=${id}`,
|
||||
`https://youtube.com/watch?v=${id}&t=42s`,
|
||||
`https://m.youtube.com/watch?feature=share&v=${id}`,
|
||||
`https://youtu.be/${id}`,
|
||||
`https://youtu.be/${id}?si=abc`,
|
||||
`https://www.youtube.com/shorts/${id}`,
|
||||
`https://www.youtube.com/embed/${id}`,
|
||||
` https://www.youtube-nocookie.com/embed/${id} `,
|
||||
]) {
|
||||
expect(parseEmbedUrl(url), url).toEqual({ provider: 'youtube', id })
|
||||
}
|
||||
for (const url of [
|
||||
'https://vimeo.com/123456',
|
||||
'https://www.youtube.com/watch?v=short',
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQQ',
|
||||
'https://youtu.be/dQw4w9WgXc!',
|
||||
'https://evil.test/embed/dQw4w9WgXcQ',
|
||||
'javascript:alert(1)//youtu.be/dQw4w9WgXcQ',
|
||||
'ftp://youtu.be/dQw4w9WgXcQ',
|
||||
'not a url',
|
||||
'',
|
||||
]) {
|
||||
expect(parseEmbedUrl(url), url).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trips the exact placeholder and shows a card, never an iframe', () => {
|
||||
const placeholder = '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>'
|
||||
const { el, editor } = mount(`<p>a</p>${placeholder}`)
|
||||
expect(editor.state.doc.child(1).type.name).toBe('embed')
|
||||
expect(el.querySelector('iframe')).toBeNull()
|
||||
expect(el.querySelector('.blogeditor__embed')!.textContent).toContain('YouTube')
|
||||
insertAtBlock(editor, 0, 'X')
|
||||
expect(el.value).toBe(`<p>Xa</p>${placeholder}`)
|
||||
|
||||
const fresh = mount('')
|
||||
expect(fresh.editor.commands.setEmbed({ provider: 'youtube', id: 'dQw4w9WgXcQ' })).toBe(true)
|
||||
expect(fresh.el.value).toBe(placeholder)
|
||||
expect(fresh.editor.commands.setEmbed({ provider: 'youtube', id: 'bad' })).toBe(false)
|
||||
expect(fresh.editor.commands.setEmbed({ provider: 'vimeo', id: 'dQw4w9WgXcQ' })).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a malformed placeholder as raw HTML', () => {
|
||||
const { editor } = mount('<figure data-embed="youtube" data-id="bad"></figure>')
|
||||
expect(editor.state.doc.child(0).type.name).toBe('rawHtml')
|
||||
})
|
||||
|
||||
it('inserts an embed from the toolbar URL input and refuses unsupported URLs', () => {
|
||||
const { el } = mount('')
|
||||
click(button(el, 'embed'))
|
||||
const embedForm = form(el, 'embed')!
|
||||
fill(embedForm.querySelector('input'), 'https://vimeo.com/1')
|
||||
click(embedForm.querySelector('[data-action="insert"]'))
|
||||
expect(form(el, 'embed')).not.toBeNull()
|
||||
expect(embedForm.querySelector('[data-error]')!.textContent).toBe('This video URL is not supported. Paste a YouTube link.')
|
||||
expect(el.value).toBe('')
|
||||
|
||||
fill(embedForm.querySelector('input'), 'https://youtu.be/dQw4w9WgXcQ')
|
||||
click(embedForm.querySelector('[data-action="insert"]'))
|
||||
expect(form(el, 'embed')).toBeNull()
|
||||
expect(el.value).toBe('<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('image uploads', () => {
|
||||
function pick(el: HTMLElement, files: File[]): void {
|
||||
const input = el.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||
expect(input.accept).toBe('image/jpeg,image/png,image/gif,image/webp')
|
||||
Object.defineProperty(input, 'files', { value: files, configurable: true })
|
||||
input.dispatchEvent(new Event('change'))
|
||||
}
|
||||
|
||||
function answer(el: HTMLElement, detail: Record<string, string>): void {
|
||||
el.dispatchEvent(new CustomEvent('summer-upload-result', { detail }))
|
||||
}
|
||||
|
||||
const png = () => new File([new Uint8Array([137, 80, 78, 71])], 'grzyb.png', { type: 'image/png' })
|
||||
|
||||
// IN-04: over plain HTTP from a host other than localhost the page is not
|
||||
// a secure context and crypto.randomUUID does not exist.
|
||||
it('makes upload ids without crypto.randomUUID outside a secure context', () => {
|
||||
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
const real = globalThis.crypto
|
||||
try {
|
||||
vi.stubGlobal('crypto', { getRandomValues: real.getRandomValues.bind(real) })
|
||||
const ids = [uploadRequestId(), uploadRequestId()]
|
||||
expect(ids[0]).toMatch(uuid)
|
||||
expect(ids[1]).toMatch(uuid)
|
||||
expect(ids[0]).not.toBe(ids[1])
|
||||
|
||||
const { el, uploads } = mount('')
|
||||
click(button(el, 'image'))
|
||||
pick(el, [png()])
|
||||
expect(uploads).toHaveLength(1)
|
||||
expect(uploads[0]!.id).toMatch(uuid)
|
||||
expect(status(el)).toBe('Uploading grzyb.png…')
|
||||
answer(el, { id: uploads[0]!.id, url: '/storage/app/uploads/public/journal/grzyb.png' })
|
||||
expect(form(el, 'image')).not.toBeNull()
|
||||
|
||||
vi.stubGlobal('crypto', undefined)
|
||||
const plain = [uploadRequestId(), uploadRequestId()]
|
||||
expect(plain[0]).toMatch(/^upload-[0-9a-z]+-\d+$/)
|
||||
expect(plain[0]).not.toBe(plain[1])
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
expect(globalThis.crypto).toBe(real)
|
||||
})
|
||||
|
||||
it('sends a picked image as summer-upload and inserts img with alt text', () => {
|
||||
const { el, uploads } = mount('')
|
||||
click(button(el, 'image'))
|
||||
pick(el, [png()])
|
||||
expect(uploads).toHaveLength(1)
|
||||
expect(uploads[0]!.file.name).toBe('grzyb.png')
|
||||
expect(uploads[0]!.id).toMatch(/[0-9a-f-]{36}/)
|
||||
expect(status(el)).toBe('Uploading grzyb.png…')
|
||||
|
||||
answer(el, { id: 'someone-else', url: '/x.png' })
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
|
||||
answer(el, { id: uploads[0]!.id, url: '/storage/app/uploads/public/journal/grzyb.png' })
|
||||
const imageForm = form(el, 'image')!
|
||||
click(imageForm.querySelector('[data-action="insert"]'))
|
||||
expect(imageForm.querySelector('[data-error]')!.textContent).toBe('Enter the alt text.')
|
||||
expect(el.value).toBe('')
|
||||
|
||||
fill(imageForm.querySelector('input[name="alt"]'), 'Grzyb: czy to "Turkey Tail"?')
|
||||
click(imageForm.querySelector('[data-action="insert"]'))
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
expect(el.value).toBe('<p><img src="/storage/app/uploads/public/journal/grzyb.png" alt="Grzyb: czy to "Turkey Tail"?"></p>')
|
||||
})
|
||||
|
||||
it('inserts figure with figcaption when a caption is given', () => {
|
||||
const { el, uploads } = mount('')
|
||||
pick(el, [png()])
|
||||
answer(el, { id: uploads[0]!.id, url: '/a.png' })
|
||||
const imageForm = form(el, 'image')!
|
||||
fill(imageForm.querySelector('input[name="alt"]'), 'Owocniki')
|
||||
fill(imageForm.querySelector('input[name="caption"]'), 'Owocniki na pniu.')
|
||||
click(imageForm.querySelector('[data-action="insert"]'))
|
||||
expect(el.value).toBe('<figure><img src="/a.png" alt="Owocniki"><figcaption>Owocniki na pniu.</figcaption></figure>')
|
||||
})
|
||||
|
||||
it('shows an upload error and inserts nothing', () => {
|
||||
const { el, uploads, changes } = mount('<p>a</p>')
|
||||
pick(el, [png()])
|
||||
answer(el, { id: uploads[0]!.id, error: 'File too large' })
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
expect(status(el)).toBe('Upload failed: File too large')
|
||||
expect(el.value).toBe('<p>a</p>')
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('ends a pending upload after 60 seconds', () => {
|
||||
vi.useFakeTimers()
|
||||
const { el, uploads } = mount('')
|
||||
pick(el, [png()])
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(status(el)).toBe('The upload of grzyb.png took too long.')
|
||||
answer(el, { id: uploads[0]!.id, url: '/late.png' })
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
})
|
||||
|
||||
it('uploads image files dropped on the editor', () => {
|
||||
const { el, editor, uploads } = mount('<p>a</p>')
|
||||
let prevented = false
|
||||
const event = {
|
||||
dataTransfer: { files: [png(), new File(['x'], 'notes.txt', { type: 'text/plain' })] },
|
||||
preventDefault: () => {
|
||||
prevented = true
|
||||
},
|
||||
} as unknown as DragEvent
|
||||
const handled = editor.view.someProp('handleDrop', (f) => f(editor.view, event, editor.state.doc.slice(0, 0), false))
|
||||
expect(handled).toBe(true)
|
||||
expect(prevented).toBe(true)
|
||||
expect(uploads.map((u) => u.file.name)).toEqual(['grzyb.png'])
|
||||
expect(el.value).toBe('<p>a</p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('links and the URL rule', () => {
|
||||
it('accepts only http, https, mailto and relative URLs', () => {
|
||||
for (const url of ['http://a.test', 'https://a.test/x?y#z', 'HTTPS://A.TEST', 'mailto:a@b.test', '/rel', 'rel/path', '#frag', '?q=1', '']) {
|
||||
expect(isPolicyUrl(url), url).toBe(true)
|
||||
}
|
||||
for (const url of [
|
||||
'javascript:alert(1)',
|
||||
'JavaScript:alert(1)',
|
||||
' javascript:alert(1)',
|
||||
'java\tscript:alert(1)',
|
||||
'java\nscript:alert(1)',
|
||||
'vbscript:msgbox(1)',
|
||||
'data:text/html,<b>x</b>',
|
||||
'tel:+48123',
|
||||
'ftp://a.test/',
|
||||
]) {
|
||||
expect(isPolicyUrl(url), JSON.stringify(url)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses links outside the rule in commands and in stored HTML', () => {
|
||||
const { el, editor } = mount('<p>text</p><p><a href="javascript:alert(1)">bad</a></p>')
|
||||
expect(el.querySelector('.ProseMirror a')).toBeNull()
|
||||
editor.commands.setTextSelection({ from: 1, to: 5 })
|
||||
expect(editor.commands.setLink({ href: 'javascript:alert(1)' })).toBe(false)
|
||||
expect(editor.commands.setLink({ href: 'tel:+48123' })).toBe(false)
|
||||
expect(editor.commands.setLink({ href: 'java\tscript:alert(1)' })).toBe(false)
|
||||
})
|
||||
|
||||
it('creates, refuses and removes links through the toolbar input', () => {
|
||||
const { el, editor } = mount('<p>link</p>')
|
||||
editor.commands.setTextSelection({ from: 1, to: 5 })
|
||||
click(button(el, 'link'))
|
||||
const linkForm = form(el, 'link')!
|
||||
fill(linkForm.querySelector('input'), 'data:text/html,x')
|
||||
click(linkForm.querySelector('[data-action="apply"]'))
|
||||
expect(linkForm.querySelector('[data-error]')!.textContent).toBe('Links must use http, https, mailto or a relative URL.')
|
||||
expect(el.value).toBe('<p>link</p>')
|
||||
|
||||
fill(linkForm.querySelector('input'), 'mailto:redakcja@example.test')
|
||||
click(linkForm.querySelector('[data-action="apply"]'))
|
||||
expect(el.value).toBe('<p><a href="mailto:redakcja@example.test">link</a></p>')
|
||||
|
||||
editor.commands.setTextSelection(2)
|
||||
click(button(el, 'link'))
|
||||
const again = form(el, 'link')!
|
||||
expect((again.querySelector('input') as HTMLInputElement).value).toBe('mailto:redakcja@example.test')
|
||||
fill(again.querySelector('input'), '')
|
||||
click(again.querySelector('[data-action="apply"]'))
|
||||
expect(el.value).toBe('<p>link</p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toolbar', () => {
|
||||
it('offers every D-04 feature and the tabs', () => {
|
||||
const { el } = mount('')
|
||||
for (const command of [
|
||||
'paragraph',
|
||||
'heading2',
|
||||
'heading3',
|
||||
'heading4',
|
||||
'bold',
|
||||
'italic',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'link',
|
||||
'blockquote',
|
||||
'insertTable',
|
||||
'addRow',
|
||||
'deleteRow',
|
||||
'addColumn',
|
||||
'deleteColumn',
|
||||
'deleteTable',
|
||||
'image',
|
||||
'embed',
|
||||
'rawHtml',
|
||||
'undo',
|
||||
'redo',
|
||||
]) {
|
||||
expect(button(el, command).getAttribute('aria-label'), command).toBeTruthy()
|
||||
}
|
||||
expect(el.querySelector('[data-tab="wysiwyg"]')!.textContent).toBe('WYSIWYG')
|
||||
expect(el.querySelector('[data-tab="source"]')!.textContent).toBe('Source')
|
||||
})
|
||||
|
||||
it('uses the Polish labels for ui-locale pl', () => {
|
||||
const { el } = mount('', { 'ui-locale': 'pl' })
|
||||
expect(el.querySelector('[data-tab="source"]')!.textContent).toBe('Źródło')
|
||||
expect(button(el, 'bold').getAttribute('aria-label')).toBe('Pogrubienie')
|
||||
})
|
||||
|
||||
it('formats text and edits tables from the toolbar', () => {
|
||||
const { el, editor } = mount('<p>abc</p>')
|
||||
editor.commands.setTextSelection({ from: 1, to: 4 })
|
||||
click(button(el, 'bold'))
|
||||
expect(el.value).toBe('<p><strong>abc</strong></p>')
|
||||
expect(button(el, 'bold').getAttribute('aria-pressed')).toBe('true')
|
||||
click(button(el, 'undo'))
|
||||
expect(el.value).toBe('<p>abc</p>')
|
||||
click(button(el, 'redo'))
|
||||
expect(el.value).toBe('<p><strong>abc</strong></p>')
|
||||
click(button(el, 'heading3'))
|
||||
expect(el.value).toBe('<h3><strong>abc</strong></h3>')
|
||||
|
||||
editor.commands.setTextSelection(4)
|
||||
click(button(el, 'insertTable'))
|
||||
expect(el.value).toContain('<table><thead><tr><th></th><th></th><th></th></tr></thead>')
|
||||
click(button(el, 'addRow'))
|
||||
click(button(el, 'deleteColumn'))
|
||||
expect(el.value.match(/<tr>/g)).toHaveLength(4)
|
||||
expect(el.value).toContain('<tr><th></th><th></th></tr>')
|
||||
click(button(el, 'deleteTable'))
|
||||
expect(el.value).not.toContain('<table')
|
||||
})
|
||||
|
||||
it('inserts a raw HTML block and opens the source tab at it', () => {
|
||||
const { el, editor } = mount('<p>a</p>')
|
||||
editor.commands.setTextSelection(2)
|
||||
click(button(el, 'rawHtml'))
|
||||
const textarea = el.querySelector('textarea')!
|
||||
expect(textarea.hidden).toBe(false)
|
||||
expect(el.value).toBe('<p>a</p><div></div>')
|
||||
expect(textarea.value).toBe('<p>a</p><div></div>')
|
||||
expect(textarea.selectionStart).toBe('<p>a</p><div>'.length)
|
||||
})
|
||||
|
||||
it('edits a raw HTML card in the source tab', () => {
|
||||
const { el } = mount('<p>a</p>\n<footer>f</footer>')
|
||||
click(el.querySelector('.blogeditor__raw button'))
|
||||
const textarea = el.querySelector('textarea')!
|
||||
expect(textarea.hidden).toBe(false)
|
||||
expect(textarea.value.slice(textarea.selectionStart, textarea.selectionEnd)).toBe('<footer>f</footer>')
|
||||
})
|
||||
})
|
||||
26
token_scopes.go
Normal file
26
token_scopes.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package journal
|
||||
|
||||
import "git.golem15.com/golem15/summercms/modules/pact"
|
||||
|
||||
var _ pact.HasTokenScopes = (*Plugin)(nil)
|
||||
|
||||
// TokenScopes registers personal API token scopes for the Journal plugin.
|
||||
func (p *Plugin) TokenScopes() []pact.TokenScope {
|
||||
all := []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
|
||||
return []pact.TokenScope{
|
||||
{
|
||||
Name: "journal",
|
||||
Label: "golem15.journal::lang.scopes.journal",
|
||||
Description: "golem15.journal::lang.scopes.journal_description",
|
||||
Permissions: []string{"golem15.journal.*"},
|
||||
Methods: all,
|
||||
},
|
||||
{
|
||||
Name: "journal:read",
|
||||
Label: "golem15.journal::lang.scopes.journal_read",
|
||||
Description: "golem15.journal::lang.scopes.journal_read_description",
|
||||
Permissions: []string{"golem15.journal.*"},
|
||||
Methods: []string{"GET", "HEAD"},
|
||||
},
|
||||
}
|
||||
}
|
||||
117
token_scopes_test.go
Normal file
117
token_scopes_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package journal_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
journal "git.golem15.com/golem15/sm-journal-plugin"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
func TestJournalTokenScopes(t *testing.T) {
|
||||
p := &journal.Plugin{}
|
||||
scopes := p.TokenScopes()
|
||||
if len(scopes) != 2 {
|
||||
t.Fatalf("TokenScopes len = %d, want 2", len(scopes))
|
||||
}
|
||||
byName := map[string]pact.TokenScope{}
|
||||
for _, s := range scopes {
|
||||
byName[s.Name] = s
|
||||
}
|
||||
journalScope, ok := byName["journal"]
|
||||
if !ok {
|
||||
t.Fatal("missing journal scope")
|
||||
}
|
||||
readScope, ok := byName["journal:read"]
|
||||
if !ok {
|
||||
t.Fatal("missing journal:read scope")
|
||||
}
|
||||
if len(journalScope.Permissions) != 1 || journalScope.Permissions[0] != "golem15.journal.*" {
|
||||
t.Fatalf("journal permissions = %#v", journalScope.Permissions)
|
||||
}
|
||||
if len(readScope.Permissions) != 1 || readScope.Permissions[0] != "golem15.journal.*" {
|
||||
t.Fatalf("journal:read permissions = %#v", readScope.Permissions)
|
||||
}
|
||||
all := []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
|
||||
if got := journalScope.Methods; !stringSlicesEqual(got, all) {
|
||||
t.Fatalf("journal methods = %#v, want %#v", got, all)
|
||||
}
|
||||
if got := readScope.Methods; !stringSlicesEqual(got, []string{"GET", "HEAD"}) {
|
||||
t.Fatalf("journal:read methods = %#v", got)
|
||||
}
|
||||
|
||||
keys := []string{
|
||||
journalScope.Label, journalScope.Description,
|
||||
readScope.Label, readScope.Description,
|
||||
}
|
||||
for _, lang := range []string{"en", "pl"} {
|
||||
path := filepath.Join("lang", lang, "lang.yaml")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var root map[string]any
|
||||
if err := yaml.Unmarshal(raw, &root); err != nil {
|
||||
t.Fatalf("yaml %s: %v", path, err)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if !phraseExists(root, key) {
|
||||
t.Fatalf("%s missing phrase key %s", path, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stringSlicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// phraseExists resolves plugin keys like golem15.journal::lang.scopes.journal
|
||||
// against the plugin lang.yaml root (scopes.journal under the file).
|
||||
func phraseExists(root map[string]any, key string) bool {
|
||||
const prefix = "golem15.journal::lang."
|
||||
if len(key) < len(prefix) || key[:len(prefix)] != prefix {
|
||||
return false
|
||||
}
|
||||
parts := splitDot(key[len(prefix):])
|
||||
var cur any = root
|
||||
for _, p := range parts {
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
cur, ok = m[p]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
switch v := cur.(type) {
|
||||
case string:
|
||||
return v != ""
|
||||
default:
|
||||
return cur != nil
|
||||
}
|
||||
}
|
||||
|
||||
func splitDot(s string) []string {
|
||||
out := []string{}
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '.' {
|
||||
out = append(out, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
out = append(out, s[start:])
|
||||
return out
|
||||
}
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["vite/client", "node"]
|
||||
},
|
||||
"include": ["assets/src", "tests", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// addCategoryContent adds the category HTML body (D-21). It must stay the last
|
||||
// migration so RollbackLast drops only this column and keeps the rows.
|
||||
var addCategoryContent = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202610090001_add_content_to_golem15_journal_categories",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE golem15_journal_categories ADD COLUMN IF NOT EXISTS content TEXT NULL`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE golem15_journal_categories DROP COLUMN IF EXISTS content`).Error
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(addCategoryContent...)
|
||||
}
|
||||
82
updates/category_content_migration_test.go
Normal file
82
updates/category_content_migration_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
)
|
||||
|
||||
func TestCategoryContentMigration(t *testing.T) {
|
||||
sqlDB := dedicatedDB(t, fmt.Sprintf("journal_catcontent_%d", time.Now().UnixNano()))
|
||||
gdb, err := lagoon.Use(t.Context(), sqlDB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := gormigrate.New(gdb, &gormigrate.Options{
|
||||
TableName: "summer_migrations_summercms_cabana",
|
||||
UseTransaction: true,
|
||||
}, lagoon.BackendAdminMigrations)
|
||||
if err := backend.Migrate(); err != nil {
|
||||
t.Fatalf("backend_users migrations: %v", err)
|
||||
}
|
||||
m := journalMigrator(gdb)
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
all := All()
|
||||
if last := all[len(all)-1].ID; last != "202610090001_add_content_to_golem15_journal_categories" {
|
||||
t.Fatalf("last migration = %s, want 202610090001_add_content_to_golem15_journal_categories", last)
|
||||
}
|
||||
if !gdb.Migrator().HasColumn("golem15_journal_categories", "content") {
|
||||
t.Fatal("golem15_journal_categories.content missing")
|
||||
}
|
||||
assertUdt(t, gdb, "golem15_journal_categories", "content", "text")
|
||||
var nullable string
|
||||
if err := gdb.WithContext(t.Context()).Raw(
|
||||
`SELECT is_nullable FROM information_schema.columns WHERE table_name = 'golem15_journal_categories' AND column_name = 'content'`,
|
||||
).Scan(&nullable).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nullable != "YES" {
|
||||
t.Fatalf("content is_nullable = %q, want YES", nullable)
|
||||
}
|
||||
|
||||
if err := gdb.Exec(`UPDATE golem15_journal_categories SET content = '<p>x</p>' WHERE slug = 'uncategorized'`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatalf("rollback last: %v", err)
|
||||
}
|
||||
if !gdb.Migrator().HasTable("golem15_journal_categories") {
|
||||
t.Fatal("golem15_journal_categories dropped by the content rollback")
|
||||
}
|
||||
if gdb.Migrator().HasColumn("golem15_journal_categories", "content") {
|
||||
t.Fatal("content column survived rollback")
|
||||
}
|
||||
var rows int64
|
||||
if err := gdb.Raw(`SELECT COUNT(*) FROM golem15_journal_categories WHERE slug = 'uncategorized'`).Scan(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows != 1 {
|
||||
t.Fatalf("uncategorized rows after rollback = %d, want 1", rows)
|
||||
}
|
||||
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("re-migrate: %v", err)
|
||||
}
|
||||
if !gdb.Migrator().HasColumn("golem15_journal_categories", "content") {
|
||||
t.Fatal("content column missing after re-migrate")
|
||||
}
|
||||
var content sql.NullString
|
||||
if err := gdb.Raw(`SELECT content FROM golem15_journal_categories WHERE slug = 'uncategorized'`).Scan(&content).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content.Valid {
|
||||
t.Fatalf("uncategorized content after re-migrate = %q, want NULL", content.String)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package updates
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -43,13 +44,11 @@ func TestMain(m *testing.M) {
|
||||
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 {
|
||||
for _, a := range os.Args {
|
||||
if a == "-test.short" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
flag.Parse()
|
||||
return testing.Short()
|
||||
}
|
||||
|
||||
func startJournalPostgres(ctx context.Context) error {
|
||||
@@ -186,7 +185,7 @@ func TestJournalTables(t *testing.T) {
|
||||
assertUniqueSlug(t, gdb, "golem15_journal_tags", "unique-tag-slug")
|
||||
assertUniqueSlug(t, gdb, "golem15_journal_categories", "unique-cat-slug")
|
||||
|
||||
if got := (models.Category{}).Fillable(); !reflect.DeepEqual(got, []string{"name", "slug", "code", "description", "parent_id"}) {
|
||||
if got := (models.Category{}).Fillable(); !reflect.DeepEqual(got, []string{"name", "slug", "code", "description", "content", "parent_id"}) {
|
||||
t.Fatalf("Category.Fillable = %#v", got)
|
||||
}
|
||||
if got := (models.Tag{}).Fillable(); !reflect.DeepEqual(got, []string{"name", "slug", "description"}) {
|
||||
|
||||
41
vite.config.ts
Normal file
41
vite.config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// Build of the Blog Editor custom element (D-16): one ES module entry,
|
||||
// assets/src/blogeditor/index.ts, loaded by the admin SPA as a module script.
|
||||
// It is built like a library entry (exports kept, fixed output names with no
|
||||
// content hash; cabana appends ?v=<sha256 prefix> itself) but through the
|
||||
// application pipeline, because Vite's library mode never minifies
|
||||
// whitespace in ES output. The bundle and its stylesheet are committed under
|
||||
// assets/dist and embedded through admin.go; scripts/check-dist.sh fails
|
||||
// when they are stale.
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'assets/dist',
|
||||
emptyOutDir: true,
|
||||
target: 'es2022',
|
||||
modulePreload: false,
|
||||
copyPublicDir: false,
|
||||
reportCompressedSize: true,
|
||||
rollupOptions: {
|
||||
input: 'assets/src/blogeditor/index.ts',
|
||||
preserveEntrySignatures: 'exports-only',
|
||||
output: {
|
||||
format: 'es',
|
||||
entryFileNames: 'blogeditor.js',
|
||||
chunkFileNames: 'blogeditor-[name].js',
|
||||
assetFileNames: 'blogeditor[extname]',
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
// Tests never reach the network: happy-dom must not load the pages of
|
||||
// iframes (the preview frame's srcdoc holds test payloads with remote
|
||||
// URLs).
|
||||
environmentOptions: {
|
||||
happyDOM: { settings: { disableIframePageLoading: true } },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user