feat: image storage generalization, user profile, and federation polish
- Replace PosterStorage with generic ImageStorage port (IMAGE_STORAGE_BACKEND/PATH env vars)
- Rename poster-storage crate to image-storage; serve at /images/{*key}
- Add bio and avatar_path to User model (migration 0009_user_profile)
- update_profile use case with avatar upload, mime validation, old avatar cleanup
- GET/PUT /api/v1/profile and GET/POST /settings/profile HTML page
- Enrich AP Person actor with summary, icon, url, discoverable fields
- Store remote actor avatar_url (migration 0010_ap_remote_actor_avatar)
- Shared inbox delivery via collect_inboxes deduplication
- Broadcast Update(Person) to followers on UserUpdated event
- Paginated outbox: OrderedCollection + OrderedCollectionPage with cursor
- Announce/boost tracking in ap_announces table (migration 0011_ap_announces)
This commit is contained in:
@@ -18,3 +18,4 @@ pub mod log_review;
|
||||
pub mod login;
|
||||
pub mod register;
|
||||
pub mod sync_poster;
|
||||
pub mod update_profile;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{ExternalMetadataId, MovieId},
|
||||
value_objects::{ExternalMetadataId, MovieId, PosterPath},
|
||||
};
|
||||
|
||||
use crate::{commands::SyncPosterCommand, context::AppContext};
|
||||
@@ -36,11 +36,12 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
|
||||
let image_bytes = ctx.poster_fetcher.fetch_poster_bytes(&poster_url).await?;
|
||||
|
||||
let stored_path = ctx
|
||||
.poster_storage
|
||||
.store_poster(&movie_id, &image_bytes)
|
||||
.image_storage
|
||||
.store(&movie_id.value().to_string(), &image_bytes)
|
||||
.await?;
|
||||
let poster_path = PosterPath::new(stored_path)?;
|
||||
|
||||
movie.update_poster(stored_path);
|
||||
movie.update_poster(poster_path);
|
||||
ctx.movie_repository.upsert_movie(&movie).await?;
|
||||
|
||||
Ok(())
|
||||
|
||||
51
crates/application/src/use_cases/update_profile.rs
Normal file
51
crates/application/src/use_cases/update_profile.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
use crate::context::AppContext;
|
||||
|
||||
pub struct UpdateProfileCommand {
|
||||
pub user_id: uuid::Uuid,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_bytes: Option<Vec<u8>>,
|
||||
pub avatar_content_type: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), DomainError> {
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
|
||||
let user = ctx
|
||||
.user_repository
|
||||
.find_by_id(&user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
|
||||
|
||||
let new_avatar_path = if let Some(bytes) = cmd.avatar_bytes {
|
||||
let content_type = cmd.avatar_content_type.as_deref().unwrap_or("");
|
||||
if !["image/jpeg", "image/png", "image/webp"].contains(&content_type) {
|
||||
return Err(DomainError::ValidationError(
|
||||
"Avatar must be jpeg, png, or webp".into(),
|
||||
));
|
||||
}
|
||||
if let Some(old_path) = user.avatar_path() {
|
||||
let _ = ctx.image_storage.delete(old_path).await;
|
||||
}
|
||||
let key = format!("avatars/{}", user_id.value());
|
||||
let stored = ctx.image_storage.store(&key, &bytes).await?;
|
||||
Some(stored)
|
||||
} else {
|
||||
user.avatar_path().map(|s| s.to_string())
|
||||
};
|
||||
|
||||
ctx.user_repository
|
||||
.update_profile(&user_id, cmd.bio, new_avatar_path)
|
||||
.await?;
|
||||
|
||||
ctx.event_publisher
|
||||
.publish(&DomainEvent::UserUpdated { user_id })
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user