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:
@@ -2,9 +2,10 @@ use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
|
||||
ImageStorage,
|
||||
ImportProfileRepository, ImportSessionRepository,
|
||||
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient,
|
||||
PosterStorage, ReviewRepository, StatsRepository, UserRepository,
|
||||
ReviewRepository, StatsRepository, UserRepository,
|
||||
};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
@@ -19,7 +20,7 @@ pub struct AppContext {
|
||||
pub stats_repository: Arc<dyn StatsRepository>,
|
||||
pub metadata_client: Arc<dyn MetadataClient>,
|
||||
pub poster_fetcher: Arc<dyn PosterFetcherClient>,
|
||||
pub poster_storage: Arc<dyn PosterStorage>,
|
||||
pub image_storage: Arc<dyn ImageStorage>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
pub auth_service: Arc<dyn AuthService>,
|
||||
pub password_hasher: Arc<dyn PasswordHasher>,
|
||||
|
||||
@@ -145,6 +145,13 @@ pub struct ImportPreviewPageData {
|
||||
pub rows: Vec<ImportPreviewRow>,
|
||||
}
|
||||
|
||||
pub struct ProfileSettingsPageData {
|
||||
pub ctx: HtmlPageContext,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub saved: bool,
|
||||
}
|
||||
|
||||
pub trait HtmlRenderer: Send + Sync {
|
||||
fn render_diary_page(
|
||||
&self,
|
||||
@@ -163,6 +170,10 @@ pub trait HtmlRenderer: Send + Sync {
|
||||
fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result<String, String>;
|
||||
fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result<String, String>;
|
||||
fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result<String, String>;
|
||||
fn render_profile_settings_page(
|
||||
&self,
|
||||
data: ProfileSettingsPageData,
|
||||
) -> Result<String, String>;
|
||||
}
|
||||
|
||||
pub trait RssFeedRenderer: Send + Sync {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -94,6 +94,7 @@ mod tests {
|
||||
DomainEvent::ReviewLogged { .. } => "review_logged",
|
||||
DomainEvent::ReviewUpdated { .. } => "review_updated",
|
||||
DomainEvent::MovieDeleted { .. } => "movie_deleted",
|
||||
DomainEvent::UserUpdated { .. } => "user_updated",
|
||||
};
|
||||
self.calls.lock().unwrap().push(label);
|
||||
Ok(())
|
||||
|
||||
Reference in New Issue
Block a user