Avatar upload, content-addressed GET, DELETE, and PATCH cleanup #20

Merged
arne merged 3 commits from feature/avatar-upload into main 2026-05-13 01:41:49 +02:00
Owner

Resolves #16, #17, #18, #19 (parent #15).

Summary

  • #16 PNG avatar upload + public GET (tracer bullet) — new internal/imagesniff (magic-byte detection, stable rejection categories), new internal/avatarstore (content-addressed avatar-<sha256>.<ext>, atomic write + prior-file cleanup, idempotent on identical bytes), PUT /api/v1/identity/avatar (1 MiB cap, SSE fanout), public GET /avatar-<hash>.<ext> (immutable cache, ETag, If-None-Match → 304, 404 on stale/no-avatar).
  • #17 PATCH /api/v1/identity no longer accepts avatar; DisallowUnknownFields makes a stray field 400 the request.
  • #18 DELETE /api/v1/identity/avatar — idempotent, only publishes identity-changed when something was cleared.
  • #19 JPEG support — sniffer already recognises JPEG SOI variants; GET serves .jpg with Content-Type: image/jpeg.

CLIENT_API.md documents the full surface and the iOS JPEG-for-photos guidance from PRD #15.

Test plan

  • go test ./internal/imagesniff/... — format table, rejection categories
  • go test ./internal/avatarstore/... — atomic write, replacement deletes prior, idempotent, locate ignores siblings, remove, read
  • go test ./internal/api/... — PUT round-trip, 400/413 categories, GET immutable cache + ETag, stale-hash 404, no-avatar 404, anonymous GET, PNG→JPEG transition, DELETE clears + idempotency, PATCH rejects avatar
  • Manual: hit PUT/GET/DELETE against a running daemon and confirm SSE fanout on a second client
Resolves #16, #17, #18, #19 (parent #15). ## Summary - **#16** PNG avatar upload + public GET (tracer bullet) — new `internal/imagesniff` (magic-byte detection, stable rejection categories), new `internal/avatarstore` (content-addressed `avatar-<sha256>.<ext>`, atomic write + prior-file cleanup, idempotent on identical bytes), `PUT /api/v1/identity/avatar` (1 MiB cap, SSE fanout), public `GET /avatar-<hash>.<ext>` (immutable cache, ETag, If-None-Match → 304, 404 on stale/no-avatar). - **#17** PATCH `/api/v1/identity` no longer accepts `avatar`; `DisallowUnknownFields` makes a stray field 400 the request. - **#18** `DELETE /api/v1/identity/avatar` — idempotent, only publishes `identity-changed` when something was cleared. - **#19** JPEG support — sniffer already recognises JPEG SOI variants; GET serves `.jpg` with `Content-Type: image/jpeg`. CLIENT_API.md documents the full surface and the iOS JPEG-for-photos guidance from PRD #15. ## Test plan - [x] `go test ./internal/imagesniff/...` — format table, rejection categories - [x] `go test ./internal/avatarstore/...` — atomic write, replacement deletes prior, idempotent, locate ignores siblings, remove, read - [x] `go test ./internal/api/...` — PUT round-trip, 400/413 categories, GET immutable cache + ETag, stale-hash 404, no-avatar 404, anonymous GET, PNG→JPEG transition, DELETE clears + idempotency, PATCH rejects `avatar` - [ ] Manual: hit PUT/GET/DELETE against a running daemon and confirm SSE fanout on a second client
Resolves #16, #17, #18, #19 (parent #15). One branch, four issues' worth
of work; squash or split via interactive rebase before merge if desired.

#16 — PNG avatar upload + public GET (tracer bullet)
  - New internal/imagesniff: pure module, magic-byte detection. Returns
    (extension, content-type, nil) or *Error with stable Category string.
    Rejection categories: unsupported-media-type, content-type-mismatch,
    not-an-image, truncated. JPEG also recognised here (used by #19).
  - New internal/avatarstore: filesystem-backed per-identity storage.
    Content-addressed filenames (avatar-<sha256-hex>.<ext>). Atomic write
    (tmp → fsync → rename), prior-file cleanup, idempotent on identical
    bytes. Locate / Read / Remove APIs. ErrNotFound when no avatar.
  - PUT /api/v1/identity/avatar: body capped at 1 MiB via MaxBytesReader,
    sniff, store, set identity.avatar to canonical content-addressed
    URL, publish identity-changed SSE. 400 with §4.1-style category in
    the `error` field on rejection, 413 payload-too-large on cap.
  - GET /avatar-<hash>.<ext> on the per-identity mux (public — no auth).
    Cache-Control: public, max-age=31536000, immutable. ETag is the hash.
    Honours If-None-Match → 304. 404 for stale hashes or no avatar set.
    Dispatched from runner.go via a path-prefix wrap; Go's ServeMux
    can't wildcard mid-segment so the actor-doc / avatar split happens
    in code, not in the mux pattern.
  - api.Server gains Avatars field (AvatarStore interface); runner.go
    wires it from the per-identity data directory.
  - Tests: imagesniff table (PNG, JPEG variants, GIF/WEBP/HEIC reject,
    truncated, content-type-mismatch, unsupported-media-type); avatarstore
    against t.TempDir() (write, replacement deletes prior, idempotent on
    same bytes, locate ignores siblings, remove, read); HTTP-level tests
    against an httptest server (PUT PNG round-trips through identity row
    and SSE; 400 categories; 413; GET immutable cache + ETag; If-None-Match
    304; stale-hash 404; no-avatar 404; anonymous GET works).

#17 — Drop `avatar` field from PATCH /api/v1/identity
  - patchIdentity body schema is now {name?, about?}. readJSON already
    enforces DisallowUnknownFields, so a stray `avatar` field returns 400.
  - CLIENT_API.md updated: PATCH section calls out the carve-out and
    points readers at PUT/DELETE /api/v1/identity/avatar.
  - Tests: PATCH with avatar field → 400 + identity.avatar unchanged;
    PATCH with name/about still works.

#18 — DELETE /api/v1/identity/avatar
  - Removes the on-disk file via avatarstore.Remove, clears
    identity.avatar (only publishes identity-changed when it was set
    before the call), returns 204.
  - Idempotent: DELETE on empty avatar returns 204, DELETE-after-DELETE
    returns 204.
  - Tests: clears file + column; GET on previous URL returns 404;
    idempotency across consecutive deletes.

#19 — JPEG support
  - imagesniff already recognises JPEG (SOI + JFIF/EXIF/raw-DQT/APPn/COM
    fourth-byte markers); PUT handler already accepts image/jpeg via the
    sniff. GET route serves .jpg through the same /avatar- prefix handler
    with Content-Type: image/jpeg derived from the stored extension.
  - Tests: JPEG accept (multiple SOI variants); PNG→JPEG transition
    leaves only the new .jpg file and 404s the old .png URL.

CLIENT_API.md documents the full surface and includes the JPEG-for-photos
iOS guidance from PRD #15.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- deleteAvatar: clear column before removing file. Crashing between the
  two now leaves an orphan but unreachable file (next PUT sweeps it),
  versus the prior order which risked actor docs advertising a 404'ing
  avatar URL.
- avatarstore.Write: post-rename cleanup of stale siblings is now
  best-effort. The new file is already in place and the API only serves
  the column-recorded filename, so a leftover sibling is dead bytes the
  next Write will sweep. Logs avatarstore.cleanup_failed via slog.
- imagesniff.Sniff: truncation check is declared-type aware (PNG needs
  8 bytes, JPEG 4), and runs after sniffing so a 4-byte JPEG SOI
  declared as image/png still surfaces as content-type-mismatch rather
  than truncated. Tests cover both the new truncated case (5-byte PNG
  prefix) and the short-JPEG mismatch.
- PublicAvatarHandler HEAD: set headers from Locate metadata and return
  before calling Read, so HEAD no longer loads the file body into memory
  just to drop it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- api.Server gains an avatarMu sync.Mutex; putAvatar and deleteAvatar
  hold it across the file write + identity column update. Closes the
  race where two concurrent PUTs could interleave their cleanup passes
  and column writes and leave identity.avatar pointing at a file the
  other PUT's cleanup pass deleted.
- PublicAvatarHandler uses path.Base on the URL path instead of
  filepath.Base — semantically correct for URL paths, OS-independent.
- Sniff rejection responses no longer include err.Error() (which
  embedded the user-supplied Content-Type header in the message
  field). A new avatarRejectMessage helper returns fixed prose keyed
  by category; the wire-stable `error` code is unchanged.
- PublicAvatarHandler doc comment now explains why error responses
  use plain text rather than the /api/v1 JSON envelope (the URL
  lives at the actor-doc root and presents as a generic image URL,
  so error bodies stay shape-free).
- avatarstore.Locate tiebreaker comment dropped the "newer" framing —
  SHA-256 has no temporal meaning, this is just a deterministic pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
arne merged commit 9f84c184b5 into main 2026-05-13 01:41:49 +02:00
arne deleted branch feature/avatar-upload 2026-05-13 01:41:49 +02:00
Sign in to join this conversation.
No reviewers
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!20
No description provided.