S1: PNG avatar upload + public GET (content-addressed, immutable cache) #16
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
posta/server#16
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
PUTa PNG to their identity, fetchthe actor doc to discover the new
avatarURL, andGETthe avatarbytes — 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/avatarwithContent-Type: image/pngand rawPNG bytes succeeds: server sniffs the magic bytes, validates against
the declared type, hashes the body (SHA-256), writes the file
atomically as
avatar-<hash>.pngunder the per-identity datadirectory, deletes any prior
avatar-*file, sets the identityrow's
avatarcolumn to<canonical-actor-URL>/avatar-<hash>.png,publishes an
identity-changedSSE event, and responds200witha JSON body carrying the new URL.
GET /on theidentity's host) carries the new
avatarURL.GET /avatar-<hash>.pngon the identity's host returns200,Content-Type: image/png,Cache-Control: max-age=31536000, immutable,ETag: "<hash>", and the original bytes. Public — nobearer token.
If-None-Match: "<hash>"returns304 Not Modified.avatar-<stale-hash>.pngwhose file no longer exists(e.g. after a re-upload) returns
404 no-avatar.effectively a no-op.
400with a stablecategory in the
errorfield (unsupported-media-type,content-type-mismatch,not-an-image, ortruncated).413 payload-too-large.Acceptance criteria
Image sniff (Module A, PNG only):
internal/imagesniff) exposes a function thattakes bytes (or
io.Reader-with-limit) plus the declaredContent-Typeand returns either(extension, validated-type, nil)or a typed rejection carrying a stable category string.
\x89PNG\r\n\x1a\nand returns
("png", "image/png", nil).unsupported-media-type(declaredtype isn't
image/png),content-type-mismatch(bytes don'tmatch 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).bytes declared as PNG (mismatch); GIF
GIF87a(not-an-image);truncated (3-byte) input.
Avatar storage (Module B, content-addressed):
internal/avatarstore) takes a per-identitydata directory.
(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 otheravatar-*file in the directory.
hex) or a sentinel "no avatar set" error.
filename; the second rename is a no-op (atomic over identical
content). No orphan tmp files.
t.TempDir(): write PNG → locate returns the samefilename + 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/avatarregistered on the authenticatedmux. Request body is read through
http.MaxBytesReaderat1 << 20(1 MiB) so oversized bodies fail with413 payload-too-large.400with{"error": "<category>"}per the PRD's categorystrings.
UPDATE identity SET avatar = ?with the canonicalcontent-addressed URL (host + path of the identity's URL plus
/avatar-<hash>.png).identity-changedSSE event published (same shape and codepath as
PATCH /api/v1/identityuses today).200with JSON body{"url": "<canonical-URL>", "hash": "<hex>"}.api.Servergains anAvatarStorefield;internal/daemon/runner.gowires it from the per-identity data directory.
Per-identity GET route (Module D):
/avatar-{hash}.png(and any future extension in S3).serve bytes with
Content-Type: image/png,Cache-Control: max-age=31536000, immutable,ETag: "<hash>".If-None-Match: "<hash>"for the current avatar returns304 Not Modified(no body).(different hash, or no avatar set) returns
404 no-avatarwitha small text/plain body.
/api/v1/invite/*and/setup— no bearer required.End-to-end:
newRigpattern ininternal/api/api_test.go) covers: PUT PNG → identity.avatarcolumn reflects the canonical URL → GET on that URL returns
the bytes with the immutable cache header.
go build ./...andgo test ./...pass.Blocked by
None — can start immediately.
The spec library's actor doc structure (
pkg/posta.ActorDoc.Avatar) andthe inbox's
dynamicActor()already render whatever's in the identityrow's avatar column, so no spec-library work is required.
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.Avatarfield is already in place anddynamicActor()ininternal/inbox/inbox.goalready renders whatever's in the identity row's avatar column.Category: enhancement
State: ready-for-agent