Skip to content

feat(media): make image insertion work end to end in the article editor - #132

Merged
ssavutu merged 1 commit into
mainfrom
feat/article-image-insertion
Aug 1, 2026
Merged

feat(media): make image insertion work end to end in the article editor#132
ssavutu merged 1 commit into
mainfrom
feat/article-image-insertion

Conversation

@ssavutu

@ssavutu ssavutu commented Aug 1, 2026

Copy link
Copy Markdown
Member

Inserting an image into an article body was only partly wired up. This makes the whole path work, and stops the editor offering controls the published page cannot honour.

Editors can actually upload

POST /v1/media was admin-only, while every user after the first defaults to editor. So an editor dragging an image in got a silent 403 — and because the client only handled 201, the failed attachment stayed on its local blob URL, looked fine, and vanished on reload. Delete and reindex stay admin-only.

Pasting

Handled for all three shapes it arrives in:

Paste Before Now
Screenshot from clipboard Upload path (admins only) Upload path, all editors
Rich text from another site Ignored → article hotlinks a third party Sideloaded into our library
Image from another CMS article Re-imported as a duplicate Recognised as ours, left alone

Sideloading goes through a new POST /v1/media/fetch. Since that makes the server fetch a caller-supplied URL, the SSRF guard runs in the dialer's Control hook rather than on the URL string — it sees the resolved IP, on every redirect hop, so it catches hostnames resolving to internal space, DNS rebinding, and public URLs redirecting to 169.254.169.254. Size is capped by reading one byte past the limit rather than trusting Content-Length. Covered by media_fetch_test.go.

Insert from the media library

New toolbar button, backed by /v1/media/gallery — an endpoint that already existed, was documented as "the trimmed shape used by image pickers", and had zero callers. The library's alt_text comes with the image, so an asset described once is described everywhere it's used; the picker flags assets that have none.

Articles store semantic markup

Bodies now store <figure class="wp-caption"><img src alt><figcaption class="wp-caption-text"> instead of Trix's data-trix-attachment JSON, which means nothing outside a Trix editor and rendered unstyled on the public site. The class names match the migrated WordPress corpus, so it renders through Scalene's existing #article figure styling with no changes needed there.

Load and save conversions live together in trixImageHtml.ts as explicit inverses. alt is always emitted, even empty — a missing alt attribute is an accessibility failure, whereas alt="" is a valid "decorative" signal.

Round trips verified against WP classic captions, block figures, caption-less and alt-less figures, galleries, and escaping: all stable and idempotent, with multi-image galleries and still-uploading blob attachments correctly left untouched.

One bug this caught: Trix labels a parsed <img> with contentType: "image" — no subtype. The first version of the serializer checked startsWith("image/"), which would have skipped every pasted image. Confirmed against processElement in the Trix source.

Failures are visible

A failed upload or import now removes the attachment and says why, instead of leaving a preview that cannot survive a reload and would publish an article whose image silently isn't there.

Resize and alignment removed

The public site sizes article images entirely in CSS — #article figure img { width: 100% } — which overrides anything an author sets. Both gestures appeared to work in the editor and changed nothing on the page.

Emitting sizes would have been actively harmful, not merely useless: width/height are presentational hints that CSS outranks, so the width would be overridden while the height still applied, stretching every image. There's no height: auto on the public side to save it.

Drag-to-reorder is kept — it does survive. Alignment already present on migrated content is preserved through a save; it just can't be set from here anymore. Flattening it out of the corpus would be a one-line serializer change, but that should be a deliberate call rather than a side effect of removing a gesture.

Testing

  • go build ./... && go test ./... — all green, including new SSRF guard tests
  • tsc --noEmit, eslint src/, vite build — all clean
  • Serializer round trips verified against the case list above
  • Swagger docs regenerated (diff is exactly the one new endpoint)

🤖 Generated with Claude Code

Inserting an image into an article body was only partly wired up. This
makes the whole path work and stops the editor offering controls the
published page cannot honour.

Uploading is now an editor capability. POST /v1/media was admin-only
while every user after the first defaults to editor, so an editor
dragging an image in got a silent 403: the failed attachment stayed on
its local blob URL, looked fine, and vanished on reload. Delete and
reindex stay admin-only.

Pasting is handled for all three of its shapes. A screenshot off the
clipboard arrives with a File and takes the upload path. Rich text
copied from another site arrives as a bare remote URL and was previously
ignored, leaving the article hotlinking a third party; it is now
sideloaded through a new POST /v1/media/fetch. An image copied from
another article in this CMS is recognised as already ours and left
alone rather than duplicated.

Because that endpoint makes the server fetch a caller-supplied URL, the
SSRF guard runs in the dialer's Control hook rather than on the URL
string, so it inspects the resolved IP on every redirect hop and blocks
hostnames resolving to internal space, DNS rebinding, and redirects to
the cloud metadata endpoint. Size is capped by reading one byte past the
limit rather than trusting Content-Length.

Images can now be inserted from the media library via a new toolbar
button, backed by the /v1/media/gallery endpoint that already existed
and had no callers. The library's alt text comes with the image, so an
asset described once is described everywhere it is used, and the picker
flags assets that have none.

Article bodies now store plain semantic markup -- figure/img/figcaption
using the same WordPress class names as the migrated corpus -- instead
of Trix's data-trix-attachment JSON, which means nothing outside a Trix
editor. The load and save conversions live together in trixImageHtml.ts
as explicit inverses. alt is always emitted, since a missing alt
attribute is an accessibility failure while alt="" is a valid signal.

Failed uploads and imports now remove the attachment and say why,
instead of leaving a preview that cannot survive a reload.

Finally, image resizing and alignment are removed from the editor. The
public site sizes article images entirely in CSS (#article figure img {
width: 100% }), which overrides anything an author sets, so both were
gestures that appeared to work and changed nothing on the page. Worse,
width/height are presentational hints that CSS outranks, so emitting
them would have left the width overridden while the height still
applied, stretching every image. Drag-to-reorder is kept because it does
survive. Alignment already present on migrated content is still
preserved through a save; it just can no longer be set here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ssavutu
ssavutu merged commit 24b42a1 into main Aug 1, 2026
6 checks passed
@ssavutu
ssavutu deleted the feat/article-image-insertion branch August 1, 2026 03:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant