Files
Oxicloud/docs/plan/User-Image-support.md
T

12 KiB
Raw Blame History

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

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:

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:

  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):

#[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 _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:

.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:

.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:

  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

# 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