use imaging::layout::{ImageVariant, PreviewState}; use imaging::preset::PreviewPreset; use models::users::user; use sea_orm::{ActiveModelTrait, EntityTrait, Set}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::ToSchema; use crate::errors::UserError; use cache::AppRedis; use db::AppDatabase; use std::sync::Arc; use storage::S3Client; pub const MAX_AVATAR_BYTES: usize = 5 * 1024 * 1024; pub const AVATAR_PREFIX: &str = "avatars"; pub const AVATAR_URL_TTL_SECS: u64 = 24 * 60 * 60; /// Strictly shorter than the signature lifetime, so a cache hit can never hand /// out a URL that is about to expire. pub const AVATAR_URL_CACHE_TTL_SECS: u64 = AVATAR_URL_TTL_SECS / 5 * 4; pub const AVATAR_URL_CACHE_PREFIX: &str = "user:avatar:url:v2"; fn avatar_url_cache_key(uid: i64) -> String { format!("{AVATAR_URL_CACHE_PREFIX}:{uid}") } fn avatar_meta_key(uid: i64) -> String { format!("{AVATAR_PREFIX}/{uid}.meta.json") } fn avatar_object_key(stored_url: &str) -> Option { let key = stored_url.strip_prefix("storage://")?; if key.is_empty() { return None; } Some(key.to_string()) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "lowercase")] pub enum AvatarFormat { Jpg, Png, Webp, Gif, } impl AvatarFormat { pub fn extension(self) -> &'static str { match self { Self::Jpg => "jpg", Self::Png => "png", Self::Webp => "webp", Self::Gif => "gif", } } pub fn content_type(self) -> &'static str { match self { Self::Jpg => "image/jpeg", Self::Png => "image/png", Self::Webp => "image/webp", Self::Gif => "image/gif", } } pub fn from_content_type(ct: &str) -> Option { match ct.to_ascii_lowercase().as_str() { "image/jpeg" | "image/jpg" => Some(Self::Jpg), "image/png" => Some(Self::Png), "image/webp" => Some(Self::Webp), "image/gif" => Some(Self::Gif), _ => None, } } pub fn from_extension(ext: &str) -> Option { match ext.to_ascii_lowercase().as_str() { "jpg" | "jpeg" => Some(Self::Jpg), "png" => Some(Self::Png), "webp" => Some(Self::Webp), "gif" => Some(Self::Gif), _ => None, } } } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AvatarDto { pub user_id: i64, /// Browser-facing URL. Points at the compressed derivative when one exists, /// and at the uploaded bytes otherwise. pub url: String, /// Format of the uploaded original. pub format: AvatarFormat, /// Size of the uploaded original in bytes. pub bytes: u64, pub updated_at: OffsetDateTime, } #[derive(Clone)] pub struct AvatarService { database: Arc, storage: Arc, redis: AppRedis, } impl AvatarService { pub fn new(database: Arc, storage: Arc, redis: AppRedis) -> Self { Self { database, storage, redis, } } /// Redis key for the presigned URL of one representation. The default /// (`preview`) keeps the historical key so the negative cache entry left by a /// previously-skipped avatar cannot pin a stale URL for another variant. fn avatar_url_cache_key(uid: i64, variant: ImageVariant) -> String { match variant { ImageVariant::Preview => avatar_url_cache_key(uid), ImageVariant::Original => format!("{}:original", avatar_url_cache_key(uid)), } } /// Presigned URL of the representation the caller asked for, plus the /// bytes/metadata the API reports for it. pub async fn avatar_presigned_url( &self, uid: i64, variant: ImageVariant, ) -> Result, UserError> { let Some(stored_url) = self.avatar_variant_locator(uid, variant).await? else { return Ok(None); }; let cache_key = Self::avatar_url_cache_key(uid, variant); match cache::client::get_string(&self.redis, &cache_key).await { Ok(Some(url)) => return Ok(Some(url)), Ok(None) => {} Err(error) => { tracing::warn!(%error, "failed to read avatar url cache"); } } let key = match avatar_object_key(&stored_url) { Some(key) => key, // Imported avatars live on someone else's host; there is nothing to presign. None => return Ok(Some(stored_url)), }; let url = self .storage .as_ref() .presign_get(&key, AVATAR_URL_TTL_SECS as u32) .map_err(UserError::from)?; if let Err(error) = cache::client::set_string(&self.redis, &cache_key, &url, AVATAR_URL_CACHE_TTL_SECS) .await { tracing::warn!(%error, "failed to cache avatar presigned url"); } Ok(Some(url)) } /// Stored locator for the avatar of `variant`. Avatars are the one image /// class whose *default* read is the compressed derivative: they are only /// ever rendered as small circles, so serving the original would charge /// every list view for the upload. Callers that need the full-size bytes ask /// for [`ImageVariant::Original`]. async fn avatar_variant_locator( &self, uid: i64, variant: ImageVariant, ) -> Result, UserError> { let user = user::Entity::find_by_id(uid) .one(self.database.read()) .await? .ok_or(UserError::NotFound)?; if user.local_avatar_url.is_empty() { return Ok(None); } if variant == ImageVariant::Original { return Ok(Some(user.local_avatar_url)); } let Some(original_key) = avatar_object_key(&user.local_avatar_url) else { return Ok(Some(user.local_avatar_url)); }; let key_base = media::preview_key_base_of(&original_key); let recorded = self.recorded_avatar_preview(uid, &key_base).await; let state = media::ensure_preview( self.storage.as_ref(), &self.redis, &key_base, &original_key, { let original_key = &original_key; match original_key.rsplit('.').next().unwrap_or_default() { "png" => "image/png", "webp" => "image/webp", "gif" => "image/gif", _ => "image/jpeg", } }, PreviewPreset::Avatar, recorded.as_ref(), ) .await; Ok(Some(match state { Some(PreviewState::Ready(ready)) => format!("storage://{}", ready.key), Some(PreviewState::Skipped(_)) | None => user.local_avatar_url, })) } /// Preview state from the avatar meta sidecar, falling back to probing the /// object store so an avatar uploaded before previews existed is still /// recognised once its derivative has been generated. async fn recorded_avatar_preview(&self, uid: i64, key_base: &str) -> Option { let meta_key = avatar_meta_key(uid); if let Ok(bytes) = self.storage.as_ref().get(&meta_key).await && let Ok(record) = serde_json::from_slice::(&bytes) && let Some(state) = media::recorded_preview(&record) { return Some(state); } media::find_stored_preview(self.storage.as_ref(), key_base).await } /// Persist the sidecar next to the original, recording which derivative is /// current so the next read does not decode anything again. async fn write_avatar_meta( &self, uid: i64, fmt: AvatarFormat, bytes: usize, now: OffsetDateTime, preview: Option<&PreviewState>, ) { let mut meta = serde_json::json!({ "uid": uid, "format": fmt.extension(), "bytes": bytes, "uploaded_at": now, }); if let Some(state) = preview { meta = media::record_preview(meta, state); } match serde_json::to_vec(&meta) { Ok(meta_bytes) => { if let Err(error) = self .storage .as_ref() .put_with_content_type(&avatar_meta_key(uid), &meta_bytes, "application/json") .await { tracing::warn!(%error, "failed to write user avatar meta json"); } } Err(error) => { tracing::warn!(%error, "failed to serialize user avatar meta json"); } } } /// Drop both encodings of the derivative. Called before writing a new /// original (the preset may switch JPEG<->WebP) and again on delete. async fn delete_avatar_previews(&self, original_key: &str) { let key_base = media::preview_key_base_of(original_key); for candidate in imaging::layout::preview_candidate_keys(&key_base) { if let Err(error) = self.storage.as_ref().delete(&candidate).await { tracing::warn!(%error, key = %candidate, "failed to delete old avatar preview"); } } } async fn invalidate_avatar_cache(&self, uid: i64) { for variant in [ImageVariant::Preview, ImageVariant::Original] { if let Err(error) = cache::client::delete(&self.redis, &Self::avatar_url_cache_key(uid, variant)).await { tracing::warn!(%error, "failed to invalidate avatar url cache"); } } } #[tracing::instrument(name = "user.upload_avatar_stream", skip_all, fields(uid = %uid))] pub async fn upload_avatar_stream( &self, uid: i64, content_type: &str, bytes: &[u8], ) -> Result { let fmt = AvatarFormat::from_content_type(content_type).ok_or_else(|| { UserError::BadRequest(format!( "unsupported content_type '{content_type}' (allowed: image/jpeg, image/png, image/webp, image/gif)" )) })?; if bytes.is_empty() { return Err(UserError::BadRequest("avatar payload is empty".into())); } if bytes.len() > MAX_AVATAR_BYTES { return Err(UserError::BadRequest(format!( "avatar exceeds max size of {MAX_AVATAR_BYTES} bytes" ))); } let previous = user::Entity::find_by_id(uid) .one(self.database.read()) .await? .ok_or(UserError::NotFound)? .local_avatar_url; if let Some(old_key) = avatar_object_key(&previous) && old_key != format!("{AVATAR_PREFIX}/{uid}.{}", fmt.extension()) { self.delete_avatar_previews(&old_key).await; } let key = format!("{AVATAR_PREFIX}/{uid}.{}", fmt.extension()); self.storage .as_ref() .put_with_content_type(&key, bytes, fmt.content_type()) .await .map_err(UserError::from)?; let now = OffsetDateTime::now_utc(); let stored_url = format!("storage://{AVATAR_PREFIX}/{uid}.{}", fmt.extension()); let user = user::Entity::find_by_id(uid) .one(self.database.read()) .await? .ok_or(UserError::NotFound)?; let mut active: user::ActiveModel = user.into(); active.local_avatar_url = Set(stored_url.clone()); active.updated_at = Set(now); active.update(self.database.write()).await?; // The preview is built immediately so the first list view after an upload // already has it; a failure here is not an upload failure. let key_base = media::preview_key_base_of(&key); // The old derivative shares this base only when the extension is reused; // clearing it first keeps exactly one candidate object alive. self.delete_avatar_previews(&key).await; let preview = media::ensure_preview( self.storage.as_ref(), &self.redis, &key_base, &key, fmt.content_type(), PreviewPreset::Avatar, None, ) .await; if preview.is_none() { // Nothing to record: the original is the preview, and repeating the // attempt on every read would burn CPU for no benefit. The `None` // above means either "skipped" or "generation failed"; only a real // skip is worth remembering, and the renderer logs the difference. tracing::debug!(%uid, "avatar preview not generated; serving the original"); } self.invalidate_avatar_cache(uid).await; self.write_avatar_meta(uid, fmt, bytes.len(), now, preview.as_ref()) .await; let url = self .avatar_presigned_url(uid, ImageVariant::Preview) .await? .unwrap_or(stored_url); Ok(AvatarDto { user_id: uid, url, format: fmt, bytes: bytes.len() as u64, updated_at: now, }) } #[tracing::instrument(name = "user.delete_avatar", skip_all, fields(uid = %uid))] pub async fn delete_avatar(&self, uid: i64) -> Result<(), UserError> { let user = user::Entity::find_by_id(uid) .one(self.database.read()) .await? .ok_or(UserError::NotFound)?; let old_url = user.local_avatar_url.clone(); let now = OffsetDateTime::now_utc(); let mut active: user::ActiveModel = user.into(); active.local_avatar_url = Set(String::new()); active.updated_at = Set(now); active.update(self.database.write()).await?; self.invalidate_avatar_cache(uid).await; if let Some(old_key) = avatar_object_key(&old_url) && old_key.starts_with(&format!("{AVATAR_PREFIX}/")) { if let Err(error) = self.storage.as_ref().delete(&old_key).await { tracing::warn!(%error, "failed to delete old user avatar object"); } self.delete_avatar_previews(&old_key).await; if let Err(error) = self.storage.as_ref().delete(&avatar_meta_key(uid)).await { tracing::warn!(%error, "failed to delete old user avatar meta json"); } } Ok(()) } /// Avatars are exempt from profile visibility, platform-wide. /// /// An avatar is not extra information about a person, it is how a room /// tells its speakers apart: the transcript, the member list and every /// reaction row already carry this user's display name, so gating the /// picture only renders those views with a broken image. `visibility` /// therefore governs the profile page and its fields — bio, email, /// location, activity — and never this URL. /// /// `is_active` is exempt for the same reason, and it matters more than it /// looks: registration writes `is_active = false` until the email is /// verified, so gating on it made every new member's avatar 404 for the /// rest of the room during their first session. /// /// Still 404: no such user, or a user who has never uploaded an avatar. /// Authentication is required (the handler), just not any per-user rule. pub async fn avatar_redirect_url( &self, target_uid: i64, variant: ImageVariant, ) -> Result { self.avatar_presigned_url(target_uid, variant) .await? .ok_or(UserError::NotFound) } pub async fn current_avatar(&self, uid: i64) -> Result, UserError> { let user = user::Entity::find_by_id(uid) .one(self.database.read()) .await? .ok_or(UserError::NotFound)?; if user.local_avatar_url.is_empty() { return Ok(None); } let ext = user.local_avatar_url.rsplit('.').next().unwrap_or_default(); let fmt = AvatarFormat::from_extension(ext).unwrap_or(AvatarFormat::Jpg); let url = self .avatar_presigned_url(uid, ImageVariant::Preview) .await? .ok_or(UserError::NotFound)?; Ok(Some(AvatarDto { user_id: uid, url, format: fmt, bytes: 0, updated_at: user.updated_at, })) } }