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

Merged
rosa merged 11 commits from feat/post-photo-media into main 2026-08-07 22:02:21 +00:00
Owner

Implements #131: a Post can carry a single optional Photo (its IndieWeb
photo), uploaded, optimized, and stored as a BLOB in SQLite, served from this
instance's own origin, and shown at the top of the Post under the byline. Both
the web editor and Micropub can attach one.

Built bottom-up in ten phases, each compiling and independently tested:

  1. DomainMedia model + Photo/PhotoRef, threaded through Post
  2. Persistencemedia table + photo_media_id/photo_alt, port ops
  3. Image pipeline — decode → magic-byte allowlist (JPEG/PNG/WebP) → reject
    SVG → strip EXIF (orientation applied first) → downscale-only → AVIF
  4. ServingGET /media/{id} at an Unlisted-grade capability URL
  5. Shared create-Media op behind the ImageProcessor port
  6. Micropub media endpoint POST /media + the media scope
  7. Micropub photo — create / update / q=source, URL→id resolution
  8. Web editor — inline photo upload (multipart), remove > replace > keep
  9. Renderingu-photo under the byline
  10. Feeds — RSS media:content + Atom rel="enclosure"

Design change from the ADRs

The stored rendition format is AVIF, not WebP (ADR-0017 and the migration
comment updated to match). The input allowlist still decodes JPEG/PNG/WebP.

Security invariants (all test-covered)

  • SVG rejected at ingest (stored-XSS on same-origin navigation); type sniffed
    from magic bytes, never the client's Content-Type.
  • Pre-decode byte + pixel-dimension guards (decompression-bomb).
  • A Post's photo may only reference same-origin, author-owned Media — a
    third-party URL or another author's Media is rejected.
  • Media served at a random-v4 capability URL with no per-request authz
    (ADR-0018): a Private Post's Photo is Unlisted-grade, documented and accepted.

Notes

  • New dependency: image (default-features off; JPEG/PNG/WebP decode + AVIF
    encode via rav1e). cargo audit is clean.
  • The design docs (CONTEXT.md glossary + ADR-0017/0018/0019) ride in this branch
    since they are the design for this feature.
  • Deferred, tracked separately: #132 per-user quota, #133 Micropub inline
    multipart photo in create, #134 orphan-Media GC, #135 responsive renditions.

Full CI (audit / fmt / yaml / tests) is green.

Closes #131

Implements #131: a Post can carry a single optional **Photo** (its IndieWeb `photo`), uploaded, optimized, and stored as a BLOB in SQLite, served from this instance's own origin, and shown at the top of the Post under the byline. Both the web editor and Micropub can attach one. Built bottom-up in ten phases, each compiling and independently tested: 1. **Domain** — `Media` model + `Photo`/`PhotoRef`, threaded through `Post` 2. **Persistence** — `media` table + `photo_media_id`/`photo_alt`, port ops 3. **Image pipeline** — decode → magic-byte allowlist (JPEG/PNG/WebP) → reject SVG → strip EXIF (orientation applied first) → downscale-only → **AVIF** 4. **Serving** — `GET /media/{id}` at an Unlisted-grade capability URL 5. **Shared create-Media op** behind the `ImageProcessor` port 6. **Micropub media endpoint** `POST /media` + the `media` scope 7. **Micropub `photo`** — create / update / `q=source`, URL→id resolution 8. **Web editor** — inline photo upload (multipart), remove > replace > keep 9. **Rendering** — `u-photo` under the byline 10. **Feeds** — RSS `media:content` + Atom `rel="enclosure"` ### Design change from the ADRs The stored rendition format is **AVIF**, not WebP (ADR-0017 and the migration comment updated to match). The input allowlist still decodes JPEG/PNG/WebP. ### Security invariants (all test-covered) - SVG rejected at ingest (stored-XSS on same-origin navigation); type sniffed from magic bytes, never the client's `Content-Type`. - Pre-decode byte + pixel-dimension guards (decompression-bomb). - A Post's `photo` may only reference **same-origin, author-owned** Media — a third-party URL or another author's Media is rejected. - Media served at a random-v4 capability URL with no per-request authz (ADR-0018): a Private Post's Photo is Unlisted-grade, documented and accepted. ### Notes - New dependency: `image` (default-features off; JPEG/PNG/WebP decode + AVIF encode via `rav1e`). `cargo audit` is clean. - The design docs (CONTEXT.md glossary + ADR-0017/0018/0019) ride in this branch since they are the design for this feature. - Deferred, tracked separately: #132 per-user quota, #133 Micropub inline multipart photo in create, #134 orphan-Media GC, #135 responsive renditions. Full CI (audit / fmt / yaml / tests) is green. Closes #131
rosa added 12 commits 2026-08-06 21:38:14 +00:00
Add the domain model and decisions for an optional per-Post Photo (IndieWeb
`photo`) backed by an optimized-BLOB media store in SQLite (see #131).

- CONTEXT.md: new Photo and Media terms; sharpen Private to admit its
  guarantee does not cover a referenced Photo's bytes.
- ADR-0017: media stored as optimized WebP BLOBs in SQLite (allowlist, SVG
  rejection, single-rendition re-encode; media table carries size + dimensions).
- ADR-0018: media served at an unlisted-grade capability URL, with the
  Private-is-only-Unlisted-grade trade-off and the random-id requirement.
- ADR-0019: media creation as one domain op behind the web-editor and
  Micropub media-endpoint adapters; web post forms become multipart.
Phase 1 of #131: the domain vocabulary for a Post's optional Photo,
backed by a same-origin SQLite media store.

- New models/media.rs: MediaId (random v4, capability-grade per ADR-0018),
  MediaType (WebP-only stored rendition, ADR-0017), PhotoAlt (bounded,
  blank-collapses-to-None), PhotoRef { media_id, alt } (internal,
  same-origin-by-construction), and the Media metadata aggregate.
- photo: Option<PhotoRef> threaded through Post (+ Post::new + getter),
  PostCreate, PostEdit, and the CreatePostRequest port DTO; create_post
  forwards it and the mock repo round-trips it.
- MicropubUpdate::apply_to carries a Post's existing photo through untouched
  (SingleUpdate<PhotoRef> is a later phase).
- permalinks: media_path / media_url for the flat /media/{id} route.
- Placeholders (None), each tagged #131, where later phases take over:
  sqlite reconstitution, Micropub create, both web post forms.
Phase 2 of #131.

- Migration 20260806000000_media: a `media` table (random v4 id, user_id
  ON DELETE CASCADE, content_type, bytes BLOB, byte_size, width, height,
  created_at) and `photo_media_id` (FK, ON DELETE SET NULL) + `photo_alt`
  columns on `posts`. A deleted Media leaves a Post photo-less rather than
  dangling (orphans tolerated, ADR-0017).
- AppRepository gains create_media, find_media_by_id (metadata only), and
  load_media_bytes (BLOB + type, for serving); CreateMediaRequest and the
  MediaBytes view carry the shapes.
- SQLite adapter: MediaRow mapping (INTEGER narrowed to u64/u32 as corruption
  checks), the three media queries, and photo_media_id/photo_alt threaded
  through create_post/update_post and PostRow reconstitution (replacing the
  Phase 1 None placeholder).
- Mock repo gains a media store and the same three ops.
- Media conformance suite (create/fetch round-trip, missing-is-None, post
  photo round-trips with and without alt) run against both adapters.
- Regenerated the .sqlx offline cache.
Phase 3 of #131. Also changes the stored rendition format from WebP to AVIF
per a design revision.

- New ImageProcessor port (domain): raw bytes -> one OptimizedImage, keeping
  the image codec out of the domain. ImageProcessingError distinguishes
  client-fault (Unsupported / TooLarge / Invalid) from server-fault (Encode).
- AvifImageProcessor adapter (infra) over the `image` crate: magic-byte format
  sniff + JPEG/PNG/WebP allowlist (SVG and all else rejected, not sanitized),
  a pre-decode byte ceiling and decoder dimension limit (decompression-bomb
  guard), EXIF orientation applied then metadata dropped by re-encoding,
  downscale-only to a 2048px long edge, re-encoded to AVIF. Pixel bound and
  encoder quality/speed are tunable constants, not ADR-frozen. CPU-bound work
  runs in spawn_blocking.
- MediaType::Webp -> Avif (image/avif); ADR-0017 and the migration comment
  updated to AVIF. Input allowlist still decodes WebP.
- `image` added to the workspace (default-features off; jpeg/png/webp decode +
  avif encode).
- 7 pipeline tests: png->avif, no-upscale, downscale-to-bound, oversize-bytes,
  non-image, svg, and truncated-input rejections.
Phase 4 of #131.

- AppService::load_media_bytes delegates the serving fetch to the repo.
- New get_media handler + /media/{id} route: parses the id, loads the bytes,
  and responds with the stored content type and a long immutable Cache-Control
  (the rendition never changes under an id). No per-request authorization — the
  unguessable id is the capability (ADR-0018); an unparseable or unknown id is a
  plain 404 that never hints an id exists.
- No governor on the route: renditions are immutable, cacheable static content
  and the id is unguessable, so there is nothing to enumerate or throttle that
  the cache does not already blunt.

Serving substance (load_media_bytes) is covered by the Phase 2 conformance
suite; the handler is thin glue and the web crate has no HTTP-test harness.
Phase 5 of #131 (ADR-0019).

- AppService gains a 4th generic I: ImageProcessor and an image_processor
  field. The image pipeline is a driven port injected like the repo, mailer,
  and webmention client — so the one create-Media op owns optimize+persist and
  neither upload surface does.
- New AppService::create_media(author, bytes): optimizes the raw bytes through
  the port, then persists the single rendition; raw bytes are kept out of the
  span. CreateMediaError splits image-pipeline (client) from persistence
  (server) faults.
- Wiring threaded through: InfraAppService alias, web AppState, and the real
  AppService::new in server main + server test harness now pass an
  AvifImageProcessor. All test call sites pass a new NoOpImageProcessor mock
  (returns a fixed distinctive rendition).
- create_media test proves the op stored the processor's optimized output, not
  the raw input.
Phase 6 of #131.

- Promote the dormant `media` Scope to a real variant (parse/as_str), shown on
  the consent screen ("Upload photos") and selectable in the consent form.
- AppService::micropub_create_media: enforce the `media` scope, then reuse
  create_media. A pipeline rejection is a client fault (MediaRejected -> 400
  invalid_request); an encode failure is ours (500). New domain
  MicropubError::MediaRejected.
- POST /media handler: token-authed, `media`-scoped multipart upload; the one
  `file` part is optimized, stored, and its capability URL returned in Location
  (Micropub §3.6). Web MicropubError gains MissingFile / MultipartRead.
- q=config advertises `media-endpoint`.
- Body-limit: swap the global tower-http RequestBodyLimitLayer(1MiB) for an
  overridable axum DefaultBodyLimit, and raise it to 10MiB on POST /media only
  (inner layer wins, ADR-0019). axum `multipart` feature enabled.
- Tests: domain scope-gate + happy path; server e2e uploads a PNG, gets 201 +
  Location, and serves it back as image/avif.
Phase 7 of #131.

- Domain PhotoInput { url, alt }: a Photo as it arrives in a Micropub request,
  before the service resolves its URL to a PhotoRef. MicropubCreate.photo and
  MicropubUpdate.photo (a SingleUpdate) carry it.
- permalinks::parse_media_url: same-origin /media/{id} -> MediaId, the inverse of
  media_url and the same-origin gate.
- AppService::resolve_photo: URL -> same-origin, existing, author-owned PhotoRef,
  else MicropubError::InvalidPhoto. Wired into micropub_create (resolve before
  building the draft) and micropub_update (override edit.photo after apply_to
  only when the update touches photo; Clear removes it). apply_to still carries
  an untouched Photo through unchanged.
- q=source emits photo as the same-origin URL (mf2 { value, alt } form with alt,
  bare URL without). MicropubSource::from -> from_post(base_url, post).
- Web parsing: photo in JSON create/replace/delete and form (photo / photo[]);
  reject-multiple -> MicropubError::TooManyPhotos (a Post has one Photo). alt is
  JSON-only.
- Tests: domain resolve (owned happy path; reject third-party origin and another
  author's media; update-clear); dtos single_photo (alt / empty / too-many);
  server e2e uploads a photo, creates a note referencing it, and reads it back
  via q=source.
Phase 8 of #131 (ADR-0019).

- The new/post and edit forms become multipart/form-data with a file input, an
  editable alt-text field, and (on edit) a "remove photo" checkbox over a
  preview of the current image.
- ValidatedMultipart<T>: the multipart counterpart to ValidatedForm — collects
  text fields and deserializes them into T exactly as the urlencoded path would,
  and captures the `photo` file part separately (an empty part = no file).
- Handlers resolve the Photo with remove > replace > keep precedence
  (photo_action): removal clears it, an upload optimizes+stores a new Media and
  references it, otherwise the existing Media is kept with the (possibly edited)
  alt. remove_photo is a real bool via a checkbox deserializer.
- create_media failures map to AppError (bad upload -> 400, encode -> 500).
- Body limit raised to 10 MiB on /new/post and the edit route only (inner
  DefaultBodyLimit wins).
- Tests: photo_action precedence unit test; the create_post/edit_post server
  helpers and inline draft posts move to multipart; a server e2e uploads a photo
  through the web editor (verifying photo_media_id + photo_alt) then removes it.
Phase 9 of #131.

- The post page renders `<img class="u-photo blog-post-photo">` between the
  header/byline and the content when the Post carries a Photo, sourced from its
  same-origin media URL. A Photo with no alt renders alt="" (decorative), never
  a missing attribute. u-photo marks it up as the h-entry's photo (mf2).
- CSS fragment: the photo is a block, full content-width, auto height, with
  vertical margin.
- example_post (the static typography demo) carries no Media, so it is left as
  is — its header/content structure already parallels post().
- The web upload e2e now also asserts the rendered u-photo (URL + alt) appears
  while the Photo is present and is gone after removal.
feat(feeds): carry a Post's Photo as RSS media:content + Atom enclosure
Some checks failed
ci/woodpecker/push/clippy Pipeline was canceled
ci/woodpecker/push/test Pipeline was canceled
fc76b7b926
Phase 10 of #131 (final phase).

- FeedEntry gains an optional FeedPhoto (same-origin URL + content type, byte
  size, and pixel dimensions — the stored metadata, cited without re-decoding).
- RSS: a Media RSS media:content element per item (url, type, medium, fileSize,
  width, height), with the `media` namespace declared on the channel. rss 2.x
  has no typed MRSS, so it's built as a generic extension.
- Atom: a native <link rel="enclosure"> (href, type, length) alongside the
  alternate link.
- ListedPost exposes photo(); AppService::find_media_by_id delegates the
  metadata read. The feed handlers resolve each Photo's Media into a map
  (feed_photo_media) and hand it to FeedData::from_profile; a Photo whose Media
  no longer resolves is left out.
- Tests: a dtos test asserts media:content (+ namespace) and enclosure render
  and the RSS still parses; the web-upload e2e now also checks the photo appears
  in both /rss.xml and /atom.xml.
rosa force-pushed feat/post-photo-media from fc76b7b926
Some checks failed
ci/woodpecker/push/clippy Pipeline was canceled
ci/woodpecker/push/test Pipeline was canceled
to 0fa975f458
All checks were successful
ci/woodpecker/push/clippy Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
2026-08-06 21:39:45 +00:00
Compare
rosa force-pushed feat/post-photo-media from 0fa975f458
All checks were successful
ci/woodpecker/push/clippy Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
to 243c5d80ca
Some checks failed
ci/woodpecker/push/clippy Pipeline was canceled
ci/woodpecker/push/test Pipeline was canceled
2026-08-06 22:41:23 +00:00
Compare
rosa force-pushed feat/post-photo-media from 243c5d80ca
Some checks failed
ci/woodpecker/push/clippy Pipeline was canceled
ci/woodpecker/push/test Pipeline was canceled
to 9f42e0bfa5
All checks were successful
ci/woodpecker/push/clippy Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
2026-08-07 22:02:07 +00:00
Compare
rosa merged commit cf1f782f4a into main 2026-08-07 22:02:21 +00:00
rosa deleted branch feat/post-photo-media 2026-08-07 22:02:21 +00:00
Sign in to join this conversation.
No description provided.