const MAX_TOP_FRIENDS: usize = 8; use bytes::Bytes; use domain::{ errors::DomainError, events::DomainEvent, models::{ top_friend::TopFriend, user::{UpdateProfileInput, User}, }, ports::{ EventPublisher, MediaStore, TopFriendRepository, UserReader, UserRepository, UserWriter, }, value_objects::{UserId, Username}, }; pub async fn get_user(users: &dyn UserReader, user_id: &UserId) -> Result { users .find_by_id(user_id) .await? .ok_or(DomainError::NotFound) } pub async fn get_user_by_username( users: &dyn UserReader, username: &str, ) -> Result { let username = Username::new(username).map_err(|_| DomainError::NotFound)?; users .find_by_username(&username) .await? .ok_or(DomainError::NotFound) } /// Resolve a path segment that is either a UUID (AP actor URL) or a username. pub async fn get_user_by_id_or_username( users: &dyn UserReader, id_or_username: &str, ) -> Result { if let Ok(uuid) = uuid::Uuid::parse_str(id_or_username) { users .find_by_id(&UserId::from_uuid(uuid)) .await? .ok_or(DomainError::NotFound) } else { get_user_by_username(users, id_or_username).await } } pub async fn update_profile( users: &dyn UserWriter, events: &dyn EventPublisher, user_id: &UserId, input: UpdateProfileInput, ) -> Result<(), DomainError> { users.update_profile(user_id, input).await?; events .publish(&DomainEvent::ProfileUpdated { user_id: user_id.clone(), }) .await } pub async fn get_top_friends( top_friends: &dyn TopFriendRepository, user_id: &UserId, ) -> Result, DomainError> { top_friends.list_for_user(user_id).await } pub async fn set_top_friends( top_friends: &dyn TopFriendRepository, user_id: &UserId, friend_ids: Vec, ) -> Result<(), DomainError> { if friend_ids.len() > MAX_TOP_FRIENDS { return Err(DomainError::InvalidInput("top friends: max 8".into())); } let friends: Vec<(UserId, i16)> = friend_ids .into_iter() .enumerate() .map(|(i, id)| (id, (i + 1) as i16)) .collect(); top_friends.set_top_friends(user_id, friends).await } #[derive(Clone)] pub struct UploadConfig { pub max_bytes: usize, pub allowed_content_types: Vec, } impl Default for UploadConfig { fn default() -> Self { Self { max_bytes: 5 * 1024 * 1024, allowed_content_types: vec![ "image/jpeg".into(), "image/png".into(), "image/gif".into(), "image/webp".into(), "image/avif".into(), ], } } } fn mime_to_ext(mime: &str) -> Result<&'static str, DomainError> { match mime { "image/jpeg" => Ok("jpg"), "image/png" => Ok("png"), "image/gif" => Ok("gif"), "image/webp" => Ok("webp"), "image/avif" => Ok("avif"), _ => Err(DomainError::InvalidInput("unsupported content type".into())), } } #[allow(clippy::too_many_arguments)] async fn store_image( media: &dyn MediaStore, base_url: &str, cfg: &UploadConfig, content_type: &str, data: Bytes, user_id: &UserId, key_segment: &str, old_url: Option<&str>, ) -> Result { if !cfg.allowed_content_types.iter().any(|t| t == content_type) { return Err(DomainError::InvalidInput("unsupported content type".into())); } if data.len() > cfg.max_bytes { return Err(DomainError::InvalidInput("file too large".into())); } let ext = mime_to_ext(content_type)?; if let Some(old) = old_url { let prefix = format!("{base_url}/media/"); if let Some(old_key) = old.strip_prefix(&prefix) { media.delete(old_key).await?; } } let key = format!("users/{}/{key_segment}.{ext}", user_id.as_uuid()); let stream = Box::pin(futures::stream::once(async move { Ok(data) })); media.put(&key, stream).await?; Ok(key) } #[allow(clippy::too_many_arguments)] pub async fn upload_avatar( users: &dyn UserRepository, media: &dyn MediaStore, events: &dyn EventPublisher, user_id: &UserId, base_url: &str, cfg: &UploadConfig, content_type: &str, data: Bytes, ) -> Result<(), DomainError> { let current = users .find_by_id(user_id) .await? .ok_or(DomainError::NotFound)?; let key = store_image( media, base_url, cfg, content_type, data, user_id, "avatar", current.avatar_url.as_deref(), ) .await?; users .update_profile( user_id, UpdateProfileInput { avatar_url: Some(format!("{base_url}/media/{key}")), ..Default::default() }, ) .await?; events .publish(&DomainEvent::ProfileUpdated { user_id: user_id.clone(), }) .await } #[allow(clippy::too_many_arguments)] pub async fn upload_banner( users: &dyn UserRepository, media: &dyn MediaStore, events: &dyn EventPublisher, user_id: &UserId, base_url: &str, cfg: &UploadConfig, content_type: &str, data: Bytes, ) -> Result<(), DomainError> { let current = users .find_by_id(user_id) .await? .ok_or(DomainError::NotFound)?; let key = store_image( media, base_url, cfg, content_type, data, user_id, "banner", current.header_url.as_deref(), ) .await?; users .update_profile( user_id, UpdateProfileInput { header_url: Some(format!("{base_url}/media/{key}")), ..Default::default() }, ) .await?; events .publish(&DomainEvent::ProfileUpdated { user_id: user_id.clone(), }) .await } #[cfg(test)] mod tests;