Posts can carry a Photo (IndieWeb photo) backed by a SQLite media store #131

Closed
opened 2026-08-06 15:39:32 +00:00 by rosa · 1 comment
Owner

Posts can carry a Photo (IndieWeb photo) backed by a SQLite media store

Let a Post carry a single optional Photo — its IndieWeb photo — rendered at
the top of the Post under the byline. Any Post may carry one, Article or Note. The
image is uploaded, optimized, and stored as a BLOB in SQLite, and served from
this instance's own origin. Both the web editor and Micropub can attach one.

Design was settled in a grilling / domain-modeling session; this issue is the
umbrella that carries it into code.

Design docs

  • ADR: docs/adr/0017-media-is-stored-as-optimized-blobs-in-sqlite.md — store,
    optimization, security allowlist
  • ADR: docs/adr/0018-media-is-served-at-an-unlisted-grade-capability-url.md
    serving + the Private-is-only-Unlisted-grade trade-off
  • ADR: docs/adr/0019-media-creation-is-one-domain-op-behind-multiple-adapters.md
    — shared create-Media op + multipart forms
  • Glossary: CONTEXT.md — new terms Photo, Media; Private sharpened

Settled decisions

  • Native photo property, not a bespoke "featured image". Class is u-photo.
  • At most one Photo per Post; a request with more than one is rejected, not truncated.
  • Photo = { url, alt: optional }; render alt="" when absent.
  • Media is first-class, User-owned, same-origin-only; a Post's Photo references
    Media the author owns via a photo_media_id FK (URL→id resolution is the
    same-origin enforcement).
  • Media lifecycle is independent of Posts; orphans tolerated, no auto-delete on Photo removal.
  • Served at an unlisted-grade capability URL (random v4 id, no per-request authz).
  • Uploads: any authenticated User / media-scoped token, no Operator gate; per-upload size cap.
  • Formats: allowlist JPEG/PNG/WebP (magic-byte sniff), reject SVG, re-encode to one WebP, downscale-only.
  • Micropub v1: media-endpoint URL only; advertise media-endpoint in q=config.
  • Feeds: RSS MRSS media:content; Atom native <link rel="enclosure">.

Phases (bottom-up; each compiles and is independently testable)

  1. DomainMedia model + Photo/PhotoRef { media_id, alt } value objects;
    photo: Option<PhotoRef> on Post/PostCreate/PostEdit; thread through
    Post::new. Glossary terms already in CONTEXT.md.
  2. Persistencemedia table (random v4 id, user_id ON DELETE CASCADE,
    content_type, bytes BLOB, byte_size, width, height, created_at);
    photo_media_id FK + photo_alt on posts; migration; Media port ops
    (create + fetch); row mappings; mocks; regenerate .sqlx.
  3. Image pipeline — decode → allowlist (JPEG/PNG/WebP, magic-byte) → reject SVG
    → strip EXIF → downscale-only → WebP re-encode, with pre-decode dimension/byte
    guards. Pick the initial pixel-bound + encoder quality here (tunable, not
    ADR-frozen).
  4. Media servingGET /media/{id} capability route, immutable Cache-Control,
    no per-request authz (ADR-0018).
  5. Shared create-Media domain op on AppService (ADR-0019).
  6. Micropub media endpointPOST /media; promote the dormant media
    Scope to a real variant (token parsing, consent screen scope_label/
    scope_field); advertise media-endpoint in q=config.
  7. Micropub photo property — parse in create/update DTOs (reject-on-multiple
    → new MicropubError::TooManyPhotos); photo as SingleUpdate<PhotoRef> in
    apply_to; emit in q=source; URL→media_id same-origin + author-owned resolution.
  8. Web editornew/post + edit forms become multipart/form-data; file
    input + "remove photo" checkbox + editable alt; remove > replace > keep;
    ValidatedForm learns multipart; per-route body-limit bump (not global).
  9. Rendering<img class="u-photo" alt="…"> under the byline before
    .e-content; example_post parity; CSS fragment.
  10. Feeds — RSS media:content (declare the media namespace on the channel,
    attach a generic Extension per item); Atom <link rel="enclosure">;
    FeedEntry/FeedData::from_profile carry the Media metadata.

Deferred

Tracked separately — see the linked issues in the comments.

## Posts can carry a Photo (IndieWeb `photo`) backed by a SQLite media store Let a Post carry a single optional **Photo** — its IndieWeb `photo` — rendered at the top of the Post under the byline. Any Post may carry one, Article or Note. The image is uploaded, **optimized, and stored as a BLOB in SQLite**, and served from this instance's own origin. Both the web editor and Micropub can attach one. Design was settled in a grilling / domain-modeling session; this issue is the umbrella that carries it into code. ### Design docs - ADR: `docs/adr/0017-media-is-stored-as-optimized-blobs-in-sqlite.md` — store, optimization, security allowlist - ADR: `docs/adr/0018-media-is-served-at-an-unlisted-grade-capability-url.md` — serving + the Private-is-only-Unlisted-grade trade-off - ADR: `docs/adr/0019-media-creation-is-one-domain-op-behind-multiple-adapters.md` — shared create-Media op + multipart forms - Glossary: `CONTEXT.md` — new terms **Photo**, **Media**; **Private** sharpened ### Settled decisions - Native `photo` property, **not** a bespoke "featured image". Class is `u-photo`. - **At most one** Photo per Post; a request with more than one is **rejected**, not truncated. - Photo = `{ url, alt: optional }`; render `alt=""` when absent. - **Media** is first-class, User-owned, same-origin-only; a Post's Photo references Media the **author owns** via a `photo_media_id` FK (URL→id resolution is the same-origin enforcement). - Media lifecycle is independent of Posts; **orphans tolerated**, no auto-delete on Photo removal. - Served at an **unlisted-grade capability URL** (random v4 id, no per-request authz). - Uploads: any authenticated User / `media`-scoped token, **no Operator gate**; per-upload size cap. - Formats: allowlist **JPEG/PNG/WebP** (magic-byte sniff), **reject SVG**, re-encode to one **WebP**, downscale-only. - Micropub v1: **media-endpoint URL only**; advertise `media-endpoint` in `q=config`. - Feeds: **RSS MRSS `media:content`**; **Atom native `<link rel="enclosure">`**. ### Phases (bottom-up; each compiles and is independently testable) 1. **Domain** — `Media` model + `Photo`/`PhotoRef { media_id, alt }` value objects; `photo: Option<PhotoRef>` on `Post`/`PostCreate`/`PostEdit`; thread through `Post::new`. Glossary terms already in `CONTEXT.md`. 2. **Persistence** — `media` table (random **v4** id, `user_id` `ON DELETE CASCADE`, `content_type`, `bytes` BLOB, `byte_size`, `width`, `height`, `created_at`); `photo_media_id` FK + `photo_alt` on `posts`; migration; `Media` port ops (create + fetch); row mappings; mocks; regenerate `.sqlx`. 3. **Image pipeline** — decode → allowlist (JPEG/PNG/WebP, magic-byte) → reject SVG → strip EXIF → downscale-only → WebP re-encode, with pre-decode dimension/byte guards. **Pick the initial pixel-bound + encoder quality here** (tunable, not ADR-frozen). 4. **Media serving** — `GET /media/{id}` capability route, immutable `Cache-Control`, no per-request authz (ADR-0018). 5. **Shared create-Media domain op** on `AppService` (ADR-0019). 6. **Micropub media endpoint** — `POST /media`; promote the dormant `media` **Scope** to a real variant (token parsing, consent screen `scope_label`/ `scope_field`); advertise `media-endpoint` in `q=config`. 7. **Micropub `photo` property** — parse in create/update DTOs (reject-on-multiple → new `MicropubError::TooManyPhotos`); `photo` as `SingleUpdate<PhotoRef>` in `apply_to`; emit in `q=source`; URL→`media_id` same-origin + author-owned resolution. 8. **Web editor** — `new/post` + `edit` forms become `multipart/form-data`; file input + "remove photo" checkbox + editable alt; **remove > replace > keep**; `ValidatedForm` learns multipart; per-route body-limit bump (not global). 9. **Rendering** — `<img class="u-photo" alt="…">` under the byline before `.e-content`; `example_post` parity; CSS fragment. 10. **Feeds** — RSS `media:content` (declare the `media` namespace on the channel, attach a generic `Extension` per item); Atom `<link rel="enclosure">`; `FeedEntry`/`FeedData::from_profile` carry the Media metadata. ### Deferred Tracked separately — see the linked issues in the comments.
Author
Owner

Deferred decisions, tracked separately:

  • #132 — per-user Media storage quota
  • #133 — Micropub inline photo file in create request
  • #134 — orphan-Media garbage collection
  • #135 — responsive / multi-resolution renditions

Initial optimization tuning (pixel-bound + encoder quality) is chosen in phase 3
of this issue, not deferred — ADR-0017 leaves it a tunable knob rather than an
ADR-frozen value.

Deferred decisions, tracked separately: - #132 — per-user Media storage quota - #133 — Micropub inline photo file in create request - #134 — orphan-Media garbage collection - #135 — responsive / multi-resolution renditions Initial optimization tuning (pixel-bound + encoder quality) is chosen in phase 3 of this issue, **not** deferred — ADR-0017 leaves it a tunable knob rather than an ADR-frozen value.
rosa closed this issue 2026-08-07 22:02:21 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rosa/vernier#131
No description provided.