235 lines
6.0 KiB
Rust
235 lines
6.0 KiB
Rust
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<User, DomainError> {
|
|
users
|
|
.find_by_id(user_id)
|
|
.await?
|
|
.ok_or(DomainError::NotFound)
|
|
}
|
|
|
|
pub async fn get_user_by_username(
|
|
users: &dyn UserReader,
|
|
username: &str,
|
|
) -> Result<User, DomainError> {
|
|
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<User, DomainError> {
|
|
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<Vec<(TopFriend, User)>, 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<UserId>,
|
|
) -> 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<String>,
|
|
}
|
|
|
|
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<String, DomainError> {
|
|
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;
|