feat: add image upload for avatar and banner
This commit is contained in:
@@ -17,6 +17,7 @@ uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
url = { workspace = true }
|
||||
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] }
|
||||
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
|
||||
|
||||
51
crates/presentation/src/handlers/media.rs
Normal file
51
crates/presentation/src/handlers/media.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use crate::{
|
||||
errors::ApiError,
|
||||
extractors::{Deps, FromAppState},
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::Path,
|
||||
http::header,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use domain::ports::MediaStore;
|
||||
use futures::TryStreamExt;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct MediaDeps {
|
||||
pub media: Arc<dyn MediaStore>,
|
||||
}
|
||||
|
||||
impl FromAppState for MediaDeps {
|
||||
fn from_state(s: &AppState) -> Self {
|
||||
Self {
|
||||
media: s.media.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ext_to_mime(ext: &str) -> &'static str {
|
||||
match ext {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"png" => "image/png",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"avif" => "image/avif",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_media(
|
||||
Deps(d): Deps<MediaDeps>,
|
||||
Path(path): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let stream = d.media.get(&path).await?;
|
||||
let content_type = path
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.map(ext_to_mime)
|
||||
.unwrap_or("application/octet-stream");
|
||||
let body = Body::from_stream(stream.map_err(|e| e.to_string()));
|
||||
Ok(([(header::CONTENT_TYPE, content_type)], body).into_response())
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod federation_actors;
|
||||
pub mod federation_management;
|
||||
pub mod feed;
|
||||
pub mod health;
|
||||
pub mod media;
|
||||
pub mod notifications;
|
||||
pub mod social;
|
||||
pub mod thoughts;
|
||||
|
||||
@@ -10,16 +10,20 @@ use api_types::{
|
||||
};
|
||||
use application::use_cases::profile::{
|
||||
get_user as fetch_user, get_user_by_id_or_username, update_profile,
|
||||
upload_avatar as upload_avatar_uc, upload_banner as upload_banner_uc, UploadConfig,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
extract::{Multipart, Path, Query},
|
||||
http::{header, HeaderMap},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use domain::{
|
||||
models::user::UpdateProfileInput,
|
||||
ports::{EventPublisher, FederationActionPort, FollowRepository, SearchPort, UserRepository},
|
||||
ports::{
|
||||
EventPublisher, FederationActionPort, FollowRepository, MediaStore, SearchPort,
|
||||
UserRepository,
|
||||
},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -29,6 +33,9 @@ pub struct UsersDeps {
|
||||
pub follows: Arc<dyn FollowRepository>,
|
||||
pub federation: Arc<dyn FederationActionPort>,
|
||||
pub search: Arc<dyn SearchPort>,
|
||||
pub media: Arc<dyn MediaStore>,
|
||||
pub upload_config: UploadConfig,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl FromAppState for UsersDeps {
|
||||
@@ -39,6 +46,9 @@ impl FromAppState for UsersDeps {
|
||||
follows: s.follows.clone(),
|
||||
federation: s.federation.clone(),
|
||||
search: s.search.clone(),
|
||||
media: s.media.clone(),
|
||||
upload_config: s.upload_config.clone(),
|
||||
base_url: s.base_url.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +98,8 @@ pub async fn get_user(
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
// avatar_url and header_url in UpdateProfileRequest are accepted as-is (external
|
||||
// URLs allowed). The upload use-cases handle storage-backed uploads separately.
|
||||
pub async fn patch_profile(
|
||||
Deps(d): Deps<UsersDeps>,
|
||||
AuthUser(uid): AuthUser,
|
||||
@@ -228,5 +240,77 @@ pub async fn lookup_handler(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn upload_avatar(
|
||||
Deps(d): Deps<UsersDeps>,
|
||||
AuthUser(uid): AuthUser,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<UserResponse>, ApiError> {
|
||||
let field = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|_| ApiError::BadRequest("invalid multipart".into()))?
|
||||
.ok_or_else(|| ApiError::BadRequest("no file field".into()))?;
|
||||
// Content-type is client-supplied; the use-case allowlist prevents obviously
|
||||
// wrong types, but magic-byte validation is not performed. Serve media files
|
||||
// from an isolated origin to prevent MIME-based XSS.
|
||||
let content_type = field
|
||||
.content_type()
|
||||
.ok_or_else(|| ApiError::BadRequest("missing content-type on field".into()))?
|
||||
.to_string();
|
||||
let data = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|_| ApiError::BadRequest("failed to read upload".into()))?;
|
||||
upload_avatar_uc(
|
||||
&*d.users,
|
||||
&*d.media,
|
||||
&*d.events,
|
||||
&uid,
|
||||
&d.base_url,
|
||||
&d.upload_config,
|
||||
&content_type,
|
||||
data,
|
||||
)
|
||||
.await?;
|
||||
let user = fetch_user(&*d.users, &uid).await?;
|
||||
Ok(Json(to_user_response(&user)))
|
||||
}
|
||||
|
||||
pub async fn upload_banner(
|
||||
Deps(d): Deps<UsersDeps>,
|
||||
AuthUser(uid): AuthUser,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<UserResponse>, ApiError> {
|
||||
let field = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|_| ApiError::BadRequest("invalid multipart".into()))?
|
||||
.ok_or_else(|| ApiError::BadRequest("no file field".into()))?;
|
||||
// Content-type is client-supplied; the use-case allowlist prevents obviously
|
||||
// wrong types, but magic-byte validation is not performed. Serve media files
|
||||
// from an isolated origin to prevent MIME-based XSS.
|
||||
let content_type = field
|
||||
.content_type()
|
||||
.ok_or_else(|| ApiError::BadRequest("missing content-type on field".into()))?
|
||||
.to_string();
|
||||
let data = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|_| ApiError::BadRequest("failed to read upload".into()))?;
|
||||
upload_banner_uc(
|
||||
&*d.users,
|
||||
&*d.media,
|
||||
&*d.events,
|
||||
&uid,
|
||||
&d.base_url,
|
||||
&d.upload_config,
|
||||
&content_type,
|
||||
data,
|
||||
)
|
||||
.await?;
|
||||
let user = fetch_user(&*d.users, &uid).await?;
|
||||
Ok(Json(to_user_response(&user)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::{handlers::*, openapi, state::AppState};
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
routing::{delete, get, patch, post, put},
|
||||
Router,
|
||||
};
|
||||
@@ -16,6 +17,14 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/users/count", get(users::get_user_count))
|
||||
.route("/users/lookup", get(users::lookup_handler))
|
||||
.route("/users/me", get(users::get_me).patch(users::patch_profile))
|
||||
.route(
|
||||
"/users/me/avatar",
|
||||
put(users::upload_avatar).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/users/me/banner",
|
||||
put(users::upload_banner).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
|
||||
)
|
||||
.route("/users/me/following", get(users::get_me_following))
|
||||
.route("/users/me/top-friends", put(social::put_top_friends))
|
||||
.route("/users/{username}", get(users::get_user))
|
||||
@@ -113,5 +122,5 @@ pub fn router() -> Router<AppState> {
|
||||
)
|
||||
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler));
|
||||
|
||||
openapi::serve(api_routes)
|
||||
openapi::serve(api_routes).route("/media/{*path}", get(media::get_media))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use activitypub::ActivityPubRepository;
|
||||
use application::use_cases::profile::UploadConfig;
|
||||
use domain::ports::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -27,4 +28,7 @@ pub struct AppState {
|
||||
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
|
||||
pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
|
||||
pub engagement: Arc<dyn EngagementRepository>,
|
||||
pub media: Arc<dyn MediaStore>,
|
||||
pub upload_config: UploadConfig,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::state::AppState;
|
||||
use activitypub::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
||||
use application::use_cases::profile::UploadConfig;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||
ports::{AuthService, DataStream, GeneratedToken, MediaStore, PasswordHasher},
|
||||
testing::{NoOpOutboxWriter, TestStore},
|
||||
value_objects::{PasswordHash, ThoughtId, UserId},
|
||||
};
|
||||
@@ -98,6 +99,21 @@ impl ActivityPubRepository for NoOpApRepo {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoOpMediaStore;
|
||||
|
||||
#[async_trait]
|
||||
impl MediaStore for NoOpMediaStore {
|
||||
async fn put(&self, _key: &str, _data: DataStream) -> Result<(), DomainError> {
|
||||
Err(DomainError::Internal("noop".into()))
|
||||
}
|
||||
async fn get(&self, _key: &str) -> Result<DataStream, DomainError> {
|
||||
Err(DomainError::Internal("noop".into()))
|
||||
}
|
||||
async fn delete(&self, _key: &str) -> Result<(), DomainError> {
|
||||
Err(DomainError::Internal("noop".into()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make_state() -> AppState {
|
||||
let store = Arc::new(TestStore::default());
|
||||
AppState {
|
||||
@@ -124,5 +140,8 @@ pub fn make_state() -> AppState {
|
||||
federation_scheduler: store.clone(),
|
||||
api_key_auth: store.clone(),
|
||||
engagement: store.clone(),
|
||||
media: Arc::new(NoOpMediaStore),
|
||||
upload_config: UploadConfig::default(),
|
||||
base_url: "http://localhost:3000".into(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user