# Design — Profile picture upload > Companion to [`./spec.md`](./spec.md). The spec says *what*; this says *how*, and records > the alternatives weighed for the non-obvious choices. ## Component overview ``` ProfileSettings.svelte ──► +page.server.ts (form action) (preview, validate) │ POST /api/users/me/avatar (multipart) ▼ UserAvatarController ── @RequirePermission(authenticated) │ ownership/admin check for /{id} ▼ UserService.setAvatar(userId, MultipartFile) │ validate type+size → ErrorCode ├──► FileService.put("avatars/{userId}", bytes) (MinIO) └──► userRepository.save(user.avatarObjectKey=key) ▼ UserProfileView { …, avatarUrl } ``` Reads: `GET /api/users/{id}/avatar` streams the object through the authenticated API (`FileService.get`), so no public S3 URL is ever exposed. `avatarUrl` in the view is simply `/api/users/{id}/avatar` when a key exists, else `null`. ## Key decisions | Decision | Choice | Why | |---|---|---| | Where avatars live | Existing archive bucket, `avatars/{userId}` prefix | No new bucket/env var/Compose change — see [ADR-001](./adr-001-avatars-reuse-archive-bucket.md). | | URL exposure | Authenticated proxy endpoint, not a signed/public URL | Same auth surface as the rest of the API; no key leakage (Information disclosure). | | Object key | Deterministic `avatars/{userId}` (no random suffix) | A new upload overwrites the old object — no orphan-cleanup job needed (REQ-001). | | `avatarObjectKey` binding | Server-set in `UserService` only | Never bound from request body — prevents pointing a user's avatar at an arbitrary object (Tampering / CWE-639). | | Validation site | `UserService`, boundary-only | Type + size checked once, at the service boundary, mapped to `ErrorCode` (constitution §2.3). | ## Layering & conventions - Controller → `UserService` only; `UserService` owns `userRepository` and calls `FileService` (its public API), never another domain's repository. (constitution §1.2–1.3) - New `ErrorCode.AVATAR_TOO_LARGE` requires the four-site update (see `tasks.md` T-1). - `UserProfileView.avatarUrl` is `String` (nullable) with `@Schema` describing the proxy path; not marked `requiredMode = REQUIRED` because it is legitimately null (REQ-004). - After backend changes: `npm run generate:api` regenerates `avatarUrl` into the TS types. ## Non-functional notes - Size cap (2 MB, REQ-008) is enforced **before** the object touches MinIO — the multipart is read into a bounded buffer; Spring's `spring.servlet.multipart.max-file-size` is set to a matching ceiling so an oversized body is rejected at the container edge too. - No N+1 risk: the profile view derives `avatarUrl` from the already-loaded `avatarObjectKey` column; no extra query, no S3 round-trip on list/read paths. - The proxy `GET` streams bytes (no full-buffer) and sets a short `Cache-Control` so an updated avatar propagates quickly. ## Test strategy (maps to tasks.md) | Level | What | Tooling | |---|---|---| | Unit | `UserService.setAvatar` validation + storage interactions | JUnit + Mockito (mock `FileService`) | | Slice | controller auth, status codes, error codes | `@WebMvcTest` | | E2E | upload → preview → confirm → avatar visible; remove → initials | Playwright | | Component | initials placeholder when `avatarUrl` is null | `vitest-browser-svelte` (`*.svelte.spec.ts`) |