use crate::{Access, Capability, require}; use cache::AppRedis; use std::sync::Arc; use storage::S3Client; #[derive(Clone)] pub struct SpaceImages { access: Access, storage: Arc, redis: AppRedis, } impl SpaceImages { pub fn new(access: Access, storage: Arc, redis: AppRedis) -> Self { Self { access, storage, redis, } } async fn load_space(&self, slug: &str) -> Result { Ok(self.access.by_slug(slug).await?.record) } } use imaging::layout::{ImageVariant, PreviewState}; use imaging::preset::PreviewPreset; use models::space::{space_extras, spaces}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::errors::SpaceError; 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 = 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 SPACE_IMAGE_URL_CACHE_TTL_SECS: u64 = SPACE_IMAGE_URL_TTL_SECS / 5 * 4; pub const SPACE_IMAGE_URL_CACHE_PREFIX: &str = "space:image:url:v2"; #[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", } } } /// Keyed by variant too: the preview and the original are different objects, so /// sharing one key would pin whichever representation was requested first. fn space_image_url_cache_key( space_id: Uuid, kind: SpaceImageKind, variant: ImageVariant, ) -> String { let variant = match variant { ImageVariant::Preview => "preview", ImageVariant::Original => "original", }; format!( "{SPACE_IMAGE_URL_CACHE_PREFIX}:{space_id}:{}:{variant}", 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() ) } 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, extras: Option<&space_extras::Model>, kind: SpaceImageKind, ) -> Option { match kind { SpaceImageKind::Avatar => extras .and_then(|row| row.avatar_url.clone()) .or_else(|| space.avatar_url.clone()), SpaceImageKind::Banner => extras.and_then(|row| row.banner_url.clone()), SpaceImageKind::InviteBanner => extras.and_then(|row| row.invite_banner_url.clone()), SpaceImageKind::EmbedSplash => extras.and_then(|row| row.embed_splash_url.clone()), } .filter(|url| !url.is_empty()) } async fn write_space_image( access: &Access, space_id: Uuid, kind: SpaceImageKind, url: Option, width: Option, height: Option, ) -> Result<(), SpaceError> { let (avatar, banner, banner_w, banner_h, invite, invite_w, invite_h, embed, embed_w, embed_h) = match kind { SpaceImageKind::Avatar => ( Some(url), None, None, None, None, None, None, None, None, None, ), SpaceImageKind::Banner => ( None, Some(url), Some(width), Some(height), None, None, None, None, None, None, ), SpaceImageKind::InviteBanner => ( None, None, None, None, Some(url), Some(width), Some(height), None, None, None, ), SpaceImageKind::EmbedSplash => ( None, None, None, None, None, None, None, Some(url), Some(width), Some(height), ), }; crate::authority::upsert_extras( &access.database, space_id, avatar, None, banner, banner_w, banner_h, invite, invite_w, invite_h, embed, embed_w, embed_h, None, ) .await?; Ok(()) } /// 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 SpaceImages { /// 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 = media::preview_key_base_of(&original_key); let recorded = self .recorded_space_image_preview(space_id, kind, &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::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.as_ref().get(&meta_key).await; if let Ok(bytes) = fetched && 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 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 = media::record_preview(meta, state); } if let Err(error) = self .storage .as_ref() .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 = 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 space image preview"); } } } async fn presign_space_image_url( &self, space_id: Uuid, kind: SpaceImageKind, variant: ImageVariant, stored_url: &str, ) -> Result { let cache_key = space_image_url_cache_key(space_id, kind, variant); match cache::client::get_string(&self.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 .as_ref() .presign_get(&key, SPACE_IMAGE_URL_TTL_SECS as u32)?; if let Err(error) = cache::client::set_string( &self.redis, &cache_key, &url, SPACE_IMAGE_URL_CACHE_TTL_SECS, ) .await { tracing::warn!(%error, "failed to cache space image presigned url"); } Ok(url) } /// Drop the cached URL of both representations; keys are variant-scoped, so /// one invalidation target is no longer enough. async fn invalidate_space_image_url_cache(&self, space_id: Uuid, kind: SpaceImageKind) { for variant in [ImageVariant::Preview, ImageVariant::Original] { if let Err(error) = cache::client::delete( &self.redis, &space_image_url_cache_key(space_id, kind, variant), ) .await { tracing::warn!(%error, "failed to invalidate space image url cache"); } } } /// 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 = self.load_space(space_name).await?; if !crate::authority::space_is_active(&space) { return Err(SpaceError::NotFound); } if space.join_policy == models::space::SpaceJoinPolicy::Private { let Some(uid) = caller else { return Err(SpaceError::NotFound); }; if !self .access .by_id(space.id) .await? .can(uid, Capability::Read) .await? { return Err(SpaceError::NotFound); } } 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 = self.load_space(space_name).await?; require( &self.access.by_id(space.id).await?, caller, Capability::ManageChannels, ) .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 slug = space.slug.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 .as_ref() .put_with_content_type(&object_key, bytes, fmt.content_type()) .await?; write_space_image( &self.access, space_id, kind, Some(stored_url.clone()), width, height, ) .await?; self.invalidate_space_image_url_cache(space_id, kind).await; // 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 = media::preview_key_base_of(&object_key); let preview = media::ensure_preview( self.storage.as_ref(), &self.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::dto::resolve_space_image_url( &slug, 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 = self.load_space(space_name).await?; require( &self.access.by_id(space.id).await?, caller, Capability::ManageChannels, ) .await?; let space_id = space.id; let extras = crate::authority::extras(self.access.database.read(), space_id).await?; let old_url = stored_space_image_url(&space, extras.as_ref(), kind); write_space_image(&self.access, space_id, kind, None, None, None).await?; self.invalidate_space_image_url_cache(space_id, kind).await; if let Some(key) = old_url.as_deref().and_then(space_image_object_key) { if let Err(error) = self.storage.as_ref().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 .as_ref() .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 extras = crate::authority::extras(self.access.database.read(), space.id).await?; let Some(url) = stored_space_image_url(&space, extras.as_ref(), kind) else { return Ok(None); }; let (width, height) = match kind { SpaceImageKind::Avatar => (None, None), SpaceImageKind::Banner => ( extras.as_ref().and_then(|row| row.banner_width), extras.as_ref().and_then(|row| row.banner_height), ), SpaceImageKind::InviteBanner => ( extras.as_ref().and_then(|row| row.invite_banner_width), extras.as_ref().and_then(|row| row.invite_banner_height), ), SpaceImageKind::EmbedSplash => ( extras.as_ref().and_then(|row| row.embed_splash_width), extras.as_ref().and_then(|row| row.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.as_ref().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::dto::resolve_space_image_url( &space.slug, kind.as_str(), Some(url), ) .expect("resolved"), format, width, height, bytes, updated_at: extras .map(|row| row.updated_at) .unwrap_or_else(OffsetDateTime::now_utc), })) } /// 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 extras = crate::authority::extras(self.access.database.read(), space.id).await?; let stored_url = stored_space_image_url(&space, extras.as_ref(), 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, variant, &locator) .await } }