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:
2026-05-11 22:59:52 +02:00
parent 8a254346f4
commit 80f620c840
89 changed files with 2231 additions and 499 deletions

View File

@@ -18,3 +18,4 @@ pub mod log_review;
pub mod login;
pub mod register;
pub mod sync_poster;
pub mod update_profile;

View File

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

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