Introduces the SDD root: a v1.0.0 constitution and machine-readable AGENTS.md grounded in the project's real conventions; six EARS-aware persona spec-review checklists that cross-reference .claude/personas/; feature-spec/ADR/threat-model/ api-contract templates; a fully worked _example feature; a living RTM; and an adrs/ pointer that reuses the existing docs/adr/ archive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3.7 KiB
3.7 KiB
Design — Profile picture upload
Companion to
./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. |
| 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 →
UserServiceonly;UserServiceownsuserRepositoryand callsFileService(its public API), never another domain's repository. (constitution §1.2–1.3) - New
ErrorCode.AVATAR_TOO_LARGErequires the four-site update (seetasks.mdT-1). UserProfileView.avatarUrlisString(nullable) with@Schemadescribing the proxy path; not markedrequiredMode = REQUIREDbecause it is legitimately null (REQ-004).- After backend changes:
npm run generate:apiregeneratesavatarUrlinto 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-sizeis set to a matching ceiling so an oversized body is rejected at the container edge too. - No N+1 risk: the profile view derives
avatarUrlfrom the already-loadedavatarObjectKeycolumn; no extra query, no S3 round-trip on list/read paths. - The proxy
GETstreams bytes (no full-buffer) and sets a shortCache-Controlso 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) |