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

@@ -0,0 +1,58 @@
use async_trait::async_trait;
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
use sqlx::Row;
use super::PostgresFederationRepository;
#[async_trait]
impl FederatedProfileQuery for PostgresFederationRepository {
async fn get_federated_profile(
&self,
synthetic_user_id: uuid::Uuid,
) -> Result<Option<FederatedProfile>, DomainError> {
let uid = synthetic_user_id.to_string();
let actor_url: Option<String> = sqlx::query_scalar(
"SELECT remote_actor_url FROM reviews
WHERE user_id = $1 AND remote_actor_url IS NOT NULL
LIMIT 1",
)
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let actor_url = match actor_url {
Some(url) => url,
None => return Ok(None),
};
let row = sqlx::query(
"SELECT handle, display_name, bio, avatar_url, banner_url
FROM ap_remote_actors WHERE url = $1",
)
.bind(&actor_url)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
match row {
Some(r) => Ok(Some(FederatedProfile {
actor_url,
handle: r.get("handle"),
display_name: r.try_get("display_name").ok().flatten(),
bio: r.try_get("bio").ok().flatten(),
avatar_url: r.try_get("avatar_url").ok().flatten(),
banner_url: r.try_get("banner_url").ok().flatten(),
})),
None => Ok(Some(FederatedProfile {
handle: actor_url.clone(),
actor_url,
display_name: None,
bio: None,
avatar_url: None,
banner_url: None,
})),
}
}
}

View File

@@ -2,6 +2,7 @@ mod activity;
mod actor;
pub mod ap_content;
mod blocklist;
mod federated_profile;
mod follow;
pub mod remote_goals;
mod review;
@@ -75,6 +76,12 @@ impl PostgresFederationRepository {
}
}
pub fn create_federated_profile_query(
pool: PgPool,
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
std::sync::Arc::new(PostgresFederationRepository::new(pool))
}
pub fn wire(pool: PgPool) -> activitypub::FederationRepos {
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool));
(

View File

@@ -114,19 +114,24 @@ impl PostgresDiaryRepository {
&self,
user_id: &str,
search: Option<&str>,
include_remote: bool,
) -> Result<i64, DomainError> {
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
let sql = if has_search {
"SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND m.title ILIKE '%' || $2 || '%'"
.to_string()
let remote_clause = if include_remote {
""
} else {
" AND r.remote_actor_url IS NULL"
};
let search_clause = if has_search {
" AND m.title ILIKE '%' || $2 || '%'"
} else {
""
};
let sql = format!(
"SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1"
.to_string()
};
WHERE r.user_id = $1{remote_clause}{search_clause}"
);
let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id);
if has_search {
q = q.bind(search.unwrap());
@@ -139,6 +144,7 @@ impl PostgresDiaryRepository {
user_id: &str,
sort: &SortDirection,
search: Option<&str>,
include_remote: bool,
limit: i64,
offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> {
@@ -149,9 +155,13 @@ impl PostgresDiaryRepository {
SortDirection::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC",
};
let remote_clause = if include_remote {
""
} else {
" AND r.remote_actor_url IS NULL"
};
// Build param counter: user_id=$1, optional search=$2, limit=$N-1, offset=$N
let mut p: i32 = 1; // $1 is user_id
let mut p: i32 = 1;
let search_clause = if has_search {
p += 1;
format!(" AND m.title ILIKE '%' || ${} || '%'", p)
@@ -171,10 +181,9 @@ impl PostgresDiaryRepository {
r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL{}
ORDER BY {}
LIMIT {} OFFSET {}",
search_clause, order_clause, limit_param, offset_param
WHERE r.user_id = $1{remote_clause}{search_clause}
ORDER BY {order_clause}
LIMIT {limit_param} OFFSET {offset_param}",
);
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
@@ -213,9 +222,17 @@ impl DiaryRepository for PostgresDiaryRepository {
(None, Some(uid)) => {
let uid_str = uid.value().to_string();
let search = filter.search.as_deref();
let inc = filter.include_remote;
tokio::try_join!(
self.count_user_diary_entries(&uid_str, search),
self.fetch_user_diary_rows(&uid_str, &filter.sort_by, search, limit, offset)
self.count_user_diary_entries(&uid_str, search, inc),
self.fetch_user_diary_rows(
&uid_str,
&filter.sort_by,
search,
inc,
limit,
offset
)
)?
}
(Some(_), Some(_)) => {

View File

@@ -0,0 +1,58 @@
use async_trait::async_trait;
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
use sqlx::Row;
use super::SqliteFederationRepository;
#[async_trait]
impl FederatedProfileQuery for SqliteFederationRepository {
async fn get_federated_profile(
&self,
synthetic_user_id: uuid::Uuid,
) -> Result<Option<FederatedProfile>, DomainError> {
let uid = synthetic_user_id.to_string();
let actor_url: Option<String> = sqlx::query_scalar(
"SELECT remote_actor_url FROM reviews
WHERE user_id = ? AND remote_actor_url IS NOT NULL
LIMIT 1",
)
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let actor_url = match actor_url {
Some(url) => url,
None => return Ok(None),
};
let row = sqlx::query(
"SELECT handle, display_name, bio, avatar_url, banner_url
FROM ap_remote_actors WHERE url = ?",
)
.bind(&actor_url)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
match row {
Some(r) => Ok(Some(FederatedProfile {
actor_url,
handle: r.get("handle"),
display_name: r.try_get("display_name").ok().flatten(),
bio: r.try_get("bio").ok().flatten(),
avatar_url: r.try_get("avatar_url").ok().flatten(),
banner_url: r.try_get("banner_url").ok().flatten(),
})),
None => Ok(Some(FederatedProfile {
handle: actor_url.clone(),
actor_url,
display_name: None,
bio: None,
avatar_url: None,
banner_url: None,
})),
}
}
}

View File

@@ -1,13 +1,15 @@
mod activity;
mod actor;
pub mod ap_content;
mod blocklist;
mod federated_profile;
mod follow;
pub mod remote_goals;
mod review;
mod social;
mod watchlist;
pub mod ap_content;
pub mod remote_goals;
pub use ap_content::SqliteApContentQuery;
pub use remote_goals::SqliteRemoteGoalRepository;
@@ -86,6 +88,12 @@ impl SqliteFederationRepository {
}
}
pub fn create_federated_profile_query(
pool: SqlitePool,
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
std::sync::Arc::new(SqliteFederationRepository::new(pool))
}
pub fn wire(pool: SqlitePool) -> activitypub::FederationRepos {
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool));
(

View File

@@ -108,19 +108,24 @@ impl SqliteDiaryRepository {
&self,
user_id: &str,
search: Option<&str>,
include_remote: bool,
) -> Result<i64, DomainError> {
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
let sql = if has_search {
"SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND m.title LIKE '%' || ? || '%'"
.to_string()
let remote_clause = if include_remote {
""
} else {
" AND r.remote_actor_url IS NULL"
};
let search_clause = if has_search {
" AND m.title LIKE '%' || ? || '%'"
} else {
""
};
let sql = format!(
"SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ?"
.to_string()
};
WHERE r.user_id = ?{remote_clause}{search_clause}"
);
let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id);
if has_search {
q = q.bind(search.unwrap());
@@ -133,10 +138,16 @@ impl SqliteDiaryRepository {
user_id: &str,
sort: &SortDirection,
search: Option<&str>,
include_remote: bool,
limit: i64,
offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> {
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
let remote_clause = if include_remote {
""
} else {
" AND r.remote_actor_url IS NULL"
};
let search_clause = if has_search {
" AND m.title LIKE '%' || ? || '%'"
} else {
@@ -153,10 +164,9 @@ impl SqliteDiaryRepository {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND r.remote_actor_url IS NULL{}
ORDER BY {}
WHERE r.user_id = ?{remote_clause}{search_clause}
ORDER BY {order_clause}
LIMIT ? OFFSET ?",
search_clause, order_clause
);
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
if has_search {
@@ -194,9 +204,17 @@ impl DiaryRepository for SqliteDiaryRepository {
(None, Some(uid)) => {
let uid_str = uid.value().to_string();
let search = filter.search.as_deref();
let inc = filter.include_remote;
tokio::try_join!(
self.count_user_diary_entries(&uid_str, search),
self.fetch_user_diary_rows(&uid_str, &filter.sort_by, search, limit, offset)
self.count_user_diary_entries(&uid_str, search, inc),
self.fetch_user_diary_rows(
&uid_str,
&filter.sort_by,
search,
inc,
limit,
offset
)
)?
}
(Some(_), Some(_)) => {