951a522
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 crate::services::service::UserService;
pub const MAX_AVATAR_BYTES: usize = 5 * 1024 * 1024;
pub const AVATAR_PREFIX: &str = "avatars";
pub const AVATAR_URL_TTL_SECS: u64 = 7 * 24 * 60 * 60;
pub const AVATAR_URL_CACHE_PREFIX: &str = "user:avatar:url";
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<String> {
let key = stored_url.strip_prefix("storage://")?;
if key.is_empty() {
return None;
}
Some(key.to_string())
}
/// Content type to hand the preview generator for a stored avatar object. The
/// stored locator carries the uploaded extension, which is authoritative enough
/// for a decode attempt (and a mismatch only costs one skipped render).
fn avatar_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",
}
}
#[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> {
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<Self> {
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,
}
impl UserService {
/// 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<Option<String>, 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 export::redis::get_string(&self.state.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()
.client()
.presign_get(&key, AVATAR_URL_TTL_SECS as u32)
.map_err(UserError::from)?;
if let Err(error) =
export::redis::set_string(&self.state.redis, &cache_key, &url, AVATAR_URL_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<Option<String>, UserError> {
let user = user::Entity::find_by_id(uid)
.one(self.db().read())
.await?
.ok_or(UserError::NotFound)?;
if user.avatar_url.is_empty() {
return Ok(None);
}
if variant == ImageVariant::Original {
return Ok(Some(user.avatar_url));
}
let Some(original_key) = avatar_object_key(&user.avatar_url) else {
return Ok(Some(user.avatar_url));
};
let key_base = export::imaging::preview_key_base_of(&original_key);
let recorded = self.recorded_avatar_preview(uid, &key_base).await;
let state = export::imaging::ensure_preview(
self.storage().client(),
&self.state.redis,
&key_base,
&original_key,
avatar_content_type(&original_key),
PreviewPreset::Avatar,
recorded.as_ref(),
)
.await;
Ok(Some(match state {
Some(PreviewState::Ready(ready)) => format!("storage://{}", ready.key),
Some(PreviewState::Skipped(_)) | None => user.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<PreviewState> {
let meta_key = avatar_meta_key(uid);
if let Ok(bytes) = self.storage().client().get(&meta_key).await
&& 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().client(), 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 = export::imaging::record_preview(meta, state);
}
match serde_json::to_vec(&meta) {
Ok(meta_bytes) => {
if let Err(error) = self
.storage()
.client()
.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 = 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 avatar preview");
}
}
}
async fn invalidate_avatar_cache(&self, uid: i64) {
for variant in [ImageVariant::Preview, ImageVariant::Original] {
if let Err(error) =
export::redis::delete(&self.state.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<AvatarDto, UserError> {
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.db().read())
.await?
.ok_or(UserError::NotFound)?
.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()
.client()
.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.db().read())
.await?
.ok_or(UserError::NotFound)?;
let mut active: user::ActiveModel = user.into();
active.avatar_url = Set(stored_url.clone());
active.updated_at = Set(now);
active.update(self.db().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 = export::imaging::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 = export::imaging::ensure_preview(
self.storage().client(),
&self.state.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.db().read())
.await?
.ok_or(UserError::NotFound)?;
let old_url = user.avatar_url.clone();
let now = OffsetDateTime::now_utc();
let mut active: user::ActiveModel = user.into();
active.avatar_url = Set(String::new());
active.updated_at = Set(now);
active.update(self.db().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().client().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().client().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<String, UserError> {
self.avatar_presigned_url(target_uid, variant)
.await?
.ok_or(UserError::NotFound)
}
pub async fn current_avatar(&self, uid: i64) -> Result<Option<AvatarDto>, UserError> {
let user = user::Entity::find_by_id(uid)
.one(self.db().read())
.await?
.ok_or(UserError::NotFound)?;
if user.avatar_url.is_empty() {
return Ok(None);
}
let ext = user.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,
}))
}
}