12 KiB
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
PHOTOformat so the system address book exports it correctly - Be editable only for local (username+password) accounts
- Be synced automatically from OIDC
pictureclaim on every login for OIDC accounts - Surface in
userVignettecomponents (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
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()andUser::new_oidc()— initialise toNone- 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
imageto everySELECTthat builds aUser(row-mapper) - Extend the
UPDATESQL inupdate_user()to includeimage = $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>toOidcIdClaims
src/infrastructure/services/oidc_service.rs
- Add
picture: Option<String>to bothIdTokenClaimsandUserInfoResponsestructs - Pass
pictureinto the returnedOidcIdClaims
src/application/services/auth_application_service.rs — in oidc_callback():
- Create path: pass
claims.picturetoUser::new_oidc()
(or calluser.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:
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/):
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
pub async fn update_user_image(
&self,
caller_id: Uuid,
image: Option<String>,
) -> Result<(), AppError>
Logic:
- Load user from repository
- If
user.is_oidc_user()→ returnAppError::Forbidden - If
image.is_some()→ validate format + length; returnAppError::Validationif invalid - 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):
#[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():
.route("/me/image", put(update_user_image))
9. System Address Book
src/interfaces/api/handlers/contacts_handler.rs — user_to_contact():
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_photoIndexfromc.photo_urlalongside 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="…">insideuser-vignette__avatar - Wire
onerroron the img to fall back to initials (guard against broken URLs)
CSS addition in userVignette.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.imagefrom the storedoxicloud_userin localStorage #user-avatar(38 px circle): ifuser.imageis set, replace inner HTML with<img src="…" alt="…">instead of initials text; wireonerrorfallback to initials#user-menu-avatar(48 px circle in dropdown): same treatment- When
profile.jssaves a new image successfully, it must also refresh the storedoxicloud_userin localStorage (re-fetch/api/auth/meand update) then callupdateUserMenuData()
static/css/components/userMenu.css — add inside the file:
.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
/**
* 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:
- Read file with
FileReader→ data URL - Create
<img>element and wait foronload - Compute output dimensions: scale down proportionally if either dimension >
maxSize; never scale up - Draw onto
OffscreenCanvas(or regular<canvas>) at the computed size - Export with
canvas.toBlob('image/webp', 0.85)(fallback toimage/jpegif WebP not supported) - 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-avatarsupport 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
- URL tab:
- Save / Cancel / Remove (clear) buttons
static/js/views/profile/profile.js
Display:
- If
user.image: set#p-avatarto<img src="…">(withonerror→ initials fallback) - If
user.can_edit_image: show edit pencil - For OIDC users: show photo if
user.imageset; 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/imagewith{ 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/imagewith{ 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, updateoxicloud_userin localStorage - Call
updateUserMenuData()to refresh top-right avatar immediately - Collapse the edit panel and update
#p-avatarin-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
# 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:
- Local user → profile page → edit image → paste
https://example.com/me.jpg→ Save → avatar shows photo - Local user → paste
data:image/png;base64,…→ Save → works - Local user → paste invalid string → Save → 422 error shown
- Local user → clear image (empty) → Save → avatar reverts to initials
- OIDC user →
pictureclaim present → after login,GET /api/auth/mereturnsimage→ profile shows photo, no edit button - OIDC user →
pictureclaim absent →imageis null → profile shows initials - SharedWithMe owner column → users with photos show
<img>, others show initials - ShareModal People section → member avatars show photos where available
- CardDAV client sync → system address book contact has
PHOTOproperty set - After saving a photo on the profile page → top-right avatar button and dropdown header both update immediately without a page reload
- Upload a large PNG (e.g. 2000×2000) → client resizes to 104×104 WebP, preview appears, Save sends data URI, backend accepts (< 10 KB)
- Upload a 300×300 image → client does NOT upscale, stores at original dimensions
- Upload a non-image file (PDF) → rejected client-side before any network call