feat: add image upload for avatar and banner

This commit is contained in:
2026-05-24 02:06:47 +02:00
parent 1874954ad7
commit 01932cf337
40 changed files with 1396 additions and 112 deletions

View File

@@ -15,6 +15,8 @@ hex = "0.4"
tracing = { workspace = true }
url = { workspace = true }
tokio = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }

View File

@@ -1,5 +1,6 @@
const MAX_TOP_FRIENDS: usize = 8;
use bytes::Bytes;
use domain::{
errors::DomainError,
events::DomainEvent,
@@ -7,7 +8,9 @@ use domain::{
top_friend::TopFriend,
user::{UpdateProfileInput, User},
},
ports::{EventPublisher, TopFriendRepository, UserReader, UserWriter},
ports::{
EventPublisher, MediaStore, TopFriendRepository, UserReader, UserRepository, UserWriter,
},
value_objects::{UserId, Username},
};
@@ -81,5 +84,151 @@ pub async fn set_top_friends(
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;

View File

@@ -5,6 +5,7 @@ use domain::{
testing::TestStore,
value_objects::{Email, PasswordHash, UserId, Username},
};
use std::sync::{Arc, Mutex};
fn make_user() -> User {
User::new_local(
@@ -64,3 +65,219 @@ async fn get_user_by_username_returns_correct_user() {
let found = get_user_by_username(&store, "alice").await.unwrap();
assert_eq!(found.id, user.id);
}
// ── upload tests ──────────────────────────────────────────────────────────────
use bytes::Bytes;
use domain::ports::{DataStream, MediaStore};
use std::collections::HashMap;
#[derive(Default, Clone)]
struct MockMedia {
store: Arc<Mutex<HashMap<String, Bytes>>>,
deleted: Arc<Mutex<Vec<String>>>,
}
#[async_trait::async_trait]
impl MediaStore for MockMedia {
async fn put(&self, key: &str, mut data: DataStream) -> Result<(), DomainError> {
use futures::stream::StreamExt;
let mut buf = Vec::new();
while let Some(chunk) = data.next().await {
buf.extend_from_slice(&chunk?);
}
self.store
.lock()
.unwrap()
.insert(key.to_string(), Bytes::from(buf));
Ok(())
}
async fn get(&self, key: &str) -> Result<DataStream, DomainError> {
let bytes = self
.store
.lock()
.unwrap()
.get(key)
.cloned()
.ok_or(DomainError::NotFound)?;
Ok(Box::pin(futures::stream::once(async move { Ok(bytes) })))
}
async fn delete(&self, key: &str) -> Result<(), DomainError> {
self.store.lock().unwrap().remove(key);
self.deleted.lock().unwrap().push(key.to_string());
Ok(())
}
}
fn default_cfg() -> UploadConfig {
UploadConfig::default()
}
#[tokio::test]
async fn upload_avatar_rejects_unsupported_mime() {
let store = TestStore::default();
let media = MockMedia::default();
let user = make_user();
store.users.lock().unwrap().push(user.clone());
let err = upload_avatar(
&store,
&media,
&store,
&user.id,
"http://localhost",
&default_cfg(),
"text/plain",
Bytes::from("hi"),
)
.await
.unwrap_err();
assert!(matches!(err, DomainError::InvalidInput(_)));
}
#[tokio::test]
async fn upload_avatar_rejects_oversized_data() {
let store = TestStore::default();
let media = MockMedia::default();
let user = make_user();
store.users.lock().unwrap().push(user.clone());
let big = Bytes::from(vec![0u8; 6 * 1024 * 1024]);
let err = upload_avatar(
&store,
&media,
&store,
&user.id,
"http://localhost",
&default_cfg(),
"image/jpeg",
big,
)
.await
.unwrap_err();
assert!(matches!(err, DomainError::InvalidInput(_)));
}
#[tokio::test]
async fn upload_avatar_stores_file_and_updates_url() {
let store = TestStore::default();
let media = MockMedia::default();
let user = make_user();
store.users.lock().unwrap().push(user.clone());
upload_avatar(
&store,
&media,
&store,
&user.id,
"http://localhost",
&default_cfg(),
"image/jpeg",
Bytes::from("img"),
)
.await
.unwrap();
let key = format!("users/{}/avatar.jpg", user.id.as_uuid());
assert!(media.store.lock().unwrap().contains_key(&key));
let saved = store
.users
.lock()
.unwrap()
.iter()
.find(|u| u.id == user.id)
.unwrap()
.clone();
assert_eq!(
saved.avatar_url,
Some(format!("http://localhost/media/{key}"))
);
}
#[tokio::test]
async fn upload_avatar_deletes_old_file_on_reupload() {
let store = TestStore::default();
let media = MockMedia::default();
let mut user = make_user();
let old_key = format!("users/{}/avatar.png", user.id.as_uuid());
user.avatar_url = Some(format!("http://localhost/media/{old_key}"));
store.users.lock().unwrap().push(user.clone());
media
.store
.lock()
.unwrap()
.insert(old_key.clone(), Bytes::from("old"));
upload_avatar(
&store,
&media,
&store,
&user.id,
"http://localhost",
&default_cfg(),
"image/jpeg",
Bytes::from("new"),
)
.await
.unwrap();
assert!(!media.store.lock().unwrap().contains_key(&old_key));
assert!(media.deleted.lock().unwrap().contains(&old_key));
}
#[tokio::test]
async fn upload_banner_stores_file_and_updates_header_url() {
let store = TestStore::default();
let media = MockMedia::default();
let user = make_user();
store.users.lock().unwrap().push(user.clone());
upload_banner(
&store,
&media,
&store,
&user.id,
"http://localhost",
&default_cfg(),
"image/png",
Bytes::from("banner"),
)
.await
.unwrap();
let key = format!("users/{}/banner.png", user.id.as_uuid());
assert!(media.store.lock().unwrap().contains_key(&key));
let saved = store
.users
.lock()
.unwrap()
.iter()
.find(|u| u.id == user.id)
.unwrap()
.clone();
assert_eq!(
saved.header_url,
Some(format!("http://localhost/media/{key}"))
);
}
#[tokio::test]
async fn upload_banner_deletes_old_file_on_reupload() {
let store = TestStore::default();
let media = MockMedia::default();
let mut user = make_user();
let old_key = format!("users/{}/banner.jpg", user.id.as_uuid());
user.header_url = Some(format!("http://localhost/media/{old_key}"));
store.users.lock().unwrap().push(user.clone());
media
.store
.lock()
.unwrap()
.insert(old_key.clone(), Bytes::from("old"));
upload_banner(
&store,
&media,
&store,
&user.id,
"http://localhost",
&default_cfg(),
"image/png",
Bytes::from("new"),
)
.await
.unwrap();
assert!(!media.store.lock().unwrap().contains_key(&old_key));
assert!(media.deleted.lock().unwrap().contains(&old_key));
}