Operator-uploaded avatar served from the identity's URL #15
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
posta/server#15
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?
Problem Statement
The actor doc has an
avatarfield (SPEC §5.2, render-only metadata), butposta-server today gives the operator no way to actually host an avatar
image. The identity row can carry an
avatarURL string — set viaPATCH /api/v1/identity— but the operator has to host the imagethemselves on an unrelated CDN or web server, then paste the URL in.
This is a blocker for the iOS app: in-app avatar editing is a baseline
user-facing feature, and the app has no business uploading anywhere
except the user's own posta-server. Without server-hosted avatars the
iOS UX collapses to "upload your avatar to S3 and paste the URL into
Settings" — not viable.
Solution
Add a per-identity avatar upload to the REST surface, store the image on
disk under the per-identity data directory, and serve it as a static
file from the same host the identity's actor doc lives on. The avatar's
URL is content-addressed (
/avatar-<sha256-hex>.<ext>) and iswritten into the
identity.avatarcolumn so the actor doc'savatarfield renders it automatically on next GET. Content-addressed naming
lets us ship the avatar as immutable (
max-age=1y); when Alice uploadsa new avatar the URL itself changes, peers fetch the new URL after their
actor-doc cache refreshes (≤5 min), and the old URL becomes unreferenced.
End-to-end:
PUT /api/v1/identity/avatarwith raw image bytes and aContent-Typeheader. Server sniffs magic bytes, validates againstthe declared type, rejects mismatches and unsupported types with
400. On success: hashes the bytes, writes the file atomically asavatar-<hash>.<ext>under the per-identity directory, deletes anyprior
avatar-*file, sets the identity row'savatarto<canonical-URL>/avatar-<hash>.<ext>.avatarURL, and GET it directly from the daemon. The daemon servesit public (no auth), with
Cache-Control: max-age=31536000, immutableand
ETag. Clients cache it effectively forever; propagation of anew avatar happens automatically because the URL changes.
DELETE /api/v1/identity/avatarremoves the file and clears the column.User Stories
Remove photoaction that deletes my server-side avatar without replacing it./var/lib/posta/<slug>/), so backup and restore of one identity is still one rsync.file avatar-*.pngand have it show as a real PNG.404 no-avatar-setvs500 read-failed) so I can tell whether the file is missing or the disk is unhappy.413 payload-too-large, not buffered in memory.Content-Typevalidation to be magic-byte-based, not trust the header, so aContent-Type: image/pngbody that's actually a shell script can't get written to disk under a.pngname.avatar-*file cleaned up on upload so the per-identity directory doesn't accumulate stale files indefinitely.avatarfield to be either a fully-qualified URL or absent — never a relative path — so spec-compliant verifiers can consume it without rewriting.Implementation Decisions
Module A — image sniff (
internal/imagesniff, new, deep)A pure module that takes raw bytes (or an
io.Reader-with-limit) and thedeclared
Content-Type, and returns either the validated content type +canonical file extension or a typed rejection. v0 accepts only PNG and
JPEG; WEBP and HEIC are out of scope. Magic-byte detection is
authoritative — the request
Content-Typeis cross-checked but thesniffed type wins, so a mismatch is rejected as
content-type-mismatchrather than silently saved under the wrong extension.
Rejection categories:
unsupported-media-type— declared type isn'timage/pngorimage/jpegcontent-type-mismatch— bytes don't match the declared typenot-an-image— magic bytes don't match any supported typetruncated— body shorter than the magic-byte headerModule B — avatar storage (
internal/avatarstoreor ininternal/store, new)Filesystem-backed per-identity storage with content-addressed filenames.
Public surface (TBD names):
avatar-<hash>.<ext>.tmp,os.WriteFile,os.Renametoavatar-<hash>.<ext>. Then delete any otheravatar-*files in thedirectory so each identity has at most one avatar on disk.
hash is recoverable from the filename; no sibling metadata file.
The module owns the directory contract: callers pass the per-identity
data dir, the module handles the rest. Idempotency: uploading the same
bytes twice produces the same filename and is a no-op (the rename
succeeds atomically over the identical file).
ETagis the hex digestin the filename, so it's free to compute.
Module C — API avatar handlers (
internal/api)Two new routes on the authed mux:
PUT /api/v1/identity/avatar— body is raw image bytes. Body cap isenforced via
http.MaxBytesReaderat 1 MiB so oversized uploadsfail at read time with
413 payload-too-large. Sniff via Module A,store via Module B, then
UPDATE identity SET avatar = ?with thecanonical content-addressed avatar URL. Publish an
identity-changedSSE event (same as
PATCH /api/v1/identitydoes today). Returns200with a small JSON body carrying the new avatar URL and thehash (also derivable from the URL).
DELETE /api/v1/identity/avatar— Module B removes the file,UPDATE identity SET avatar = ''clears the column. Publishidentity-changed. Returns204.The Server struct gains an
AvatarStorefield, wired frominternal/daemon/runner.go(per-identity data dir → per-identity storeinstance).
Module D — per-identity GET route
The per-identity mux (built in
daemon/runner.go) currently mounts/api/v1/,/setup, and/(the inbox/actor handler). Add a newpublic route that handles
GET /avatar-<hash>.{png,jpg}(pattern, notfixed path). The handler asks the AvatarStore for the current file; if
the requested filename doesn't match the on-disk file (e.g. a stale
URL after a re-upload) or no avatar is set, returns
404 no-avatarwith a small text body. Otherwise serves the file bytes with
Content-Type: image/png|jpeg,Cache-Control: max-age=31536000, immutable,ETag: "<hash>". HonoursIf-None-Matchfor304 Not Modified(cheap for clients with HTTP caching disabled).Auth bypass: the avatar route joins
/api/v1/invite/*and/setupinthe public-prefix carve-out (
PublicAuthBypassPrefixesininternal/api).Module E — PATCH /api/v1/identity behavior
The existing PATCH endpoint accepts
name,about,avatar. v0decision: drop
avatarfrom the PATCH allowed fields. The only way toset the avatar URL is via the new PUT endpoint, which guarantees the
URL points at a real file the daemon serves AND uses a content-addressed
shape that immutable caching depends on. Operators with external avatar
URLs (CDN, etc.) lose that path in v0; if reinstated later it should be
a separate
external-avatar-urlfield that PATCH accepts, not are-overload of the same field.
Actor doc wiring
No code change needed.
dynamicActor()ininternal/inbox/inbox.goalready reads
identity.avataron every GET; once the PUT handlerwrites the URL into that column, the actor doc reflects it
automatically. Peers see the change within their actor-doc cache TTL
(≤5 min, §5.3).
Avatar URL shape
<canonical-actor-URL>/avatar-<sha256-hex>.<ext>. Forhttps://arne.posta.noand SHA-256 prefixabc123...of a PNG, that'shttps://arne.posta.no/avatar-abc123....png. For multi-tenantpath-based hosts (
https://posta.no/u/arne) it'shttps://posta.no/u/arne/avatar-abc123....png. The mux must routeboth shapes; existing per-identity dispatch already keys on Host, and
pathOf(opts.URL)in the inbox covers the path prefix.The hash is the full 64-hex-char SHA-256. Truncation (e.g. first 16
chars) would shorten URLs but trade off integrity; v0 ships the full
digest.
Cache and propagation
Avatar URL:
Cache-Control: max-age=31536000, immutable+ETag.Because the URL is content-addressed, the file at that URL never
changes — clients can cache forever, no revalidation needed.
Propagation of a new avatar happens via the actor doc: the URL inside
the actor doc changes, peers see the new URL after their ≤5 min
actor-doc cache (§5.3) refreshes, fetch the new avatar with a fresh
URL that bypasses their HTTP cache entirely.
The actor-doc cache itself remains capped at 5 min per §5.3 — no
change required.
Size cap
1 MiB. Covers the photographic-PNG case (a portrait shot at 512×512
can run 300–800 KB as PNG), JPEG of any reasonable resolution, and
leaves headroom for slightly larger uploads from clients that don't
aggressively downscale. iOS guidance (Further Notes) recommends
JPEG for photographs to stay well under the cap; the 1 MiB ceiling is
the server's safety net, not the expected upload size.
Testing Decisions
What makes a good test here. Tests exercise external behavior at
module seams. Module A's contract is "bytes → (extension, error)"; no
HTTP, no disk. Module B's contract is "directory + payload → atomic
file replace with content-addressed name"; tested against a
t.TempDir(). Modules C and D test through the HTTP surface (existingnewRigpattern ininternal/api/api_test.go).Modules under test:
signature
\x89PNG\r\n\x1a\n) and valid JPEG (\xff\xd8\xff\xe0,\xff\xd8\xff\xe1,\xff\xd8\xff\xdb). Rejection cases: HEIC(
ftypbrand), GIF (GIF87a/GIF89a), WEBP (RIFF/WEBP),truncated PNG (under 8 bytes), Content-Type mismatch (PNG bytes
declared as
image/jpeg). Table-driven, no I/O.t.TempDir()rig. Write PNG → readreturns the same bytes + extension + hash. Write JPEG over the
existing PNG → only the new
avatar-<newhash>.jpgsurvives. Writeidentical PNG twice → same filename, no-op. Remove → file gone,
second remove is a no-op. Crash-simulation by failing the
Rename→ assert the prior file is untouched.
newRig(existing test rig ininternal/api/api_test.go). PUT with a valid PNG returns200,identity.avatar column carries the canonical content-addressed URL,
response body's
hashmatches the URL. PUT with a JPEG declared asPNG returns
400witherror: content-type-mismatch. PUT exceeding1 MiB returns
413. DELETE returns204and clears the column.SSE subscriber receives one
identity-changedevent per mutation.a pre-seeded AvatarStore. GET on the current filename returns
200with correct
Content-TypeandCache-Control: max-age=31536000, immutable. GET with matchingIf-None-Matchreturns304. GET ona stale (no-longer-current) filename returns
404. GET with noavatar set returns
404. Auth bypass works (no bearer token).Prior art for the rig:
internal/api/api_test.go newRigalready wiresin-memory SQLite + httptest.NewServer; same pattern for C and D. Module
A is pure and tested in isolation. Module B uses
t.TempDir()with noother dependencies.
Out of Scope
before upload, and WEBP/AVIF adoption can wait until the spec library
has a chance to weigh in on the canonical set of accepted types.
whatever bytes the client sent. Clients are responsible for sending
appropriately-sized images. Defer until usage shows the bandwidth
cost matters.
resize at render time.
consumers fetch and inspect locally.
scope-cut. If operators need to host elsewhere later, add an explicit
external-avatar-urlfield to PATCH at that point.URLs. None exist in production at the time of writing (the field
has been wired but never used by clients); any test data with an
externally-pointed
identity.avatarvalue remains as-is and thedaemon will keep serving the actor doc with that URL until the
operator overwrites it via PUT or DELETE.
Further Notes
SHOULD downscale to ~512×512 and re-encode as JPEG before upload.
PNG is the wrong format for photographic content — a portrait shot
saved as PNG at 512×512 typically lands 300–800 KB, vs ~30–120 KB
as JPEG quality-80. PNG is appropriate for logo/graphic-style
avatars (flat colors, transparency). The server's 1 MiB cap is the
safety net, not the expected upload size.
URL (PRD #10). The identity's URL is already canonical at runtime
(per #12); appending
/avatar-<hash>.<ext>preserves canonicalitybecause the lowercase hex hash + lowercase
.png/.jpgpath-segment satisfies §4.1 (no userinfo, no query, no fragment, no
dot-segments).
Cache-Control: max-age=300) on the actordoc itself stays unchanged — the avatar's separate immutable caching
works in concert with it: actor doc TTL bounds how fast a new avatar
URL propagates; immutable caching means once propagated the file is
fetched at most once per client.
PUTendpoint is the minimumsurface iOS needs. The iOS app's avatar-edit screen depends on this
shipping; track that dependency on the iOS-side issue tracker.
Umbrella PRD. No code goes directly into this issue; the implementation lives in four child slices:
No spec-library precondition this time — every slice is self-contained against the current
pkg/postasurface.Closing this issue is a maintainer decision after the four children land — hence ready-for-human rather than ready-for-agent.
Category: enhancement
State: ready-for-human