use imaging::layout::{ImageVariant, PreviewState}; use imaging::preset::PreviewPreset; use models::space::spaces; use sea_orm::{ActiveModelTrait, Set}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::errors::SpaceError; use crate::services::service::SpaceService; use crate::share::authz::{MANAGE_CHANNELS, effective_permissions, require_perm}; use crate::share::lookup::lookup_space_by_name; pub const MAX_SPACE_IMAGE_BYTES: usize = 5 * 1024 * 1024; pub const SPACE_IMAGE_PREFIX: &str = "space-images"; pub const SPACE_IMAGE_URL_TTL_SECS: u64 = 7 * 24 * 60 * 60; pub const SPACE_IMAGE_URL_CACHE_PREFIX: &str = "space:image:url"; #[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 { let clean = ct.split(';').next().unwrap_or(ct).trim().to_lowercase(); match clean.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 { let clean = ext.trim().trim_start_matches('.').to_lowercase(); match clean.as_str() { "jpg" | "jpeg" => Some(Self::Jpg), "png" => Some(Self::Png), "webp" => Some(Self::Webp), "gif" => Some(Self::Gif), _ => None, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum SpaceImageKind { Avatar, Banner, InviteBanner, EmbedSplash, } impl SpaceImageKind { pub fn as_str(&self) -> &'static str { match self { Self::Avatar => "avatar", Self::Banner => "banner", Self::InviteBanner => "invite_banner", Self::EmbedSplash => "embed_splash", } } } fn space_image_url_cache_key(space_id: Uuid, kind: SpaceImageKind) -> String { format!( "{SPACE_IMAGE_URL_CACHE_PREFIX}:{space_id}:{}", kind.as_str() ) } fn space_image_meta_key(space_id: Uuid, kind: SpaceImageKind) -> String { format!( "{SPACE_IMAGE_PREFIX}/{space_id}/{}.meta.json", kind.as_str() ) } /// Content type to hand the preview generator for one stored object. The stored /// locator carries the uploaded extension; a wrong guess only costs a skipped /// render, because the decoder sniffs the real format from the bytes. fn space_image_content_type(original_key: &str) -> &'static str { match original_key.rsplit('.').next().unwrap_or_default() { "png" => "image/png", "webp" => "image/webp", "gif" => "image/gif", _ => "image/jpeg", } } fn space_image_object_key(stored_url: &str) -> Option { let key = stored_url.strip_prefix("storage://")?; if key.is_empty() { return None; } Some(key.to_string()) } /// Stored locator for the image row of `kind`; `None` when it was never uploaded. fn stored_space_image_url(space: &spaces::Model, kind: SpaceImageKind) -> Option { match kind { SpaceImageKind::Avatar => space.avatar_url.clone(), SpaceImageKind::Banner => space.banner_url.clone(), SpaceImageKind::InviteBanner => space.invite_banner_url.clone(), SpaceImageKind::EmbedSplash => space.embed_splash_url.clone(), } .filter(|url| !url.is_empty()) } fn write_space_image( am: &mut spaces::ActiveModel, kind: SpaceImageKind, url: Option, width: Option, height: Option, ) { match kind { SpaceImageKind::Avatar => { am.avatar_url = Set(url); } SpaceImageKind::Banner => { am.banner_url = Set(url); am.banner_width = Set(width); am.banner_height = Set(height); } SpaceImageKind::InviteBanner => { am.invite_banner_url = Set(url); am.invite_banner_width = Set(width); am.invite_banner_height = Set(height); } SpaceImageKind::EmbedSplash => { am.embed_splash_url = Set(url); am.embed_splash_width = Set(width); am.embed_splash_height = Set(height); } } } /// Everything the sidecar records about one written image. Grouped into a struct /// rather than passed as eight positional arguments. struct SpaceImageMeta<'a> { fmt: AvatarFormat, width: Option, height: Option, bytes: usize, uploaded_at: OffsetDateTime, preview: Option<&'a PreviewState>, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct SpaceImageDto { pub space_id: Uuid, pub kind: SpaceImageKind, pub url: String, pub format: AvatarFormat, pub width: Option, pub height: Option, pub bytes: u64, pub updated_at: OffsetDateTime, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct SpaceImageKindPath { pub space_name: String, pub kind: SpaceImageKind, } /// Banner geometry is recorded, not inferred: the browser knows the intrinsic /// size for free, the server would have to decode the image to learn it. #[derive(Debug, Clone, Default, Deserialize, IntoParams, ToSchema)] pub struct SpaceImageUploadQuery { #[serde(default)] pub width: Option, #[serde(default)] pub height: Option, } impl SpaceService { /// Resolve the derivative for one stored space image, generating it on first /// demand. Returns the `storage://` locator of the derivative, falling back /// to the original whenever a preview cannot be produced (animation, /// oversize, unsupported format, failed render). async fn space_image_variant_locator( &self, space_id: Uuid, kind: SpaceImageKind, stored_url: &str, ) -> String { let Some(original_key) = space_image_object_key(stored_url) else { return stored_url.to_string(); }; let key_base = export::imaging::preview_key_base_of(&original_key); let recorded = self .recorded_space_image_preview(space_id, kind, &key_base) .await; let state = export::imaging::ensure_preview( self.storage().client(), &self.state.redis, &key_base, &original_key, space_image_content_type(&original_key), PreviewPreset::Content, recorded.as_ref(), ) .await; match state { Some(PreviewState::Ready(ready)) => format!("storage://{}", ready.key), Some(PreviewState::Skipped(_)) | None => stored_url.to_string(), } } /// Preview state from the space image meta sidecar, falling back to probing /// the object store so images uploaded before previews existed are still /// recognised once their derivative has been generated. async fn recorded_space_image_preview( &self, space_id: Uuid, kind: SpaceImageKind, key_base: &str, ) -> Option { let meta_key = space_image_meta_key(space_id, kind); let fetched: Result, storage::StorageError> = self.storage().client().get(&meta_key).await; if let Ok(bytes) = fetched && let Ok(record) = serde_json::from_slice::(&bytes) && let Some(state) = export::imaging::recorded_preview(&record) { return Some(state); } export::imaging::find_stored_preview(self.storage().client(), key_base).await } /// Persist the sidecar with the derivative that is currently authoritative. async fn write_space_image_meta( &self, space_id: Uuid, kind: SpaceImageKind, written: SpaceImageMeta<'_>, ) { let SpaceImageMeta { fmt, width, height, bytes, uploaded_at, preview, } = written; let mut meta = serde_json::json!({ "space_id": space_id, "kind": kind.as_str(), "format": fmt.extension(), "width": width, "height": height, "bytes": bytes, "uploaded_at": uploaded_at, }); if let Some(state) = preview { meta = export::imaging::record_preview(meta, state); } if let Err(error) = self .storage() .client() .put_with_content_type( &space_image_meta_key(space_id, kind), meta.to_string().as_bytes(), "application/json", ) .await { tracing::warn!(%error, "failed to write space image meta json"); } } /// Drop both encodings of the derivative, so replacing an image can never /// leave two objects claiming to be the current preview. async fn delete_space_image_previews(&self, stored_url: &str) { let Some(original_key) = space_image_object_key(stored_url) else { return; }; let key_base = export::imaging::preview_key_base_of(&original_key); for candidate in imaging::layout::preview_candidate_keys(&key_base) { if let Err(error) = self.storage().client().delete(&candidate).await { tracing::warn!(%error, key = %candidate, "failed to delete old space image preview"); } } } async fn presign_space_image_url( &self, space_id: Uuid, kind: SpaceImageKind, stored_url: &str, ) -> Result { let cache_key = space_image_url_cache_key(space_id, kind); match export::redis::get_string(&self.state.redis, &cache_key).await { Ok(Some(url)) => return Ok(url), Ok(None) => {} Err(error) => { tracing::warn!(%error, "failed to read space image url cache"); } } let key = space_image_object_key(stored_url).ok_or(SpaceError::NotFound)?; let url = self .storage() .client() .presign_get(&key, SPACE_IMAGE_URL_TTL_SECS as u32)?; if let Err(error) = export::redis::set_string( &self.state.redis, &cache_key, &url, SPACE_IMAGE_URL_TTL_SECS, ) .await { tracing::warn!(%error, "failed to cache space image presigned url"); } Ok(url) } /// Member-visible space row for an image read, mirroring `get_space`: /// private spaces are a 404 for strangers rather than a 403, so the image /// URLs leak nothing about which spaces exist. async fn load_space_for_image_read( &self, caller: Option, space_name: &str, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; if !space.is_active { return Err(SpaceError::NotFound); } if space.is_private { let Some(uid) = caller else { return Err(SpaceError::NotFound); }; match effective_permissions(&self.state, &space, uid).await { Ok(_) => {} Err(SpaceError::Forbidden) => return Err(SpaceError::NotFound), Err(e) => return Err(e), } } Ok(space) } #[tracing::instrument(name = "space.upload_space_image", skip_all, fields(caller = %caller, space = %space_name, kind = ?kind))] pub async fn upload_space_image( &self, caller: i64, space_name: &str, kind: SpaceImageKind, content_type: &str, bytes: &[u8], dims: SpaceImageUploadQuery, ) -> Result { let SpaceImageUploadQuery { width, height } = dims; let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_CHANNELS).await?; let fmt = AvatarFormat::from_content_type(content_type).ok_or_else(|| { SpaceError::BadRequest(format!("unsupported content-type: {content_type}")) })?; if bytes.is_empty() { return Err(SpaceError::BadRequest("image payload is empty".to_string())); } if bytes.len() > MAX_SPACE_IMAGE_BYTES { return Err(SpaceError::BadRequest( "image payload exceeds 5MB limit".to_string(), )); } if width.is_some_and(|w| w < 0) || height.is_some_and(|h| h < 0) { return Err(SpaceError::BadRequest( "image dimensions must be non-negative".to_string(), )); } let space_id = space.id; let lower_name = space.lower_name.clone(); let object_key = format!( "{SPACE_IMAGE_PREFIX}/{space_id}/{}.{}", kind.as_str(), fmt.extension() ); let stored_url = format!("storage://{object_key}"); let now = OffsetDateTime::now_utc(); self.storage() .client() .put_with_content_type(&object_key, bytes, fmt.content_type()) .await?; let mut am: spaces::ActiveModel = space.into(); write_space_image(&mut am, kind, Some(stored_url.clone()), width, height); am.updated_at = Set(now); am.update(self.db().write()).await?; if let Err(error) = export::redis::delete( &self.state.redis, &space_image_url_cache_key(space_id, kind), ) .await { tracing::warn!(%error, "failed to invalidate space image url cache"); } // The derivative is built here so the very next page load already gets // the small object; the same extension may have been in use before, so // stale candidates are cleared first. self.delete_space_image_previews(&stored_url).await; let key_base = export::imaging::preview_key_base_of(&object_key); let preview = export::imaging::ensure_preview( self.storage().client(), &self.state.redis, &key_base, &object_key, fmt.content_type(), PreviewPreset::Content, None, ) .await; if preview.is_none() { tracing::debug!( %space_id, kind = kind.as_str(), "space image preview not generated; serving the original" ); } self.write_space_image_meta( space_id, kind, SpaceImageMeta { fmt, width, height, bytes: bytes.len(), uploaded_at: now, preview: preview.as_ref(), }, ) .await; Ok(SpaceImageDto { space_id, kind, url: crate::services::spaces::resolve_space_image_url( &lower_name, kind.as_str(), Some(stored_url), ) .expect("resolved"), format: fmt, width, height, bytes: bytes.len() as u64, updated_at: now, }) } #[tracing::instrument(name = "space.delete_space_image", skip_all, fields(caller = %caller, space = %space_name, kind = ?kind))] pub async fn delete_space_image( &self, caller: i64, space_name: &str, kind: SpaceImageKind, ) -> Result<(), SpaceError> { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_CHANNELS).await?; let space_id = space.id; let old_url = stored_space_image_url(&space, kind); let now = OffsetDateTime::now_utc(); let mut am: spaces::ActiveModel = space.into(); write_space_image(&mut am, kind, None, None, None); am.updated_at = Set(now); am.update(self.db().write()).await?; if let Err(error) = export::redis::delete( &self.state.redis, &space_image_url_cache_key(space_id, kind), ) .await { tracing::warn!(%error, "failed to invalidate space image url cache"); } if let Some(key) = old_url.as_deref().and_then(space_image_object_key) { if let Err(error) = self.storage().client().delete(&key).await { tracing::warn!(%error, "failed to delete old space image object"); } self.delete_space_image_previews(&key).await; if let Err(error) = self .storage() .client() .delete(&space_image_meta_key(space_id, kind)) .await { tracing::warn!(%error, "failed to delete old space image meta json"); } } Ok(()) } pub async fn get_space_image( &self, caller: Option, space_name: &str, kind: SpaceImageKind, ) -> Result, SpaceError> { let space = self.load_space_for_image_read(caller, space_name).await?; let Some(url) = stored_space_image_url(&space, kind) else { return Ok(None); }; let (width, height) = match kind { SpaceImageKind::Avatar => (None, None), SpaceImageKind::Banner => (space.banner_width, space.banner_height), SpaceImageKind::InviteBanner => (space.invite_banner_width, space.invite_banner_height), SpaceImageKind::EmbedSplash => (space.embed_splash_width, space.embed_splash_height), }; let ext = url.rsplit('.').next().unwrap_or("jpg"); let format = AvatarFormat::from_extension(ext).unwrap_or(AvatarFormat::Jpg); let mut bytes = 0u64; if let Some(key) = space_image_object_key(&url) { match self.storage().client().head(&key).await { Ok(Some((size, _))) => bytes = size, Ok(None) => {} Err(error) => { tracing::warn!(%error, "failed to head space image object"); } } } Ok(Some(SpaceImageDto { space_id: space.id, kind, url: crate::services::spaces::resolve_space_image_url( &space.lower_name, kind.as_str(), Some(url), ) .expect("resolved"), format, width, height, bytes, updated_at: space.updated_at, })) } /// Presigned S3 address behind `GET /spaces/{space}/images/{kind}/file`. /// /// Space images are rendered at layout scale, so the default read is the /// compressed derivative; `?variant=original` returns the uploaded bytes. pub async fn space_image_file_url( &self, caller: Option, space_name: &str, kind: SpaceImageKind, variant: ImageVariant, ) -> Result { let space = self.load_space_for_image_read(caller, space_name).await?; let stored_url = stored_space_image_url(&space, kind).ok_or(SpaceError::NotFound)?; let locator = match variant { ImageVariant::Original => stored_url, ImageVariant::Preview => { self.space_image_variant_locator(space.id, kind, &stored_url) .await } }; self.presign_space_image_url(space.id, kind, &locator).await } }