feat: add image upload for avatar and banner
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user