Commit Graph

60 Commits

Author SHA1 Message Date
7bbafcbf28 test(16.3-04): pin Journal token scope registration and lang keys
Assert journal and journal:read scopes, methods, permissions, and
phrase keys in en/pl lang.yaml.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 13:17:41 +02:00
09308d9552 chore(deps): require summercms v0.1.21
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 13:07:15 +02:00
f2026c5c9b feat(16.3-03): Journal token matrix, README and probe cover smoke
- Expand Postgres smoke for journal:read, unrelated probe scope, drafts,
  featured images, revoke and code-declared developer roles
- Document scopes, ResolveBackend reversal of Phase 15 D-15, and throttle

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 02:17:43 +02:00
4ec64a3b35 feat(16.3-03): journal scopes and ResolveBackend token auth
- Register journal and journal:read via pact.HasTokenScopes
- Switch API auth to cabana.ResolveBackend with golem15.journal.* cover
- Map ErrTokenScope to PHP Insufficient permissions bodies
- Add TestResolveErrorMapping and TestJournalTokenTracer

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 02:11:13 +02:00
5eea57fde1 chore(deps): require summercms v0.1.20 2026-10-10 19:27:50 +02:00
4405feeaa7 test(16.2-07): full coverage of the site cache purge
- TestSiteCachePurgeConfig: every refused configuration names its key and
  never the token; https anywhere and http to loopback are accepted
- TestSiteCachePurgeRequest: the contract request, Info and Warn lines,
  the 2 s timeout, a cancelled parent context, no token in any log line
- Postgres: category create, update and delete and post create, untouched
  update (D-03 bytes kept), failing and unreachable purges and delete each
  purge once after the commit; failed saves send none
2026-10-10 18:35:36 +02:00
09e56437fd test(16.2-07): TestJournalSVGEmbed expects the mlblogeditor post content
16.2-03 moved post content to type: mlblogeditor with htmlColumn:
content_html (D-08); the Phase 15 mlmarkdown assertion was stale and kept
the postgres gate red (G-16.2-1).
2026-10-10 18:35:36 +02:00
2a3afb5107 feat(16.2-07): purge the host site cache on every admin post and category write
- posts: FormAfterCreate/Update purge after the content_html translations,
  new FormAfterDelete; categories: FormAfterCreate and FormAfterDelete
- NewSiteCachePurger refuses half configuration, non-http(s) URLs, user
  info, fragments, http to a non-loopback host and tokens under 32 chars;
  Boot logs the endpoint host and path once
- TestPostsAdminPurgesSiteCache smoke test
- README: Configuration section (keys, contract, timing, scope), API rows
2026-10-10 18:27:12 +02:00
f3b223f131 feat(16.2-07): purge the host site cache after a category admin update
- classes.SiteCachePurger posts {kind, slug} with a bearer token to
  golem15.journal.site_cache.purge_url, 2 s timeout, logged, never fails a save
- PurgeSiteCacheAfterCommit runs it through lagoon.AfterCommit
- Boot builds and publishes the purger from the two config keys
- categories FormAfterUpdate registers the purge; Postgres test proves
  one authorized request after the commit
2026-10-10 18:19:58 +02:00
0efaa04be5 chore(deps): require summercms v0.1.19 2026-10-10 16:55:12 +02:00
b997a416a7 fix(16.2): IN-04 make upload ids without crypto.randomUUID
crypto.randomUUID exists only in a secure context, so an admin served
over plain HTTP from a LAN host threw on every editor upload while the
status line still said uploading. uploadRequestId falls back to a v4
UUID from crypto.getRandomValues, or a time and counter id when no
crypto object exists.
2026-10-10 16:39:29 +02:00
f1e6c11107 fix(16.2): WR-05 keep only view-safe attributes in the editor schema
captureAttributes and renderCaptured dropped only on* names, so id,
name, style, srcset, background and the like from stored HTML were
rendered by ProseMirror into the live admin DOM (DOM clobbering of
document.querySelector, fixed-position overlays, remote loads). htmlAttrs
now keeps an allowlist: class, title, alt, width, height, colspan,
rowspan, start, rel, target and data-*; href and src stay with the link
and image nodes. Other attributes are dropped from a block once it is
edited; untouched documents and untouched blocks keep their bytes.
2026-10-10 16:38:25 +02:00
2c961b8408 fix(16.2): WR-03 never let concurrent media uploads overwrite each other
uniqueMediaKey checked Exists and the write followed without a
precondition, so two uploads of the same name (clipboard images are all
image.png) could both pick the free key and the second replaced the
first. StoreMedia now reserves the chosen key in-process while it
writes, and writes with IfNotExist; a key another process stored after
the check moves the upload on to the next free name.
2026-10-10 16:35:40 +02:00
5af746f31f fix(16.2): WR-04 serve content_html for markdown that starts with a tag
LooksLikeHTML decided by the first character alone, so a markdown post
opening with a hero <img>, a <div> or a comment was served as raw
markdown and its first edit stored ## and ** markers as HTML in both
columns. Load now serves such a value as HTML only when content_html is
empty or missing, or holds that same HTML (the value or SanitizeHTML of
it, white space at the ends ignored); otherwise it serves content_html.
2026-10-10 16:34:16 +02:00
159323197d fix(16.2): CR-01 serve content_html when a locale's content is blank
A translation that carries only content_html (an API write through
FormatTranslation) opened as an empty editor, and the first edit in
that locale silently replaced the translated body. Load now serves the
htmlColumn attribute whenever the stored content is blank and that
attribute is not empty; an untouched save echoes it and is skipped, an
edit writes the edited HTML to both columns.
2026-10-10 16:33:08 +02:00
85963b19ef test(16.2-06): keep happy-dom iframe pages offline in the editor tests
The preview tests put payloads with remote URLs into the preview frame's
srcdoc; happy-dom tried to load them (a DNS lookup of example.test on every
run). Tests must not reach the network.
2026-10-10 16:11:35 +02:00
a578ddabda test(16.2-06): Blog Editor schema, serializer and embed coverage
- schema: every raw HTML tag rule, figure shapes, span mark, unmodelled
  attributes, handler attributes dropped, links, tables, no trailing node
- serializer: block reuse after an edit, a deletion and an insertion,
  separators and comments kept, captureBlocks fallbacks
- embeds: parseEmbedUrl table, placeholder round trip, isPolicyUrl table,
  upload id matching and timeout, labels, disabled and invalid
2026-10-10 16:03:07 +02:00
da7a0732c3 test(16.2-06): full unit coverage of the Journal Blog Editor field types
- Decode: clean values byte for byte, nil, non-string refusal, the 35
  unsafe payloads stripped with notices exactly when changed
- notices order, ten-name cut, plural forms in lang/en and lang/pl
- LooksLikeHTML, options, ListValue, Upload sniffing and safe names
- StoreMedia layout, suffixes, no overwrite, size and folder rules
- editor corpus and policy cross-check extended in place: more negatives,
  notices only when stripped, stable second pass
- TestPostsEditorHooks: create and update, default and non-default
  writes, no writes, no writer
- TestCategoriesAdminBlogEditor (Docker host): untouched saves keep
  imported markup and NULL, clean edit, strip and report, blank to NULL,
  editor upload
2026-10-10 15:55:00 +02:00
d5a5413525 test(16.2-06): pin the imported-post resave contract
- element.test.ts: an untouched value survives Source tab, tab buttons,
  preview and reattach byte for byte with no summer-change
- TestBlogEditorLoad: HTML verbatim, markdown as content_html, FormatHTML
  fallback, nil and empty values
- TestPostsAdminResaveKeepsHTML (Docker host): GET then PUT the served
  values for an HTML and a markdown post, exact bytes, no notices, then a
  clean edited save in both locales
2026-10-10 15:42:31 +02:00
2c78211cb9 docs(readme): blog editor preview pane 2026-10-10 13:57:36 +02:00
c1f6a83c2f feat(editor): live preview with embed cards and post typography
- The preview re-renders 300 ms after the last WYSIWYG edit, Source input or external value, never while off or after detach.
- YouTube placeholders show as img.youtube.com thumbnail cards with no player; the preview copy drops active elements and on* attributes, and its stylesheet uses colours resolved from the host's --c-* values.
2026-10-10 13:56:57 +02:00
df67939251 feat(editor): toggleable preview pane in a sandboxed frame
- A per-element Preview toggle (aria-pressed, off by default) splits the editor 50/50 with an auto-fit grid that wraps the pane below on narrow widths.
- The pane is an iframe with an empty sandbox, content set only through srcdoc, created on first use; it reads only the value getter, so previewing never edits or emits summer-change.
2026-10-10 13:53:48 +02:00
9d5bfc4a35 chore(deps): require summercms v0.1.18 2026-10-10 13:15:16 +02:00
2384a61e38 feat(classes): allow the Blog Editor's own markup in the HTML policy
- figure data-embed ^youtube$ and data-id ^[A-Za-z0-9_-]{11}$ (the embed
  placeholder), img alt with any text, ol start ^[0-9]+$; nothing else
- TestEditorOutputPassesPolicy checks every editor-corpus.json entry byte
  for byte with an empty report, plus the negative cases;
  TestSanitizeHTMLReport pins the removal report
- CheckHTML documented as a predicate no write path refuses with; README
  lists the four additions and the report API
2026-10-10 12:11:29 +02:00
cb1c688213 feat(admin): category content on the Blog Editor and editor image uploads
- category content is the blogeditor type (uploadFolder categories): it is
  stripped and reported like post content; the 422 CheckHTML gate is
  retired and blank content still becomes NULL
- blogeditor and mlblogeditor Upload: magic-byte check for JPEG, PNG, GIF
  and WebP matching the extension, safe lowercase names, storage through
  classes.StoreMedia in the field's uploadFolder
- TestBlogEditorUploadSmoke, TestCategoriesAdminContent asserts the strip,
  README describes the field types, admin saves and editor uploads
2026-10-10 12:08:51 +02:00
15fcad7a93 refactor(api): share media storage with the editor upload
- classes.StoreMedia holds the media upload route's storage: extension
  allowlist, 10240 KiB cap, /journal/<folder>/<name> keys with the unique
  suffix, bucket write and public URL
- UploadMedia keeps authentication, permissions, the folder and form checks
  and answers with the same 201 body and messages
- uniqueMediaKey fails instead of overwriting when every numbered name is
  taken; the media path tests move to classes
2026-10-10 12:08:42 +02:00
fbd1c85168 feat(admin): post content on the Blog Editor field type
- blogeditor and mlblogeditor field types (classes.BlogEditorTypes) on the
  plugin; post content is mlblogeditor with htmlColumn content_html
- Decode stores SanitizeHTMLReport output per locale with notices naming
  what was removed; clean HTML is stored byte for byte, nothing is refused
  because of markup
- Load serves HTML as stored and markdown posts as their content_html
- the admin hooks copy written content to content_html per locale without
  goldmark; untouched content leaves both columns as they are
- editor lang keys (en, pl plural notices), tracer smoke test, updated
  Postgres admin tests
2026-10-10 12:02:55 +02:00
cbf4891703 feat(classes): report what the HTML policy removes
- SanitizeHTMLReport returns the policy output plus HTMLRemovals: removed
  elements, attributes (repeats and changed values included) and comments,
  at most ten distinct names per list with exact counts
- SanitizeHTML is the string half of it; FormatHTML, FormatTranslation and
  CheckHTML keep their behaviour
2026-10-10 11:59:45 +02:00
086e485215 feat(editor): preservation schema, block reuse, toolbar, uploads and embeds
- preservationExtensions: raw HTML cards for unmodelled blocks (shown as
  text), figure with figcaption, the YouTube embed placeholder node, span
  marks, a global attribute map (never on* handlers), tables without
  colgroup or min-width, inline images without base64
- serializeDocument keeps the original bytes of every top-level block still
  equal to the loaded one, so editing one paragraph changes only that
  paragraph; tables serialize with thead and bare cell text
- Links accept only http, https, mailto and relative URLs (isPolicyUrl, the
  rule both sanitizer policies apply) and gain no target, rel or class
- Toolbar with headings, marks, lists, links, blockquote, table editing,
  images, embeds, raw HTML block, undo and redo; en and pl labels
- Images from the picker or a drop go out as summer-upload and are inserted
  on summer-upload-result with alt text and an optional caption
- editor-corpus.json: the editor own output, rebuilt and checked by
  corpus.test.ts (UPDATE_CORPUS=1 rewrites it) for the policy tests
- README Blog Editor section
2026-10-10 11:53:07 +02:00
47f3032dcf feat(editor): blog editor element with exact untouched value
- golem15-journal-blogeditor (BlogEditorElement): light-DOM element with a
  TipTap WYSIWYG tab and a Source tab that edits the exact HTML string
- The value is the exact served string until a user edit; external values
  go in with emitUpdate false; summer-change fires on user edits only and
  an echo set is a no-op
- StarterKit without the trailing node, links without target, rel or class;
  injectCSS false with the ProseMirror base rules in blogeditor.css under
  the element tag, colours from --c-* only
- Committed bundle embedded through admin.go; vitest smoke on an imported
  post fixture (footer, style block, spans, figure, table, entities)
2026-10-10 11:35:51 +02:00
234e6169cc build(editor): blog editor JS build and drift gate
- Private ESM package with exact pins: @tiptap/core, pm, starter-kit,
  extension-table and extension-image 3.31.4; vite 7.3.5, vitest 3.2.7,
  happy-dom 20.11.6 and typescript 5.9.3, the admin SPA toolchain
- vite.config.ts builds assets/src/blogeditor/index.ts into fixed-name
  assets/dist/blogeditor.js and blogeditor.css, minified, and runs vitest
  on happy-dom
- scripts/check-dist.sh rebuilds into a temp dir and fails when assets/dist
  is stale; node_modules is ignored
2026-10-10 11:35:44 +02:00
c60abc6d62 chore(deps): require summercms v0.1.17 2026-10-10 00:42:30 +02:00
5f47083450 fix(classes): treat duplicate attributes as a change
golang.org/x/net/html's tokenizer keeps only the first of repeated
attribute names, so FormatHTML's byte-preserving comparison saw
<a href="https://x" href="javascript:...">, <img src=ok
src="javascript:..."> and a repeated style as unchanged and stored the
raw bytes, and CheckHTML accepted them.

htmlTokens now scans each start tag's raw bytes for its attribute names,
following the tokenizer's own readTag rules, and reports the document as
changed when that list differs from the attributes the tokenizer kept.
Such input is stored as bluemonday's output, which keeps the first value
as browsers do, and CheckHTML refuses it. Input without repeated
attributes is still stored byte for byte. A differential run of 2M
random tags found no disagreement between the scan and the tokenizer.
2026-10-10 00:13:17 +02:00
0d00ec8e0d fix(api): sanitize translated content_html on every write
A translated content_html sent through the Journal API (syncTranslations
formatted only when content_html was empty) or through
journal:import-posts (no check) was stored verbatim and served by the
anonymous GET /_journal/api/v1/posts and /posts/{slug}. A translation
keyed on the default locale also wrote the host content_html column
past FormatHTML.

classes.FormatTranslation now sets content_html for every translated
locale before it is stored: rendered from a non-blank content with
FormatHTML (host-column parity; a supplied content_html is replaced),
otherwise a supplied content_html is sanitized with the new
classes.SanitizeHTML, which FormatHTML reuses. Both write paths call it.

PHP parity note: Winter's PostApiController::syncTranslations called
Post::formatHtml only when content_html was empty and otherwise stored
the supplied content_html verbatim; ImportPosts::syncTranslations stored
it verbatim; the MLJournalMarkdown admin widget always derived it from
content via formatHtml. The Go port now follows the admin widget on
every path. Response shapes and status codes for valid input are
unchanged; only the stored content_html of unsafe or content-mismatched
translations differs.
2026-10-10 00:09:47 +02:00
a3551f2bdf fix(classes): sanitize content_html with bluemonday instead of regex checks
- FormatHTML sanitizes the goldmark output with a bluemonday UGC policy
  extended only with what goldmark and the imported posts and categories
  use; unsafe markup is stripped, as Winter's Html::clean strips it
- HTML the policy keeps whole is stored byte for byte (token comparison)
- CheckHTML keeps its signature and 422 contract, refusing HTML the
  policy would change
- the three regexes are gone: the CR-01 bypasses are neutralized and
  "Data:" or "metadata:" text is no longer refused (WR-01)
2026-10-09 23:35:23 +02:00
03907ee387 test: FormatHTML keep and refuse coverage, admin re-save keeps HTML
- TestFormatHTMLKeepsRawHTML: imported-style bodies (paragraphs, attributes, https links and images, entities) round-trip; markdown keeps a trailing raw footer and style
- TestFormatHTMLRejectsUnsafeHTML: every unsafe case must return an error now that the gate is the only defence; adds svg onload, mixed-case schemes and tags, object, embed, script in an HTML block and raw data URLs
- TestPostsAdminResaveKeepsHTML: an admin update of HTML content keeps content_html
2026-10-09 22:48:59 +02:00
a675f2f197 chore(deps): require summercms v0.1.16 2026-10-09 21:32:49 +02:00
528d2d8451 fix(classes): keep raw HTML in content_html behind the safety gate
- FormatHTML renders with goldmark html.WithUnsafe(), as Winter's
  Markdown::parse passes raw HTML through, so an admin save, an import or
  an API write no longer turns imported HTML into raw HTML omitted
- rejectUnsafe still runs after the render and refuses script, iframe,
  object and embed tags, event handlers and javascript, vbscript and data
  URLs; API response shapes are unchanged
- TestFormatHTMLKeepsRawHTML covers HTML paragraphs and a raw footer
2026-10-09 20:58:49 +02:00
3d533d49f0 test(updates): expect category content in the Category.Fillable list pinned by TestJournalTables
TestJournalTables pins the exact Category.Fillable allow-list, like
models/fillable_test.go. The admin form needs content in Fillable (D-21).
v0.1.1
2026-10-09 16:26:58 +02:00
f22d08902e feat(admin): edit category content in the admin form behind the HTML gate
- add a content textarea (size huge) after description, with en and pl phrases
- add content to Category.Fillable
- export classes.CheckHTML and run it from FormBeforeCreate and FormBeforeUpdate:
  unsafe category content answers 422 on content, blank content is stored as NULL
2026-10-09 16:26:12 +02:00
740dcd086d feat(api): serve the category content field on GET /_journal/api/v1/categories
- add the nullable TEXT column golem15_journal_categories.content (migration 202610090001)
- map it as models.Category.Content and emit category content on every categories row
- document the categories row shape and the stored-as-is HTML semantics
2026-10-09 16:24:20 +02:00
57a347c97b feat(api): list an author's published posts at /authors/{author}/posts
- AuthorPosts reuses the /posts published predicate, per_page clamp, sort allow-list and row serializer
- Bearer tokens are ignored; drafts and scheduled posts never appear
- Both author routes share one resolve-or-respond helper for identical 404/500 paths
- Document query keys, envelope and 404 in README
2026-10-09 13:20:39 +02:00
908ade9c78 feat(api): anonymous author profile at /_journal/api/v1/authors/{author}
- Resolve {author} by golem15_bloghub_author_slug, then by login for users with a published post
- Project only id, names, login and author slug from backend_users; payload is id, slug, first_name, last_name
- JSON 404 {"error":"Author not found"} for unknown, soft-deleted and malformed values
- Document the route in README
2026-10-09 13:19:35 +02:00
Jakub Zych
f5cd5b16fe fix(admin): title the posts list Posts, not New post
The admin names a list page, its heading and breadcrumb after the first
side-menu item of its controller. The new_post item pointed at the posts
list too, came first and won. A side item cannot open the create form, so
drop it; the list toolbar's Create button stays.
2026-10-07 18:02:36 +02:00
Jakub Zych
9327e1e4f3 chore: pin v0.1.0 plugin dependencies v0.1.0 2026-10-07 11:11:07 +02:00
Jakub Zych
be4f33a72e fix(admin): apply Journal form follow-ups 2026-10-07 11:11:02 +02:00
Jakub Zych
d09edde080 test(15-04): complete PHPUnit map, migrations, FormatHTML, and search-gate tests
Named JOURNAL-001/002 fillable, FormatHTML unsafe tags, rollback/remigrate, RSS/categories/tags/media, and TestSearchGateOff with zero Typesense HTTP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 19:18:53 +02:00
Jakub Zych
c5b44b6a5d test(15-04): prove journal migrate-admin-list-write-draft path on Postgres
- Add TestJournalEndToEnd through party/surf/cabana on real Postgres
- Extract the admin harness and activate user+translate+journal in Requires order
- Anonymous list hides drafts; Bearer writes; JOURNAL-005 404 has no data key

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 19:11:52 +02:00
Jakub Zych
bdd1c1be61 feat(15-03): add journal media upload, RSS, and off-by-default Typesense gate
- POST /_journal/api/v1/media/upload requires access_posts and stays under journal/
- Anonymous GET rss is RSS 2.0 honoring rss_* settings; unpublished posts are omitted
- beachcomber Gate reads search_use_typesense (default false) so a fresh save makes no Typesense HTTP

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 19:03:35 +02:00
Jakub Zych
3a1dda45ec feat(15-03): enforce JOURNAL-005 draft 404 and backend-Bearer writes
- Show unpublished or future posts as 404 without a data key unless owner or access_other_posts
- POST/PUT/DELETE posts and featured-images require a backend-audience JWT and PHP {error} strings
- Anonymous GET categories and tags return the PHP list keys; frontend-audience tokens cannot write

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 18:59:12 +02:00