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

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