Skip to content
Clone
ef0b83a
654 lines 21.4 KB Rust
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;
use crate::services::service::SpaceService;
use crate::share::authz::{MANAGE_CHANNELS, effective_permissions, require_perm};
use crate::share::lookup;
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<Self> {
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<Self> {
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<String> {
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<String> {
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(
state: &export::AppState,
space_id: Uuid,
kind: SpaceImageKind,
url: Option<String>,
width: Option<i32>,
height: Option<i32>,
) -> 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(
state, 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<i32>,
height: Option<i32>,
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<i32>,
pub height: Option<i32>,
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<i32>,
#[serde(default)]
pub height: Option<i32>,
}
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(),
&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<PreviewState> {
let meta_key = space_image_meta_key(space_id, kind);
let fetched: Result<Vec<u8>, storage::StorageError> = self.storage().get(&meta_key).await;
if let Ok(bytes) = fetched
&& let Ok(record) = serde_json::from_slice::<serde_json::Value>(&bytes)
&& let Some(state) = export::imaging::recorded_preview(&record)
{
return Some(state);
}
export::imaging::find_stored_preview(self.storage(), 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()
.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().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<String, SpaceError> {
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()
.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<i64>,
space_name: &str,
) -> Result<spaces::Model, SpaceError> {
let space = lookup::lookup_space_by_name(self, caller.unwrap_or(0), 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);
};
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<SpaceImageDto, SpaceError> {
let SpaceImageUploadQuery { width, height } = dims;
let space = lookup::lookup_space_by_name(self, caller, 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 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()
.put_with_content_type(&object_key, bytes, fmt.content_type())
.await?;
write_space_image(
&self.state,
space_id,
kind,
Some(stored_url.clone()),
width,
height,
)
.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(),
&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::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 = lookup::lookup_space_by_name(self, caller, space_name).await?;
require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
let space_id = space.id;
let extras = crate::authority::extras(&self.state, space_id).await?;
let old_url = stored_space_image_url(&space, extras.as_ref(), kind);
write_space_image(&self.state, space_id, kind, None, None, None).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().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()
.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<i64>,
space_name: &str,
kind: SpaceImageKind,
) -> Result<Option<SpaceImageDto>, SpaceError> {
let space = self.load_space_for_image_read(caller, space_name).await?;
let extras = crate::authority::extras(&self.state, 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().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<i64>,
space_name: &str,
kind: SpaceImageKind,
variant: ImageVariant,
) -> Result<String, SpaceError> {
let space = self.load_space_for_image_read(caller, space_name).await?;
let extras = crate::authority::extras(&self.state, 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, &locator).await
}
}