feat(profile): consistent profile menu, first name, uploadable avatar (BUNYIP-408) #397

Merged
longjacksonle merged 1 commit from feat/BUNYIP-408-profile-menu-avatar into main 2026-07-28 20:55:08 +02:00

What

Implements BUNYIP-408: a consistent profile menu on every shell, the user shown by first name instead of a raw email, and an uploadable avatar replacing the generic silhouette.

Changes

  • Shared profile menu (bunyip-web/src/views/layout.rs): a new profile_menu (avatar button opening a <details> dropdown with Profile + Log out) replaces the standalone logout link and the raw-email text in app_topbar (dashboard + admin shells) and in the public header, so the documentation and marketing pages reach profile info through the identical affordance. Dismissal is click-away / Escape via one delegated listener; keyboard-accessible, no framework.
  • First name in the top bar: User::display_name prefers the first name and falls back to the email local part (never the full email). avatar_badge renders the uploaded image, or a gradient circle with the initial when unset.
  • Avatar storage (user_avatars BYTEA table + users.avatar_updated_at): mirrors the feedback-attachment hardening (BUNYIP-90). Bytes never touch a filesystem or static mount; they are served only through an authenticated bunyip-api handler with an explicit image Content-Type and Content-Disposition: inline. bunyip-api has no static file mount, so an avatar can never be served from an origin where it could execute (the security constraint the ticket flags). avatar_updated_at is the cheap existence marker (keeps the BYTEA off the hot user-fetch path) and the ?v= cache-buster, set/cleared in the same transaction as the blob.
  • Content-based validation: declared MIME and filename ignored; type sniffed from magic bytes (png/jpeg/webp/gif only); dimensions parsed header-only (<= 4096x4096, no decoder run); size capped at 2 MiB streaming, backstopped by DB CHECK constraints.
  • Routes: API POST/DELETE/GET /v1/users/me/avatar; web POST /settings/avatar, POST /settings/avatar/remove, same-origin proxy GET /me/avatar. The Settings Profile card gains an avatar preview, upload form, and remove button.

Note on the ticket's proposed avatar_url column

The ticket suggested a nullable avatar_url string column. Storing a raw URL a user controls, or serving uploaded bytes from a static path, is exactly the "executable origin" risk the ticket also flags. Instead the bytes are stored server-side and served through a controlled handler (matching the existing feedback-attachment precedent), and the client derives the same-origin <img> src from avatar_updated_at. Functionally equivalent, and it satisfies the validation + safe-serving acceptance criteria by construction.

Acceptance criteria

  • Consistent profile menu (profile + logout) upper-right across dashboard, admin, and public shells (incl. docs pages).
  • Top bar shows first name with a defined fallback (email local part) instead of the full email.
  • Users can upload an avatar; it displays in place of the silhouette, with an initials fallback when unset.
  • Uploads validated for content type (magic-byte sniff), size (2 MiB), and dimensions.
  • Navigation to profile info verified from every shell (rendering + helper unit tests).

Verification

  • just check-container green: fmt + clippy -D warnings + all workspace test binaries. New unit tests: content-sniff / dimension guards (API handlers::avatar), and display_name / avatar_initial / avatar_src (web api::types).
  • Migration applied against a throwaway Postgres: applies cleanly, UPSERT replaces the row, and both CHECK constraints reject an octet_length/size_bytes mismatch and an over-2-MiB blob. users.avatar_updated_at present as timestamptz.

🤖 Generated with Claude Code

## What Implements BUNYIP-408: a consistent profile menu on every shell, the user shown by first name instead of a raw email, and an uploadable avatar replacing the generic silhouette. ## Changes - **Shared profile menu** (`bunyip-web/src/views/layout.rs`): a new `profile_menu` (avatar button opening a `<details>` dropdown with Profile + Log out) replaces the standalone logout link and the raw-email text in `app_topbar` (dashboard + admin shells) and in the public `header`, so the documentation and marketing pages reach profile info through the identical affordance. Dismissal is click-away / Escape via one delegated listener; keyboard-accessible, no framework. - **First name in the top bar**: `User::display_name` prefers the first name and falls back to the email local part (never the full email). `avatar_badge` renders the uploaded image, or a gradient circle with the initial when unset. - **Avatar storage** (`user_avatars` BYTEA table + `users.avatar_updated_at`): mirrors the feedback-attachment hardening (BUNYIP-90). Bytes never touch a filesystem or static mount; they are served only through an authenticated bunyip-api handler with an explicit image `Content-Type` and `Content-Disposition: inline`. bunyip-api has no static file mount, so an avatar can never be served from an origin where it could execute (the security constraint the ticket flags). `avatar_updated_at` is the cheap existence marker (keeps the BYTEA off the hot user-fetch path) and the `?v=` cache-buster, set/cleared in the same transaction as the blob. - **Content-based validation**: declared MIME and filename ignored; type sniffed from magic bytes (png/jpeg/webp/gif only); dimensions parsed header-only (<= 4096x4096, no decoder run); size capped at 2 MiB streaming, backstopped by DB CHECK constraints. - **Routes**: API `POST/DELETE/GET /v1/users/me/avatar`; web `POST /settings/avatar`, `POST /settings/avatar/remove`, same-origin proxy `GET /me/avatar`. The Settings Profile card gains an avatar preview, upload form, and remove button. ## Note on the ticket's proposed `avatar_url` column The ticket suggested a nullable `avatar_url` string column. Storing a raw URL a user controls, or serving uploaded bytes from a static path, is exactly the "executable origin" risk the ticket also flags. Instead the bytes are stored server-side and served through a controlled handler (matching the existing feedback-attachment precedent), and the client derives the same-origin `<img>` src from `avatar_updated_at`. Functionally equivalent, and it satisfies the validation + safe-serving acceptance criteria by construction. ## Acceptance criteria - [x] Consistent profile menu (profile + logout) upper-right across dashboard, admin, and public shells (incl. docs pages). - [x] Top bar shows first name with a defined fallback (email local part) instead of the full email. - [x] Users can upload an avatar; it displays in place of the silhouette, with an initials fallback when unset. - [x] Uploads validated for content type (magic-byte sniff), size (2 MiB), and dimensions. - [x] Navigation to profile info verified from every shell (rendering + helper unit tests). ## Verification - `just check-container` green: fmt + clippy `-D warnings` + all workspace test binaries. New unit tests: content-sniff / dimension guards (API `handlers::avatar`), and `display_name` / `avatar_initial` / `avatar_src` (web `api::types`). - Migration applied against a throwaway Postgres: applies cleanly, UPSERT replaces the row, and both CHECK constraints reject an `octet_length`/`size_bytes` mismatch and an over-2-MiB blob. `users.avatar_updated_at` present as `timestamptz`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(profile): consistent profile menu, first name, uploadable avatar (BUNYIP-408)
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 54s
Check / fmt + clippy + build + tests (pull_request) Successful in 4m13s
Create release / Create release from merged PR (pull_request) Has been skipped
ea1be22db3
Reaching profile information was inconsistent across shells and the user was represented by a raw email next to a generic silhouette. Every shell now offers the same upper-right profile menu, and the user is shown by first name plus an uploadable avatar with an initials fallback.

Chrome: a shared `profile_menu` (avatar button opening a `<details>` dropdown with Profile + Log out) replaces the standalone logout link + email text in `app_topbar` (dashboard + admin shells) and in the public `header`, so the documentation and marketing pages reach profile information through the identical affordance. The top bar shows `User::display_name` (first name, falling back to the email local part) instead of the full email, and `avatar_badge` renders the uploaded image or a gradient initial. Dismissal is click-away / Escape via one delegated listener; the menu is keyboard-accessible with no framework.

Avatar storage mirrors the feedback-attachment hardening (BUNYIP-90): bytes live in a new `user_avatars` BYTEA table (one row per user, UPSERT on re-upload), never on a filesystem or static mount, and are served back only through an authenticated bunyip-api handler that sets an explicit image Content-Type and `Content-Disposition: inline`. bunyip-api has no static file mount, so an uploaded avatar can never be served from an origin where it could execute - the security constraint the ticket calls out. bunyip-web proxies the bytes at same-origin `/me/avatar` so the `<img>` loads with the session cookie. A denormalized `users.avatar_updated_at` marks existence (so the hot user-fetch path never loads the BYTEA) and doubles as the `?v=` cache-buster; it is set/cleared in the same transaction as the blob so the two never drift.

Upload validation is content-based: the declared MIME and filename are ignored, the real type is sniffed from magic bytes (image/png|jpeg|webp|gif only), dimensions are parsed header-only (<= 4096x4096, no decoder run), and size is capped at 2 MiB streaming, backstopped by DB CHECK constraints. New API routes: POST/DELETE/GET /v1/users/me/avatar. New web routes: POST /settings/avatar, POST /settings/avatar/remove, GET /me/avatar; the Settings Profile card gains an avatar preview, upload form, and remove button.

Verification: `just check-container` green (fmt + clippy -D warnings + all test binaries), including new content-sniff/dimension unit tests (API) and display-name/avatar-src helper tests (web). The migration was applied against a throwaway Postgres: it applies cleanly, the UPSERT replaces correctly, and both CHECK constraints reject an octet_length/size mismatch and an over-2-MiB blob.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5dcYueNHByRnWJDYoDX1W
longjacksonle deleted branch feat/BUNYIP-408-profile-menu-avatar 2026-07-28 20:55:08 +02:00
Sign in to join this conversation.
No reviewers
No labels
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
psa-systems/bunyip!397
No description provided.