299 lines
12 KiB
Markdown
299 lines
12 KiB
Markdown
|
|
# Plan: User Avatar / Image Support
|
|||
|
|
|
|||
|
|
## Context
|
|||
|
|
|
|||
|
|
Users need to be able to set a profile photo (avatar). The image must:
|
|||
|
|
- Be stored as a URL (`https://…`, `http://…`) or data URI (`data:image/(png|webp|jpeg);base64,…`)
|
|||
|
|
- Match the CardDAV `PHOTO` format so the system address book exports it correctly
|
|||
|
|
- Be editable **only** for local (username+password) accounts
|
|||
|
|
- Be **synced automatically from OIDC** `picture` claim on every login for OIDC accounts
|
|||
|
|
- Surface in `userVignette` components (owner column, ShareModal member rows)
|
|||
|
|
|
|||
|
|
Currently: no `image` column on `auth.users`, no `picture` claim extraction in OIDC, profile page shows initials only, `user_to_contact()` hardcodes `photo_url: None`.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Execution order
|
|||
|
|
|
|||
|
|
### 1. DB Migration
|
|||
|
|
**New file:** `migrations/20260526000000_add_user_image.sql`
|
|||
|
|
```sql
|
|||
|
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS image TEXT;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 2. Domain Entity
|
|||
|
|
**`src/domain/entities/user.rs`**
|
|||
|
|
- Add `image: Option<String>` field
|
|||
|
|
- `User::new()` and `User::new_oidc()` — initialise to `None`
|
|||
|
|
- Add getter `pub fn image(&self) -> Option<&str>`
|
|||
|
|
- Add setter `pub fn set_image(&mut self, image: Option<String>)`
|
|||
|
|
- Add owned getter for persistence `pub fn image_owned(&self) -> Option<String>`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 3. User Repository
|
|||
|
|
**`src/infrastructure/repositories/pg/user_pg_repository.rs`**
|
|||
|
|
- Add `image` to every `SELECT` that builds a `User` (row-mapper)
|
|||
|
|
- Extend the `UPDATE` SQL in `update_user()` to include `image = $11`
|
|||
|
|
- Add dedicated: `async fn update_image(&self, user_id: Uuid, image: Option<String>) -> Result<(), DomainError>`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 4. OIDC: extract `picture` claim
|
|||
|
|
**`src/application/ports/auth_ports.rs`**
|
|||
|
|
- Add `pub picture: Option<String>` to `OidcIdClaims`
|
|||
|
|
|
|||
|
|
**`src/infrastructure/services/oidc_service.rs`**
|
|||
|
|
- Add `picture: Option<String>` to both `IdTokenClaims` and `UserInfoResponse` structs
|
|||
|
|
- Pass `picture` into the returned `OidcIdClaims`
|
|||
|
|
|
|||
|
|
**`src/application/services/auth_application_service.rs`** — in `oidc_callback()`:
|
|||
|
|
- **Create path**: pass `claims.picture` to `User::new_oidc()`
|
|||
|
|
(or call `user.set_image(claims.picture.clone())` before persisting)
|
|||
|
|
- **Update path**: always call `user.set_image(claims.picture.clone())` then persist
|
|||
|
|
(OIDC image is always authoritative — overwrite even if user had set one before)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 5. User DTO
|
|||
|
|
**`src/application/dtos/user_dto.rs`**
|
|||
|
|
Add two fields to `UserDto`:
|
|||
|
|
```rust
|
|||
|
|
pub image: Option<String>,
|
|||
|
|
pub can_edit_image: bool, // true iff !user.is_oidc_user()
|
|||
|
|
```
|
|||
|
|
Populate in `UserDto::from(user)`.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 6. Validation helper (shared)
|
|||
|
|
In the auth application service (or a small `validation.rs` module in `src/common/`):
|
|||
|
|
```rust
|
|||
|
|
fn validate_image_url(image: &str) -> bool {
|
|||
|
|
image.starts_with("https://")
|
|||
|
|
|| image.starts_with("http://")
|
|||
|
|
|| image.starts_with("data:image/png;base64,")
|
|||
|
|
|| image.starts_with("data:image/webp;base64,")
|
|||
|
|
|| image.starts_with("data:image/jpeg;base64,")
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
Max length for data URIs: **10 KB** (10 608 bytes) to prevent DB abuse — a 1à4×104 WebP at quality 0.85 is well under this; a raw PNG could exceed it so the client must resize/compress first.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 7. Auth Application Service — new method
|
|||
|
|
**`src/application/services/auth_application_service.rs`**
|
|||
|
|
```rust
|
|||
|
|
pub async fn update_user_image(
|
|||
|
|
&self,
|
|||
|
|
caller_id: Uuid,
|
|||
|
|
image: Option<String>,
|
|||
|
|
) -> Result<(), AppError>
|
|||
|
|
```
|
|||
|
|
Logic:
|
|||
|
|
1. Load user from repository
|
|||
|
|
2. If `user.is_oidc_user()` → return `AppError::Forbidden`
|
|||
|
|
3. If `image.is_some()` → validate format + length; return `AppError::Validation` if invalid
|
|||
|
|
4. Call `user_repository.update_image(caller_id, image).await`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 8. Auth Handler + Route
|
|||
|
|
**`src/interfaces/api/handlers/auth_handler.rs`**
|
|||
|
|
|
|||
|
|
New DTO (inline or in a dto file):
|
|||
|
|
```rust
|
|||
|
|
#[derive(Deserialize)]
|
|||
|
|
pub struct UpdateUserImageDto {
|
|||
|
|
pub image: Option<String>, // None = clear the image
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
New handler `update_user_image` — pattern mirrors `change_password`:
|
|||
|
|
- Extract `CurrentUserId`, JSON body
|
|||
|
|
- Call service method
|
|||
|
|
- Map `AppError::Forbidden` → 403, `AppError::Validation` → 422, else 200
|
|||
|
|
|
|||
|
|
**`src/interfaces/api/routes.rs`** — in `auth_protected_routes()`:
|
|||
|
|
```rust
|
|||
|
|
.route("/me/image", put(update_user_image))
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 9. System Address Book
|
|||
|
|
**`src/interfaces/api/handlers/contacts_handler.rs`** — `user_to_contact()`:
|
|||
|
|
```rust
|
|||
|
|
photo_url: user.image.clone(), // was: None
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 10. Frontend — `systemUsers.js`
|
|||
|
|
**`static/js/model/systemUsers.js`**
|
|||
|
|
- Add `let _photoIndex = null;` (`Map<string, string|null>`)
|
|||
|
|
- In `_ensureIndex()`: build `_photoIndex` from `c.photo_url` alongside the name map
|
|||
|
|
- Inject current user's photo from `localStorage.getItem('oxicloud_user')?.image`
|
|||
|
|
- Add `async function getPhoto(userId): Promise<string|null>`
|
|||
|
|
- Export `{ prefetch, getDisplayName, getPhoto, isAvailable }`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 11. Frontend — `userVignette.js`
|
|||
|
|
**`static/js/components/userVignette.js`**
|
|||
|
|
|
|||
|
|
In `createUserVignette(userId, size)`:
|
|||
|
|
- After async name resolves, also await `systemUsers.getPhoto(userId)`
|
|||
|
|
- If photo URL is truthy: replace the initials text with `<img src="…" alt="…">` inside `user-vignette__avatar`
|
|||
|
|
- Wire `onerror` on the img to fall back to initials (guard against broken URLs)
|
|||
|
|
|
|||
|
|
CSS addition in `userVignette.css`:
|
|||
|
|
```css
|
|||
|
|
.user-vignette__avatar img {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
object-fit: cover;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 12. Frontend — User Menu (top-right)
|
|||
|
|
**`static/js/app/userMenu.js`** — `updateUserMenuData()`:
|
|||
|
|
- Read `user.image` from the stored `oxicloud_user` in localStorage
|
|||
|
|
- `#user-avatar` (38 px circle): if `user.image` is set, replace inner HTML with `<img src="…" alt="…">` instead of initials text; wire `onerror` fallback to initials
|
|||
|
|
- `#user-menu-avatar` (48 px circle in dropdown): same treatment
|
|||
|
|
- When `profile.js` saves a new image successfully, it must also refresh the stored `oxicloud_user` in localStorage (re-fetch `/api/auth/me` and update) then call `updateUserMenuData()`
|
|||
|
|
|
|||
|
|
**`static/css/components/userMenu.css`** — add inside the file:
|
|||
|
|
```css
|
|||
|
|
.user-avatar img,
|
|||
|
|
.user-menu-avatar img {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
object-fit: cover;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 13. Frontend — Image resize helper (new shared utility)
|
|||
|
|
**`static/js/utils/imageResize.js`** — new file
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
/**
|
|||
|
|
* Load a File/Blob as an Image, draw it on a Canvas, resize to fit within
|
|||
|
|
* MAX_SIZE × MAX_SIZE, and return a data URI.
|
|||
|
|
*
|
|||
|
|
* @param {File} file
|
|||
|
|
* @param {number} [maxSize=102]
|
|||
|
|
* @returns {Promise<string>} data:image/webp;base64,… (or jpeg fallback)
|
|||
|
|
*/
|
|||
|
|
export async function resizeImageToDataUrl(file, maxSize = 104)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Logic:
|
|||
|
|
1. Read file with `FileReader` → data URL
|
|||
|
|
2. Create `<img>` element and wait for `onload`
|
|||
|
|
3. Compute output dimensions: scale down proportionally if either dimension > `maxSize`; never scale up
|
|||
|
|
4. Draw onto `OffscreenCanvas` (or regular `<canvas>`) at the computed size
|
|||
|
|
5. Export with `canvas.toBlob('image/webp', 0.85)` (fallback to `image/jpeg` if WebP not supported)
|
|||
|
|
6. Convert Blob → base64 data URI via `FileReader`
|
|||
|
|
|
|||
|
|
Accepts only MIME types: `image/png`, `image/webp`, `image/jpeg` — reject others with a thrown `Error`.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 14. Frontend — Profile Page
|
|||
|
|
**`static/profile.html`**
|
|||
|
|
- Make `#p-avatar` support both `<img>` and initials text
|
|||
|
|
- Add edit button (pencil icon) visible only when `user.can_edit_image === true`
|
|||
|
|
- Add collapsible edit panel with **two input modes** (tabs or toggle):
|
|||
|
|
- **URL tab**: `<input type="url" id="p-image-url" placeholder="https://…">` with validation hint
|
|||
|
|
- **Upload tab**: `<input type="file" id="p-image-file" accept="image/png,image/jpeg,image/webp">` + live preview thumbnail
|
|||
|
|
- Save / Cancel / Remove (clear) buttons
|
|||
|
|
|
|||
|
|
**`static/js/views/profile/profile.js`**
|
|||
|
|
|
|||
|
|
*Display:*
|
|||
|
|
- If `user.image`: set `#p-avatar` to `<img src="…">` (with `onerror` → initials fallback)
|
|||
|
|
- If `user.can_edit_image`: show edit pencil
|
|||
|
|
- For OIDC users: show photo if `user.image` set; show "Managed by your identity provider" note; no edit controls
|
|||
|
|
|
|||
|
|
*URL mode save:*
|
|||
|
|
- Validate prefix client-side (`https://`, `http://`, `data:image/…;base64,`)
|
|||
|
|
- `PUT /api/auth/me/image` with `{ image: url || null }`
|
|||
|
|
|
|||
|
|
*Upload mode save:*
|
|||
|
|
- On file selection: call `resizeImageToDataUrl(file, 104)` from the new utility
|
|||
|
|
- Show preview in a `<img id="p-image-preview">` (hidden until file chosen)
|
|||
|
|
- On Save: send resulting data URI via `PUT /api/auth/me/image` with `{ image: dataUri }`
|
|||
|
|
- Show progress indicator during resize + upload (data URIs for a 104×104 WebP are ~2-5 kB)
|
|||
|
|
|
|||
|
|
*After successful save (both modes):*
|
|||
|
|
- Re-fetch `/api/auth/me`, update `oxicloud_user` in localStorage
|
|||
|
|
- Call `updateUserMenuData()` to refresh top-right avatar immediately
|
|||
|
|
- Collapse the edit panel and update `#p-avatar` in-place
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Files to modify / create
|
|||
|
|
|
|||
|
|
| File | Action |
|
|||
|
|
|---|---|
|
|||
|
|
| `migrations/20260526000000_add_user_image.sql` | **CREATE** |
|
|||
|
|
| `src/domain/entities/user.rs` | add `image` field + getter/setter |
|
|||
|
|
| `src/infrastructure/repositories/pg/user_pg_repository.rs` | add to SELECT/UPDATE + `update_image()` |
|
|||
|
|
| `src/application/ports/auth_ports.rs` | add `picture` to `OidcIdClaims` |
|
|||
|
|
| `src/infrastructure/services/oidc_service.rs` | add `picture` to claims structs |
|
|||
|
|
| `src/application/services/auth_application_service.rs` | OIDC sync + `update_user_image()` |
|
|||
|
|
| `src/application/dtos/user_dto.rs` | add `image`, `can_edit_image` |
|
|||
|
|
| `src/interfaces/api/handlers/auth_handler.rs` | `update_user_image` handler |
|
|||
|
|
| `src/interfaces/api/routes.rs` | register `PUT /auth/me/image` |
|
|||
|
|
| `src/interfaces/api/handlers/contacts_handler.rs` | `user_to_contact()` maps `image` → `photo_url` |
|
|||
|
|
| `static/js/model/systemUsers.js` | add `_photoIndex`, `getPhoto()` |
|
|||
|
|
| `static/js/components/userVignette.js` | render `<img>` when photo available |
|
|||
|
|
| `static/css/components/userVignette.css` | add `img` rule inside avatar |
|
|||
|
|
| `static/js/utils/imageResize.js` | **CREATE** — Canvas resize → WebP/JPEG data URI |
|
|||
|
|
| `static/profile.html` | avatar image + URL input + file upload + preview |
|
|||
|
|
| `static/js/views/profile/profile.js` | photo display + URL/upload edit flow + post-save menu refresh |
|
|||
|
|
| `static/js/app/userMenu.js` | render `<img>` in both avatar circles when `user.image` present |
|
|||
|
|
| `static/css/components/userMenu.css` | add `img` cover rule for `.user-avatar` and `.user-menu-avatar` |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Verification
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# Backend
|
|||
|
|
cargo fmt --all
|
|||
|
|
cargo clippy --all-features --all-targets -- -D warnings
|
|||
|
|
cargo test
|
|||
|
|
|
|||
|
|
# Frontend
|
|||
|
|
biome lint static/js/
|
|||
|
|
tsc -p jsconfig.json --noEmit
|
|||
|
|
stylelint static/css/
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Smoke tests:**
|
|||
|
|
1. Local user → profile page → edit image → paste `https://example.com/me.jpg` → Save → avatar shows photo
|
|||
|
|
2. Local user → paste `data:image/png;base64,…` → Save → works
|
|||
|
|
3. Local user → paste invalid string → Save → 422 error shown
|
|||
|
|
4. Local user → clear image (empty) → Save → avatar reverts to initials
|
|||
|
|
5. OIDC user → `picture` claim present → after login, `GET /api/auth/me` returns `image` → profile shows photo, no edit button
|
|||
|
|
6. OIDC user → `picture` claim absent → `image` is null → profile shows initials
|
|||
|
|
7. SharedWithMe owner column → users with photos show `<img>`, others show initials
|
|||
|
|
8. ShareModal People section → member avatars show photos where available
|
|||
|
|
9. CardDAV client sync → system address book contact has `PHOTO` property set
|
|||
|
|
10. After saving a photo on the profile page → top-right avatar button and dropdown header both update immediately without a page reload
|
|||
|
|
11. Upload a large PNG (e.g. 2000×2000) → client resizes to 104×104 WebP, preview appears, Save sends data URI, backend accepts (< 10 KB)
|
|||
|
|
12. Upload a 300×300 image → client does NOT upscale, stores at original dimensions
|
|||
|
|
13. Upload a non-image file (PDF) → rejected client-side before any network call
|