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

@@ -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 }

View 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())
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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))
}

View File

@@ -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,
}

View File

@@ -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(),
}
}