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

@@ -433,6 +433,13 @@ pub struct PaginationQueryParams {
pub offset: Option<u32>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct ProfileResponse {
pub username: String,
pub bio: Option<String>,
pub avatar_url: Option<String>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct MovieStatsDto {
pub total_count: u64,

View File

@@ -137,12 +137,12 @@ mod tests {
collections::{PageParams, Paginated},
},
ports::{
AuthService, DiaryRepository, EventPublisher, GeneratedToken, MetadataClient,
MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository,
AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage,
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository,
StatsRepository, UserRepository,
},
value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl,
ReleaseYear, ReviewId, UserId,
},
};
@@ -279,16 +279,10 @@ mod tests {
}
}
#[async_trait::async_trait]
impl PosterStorage for Panic {
async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> {
panic!()
}
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> {
panic!()
}
async fn delete_poster(&self, _: &PosterPath) -> Result<(), DomainError> {
panic!()
}
impl ImageStorage for Panic {
async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> { panic!() }
async fn get(&self, _: &str) -> Result<Vec<u8>, DomainError> { panic!() }
async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!() }
}
#[async_trait::async_trait]
impl AuthService for Panic {
@@ -334,6 +328,9 @@ mod tests {
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
panic!()
}
async fn update_profile(&self, _: &UserId, _: Option<String>, _: Option<String>) -> Result<(), DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl EventPublisher for Panic {
@@ -442,6 +439,7 @@ mod tests {
fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result<String, String> { panic!() }
fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result<String, String> { panic!() }
fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result<String, String> { panic!() }
fn render_profile_settings_page(&self, _: application::ports::ProfileSettingsPageData) -> Result<String, String> { panic!() }
}
impl crate::ports::RssFeedRenderer for Panic {
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
@@ -474,7 +472,7 @@ mod tests {
stats_repository: Arc::clone(&repo) as _,
metadata_client: Arc::clone(&repo) as _,
poster_fetcher: Arc::clone(&repo) as _,
poster_storage: Arc::clone(&repo) as _,
image_storage: Arc::clone(&repo) as _,
event_publisher: Arc::clone(&repo) as _,
password_hasher: Arc::clone(&repo) as _,
user_repository: Arc::clone(&repo) as _,

View File

@@ -1,6 +1,6 @@
use axum::{
Json,
extract::{Path, Query, State},
extract::{Multipart, Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
@@ -20,7 +20,7 @@ use application::{
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
get_diary, get_movie_social_page, get_review_history,
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
register as register_uc, sync_poster,
register as register_uc, sync_poster, update_profile,
},
};
use domain::{
@@ -37,8 +37,8 @@ use crate::{
ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams,
DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData,
LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto,
MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, RegisterRequest,
ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, ProfileResponse,
RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
UsersResponse,
},
@@ -290,6 +290,101 @@ pub async fn get_movie_detail(
}))
}
#[utoipa::path(
get, path = "/api/v1/profile",
responses(
(status = 200, body = ProfileResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "User not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> impl IntoResponse {
let user = match state.app_ctx.user_repository.find_by_id(&user_id).await {
Ok(Some(u)) => u,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("get_profile user lookup: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let base_url = &state.app_ctx.config.base_url;
let avatar_url = user
.avatar_path()
.map(|path| format!("{}/images/{}", base_url, path));
Json(ProfileResponse {
username: user.username().value().to_string(),
bio: user.bio().map(|s| s.to_string()),
avatar_url,
})
.into_response()
}
#[utoipa::path(
put, path = "/api/v1/profile",
responses(
(status = 204, description = "Profile updated"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn update_profile_handler(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut bio: Option<String> = None;
let mut avatar_bytes: Option<Vec<u8>> = None;
let mut avatar_content_type: Option<String> = None;
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"bio" => {
if let Ok(text) = field.text().await {
bio = Some(text);
}
}
"avatar" => {
let content_type = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() {
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = content_type;
}
}
}
_ => {}
}
}
let cmd = update_profile::UpdateProfileCommand {
user_id: user_id.value(),
bio,
avatar_bytes,
avatar_content_type,
};
match update_profile::execute(&state.app_ctx, cmd).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(domain::errors::DomainError::ValidationError(msg)) => {
tracing::warn!("update_profile validation: {}", msg);
StatusCode::BAD_REQUEST.into_response()
}
Err(e) => {
tracing::error!("update_profile error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
fn movie_to_dto(movie: &Movie) -> MovieDto {
MovieDto {
id: movie.id().value(),

View File

@@ -2,7 +2,7 @@ use std::str::FromStr;
use axum::{
Form,
extract::{Extension, Path, Query, State},
extract::{Extension, Multipart, Path, Query, State},
http::{HeaderValue, StatusCode, header::SET_COOKIE},
response::{Html, IntoResponse, Redirect},
};
@@ -14,13 +14,13 @@ use application::ports::{FollowersPageData, FollowingPageData};
use application::{
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand},
ports::{
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, RegisterPageData,
RemoteActorView,
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
ProfileSettingsPageData, RegisterPageData, RemoteActorView,
},
queries::GetMovieSocialPageQuery,
use_cases::{
delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review,
login as login_uc, register as register_uc,
login as login_uc, register as register_uc, update_profile,
},
};
use domain::models::ExportFormat;
@@ -966,3 +966,97 @@ pub async fn get_movie_detail(
}
}
}
#[derive(serde::Deserialize, Default)]
pub struct SavedQuery {
pub saved: Option<String>,
}
pub async fn get_profile_settings(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Query(params): Query<SavedQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
ctx.page_title = "Profile Settings — Movies Diary".to_string();
ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url);
let user = match state
.app_ctx
.user_repository
.find_by_id(&user_id)
.await
{
Ok(Some(u)) => u,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("get_profile_settings user lookup: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let base_url = &state.app_ctx.config.base_url;
let avatar_url = user
.avatar_path()
.map(|path| format!("{}/images/{}", base_url, path));
let saved = params.saved.as_deref() == Some("1");
let data = ProfileSettingsPageData {
ctx,
bio: user.bio().map(|s| s.to_string()),
avatar_url,
saved,
};
match state.html_renderer.render_profile_settings_page(data) {
Ok(html) => Html(html).into_response(),
Err(e) => {
tracing::error!("profile_settings template error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
pub async fn post_profile_settings(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut bio: Option<String> = None;
let mut avatar_bytes: Option<Vec<u8>> = None;
let mut avatar_content_type: Option<String> = None;
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"bio" => {
if let Ok(text) = field.text().await {
bio = Some(text);
}
}
"avatar" => {
let content_type = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() {
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = content_type;
}
}
}
_ => {}
}
}
let cmd = update_profile::UpdateProfileCommand {
user_id: user_id.value(),
bio,
avatar_bytes,
avatar_content_type,
};
let _ = update_profile::execute(&state.app_ctx, cmd).await;
Redirect::to("/settings/profile?saved=1").into_response()
}

View File

@@ -0,0 +1,25 @@
use axum::{
extract::{Path, State},
http::{StatusCode, header},
response::IntoResponse,
};
use crate::state::AppState;
pub async fn get_image(
State(state): State<AppState>,
Path(key): Path<String>,
) -> impl IntoResponse {
if key.starts_with("http://") || key.starts_with("https://") {
return axum::response::Redirect::temporary(&key).into_response();
}
match state.app_ctx.image_storage.get(&key).await {
Ok(bytes) => {
let mime = infer::get(&bytes)
.map(|t| t.mime_type())
.unwrap_or("application/octet-stream");
([(header::CONTENT_TYPE, mime)], bytes).into_response()
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}

View File

@@ -1,5 +1,5 @@
pub mod html;
pub mod posters;
pub mod images;
pub mod rss;
pub mod api;
pub mod import;

View File

@@ -1,33 +0,0 @@
use axum::{
extract::{Path, State},
http::{StatusCode, header},
response::IntoResponse,
};
use domain::value_objects::PosterPath;
use crate::state::AppState;
pub async fn get_poster(
State(state): State<AppState>,
Path(path): Path<String>,
) -> impl IntoResponse {
// If path is a remote URL, redirect directly instead of serving from local storage.
if path.starts_with("http://") || path.starts_with("https://") {
return axum::response::Redirect::temporary(&path).into_response();
}
let poster_path = match PosterPath::new(path) {
Ok(p) => p,
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
};
match state.app_ctx.poster_storage.get_poster(&poster_path).await {
Ok(bytes) => {
let mime = infer::get(&bytes)
.map(|t| t.mime_type())
.unwrap_or("application/octet-stream");
([(header::CONTENT_TYPE, mime)], bytes).into_response()
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}

View File

@@ -49,7 +49,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let (auth_service, password_hasher) = auth::create()?;
let metadata_client = metadata::create()?;
let poster_fetcher = poster_fetcher::create()?;
let poster_storage = poster_storage::create()?;
let image_storage = image_storage::create()?;
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, db_pool) =
match backend.as_str() {
@@ -155,7 +155,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
stats_repository,
metadata_client,
poster_fetcher,
poster_storage,
image_storage,
event_publisher: event_publisher_arc,
auth_service,
password_hasher,

View File

@@ -74,8 +74,14 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
routing::post(handlers::html::post_delete_review),
)
.route(
"/posters/{*path}",
routing::get(handlers::posters::get_poster),
"/images/{*key}",
routing::get(handlers::images::get_image),
)
.route(
"/posters/{path}",
routing::get(|axum::extract::Path(p): axum::extract::Path<String>| async move {
axum::response::Redirect::permanent(&format!("/images/{}", p))
}),
)
.route("/diary/export", routing::get(handlers::html::get_export))
.route("/import", routing::get(handlers::import::get_import_page))
@@ -89,6 +95,11 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
.route(
"/users/{id}/feed.rss",
routing::get(handlers::rss::get_user_feed),
)
.route(
"/settings/profile",
routing::get(handlers::html::get_profile_settings)
.post(handlers::html::post_profile_settings),
);
#[cfg(feature = "federation")]
@@ -171,7 +182,8 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
.route("/import/sessions/{id}/mapping", routing::put(handlers::import::api_put_mapping))
.route("/import/sessions/{id}/confirm", routing::post(handlers::import::api_post_confirm))
.route("/import/profiles", routing::get(handlers::import::api_get_profiles).post(handlers::import::api_post_profile))
.route("/import/profiles/{id}", routing::delete(handlers::import::api_delete_profile));
.route("/import/profiles/{id}", routing::delete(handlers::import::api_delete_profile))
.route("/profile", routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler));
#[cfg(feature = "federation")]
let base = base.merge(federation_api_routes());