S1: PNG avatar upload + public GET (content-addressed, immutable cache) #16

Closed
opened 2026-05-12 23:44:26 +02:00 by arne · 1 comment
Owner

Parent

posta/server#15 — Operator-uploaded avatar served from the identity's URL

What to build

The tracer bullet for avatar uploads: a complete PNG-only path through
the whole stack. After this slice an operator (or the iOS app, with
JPEG support shipping in S3) can PUT a PNG to their identity, fetch
the actor doc to discover the new avatar URL, and GET the avatar
bytes — no auth needed for the GET, immutable HTTP caching, propagation
of subsequent uploads via the actor-doc TTL.

End-to-end behaviour after this slice:

  • PUT /api/v1/identity/avatar with Content-Type: image/png and raw
    PNG bytes succeeds: server sniffs the magic bytes, validates against
    the declared type, hashes the body (SHA-256), writes the file
    atomically as avatar-<hash>.png under the per-identity data
    directory, deletes any prior avatar-* file, sets the identity
    row's avatar column to <canonical-actor-URL>/avatar-<hash>.png,
    publishes an identity-changed SSE event, and responds 200 with
    a JSON body carrying the new URL.
  • A subsequent fetch of the identity's actor doc (GET / on the
    identity's host) carries the new avatar URL.
  • GET /avatar-<hash>.png on the identity's host returns 200,
    Content-Type: image/png, Cache-Control: max-age=31536000, immutable, ETag: "<hash>", and the original bytes. Public — no
    bearer token.
  • A repeat GET with If-None-Match: "<hash>" returns 304 Not Modified.
  • A GET for an avatar-<stale-hash>.png whose file no longer exists
    (e.g. after a re-upload) returns 404 no-avatar.
  • Uploading the same PNG bytes twice produces the same filename and is
    effectively a no-op.
  • Uploading a body that doesn't sniff as PNG returns 400 with a stable
    category in the error field (unsupported-media-type,
    content-type-mismatch, not-an-image, or truncated).
  • Uploading a body larger than 1 MiB fails at body-read time with
    413 payload-too-large.

Acceptance criteria

Image sniff (Module A, PNG only):

  • New module (e.g. internal/imagesniff) exposes a function that
    takes bytes (or io.Reader-with-limit) plus the declared
    Content-Type and returns either (extension, validated-type, nil)
    or a typed rejection carrying a stable category string.
  • PNG accept path recognises the 8-byte signature \x89PNG\r\n\x1a\n
    and returns ("png", "image/png", nil).
  • Rejection categories surfaced: unsupported-media-type (declared
    type isn't image/png), content-type-mismatch (bytes don't
    match the declared type — e.g. JPEG bytes declared as PNG),
    not-an-image (magic bytes don't match any supported type),
    truncated (body shorter than the magic-byte header).
  • Table-driven test fixtures cover at minimum: valid PNG; JPEG
    bytes declared as PNG (mismatch); GIF GIF87a (not-an-image);
    truncated (3-byte) input.

Avatar storage (Module B, content-addressed):

  • New module (e.g. internal/avatarstore) takes a per-identity
    data directory.
  • Write API: takes bytes + extension, returns
    (filename "avatar-<hex>.<ext>", hash hex, error). Implementation:
    hash the bytes (SHA-256), write to
    <dir>/avatar-<hex>.<ext>.tmp, fsync, rename to
    <dir>/avatar-<hex>.<ext>. Then delete every other avatar-*
    file in the directory.
  • Locate API: returns the current avatar's (filename, extension,
    hex) or a sentinel "no avatar set" error.
  • Idempotency: uploading identical bytes twice produces the same
    filename; the second rename is a no-op (atomic over identical
    content). No orphan tmp files.
  • Tests over t.TempDir(): write PNG → locate returns the same
    filename + hash; write a different PNG over the first → only
    the new file remains in the directory; write identical PNG
    twice → no-op (same filename, same content).

API handlers (Module C, PUT only — DELETE in S2):

  • PUT /api/v1/identity/avatar registered on the authenticated
    mux. Request body is read through http.MaxBytesReader at
    1 << 20 (1 MiB) so oversized bodies fail with 413 payload-too-large.
  • Body sniffed via the imagesniff module; rejections surface as
    400 with {"error": "<category>"} per the PRD's category
    strings.
  • On success: file written via the avatar-storage module, then
    UPDATE identity SET avatar = ? with the canonical
    content-addressed URL (host + path of the identity's URL plus
    /avatar-<hash>.png).
  • identity-changed SSE event published (same shape and code
    path as PATCH /api/v1/identity uses today).
  • Response is 200 with JSON body {"url": "<canonical-URL>", "hash": "<hex>"}.
  • api.Server gains an AvatarStore field; internal/daemon/runner.go
    wires it from the per-identity data directory.

Per-identity GET route (Module D):

  • New route on the per-identity mux: pattern matches
    /avatar-{hash}.png (and any future extension in S3).
  • On a request whose filename matches the on-disk avatar file:
    serve bytes with Content-Type: image/png, Cache-Control: max-age=31536000, immutable, ETag: "<hash>".
  • If-None-Match: "<hash>" for the current avatar returns
    304 Not Modified (no body).
  • A request for an avatar that's not the current file
    (different hash, or no avatar set) returns 404 no-avatar with
    a small text/plain body.
  • Route is in the public-auth-bypass set alongside
    /api/v1/invite/* and /setup — no bearer required.

End-to-end:

  • HTTP-level test (against the existing newRig pattern in
    internal/api/api_test.go) covers: PUT PNG → identity.avatar
    column reflects the canonical URL → GET on that URL returns
    the bytes with the immutable cache header.
  • go build ./... and go test ./... pass.

Blocked by

None — can start immediately.

The spec library's actor doc structure (pkg/posta.ActorDoc.Avatar) and
the inbox's dynamicActor() already render whatever's in the identity
row's avatar column, so no spec-library work is required.

## Parent posta/server#15 — Operator-uploaded avatar served from the identity's URL ## What to build The tracer bullet for avatar uploads: a complete PNG-only path through the whole stack. After this slice an operator (or the iOS app, with JPEG support shipping in S3) can `PUT` a PNG to their identity, fetch the actor doc to discover the new `avatar` URL, and `GET` the avatar bytes — no auth needed for the GET, immutable HTTP caching, propagation of subsequent uploads via the actor-doc TTL. End-to-end behaviour after this slice: - `PUT /api/v1/identity/avatar` with `Content-Type: image/png` and raw PNG bytes succeeds: server sniffs the magic bytes, validates against the declared type, hashes the body (SHA-256), writes the file atomically as `avatar-<hash>.png` under the per-identity data directory, deletes any prior `avatar-*` file, sets the identity row's `avatar` column to `<canonical-actor-URL>/avatar-<hash>.png`, publishes an `identity-changed` SSE event, and responds `200` with a JSON body carrying the new URL. - A subsequent fetch of the identity's actor doc (`GET /` on the identity's host) carries the new `avatar` URL. - `GET /avatar-<hash>.png` on the identity's host returns `200`, `Content-Type: image/png`, `Cache-Control: max-age=31536000, immutable`, `ETag: "<hash>"`, and the original bytes. Public — no bearer token. - A repeat GET with `If-None-Match: "<hash>"` returns `304 Not Modified`. - A GET for an `avatar-<stale-hash>.png` whose file no longer exists (e.g. after a re-upload) returns `404 no-avatar`. - Uploading the same PNG bytes twice produces the same filename and is effectively a no-op. - Uploading a body that doesn't sniff as PNG returns `400` with a stable category in the `error` field (`unsupported-media-type`, `content-type-mismatch`, `not-an-image`, or `truncated`). - Uploading a body larger than 1 MiB fails at body-read time with `413 payload-too-large`. ## Acceptance criteria **Image sniff (Module A, PNG only):** - [ ] New module (e.g. `internal/imagesniff`) exposes a function that takes bytes (or `io.Reader`-with-limit) plus the declared `Content-Type` and returns either `(extension, validated-type, nil)` or a typed rejection carrying a stable category string. - [ ] PNG accept path recognises the 8-byte signature `\x89PNG\r\n\x1a\n` and returns `("png", "image/png", nil)`. - [ ] Rejection categories surfaced: `unsupported-media-type` (declared type isn't `image/png`), `content-type-mismatch` (bytes don't match the declared type — e.g. JPEG bytes declared as PNG), `not-an-image` (magic bytes don't match any supported type), `truncated` (body shorter than the magic-byte header). - [ ] Table-driven test fixtures cover at minimum: valid PNG; JPEG bytes declared as PNG (mismatch); GIF `GIF87a` (not-an-image); truncated (3-byte) input. **Avatar storage (Module B, content-addressed):** - [ ] New module (e.g. `internal/avatarstore`) takes a per-identity data directory. - [ ] Write API: takes bytes + extension, returns `(filename "avatar-<hex>.<ext>", hash hex, error)`. Implementation: hash the bytes (SHA-256), write to `<dir>/avatar-<hex>.<ext>.tmp`, fsync, rename to `<dir>/avatar-<hex>.<ext>`. Then delete every other `avatar-*` file in the directory. - [ ] Locate API: returns the current avatar's (filename, extension, hex) or a sentinel "no avatar set" error. - [ ] Idempotency: uploading identical bytes twice produces the same filename; the second rename is a no-op (atomic over identical content). No orphan tmp files. - [ ] Tests over `t.TempDir()`: write PNG → locate returns the same filename + hash; write a different PNG over the first → only the new file remains in the directory; write identical PNG twice → no-op (same filename, same content). **API handlers (Module C, PUT only — DELETE in S2):** - [ ] `PUT /api/v1/identity/avatar` registered on the authenticated mux. Request body is read through `http.MaxBytesReader` at `1 << 20` (1 MiB) so oversized bodies fail with `413 payload-too-large`. - [ ] Body sniffed via the imagesniff module; rejections surface as `400` with `{"error": "<category>"}` per the PRD's category strings. - [ ] On success: file written via the avatar-storage module, then `UPDATE identity SET avatar = ?` with the canonical content-addressed URL (host + path of the identity's URL plus `/avatar-<hash>.png`). - [ ] `identity-changed` SSE event published (same shape and code path as `PATCH /api/v1/identity` uses today). - [ ] Response is `200` with JSON body `{"url": "<canonical-URL>", "hash": "<hex>"}`. - [ ] `api.Server` gains an `AvatarStore` field; `internal/daemon/runner.go` wires it from the per-identity data directory. **Per-identity GET route (Module D):** - [ ] New route on the per-identity mux: pattern matches `/avatar-{hash}.png` (and any future extension in S3). - [ ] On a request whose filename matches the on-disk avatar file: serve bytes with `Content-Type: image/png`, `Cache-Control: max-age=31536000, immutable`, `ETag: "<hash>"`. - [ ] `If-None-Match: "<hash>"` for the current avatar returns `304 Not Modified` (no body). - [ ] A request for an avatar that's not the current file (different hash, or no avatar set) returns `404 no-avatar` with a small text/plain body. - [ ] Route is in the public-auth-bypass set alongside `/api/v1/invite/*` and `/setup` — no bearer required. **End-to-end:** - [ ] HTTP-level test (against the existing `newRig` pattern in `internal/api/api_test.go`) covers: PUT PNG → identity.avatar column reflects the canonical URL → GET on that URL returns the bytes with the immutable cache header. - [ ] `go build ./...` and `go test ./...` pass. ## Blocked by None — can start immediately. The spec library's actor doc structure (`pkg/posta.ActorDoc.Avatar`) and the inbox's `dynamicActor()` already render whatever's in the identity row's avatar column, so no spec-library work is required.
Author
Owner

This was generated by AI during triage.

Tracer bullet for the avatar feature. Acceptance criteria above already function as an agent brief — four modules (image sniff, avatar storage, PUT handler, GET route) ship together as one end-to-end vertical slice.

No precondition: the spec library's pkg/posta.ActorDoc.Avatar field is already in place and dynamicActor() in internal/inbox/inbox.go already renders whatever's in the identity row's avatar column.

Category: enhancement
State: ready-for-agent

> *This was generated by AI during triage.* Tracer bullet for the avatar feature. Acceptance criteria above already function as an agent brief — four modules (image sniff, avatar storage, PUT handler, GET route) ship together as one end-to-end vertical slice. No precondition: the spec library's `pkg/posta.ActorDoc.Avatar` field is already in place and `dynamicActor()` in `internal/inbox/inbox.go` already renders whatever's in the identity row's avatar column. **Category:** enhancement **State:** ready-for-agent
arne closed this issue 2026-05-13 01:41:49 +02: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
posta/server#16
No description provided.