feat: implement federated profile handling with support for remote actor URLs across multiple components
All checks were successful
CI / Check / Test (push) Successful in 1h7m53s

This commit is contained in:
2026-07-01 00:13:28 +02:00
parent d7fbf8d9e2
commit 9794babe06
25 changed files with 436 additions and 50 deletions

View File

@@ -1,14 +1,14 @@
use std::sync::Arc;
use domain::ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, GoalRepository,
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonEnrichmentClient,
PersonQuery, PosterFetcherClient, RefreshSessionRepository, RemoteGoalRepository,
RemoteWatchlistRepository, ReviewRepository, SearchCommand, SearchPort, SocialQueryPort,
StatsRepository, UserProfileFieldsRepository, UserRepository, UserSettingsRepository,
WatchEventRepository, WatchlistRepository, WebhookTokenRepository, WrapUpRepository,
WrapUpStatsQuery,
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
FederatedProfileQuery, GoalRepository, ImportProfileRepository, ImportSessionRepository,
MetadataClient, MovieProfileRepository, MovieRepository, ObjectStorage, PasswordHasher,
PersonCommand, PersonEnrichmentClient, PersonQuery, PosterFetcherClient,
RefreshSessionRepository, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository,
SearchCommand, SearchPort, SocialQueryPort, StatsRepository, UserProfileFieldsRepository,
UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository,
WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery,
};
use application::config::AppConfig;
@@ -40,6 +40,7 @@ pub struct Repositories {
pub user_settings: Arc<dyn UserSettingsRepository>,
pub remote_goal: Arc<dyn RemoteGoalRepository>,
pub refresh_session: Arc<dyn RefreshSessionRepository>,
pub federated_profile: Option<Arc<dyn FederatedProfileQuery>>,
}
#[derive(Clone)]

View File

@@ -265,20 +265,30 @@ pub async fn get_user_profile(
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
};
let user = match state
let local_user = match state
.app_ctx
.repos
.user
.find_by_id(&UserId::from_uuid(user_id))
.await
{
Ok(Some(u)) => u,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Ok(u) => u,
Err(e) => {
return crate::errors::domain_error_response(e);
}
};
if local_user.is_none() {
if let Some(ref fed_query) = state.app_ctx.repos.federated_profile
&& let Ok(Some(fed)) = fed_query.get_federated_profile(user_id).await
{
return build_federated_profile_response(&state, user_id, fed, profile_view, &params)
.await;
}
return StatusCode::NOT_FOUND.into_response();
}
let user = local_user.unwrap();
let get_profile_deps = GetProfileDeps {
stats: state.app_ctx.repos.stats.clone(),
diary: state.app_ctx.repos.diary.clone(),
@@ -294,6 +304,7 @@ pub async fn get_user_profile(
sort_by: domain::models::FeedSortBy::Date,
search: params.search,
is_own_profile: viewer_id.value() == user_id,
include_remote: false,
},
)
.await
@@ -384,6 +395,106 @@ pub async fn get_user_profile(
Some(goals_list.iter().map(goal_with_progress_to_dto).collect())
}
},
is_federated: false,
handle: None,
display_name: None,
bio: None,
actor_url: None,
})
.into_response()
}
async fn build_federated_profile_response(
state: &AppState,
user_id: Uuid,
fed: domain::models::FederatedProfile,
profile_view: application::users::queries::ProfileView,
params: &UserProfileQueryParams,
) -> axum::response::Response {
let get_profile_deps = GetProfileDeps {
stats: state.app_ctx.repos.stats.clone(),
diary: state.app_ctx.repos.diary.clone(),
social_query: state.app_ctx.repos.social_query.clone(),
};
let profile = match get_user_profile_uc::execute(
&get_profile_deps,
GetUserProfileQuery {
user_id,
view: profile_view,
limit: params.limit,
offset: params.offset,
sort_by: domain::models::FeedSortBy::Date,
search: params.search.clone(),
is_own_profile: false,
include_remote: true,
},
)
.await
{
Ok(p) => p,
Err(e) => return crate::errors::domain_error_response(e),
};
let entries = profile.entries.map(|p| DiaryResponse {
items: p
.items
.iter()
.map(crate::mappers::movies::entry_to_dto)
.collect(),
total_count: p.total_count,
limit: p.limit,
offset: p.offset,
});
let trends = profile.trends.map(|t| UserTrendsDto {
monthly_ratings: t
.monthly_ratings
.into_iter()
.map(|r| MonthlyRatingDto {
year_month: r.year_month,
month_label: r.month_label,
avg_rating: r.avg_rating,
count: r.count,
})
.collect(),
top_directors: t
.top_directors
.into_iter()
.map(|d| DirectorStatDto {
director: d.director,
count: d.count,
})
.collect(),
max_director_count: t.max_director_count,
});
let username = fed
.display_name
.clone()
.unwrap_or_else(|| fed.handle.clone());
Json(UserProfileResponse {
user_id,
username,
avatar_url: fed.avatar_url,
banner_url: fed.banner_url,
stats: UserStatsDto {
total_movies: profile.stats.total_movies,
avg_rating: profile.stats.avg_rating,
favorite_director: profile.stats.favorite_director,
most_active_month: profile.stats.most_active_month,
},
following_count: 0,
followers_count: 0,
entries,
history: None,
trends,
goals: None,
is_federated: true,
handle: Some(fed.handle),
display_name: fed.display_name,
bio: fed.bio,
actor_url: Some(fed.actor_url),
})
.into_response()
}
@@ -538,6 +649,7 @@ pub async fn get_user_profile_html(
Some(params.search.clone())
},
is_own_profile,
include_remote: false,
};
let html_profile_deps = GetProfileDeps {

View File

@@ -207,6 +207,23 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
user_settings: db.user_settings,
remote_goal: db.remote_goal,
refresh_session: db.refresh_session,
#[cfg(feature = "federation")]
federated_profile: Some({
match &db_pool {
#[cfg(feature = "sqlite-federation")]
factory::DbPool::Sqlite(pool) => {
sqlite_federation::create_federated_profile_query(pool.clone())
}
#[cfg(feature = "postgres-federation")]
factory::DbPool::Postgres(pool) => {
postgres_federation::create_federated_profile_query(pool.clone())
}
#[cfg(not(feature = "sqlite-federation"))]
_ => unreachable!(),
}
}),
#[cfg(not(feature = "federation"))]
federated_profile: None,
},
services: Services {
auth: auth_service,

View File

@@ -807,6 +807,7 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
user_settings: Arc::clone(&repo) as _,
remote_goal: Arc::clone(&repo) as _,
refresh_session: Arc::clone(&repo) as _,
federated_profile: None,
},
services: Services {
auth: auth_service,