structural refactor and codebase improvements

This commit is contained in:
2026-08-09 14:58:14 +02:00
parent 22b1dd3f56
commit c9715baab8
247 changed files with 11515 additions and 3063 deletions

View File

@@ -8,7 +8,7 @@ use domain::{
GoalQuery, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository,
UserFederationSettingsQuery,
},
value_objects::{MovieId, ReviewId, UserId},
value_objects::{InstanceIdentity, MovieId, ReviewId, UserId},
};
use std::sync::Arc;
@@ -25,7 +25,7 @@ pub struct ActivityPubEventHandler {
goal_repo: Arc<dyn GoalQuery>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
base_url: String,
instance: InstanceIdentity,
}
impl ActivityPubEventHandler {
@@ -38,7 +38,7 @@ impl ActivityPubEventHandler {
goal_repo: Arc<dyn GoalQuery>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
base_url: String,
instance: InstanceIdentity,
) -> Self {
Self {
ap_service,
@@ -48,7 +48,7 @@ impl ActivityPubEventHandler {
goal_repo,
stats_repo,
federation_settings,
base_url,
instance,
}
}
}
@@ -140,7 +140,7 @@ impl EventHandler for ActivityPubEventHandler {
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::UserDeleted { user_id } => {
let ap_id = actor_url(&self.base_url, user_id.value());
let ap_id = actor_url(&self.instance, user_id.value());
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await
@@ -179,8 +179,8 @@ impl ActivityPubEventHandler {
None => return Ok(()),
};
let ap_id = review_url(&self.base_url, review_id);
let actor = actor_url(&self.base_url, user_id.value());
let ap_id = review_url(&self.instance, review_id);
let actor = actor_url(&self.instance, user_id.value());
let movie = self
.movie_repo
@@ -210,8 +210,8 @@ impl ActivityPubEventHandler {
poster_url: movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| format!("{}/images/{}", self.base_url, p.value())),
base_url: self.base_url.clone(),
.map(|p| self.instance.image_url_for(p.value())),
base_url: self.instance.base_url().to_string(),
},
);
let json = serde_json::to_value(obj)?;
@@ -245,8 +245,8 @@ impl ActivityPubEventHandler {
None => return Ok(()),
};
let ap_id = review_url(&self.base_url, review_id);
let actor = actor_url(&self.base_url, user_id.value());
let ap_id = review_url(&self.instance, review_id);
let actor = actor_url(&self.instance, user_id.value());
let movie = self
.movie_repo
@@ -276,8 +276,8 @@ impl ActivityPubEventHandler {
poster_url: movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| format!("{}/images/{}", self.base_url, p.value())),
base_url: self.base_url.clone(),
.map(|p| self.instance.image_url_for(p.value())),
base_url: self.instance.base_url().to_string(),
},
);
let json = serde_json::to_value(obj)?;
@@ -294,7 +294,7 @@ impl ActivityPubEventHandler {
user_id: &UserId,
review_id: &ReviewId,
) -> anyhow::Result<()> {
let ap_id = review_url(&self.base_url, review_id);
let ap_id = review_url(&self.instance, review_id);
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await?;
@@ -320,8 +320,8 @@ impl ActivityPubEventHandler {
}
use crate::urls::watchlist_entry_url;
let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value());
let actor = actor_url(&self.base_url, user_id.value());
let ap_id = watchlist_entry_url(&self.instance, user_id.value(), movie_id.value());
let actor = actor_url(&self.instance, user_id.value());
let poster_url = self
.movie_repo
@@ -331,7 +331,7 @@ impl ActivityPubEventHandler {
.flatten()
.and_then(|m| {
m.poster_path()
.map(|p| format!("{}/images/{}", self.base_url, p.value()))
.map(|p| self.instance.image_url_for(p.value()))
});
let added_at_utc =
@@ -344,7 +344,7 @@ impl ActivityPubEventHandler {
external_metadata_id: external_metadata_id.clone(),
poster_url,
added_at: added_at_utc,
base_url: self.base_url.clone(),
base_url: self.instance.base_url().to_string(),
});
let json = serde_json::to_value(obj)?;
@@ -360,7 +360,7 @@ impl ActivityPubEventHandler {
movie_id: &domain::value_objects::MovieId,
) -> anyhow::Result<()> {
use crate::urls::watchlist_entry_url;
let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value());
let ap_id = watchlist_entry_url(&self.instance, user_id.value(), movie_id.value());
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await?;
@@ -383,7 +383,7 @@ impl ActivityPubEventHandler {
.map(|id| id.value().to_string());
let poster_url = movie
.poster_path()
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
.map(|p| self.instance.image_url_for(p.value()));
for entry in entries {
let review = entry.review();
@@ -398,8 +398,8 @@ impl ActivityPubEventHandler {
continue;
}
let ap_id = review_url(&self.base_url, review.id());
let actor = actor_url(&self.base_url, user_id.value());
let ap_id = review_url(&self.instance, review.id());
let actor = actor_url(&self.instance, user_id.value());
let obj = review_to_ap_object(
review,
@@ -410,7 +410,7 @@ impl ActivityPubEventHandler {
release_year: movie.release_year().value(),
external_metadata_id: external_metadata_id.clone(),
poster_url: poster_url.clone(),
base_url: self.base_url.clone(),
base_url: self.instance.base_url().to_string(),
},
);
let json = serde_json::to_value(obj)?;
@@ -450,15 +450,15 @@ impl ActivityPubEventHandler {
.count_reviews_in_year(user_id, year)
.await
.unwrap_or(0);
let ap_id = goal_url(&self.base_url, user_id.value(), year);
let actor = actor_url(&self.base_url, user_id.value());
let ap_id = goal_url(&self.instance, user_id.value(), year);
let actor = actor_url(&self.instance, user_id.value());
let obj = goal_to_ap_object(
ap_id,
actor,
year,
goal.target_count(),
current,
&self.base_url,
self.instance.base_url(),
);
let json = serde_json::to_value(obj)?;
self.ap_service
@@ -488,9 +488,16 @@ impl ActivityPubEventHandler {
.await
.unwrap_or(0);
let ap_id = goal_url(&self.base_url, user_id.value(), year);
let actor = actor_url(&self.base_url, user_id.value());
let obj = goal_to_ap_object(ap_id, actor, year, target_count, current, &self.base_url);
let ap_id = goal_url(&self.instance, user_id.value(), year);
let actor = actor_url(&self.instance, user_id.value());
let obj = goal_to_ap_object(
ap_id,
actor,
year,
target_count,
current,
self.instance.base_url(),
);
let json = serde_json::to_value(obj)?;
if is_create {
self.ap_service
@@ -513,7 +520,7 @@ impl ActivityPubEventHandler {
if !flags.goals {
return Ok(());
}
let ap_id = goal_url(&self.base_url, user_id.value(), year);
let ap_id = goal_url(&self.instance, user_id.value(), year);
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await?;

View File

@@ -0,0 +1,137 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{BlockedDomainInfo, FollowedActorInfo},
ports::{ApBackfillPort, ApDocumentPort, InstanceBlocklistPort},
};
use k_ap::ActivityPubService;
use uuid::Uuid;
/// Adapts the federation library's service to the three domain-owned ports.
///
/// A wrapper rather than a bare `impl ... for ActivityPubService` because the
/// orphan rule forbids implementing a foreign trait for a foreign type, and
/// from this crate both `ApDocumentPort` (owned by `domain`) and
/// `ActivityPubService` (owned by the external `k-ap` crate) are foreign.
///
/// One type carrying all three impls mirrors `CompositeSocialAdapter`, which
/// serves `SocialCommand`, `FollowGraphQuery`, and `BlockQuery` the same way.
pub struct ApServiceAdapter {
service: Arc<ActivityPubService>,
}
impl ApServiceAdapter {
pub fn new(service: Arc<ActivityPubService>) -> Self {
Self { service }
}
}
/// Single conversion point from the federation library's `anyhow` errors to
/// `DomainError`. The log line and the resulting error string reproduce what
/// `presentation::handlers::social::ap_to_domain` produced before the port
/// inversion moved the boundary here.
fn ap_err(e: anyhow::Error) -> DomainError {
tracing::error!("ActivityPub error: {:?}", e);
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl ApDocumentPort for ApServiceAdapter {
async fn actor_json(&self, user_id: &str) -> Result<String, DomainError> {
self.service.actor_json(user_id).await.map_err(ap_err)
}
async fn followers_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> Result<String, DomainError> {
self.service
.followers_collection_json(user_id, page)
.await
.map_err(ap_err)
}
async fn following_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> Result<String, DomainError> {
self.service
.following_collection_json(user_id, page)
.await
.map_err(ap_err)
}
}
#[async_trait]
impl InstanceBlocklistPort for ApServiceAdapter {
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomainInfo>, DomainError> {
let domains = self.service.get_blocked_domains().await.map_err(ap_err)?;
Ok(domains
.into_iter()
.map(|d| BlockedDomainInfo {
domain: d.domain,
reason: d.reason,
blocked_at: d.blocked_at,
})
.collect())
}
async fn add_blocked_domain(
&self,
domain: &str,
reason: Option<&str>,
) -> Result<(), DomainError> {
self.service
.add_blocked_domain(domain, reason)
.await
.map_err(ap_err)
}
async fn remove_blocked_domain(&self, domain: &str) -> Result<(), DomainError> {
self.service
.remove_blocked_domain(domain)
.await
.map_err(ap_err)
}
}
#[async_trait]
impl ApBackfillPort for ApServiceAdapter {
async fn get_following(
&self,
local_user_id: Uuid,
) -> Result<Vec<FollowedActorInfo>, DomainError> {
let actors = self
.service
.get_following(local_user_id)
.await
.map_err(ap_err)?;
Ok(actors
.into_iter()
.map(|a| FollowedActorInfo {
url: a.url,
outbox_url: a.outbox_url,
})
.collect())
}
async fn import_remote_outbox(
&self,
outbox_url: &str,
actor_url: &str,
) -> Result<(), DomainError> {
self.service
.import_remote_outbox(outbox_url, actor_url)
.await
.map_err(ap_err)
}
async fn run_backfill_for_follower(
&self,
owner_user_id: Uuid,
follower_inbox_url: String,
) -> Result<(), DomainError> {
self.service
.run_backfill_for_follower(owner_user_id, follower_inbox_url)
.await
.map_err(ap_err)
}
}

View File

@@ -5,7 +5,7 @@ use chrono::DateTime;
use domain::{
models::RemoteGoalEntry,
ports::{GoalQuery, RemoteGoalRepository},
value_objects::UserId,
value_objects::{InstanceIdentity, UserId},
};
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
use url::Url;
@@ -16,7 +16,7 @@ use crate::urls::{actor_url, goal_url};
pub struct GoalObjectHandler {
pub remote_goal_repo: Arc<dyn RemoteGoalRepository>,
pub goal_repo: Arc<dyn GoalQuery>,
pub base_url: String,
pub instance: InstanceIdentity,
}
#[async_trait]
@@ -34,11 +34,11 @@ impl ApContentReader for GoalObjectHandler {
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let actor = actor_url(&self.base_url, user_id);
let actor = actor_url(&self.instance, user_id);
let follower_cc = format!("{}/followers", actor);
let mut results = Vec::new();
for goal in goals {
let ap_id = goal_url(&self.base_url, user_id, goal.year());
let ap_id = goal_url(&self.instance, user_id, goal.year());
let published = DateTime::from_naive_utc_and_offset(*goal.created_at(), chrono::Utc);
let obj = goal_to_ap_object(
ap_id.clone(),
@@ -46,7 +46,7 @@ impl ApContentReader for GoalObjectHandler {
goal.year(),
goal.target_count(),
0,
&self.base_url,
self.instance.base_url(),
);
results.push(LocalObject {
ap_id,

View File

@@ -1,9 +1,9 @@
pub mod composite_handler;
pub mod event_handler;
pub mod federation_event_bridge;
pub mod federation_ports;
pub mod goal_handler;
pub mod objects;
pub mod port;
pub mod remote_review_repository;
pub mod review_handler;
pub mod social_adapter;
@@ -22,7 +22,7 @@ pub use k_ap::{
};
pub use event_handler::ActivityPubEventHandler;
pub use port::{ActivityPubPort, NoopActivityPubService};
pub use federation_ports::ApServiceAdapter;
pub use remote_review_repository::{RemoteReviewRepository, RemoteReviewUpdate};
pub use review_handler::ReviewObjectHandler;
pub use social_adapter::CompositeSocialAdapter;
@@ -41,7 +41,16 @@ pub struct FederationRepos {
}
pub struct ActivityPubWire {
pub service: std::sync::Arc<dyn ActivityPubPort>,
/// AP document serving. Prefer this over `service` from outside this crate.
pub document: std::sync::Arc<dyn domain::ports::ApDocumentPort>,
/// Instance domain blocklist. Prefer this over `service` from outside this crate.
pub blocklist: std::sync::Arc<dyn domain::ports::InstanceBlocklistPort>,
/// Post-follow content backfill. Prefer this over `service` from outside this crate.
pub backfill: std::sync::Arc<dyn domain::ports::ApBackfillPort>,
/// The concrete service, consumed by `crates/server` to construct
/// `CompositeSocialAdapter`. Everything else should use
/// `document`/`blocklist`/`backfill`.
pub service: std::sync::Arc<ActivityPubService>,
pub router: axum::Router,
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
}
@@ -64,7 +73,7 @@ pub struct ActivityPubDeps {
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub follow_command: std::sync::Arc<dyn domain::ports::FollowCommand>,
pub follow_query: std::sync::Arc<dyn domain::ports::FollowQuery>,
pub base_url: String,
pub instance: domain::value_objects::InstanceIdentity,
pub allow_registration: bool,
pub event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
}
@@ -88,7 +97,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
federation_settings,
follow_command: _,
follow_query: _,
base_url,
instance,
allow_registration,
event_publisher,
} = deps;
@@ -98,17 +107,17 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
diary_repo,
review_store,
event_publisher: std::sync::Arc::clone(&event_publisher),
base_url: base_url.clone(),
instance: instance.clone(),
});
let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler {
remote_watchlist_repo,
content_query: std::sync::Arc::clone(&local_ap_content),
base_url: base_url.clone(),
instance: instance.clone(),
});
let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler {
remote_goal_repo,
goal_repo: std::sync::Arc::clone(&goal_repo),
base_url: base_url.clone(),
instance: instance.clone(),
});
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
review: review_handler,
@@ -132,14 +141,14 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
);
let concrete = std::sync::Arc::new(
ActivityPubService::builder(base_url.clone())
ActivityPubService::builder(instance.base_url().to_string())
.activity_repo(activity_repo)
.follow_repo(follow_repo)
.actor_repo(actor_repo)
.blocklist_repo(blocklist_repo)
.user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new(
user_repo,
base_url.clone(),
instance.clone(),
)))
.signed_fetch_actor_id(INSTANCE_ACTOR_ID)
.content_reader(composite.clone() as std::sync::Arc<dyn ApContentReader>)
@@ -165,11 +174,20 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
goal_repo,
stats_repo,
federation_settings,
base_url,
instance,
)) as std::sync::Arc<dyn domain::ports::EventHandler>;
let ports = std::sync::Arc::new(federation_ports::ApServiceAdapter::new(
std::sync::Arc::clone(&concrete),
));
Ok(ActivityPubWire {
service: concrete as std::sync::Arc<dyn ActivityPubPort>,
document: std::sync::Arc::clone(&ports)
as std::sync::Arc<dyn domain::ports::ApDocumentPort>,
blocklist: std::sync::Arc::clone(&ports)
as std::sync::Arc<dyn domain::ports::InstanceBlocklistPort>,
backfill: ports as std::sync::Arc<dyn domain::ports::ApBackfillPort>,
service: concrete,
router,
event_handler,
})

View File

@@ -1,178 +0,0 @@
use async_trait::async_trait;
use uuid::Uuid;
use k_ap::{ActivityPubService, BlockedDomain, RemoteActor};
#[async_trait]
pub trait ActivityPubPort: Send + Sync {
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String>;
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()>;
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn accept_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()>;
async fn reject_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()>;
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn get_blocked_actors(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()>;
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()>;
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>>;
async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()>;
async fn followers_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> anyhow::Result<String>;
async fn following_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> anyhow::Result<String>;
async fn run_backfill_for_follower(
&self,
owner_user_id: Uuid,
follower_inbox_url: String,
) -> anyhow::Result<()>;
}
#[async_trait]
impl ActivityPubPort for ActivityPubService {
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String> {
self.actor_json(user_id).await
}
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()> {
self.follow(local_user_id, handle).await
}
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
self.unfollow(local_user_id, actor_url).await
}
async fn accept_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()> {
self.accept_follower(local_user_id, remote_actor_url).await
}
async fn reject_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()> {
self.reject_follower(local_user_id, remote_actor_url).await
}
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
self.get_following(local_user_id).await
}
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
self.remove_follower(local_user_id, actor_url).await
}
async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
self.block_actor(local_user_id, actor_url).await
}
async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
self.unblock_actor(local_user_id, actor_url).await
}
async fn get_blocked_actors(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
self.get_blocked_actors(local_user_id).await
}
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()> {
self.add_blocked_domain(domain, reason).await
}
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
self.remove_blocked_domain(domain).await
}
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
self.get_blocked_domains().await
}
async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()> {
self.import_remote_outbox(outbox_url, actor_url).await
}
async fn followers_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> anyhow::Result<String> {
self.followers_collection_json(user_id, page).await
}
async fn following_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> anyhow::Result<String> {
self.following_collection_json(user_id, page).await
}
async fn run_backfill_for_follower(
&self,
owner_user_id: Uuid,
follower_inbox_url: String,
) -> anyhow::Result<()> {
self.run_backfill_for_follower(owner_user_id, follower_inbox_url)
.await
}
}
pub struct NoopActivityPubService;
#[async_trait]
impl ActivityPubPort for NoopActivityPubService {
async fn actor_json(&self, _: &str) -> anyhow::Result<String> {
Ok(String::new())
}
async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn unfollow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn accept_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn reject_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_following(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn block_actor(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn unblock_actor(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_blocked_actors(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn add_blocked_domain(&self, _: &str, _: Option<&str>) -> anyhow::Result<()> {
Ok(())
}
async fn remove_blocked_domain(&self, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
Ok(vec![])
}
async fn import_remote_outbox(&self, _: &str, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn followers_collection_json(&self, _: Uuid, _: Option<u32>) -> anyhow::Result<String> {
Ok(String::new())
}
async fn following_collection_json(&self, _: Uuid, _: Option<u32>) -> anyhow::Result<String> {
Ok(String::new())
}
async fn run_backfill_for_follower(&self, _: Uuid, _: String) -> anyhow::Result<()> {
Ok(())
}
}

View File

@@ -5,7 +5,9 @@ use domain::{
events::DomainEvent,
models::ReviewSource,
ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery},
value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId},
value_objects::{
Comment, ExternalMetadataId, InstanceIdentity, MovieId, Rating, ReviewId, UserId,
},
};
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
use url::Url;
@@ -20,7 +22,7 @@ pub struct ReviewObjectHandler {
pub diary_repo: Arc<dyn DiaryQuery>,
pub review_store: Arc<dyn RemoteReviewRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
pub base_url: String,
pub instance: InstanceIdentity,
}
#[async_trait]
@@ -39,17 +41,17 @@ impl ApContentReader for ReviewObjectHandler {
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let actor = actor_url(&self.base_url, user_id);
let actor = actor_url(&self.instance, user_id);
let mut results = Vec::new();
for entry in entries {
let review = entry.review();
let published =
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
let movie = entry.movie();
let ap_id = review_url(&self.base_url, review.id());
let ap_id = review_url(&self.instance, review.id());
let poster_url = movie
.poster_path()
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
.map(|p| self.instance.image_url_for(p.value()));
let obj = review_to_ap_object(
review,
@@ -62,7 +64,7 @@ impl ApContentReader for ReviewObjectHandler {
.external_metadata_id()
.map(|id| id.value().to_string()),
poster_url,
base_url: self.base_url.clone(),
base_url: self.instance.base_url().to_string(),
},
);
let follower_cc = format!("{}/followers", actor);

View File

@@ -3,73 +3,33 @@ use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::{FollowCommand, FollowQuery, SocialCommand, SocialQuery, UserRepository},
value_objects::{FollowStatus, FollowTarget, SocialActor, SocialIdentity, UserId, Username},
ports::{BlockQuery, FollowGraphQuery, LocalSocial, SocialCommand, UserRepository},
value_objects::{
FollowRelation, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, UserId,
},
};
use super::ActivityPubPort;
use k_ap::ActivityPubService;
pub struct CompositeSocialAdapter {
ap_service: Arc<dyn ActivityPubPort>,
local: Arc<dyn LocalSocial>,
ap_service: Arc<ActivityPubService>,
user_repo: Arc<dyn UserRepository>,
follow_command: Arc<dyn FollowCommand>,
follow_query: Arc<dyn FollowQuery>,
base_url: String,
instance: InstanceIdentity,
}
impl CompositeSocialAdapter {
pub fn new(
ap_service: Arc<dyn ActivityPubPort>,
local: Arc<dyn LocalSocial>,
ap_service: Arc<ActivityPubService>,
user_repo: Arc<dyn UserRepository>,
follow_command: Arc<dyn FollowCommand>,
follow_query: Arc<dyn FollowQuery>,
base_url: String,
instance: InstanceIdentity,
) -> Self {
Self {
local,
ap_service,
user_repo,
follow_command,
follow_query,
base_url,
}
}
fn local_actor_url(&self, user_id: &UserId) -> String {
format!("{}/users/{}", self.base_url, user_id.value())
}
fn actor_url_from_identity(&self, identity: &SocialIdentity) -> String {
match identity {
SocialIdentity::Local(uid) => self.local_actor_url(uid),
SocialIdentity::Remote { actor_url } => actor_url.clone(),
}
}
async fn resolve_target_identity(
&self,
target: &FollowTarget,
) -> Result<SocialIdentity, DomainError> {
match target {
FollowTarget::Identity(id) => Ok(id.clone()),
FollowTarget::Handle(handle) => {
let host = handle.rsplit_once('@').map(|(_, h)| h).unwrap_or("");
let local_host = SocialIdentity::host_from_base_url(&self.base_url);
if host == local_host {
let username_str = handle
.trim_start_matches('@')
.split('@')
.next()
.unwrap_or("");
if let Ok(username) = Username::new(username_str.to_string())
&& let Some(user) = self.user_repo.find_by_username(&username).await?
{
return Ok(SocialIdentity::Local(user.id().clone()));
}
}
Ok(SocialIdentity::Remote {
actor_url: handle.clone(),
})
}
instance,
}
}
}
@@ -81,23 +41,10 @@ fn ap_err(e: anyhow::Error) -> DomainError {
#[async_trait]
impl SocialCommand for CompositeSocialAdapter {
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> {
let identity = self.resolve_target_identity(target).await?;
let identity = self.local.resolve_target(target).await?;
if let SocialIdentity::Local(ref target_id) = identity {
if follower == target_id {
return Err(DomainError::ValidationError(
"Cannot follow yourself".into(),
));
}
let follower_url = self.local_actor_url(follower);
let target_url = self.local_actor_url(target_id);
self.follow_command
.add_follower(target_id.value(), &follower_url, FollowStatus::Pending)
.await?;
self.follow_command
.add_follow(follower.value(), &target_url, FollowStatus::Pending)
.await?;
return Ok(());
if let SocialIdentity::Local(_) = identity {
return self.local.follow_resolved(follower, &identity).await;
}
let handle = match target {
@@ -109,7 +56,7 @@ impl SocialCommand for CompositeSocialAdapter {
.find_by_id(uid)
.await?
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
SocialIdentity::format_local_handle(user.username().value(), &self.base_url)
self.instance.handle_for(user.username().value())
}
SocialIdentity::Remote { actor_url } => actor_url.clone(),
},
@@ -125,21 +72,11 @@ impl SocialCommand for CompositeSocialAdapter {
follower: &UserId,
target: &SocialIdentity,
) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(target);
match target {
SocialIdentity::Local(target_id) => {
let follower_url = self.local_actor_url(follower);
self.follow_command
.remove_follow(follower.value(), &actor_url)
.await?;
self.follow_command
.remove_follower_record(target_id.value(), &follower_url)
.await?;
Ok(())
}
SocialIdentity::Local(_) => self.local.unfollow(follower, target).await,
SocialIdentity::Remote { .. } => self
.ap_service
.unfollow(follower.value(), &actor_url)
.unfollow(follower.value(), &self.instance.actor_url_of(target))
.await
.map_err(ap_err),
}
@@ -150,21 +87,11 @@ impl SocialCommand for CompositeSocialAdapter {
owner: &UserId,
requester: &SocialIdentity,
) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(requester);
match requester {
SocialIdentity::Local(requester_id) => {
let owner_url = self.local_actor_url(owner);
self.follow_command
.update_follower_status(owner.value(), &actor_url, FollowStatus::Accepted)
.await?;
self.follow_command
.update_follow_status(requester_id.value(), &owner_url, FollowStatus::Accepted)
.await?;
Ok(())
}
SocialIdentity::Local(_) => self.local.accept_follow(owner, requester).await,
SocialIdentity::Remote { .. } => self
.ap_service
.accept_follower(owner.value(), &actor_url)
.accept_follower(owner.value(), &self.instance.actor_url_of(requester))
.await
.map_err(ap_err),
}
@@ -175,21 +102,11 @@ impl SocialCommand for CompositeSocialAdapter {
owner: &UserId,
requester: &SocialIdentity,
) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(requester);
match requester {
SocialIdentity::Local(requester_id) => {
let owner_url = self.local_actor_url(owner);
self.follow_command
.update_follower_status(owner.value(), &actor_url, FollowStatus::Rejected)
.await?;
self.follow_command
.remove_follow(requester_id.value(), &owner_url)
.await?;
Ok(())
}
SocialIdentity::Local(_) => self.local.reject_follow(owner, requester).await,
SocialIdentity::Remote { .. } => self
.ap_service
.reject_follower(owner.value(), &actor_url)
.reject_follower(owner.value(), &self.instance.actor_url_of(requester))
.await
.map_err(ap_err),
}
@@ -200,28 +117,18 @@ impl SocialCommand for CompositeSocialAdapter {
owner: &UserId,
follower: &SocialIdentity,
) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(follower);
match follower {
SocialIdentity::Local(follower_id) => {
let owner_url = self.local_actor_url(owner);
self.follow_command
.remove_follower_record(owner.value(), &actor_url)
.await?;
self.follow_command
.remove_follow(follower_id.value(), &owner_url)
.await?;
Ok(())
}
SocialIdentity::Local(_) => self.local.remove_follower(owner, follower).await,
SocialIdentity::Remote { .. } => self
.ap_service
.remove_follower(owner.value(), &actor_url)
.remove_follower(owner.value(), &self.instance.actor_url_of(follower))
.await
.map_err(ap_err),
}
}
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(target);
let actor_url = self.instance.actor_url_of(target);
self.ap_service
.block_actor(blocker.value(), &actor_url)
.await
@@ -229,7 +136,7 @@ impl SocialCommand for CompositeSocialAdapter {
}
async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(target);
let actor_url = self.instance.actor_url_of(target);
self.ap_service
.unblock_actor(blocker.value(), &actor_url)
.await
@@ -238,33 +145,46 @@ impl SocialCommand for CompositeSocialAdapter {
}
#[async_trait]
impl SocialQuery for CompositeSocialAdapter {
impl FollowGraphQuery for CompositeSocialAdapter {
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.follow_query
.get_following(user.value(), &self.base_url)
.await
self.local.get_following(user).await
}
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.follow_query
.get_followers(user.value(), &self.base_url)
.await
self.local.get_followers(user).await
}
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.follow_query
.get_pending_followers(user.value(), &self.base_url)
.await
self.local.get_pending_followers(user).await
}
async fn get_pending_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.local.get_pending_following(user).await
}
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError> {
self.follow_query.count_following(user.value()).await
self.local.count_following(user).await
}
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError> {
self.follow_query.count_followers(user.value()).await
self.local.count_followers(user).await
}
async fn count_pending_followers(&self, user: &UserId) -> Result<usize, DomainError> {
self.local.count_pending_followers(user).await
}
async fn get_relation(
&self,
viewer: &UserId,
target: &SocialIdentity,
) -> Result<FollowRelation, DomainError> {
self.local.get_relation(viewer, target).await
}
}
#[async_trait]
impl BlockQuery for CompositeSocialAdapter {
async fn get_blocked(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
let actors = self
.ap_service
@@ -274,7 +194,7 @@ impl SocialQuery for CompositeSocialAdapter {
Ok(actors
.into_iter()
.map(|a| {
let identity = SocialIdentity::from_actor_url(&a.url, &self.base_url);
let identity = self.instance.identify(&a.url);
SocialActor {
identity,
handle: a.handle,
@@ -284,15 +204,4 @@ impl SocialQuery for CompositeSocialAdapter {
})
.collect())
}
async fn is_following(
&self,
follower: &UserId,
target: &SocialIdentity,
) -> Result<bool, DomainError> {
let actor_url = self.actor_url_from_identity(target);
self.follow_query
.is_following(follower.value(), &actor_url)
.await
}
}

View File

@@ -1,28 +1,43 @@
use domain::value_objects::ReviewId;
use domain::value_objects::{InstanceIdentity, ReviewId, UserId};
use url::Url;
/// Builds the canonical actor URL: `{base_url}/users/{user_id}`
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
Url::parse(&format!("{}/users/{}", base_url, user_id))
pub fn actor_url(instance: &InstanceIdentity, user_id: uuid::Uuid) -> Url {
Url::parse(&instance.actor_url_for(&UserId::from_uuid(user_id)))
.expect("base_url is always a valid URL prefix")
}
/// Builds the canonical review URL: `{base_url}/reviews/{review_id}`
pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
Url::parse(&format!("{}/reviews/{}", base_url, review_id.value()))
.expect("base_url is always a valid URL prefix")
}
pub fn goal_url(base_url: &str, user_id: uuid::Uuid, year: u16) -> Url {
Url::parse(&format!("{}/users/{}/goals/{}", base_url, user_id, year))
.expect("base_url is always a valid URL prefix")
}
/// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}`
pub fn watchlist_entry_url(base_url: &str, user_id: uuid::Uuid, movie_id: uuid::Uuid) -> Url {
pub fn review_url(instance: &InstanceIdentity, review_id: &ReviewId) -> Url {
Url::parse(&format!(
"{}/users/{}/watchlist/{}",
base_url, user_id, movie_id
"{}/reviews/{}",
instance.base_url(),
review_id.value()
))
.expect("base_url is always a valid URL prefix")
}
pub fn goal_url(instance: &InstanceIdentity, user_id: uuid::Uuid, year: u16) -> Url {
Url::parse(&format!(
"{}/users/{}/goals/{}",
instance.base_url(),
user_id,
year
))
.expect("base_url is always a valid URL prefix")
}
/// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}`
pub fn watchlist_entry_url(
instance: &InstanceIdentity,
user_id: uuid::Uuid,
movie_id: uuid::Uuid,
) -> Url {
Url::parse(&format!(
"{}/users/{}/watchlist/{}",
instance.base_url(),
user_id,
movie_id
))
.expect("base_url is always a valid URL prefix")
}

View File

@@ -1,28 +1,36 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{ports::UserRepository, value_objects::UserId};
use domain::{
ports::UserRepository,
value_objects::{InstanceIdentity, UserId},
};
use k_ap::{ApProfileField, ApUser, ApUserRepository};
use url::Url;
pub struct DomainUserRepoAdapter {
pub repo: Arc<dyn UserRepository>,
pub base_url: String,
pub instance: InstanceIdentity,
}
impl DomainUserRepoAdapter {
pub fn new(repo: Arc<dyn UserRepository>, base_url: String) -> Self {
Self { repo, base_url }
pub fn new(repo: Arc<dyn UserRepository>, instance: InstanceIdentity) -> Self {
Self { repo, instance }
}
fn build_user(&self, u: &domain::models::User) -> ApUser {
let avatar_url = u
.avatar_path()
.and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok());
.and_then(|p| Url::parse(&self.instance.image_url_for(p)).ok());
let banner_url = u
.banner_path()
.and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok());
let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok();
.and_then(|p| Url::parse(&self.instance.image_url_for(p)).ok());
let profile_url = Url::parse(&format!(
"{}/u/{}",
self.instance.base_url(),
u.username().value()
))
.ok();
ApUser {
id: u.id().value(),
username: u.username().value().to_string(),
@@ -46,12 +54,8 @@ impl DomainUserRepoAdapter {
manually_approves_followers: true,
discoverable: true,
actor_type: Default::default(),
featured_url: Url::parse(&format!(
"{}/users/{}/featured",
self.base_url,
u.id().value()
))
.ok(),
featured_url: Url::parse(&format!("{}/featured", self.instance.actor_url_for(u.id())))
.ok(),
}
}
}

View File

@@ -5,7 +5,7 @@ use chrono::DateTime;
use domain::{
models::{RemoteWatchlistEntry, WatchlistWithMovie},
ports::{LocalApContentQuery, RemoteWatchlistRepository},
value_objects::UserId,
value_objects::{InstanceIdentity, UserId},
};
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
use url::Url;
@@ -16,7 +16,7 @@ use crate::urls::{actor_url, watchlist_entry_url};
pub struct WatchlistObjectHandler {
pub remote_watchlist_repo: Arc<dyn RemoteWatchlistRepository>,
pub content_query: Arc<dyn LocalApContentQuery>,
pub base_url: String,
pub instance: InstanceIdentity,
}
#[async_trait]
@@ -34,15 +34,15 @@ impl ApContentReader for WatchlistObjectHandler {
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let actor = actor_url(&self.base_url, user_id);
let actor = actor_url(&self.instance, user_id);
let follower_cc = format!("{}/followers", actor);
let mut results = Vec::new();
for WatchlistWithMovie { entry, movie } in entries {
let ap_id = watchlist_entry_url(&self.base_url, user_id, entry.movie_id.value());
let ap_id = watchlist_entry_url(&self.instance, user_id, entry.movie_id.value());
let published = DateTime::from_naive_utc_and_offset(entry.added_at, chrono::Utc);
let poster_url = movie
.poster_path()
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
.map(|p| self.instance.image_url_for(p.value()));
let obj = watchlist_to_ap_object(WatchlistApInput {
ap_id: ap_id.clone(),
actor_url: actor.clone(),
@@ -53,7 +53,7 @@ impl ApContentReader for WatchlistObjectHandler {
.map(|id| id.value().to_string()),
poster_url,
added_at: published,
base_url: self.base_url.clone(),
base_url: self.instance.base_url().to_string(),
});
results.push(LocalObject {
ap_id,

View File

@@ -14,6 +14,7 @@ sqlx = { version = "0.8.6", features = [
activitypub = { workspace = true }
adapter-common = { workspace = true }
k-ap = { version = "0.5.0", registry = "gitea" }
postgres-social = { workspace = true }
domain = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }

View File

@@ -1,17 +1,8 @@
mod activity;
mod actor;
pub mod ap_content;
mod blocklist;
mod federated_profile;
mod follow;
mod follow_repository;
pub mod remote_goals;
mod review;
mod social;
mod watchlist;
pub use ap_content::PostgresApContentQuery;
pub use remote_goals::PostgresRemoteGoalRepository;
use k_ap::{FollowerStatus, RemoteActor};
use sqlx::{PgPool, Row};
@@ -72,23 +63,23 @@ impl PostgresFederationRepository {
}
}
pub fn create_federated_profile_query(
pub fn wire(
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));
instance: domain::value_objects::InstanceIdentity,
) -> activitypub::FederationRepos {
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool.clone()));
let social = std::sync::Arc::new(postgres_social::PostgresSocialRepository::new(
pool, instance,
));
activitypub::FederationRepos {
activity: std::sync::Arc::clone(&fed) as _,
follow: std::sync::Arc::clone(&fed) as _,
actor: std::sync::Arc::clone(&fed) as _,
blocklist: std::sync::Arc::clone(&fed) as _,
admin_query: std::sync::Arc::clone(&fed) as _,
review_store: std::sync::Arc::clone(&fed) as _,
remote_watchlist: std::sync::Arc::clone(&fed) as _,
follow_command: std::sync::Arc::clone(&fed) as _,
follow_query: fed as _,
review_store: fed as _,
admin_query: std::sync::Arc::clone(&social) as _,
remote_watchlist: std::sync::Arc::clone(&social) as _,
follow_command: std::sync::Arc::clone(&social) as _,
follow_query: social as _,
}
}

View File

@@ -0,0 +1,18 @@
[package]
name = "postgres-social"
version = "0.1.0"
edition = "2024"
[dependencies]
sqlx = { version = "0.8.6", features = [
"runtime-tokio-rustls",
"postgres",
"uuid",
"macros",
"chrono",
] }
adapter-common = { workspace = true }
domain = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
async-trait = { workspace = true }

View File

@@ -2,10 +2,10 @@ use async_trait::async_trait;
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
use sqlx::Row;
use super::PostgresFederationRepository;
use super::PostgresSocialRepository;
#[async_trait]
impl FederatedProfileQuery for PostgresFederationRepository {
impl FederatedProfileQuery for PostgresSocialRepository {
async fn get_federated_profile(
&self,
synthetic_user_id: uuid::Uuid,

View File

@@ -2,11 +2,11 @@ use async_trait::async_trait;
use chrono::Utc;
use domain::{
errors::DomainError,
value_objects::{FollowStatus, SocialActor, SocialIdentity},
value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity},
};
use sqlx::Row;
use crate::PostgresFederationRepository;
use crate::PostgresSocialRepository;
use adapter_common::datetime_to_str;
fn follow_status_to_str(status: &FollowStatus) -> &'static str {
@@ -21,8 +21,17 @@ fn infra_err(e: impl std::fmt::Display) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
fn follow_status_from_str(status: &str) -> Option<FollowStatus> {
match status {
"pending" => Some(FollowStatus::Pending),
"accepted" => Some(FollowStatus::Accepted),
"rejected" => Some(FollowStatus::Rejected),
_ => None,
}
}
#[async_trait]
impl domain::ports::FollowCommand for PostgresFederationRepository {
impl domain::ports::FollowCommand for PostgresSocialRepository {
async fn add_follow(
&self,
follower_id: uuid::Uuid,
@@ -142,9 +151,9 @@ impl domain::ports::FollowCommand for PostgresFederationRepository {
}
}
fn social_actor_from_row(row: &sqlx::postgres::PgRow, base_url: &str) -> SocialActor {
fn social_actor_from_row(row: &sqlx::postgres::PgRow, instance: &InstanceIdentity) -> SocialActor {
let actor_url: String = row.get("remote_actor_url");
let identity = SocialIdentity::from_actor_url(&actor_url, base_url);
let identity = instance.identify(&actor_url);
let (handle, display_name, avatar_url) = match &identity {
SocialIdentity::Local(_) => {
@@ -154,17 +163,18 @@ fn social_actor_from_row(row: &sqlx::postgres::PgRow, base_url: &str) -> SocialA
.try_get::<Option<String>, _>("local_avatar_path")
.ok()
.flatten()
.map(|p| format!("{}/images/{}", base_url, p));
.map(|p| instance.image_url_for(&p));
let handle = username
.as_deref()
.map(|u| SocialIdentity::format_local_handle(u, base_url))
.map(|u| instance.handle_for(u))
.unwrap_or_else(|| actor_url.clone());
(handle, display, avatar)
}
SocialIdentity::Remote { .. } => {
let handle: String = row
.try_get("remote_handle")
.try_get::<Option<String>, _>("remote_handle")
.ok()
.flatten()
.unwrap_or_else(|| actor_url.clone());
let display: Option<String> = row.try_get("remote_display").ok().flatten();
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
@@ -181,12 +191,8 @@ fn social_actor_from_row(row: &sqlx::postgres::PgRow, base_url: &str) -> SocialA
}
#[async_trait]
impl domain::ports::FollowQuery for PostgresFederationRepository {
async fn get_following(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
impl domain::ports::FollowQuery for PostgresSocialRepository {
async fn get_following(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
@@ -197,22 +203,18 @@ impl domain::ports::FollowQuery for PostgresFederationRepository {
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $2 AND f.status = 'accepted'",
)
.bind(base_url)
.bind(self.instance.base_url())
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(infra_err)?;
Ok(rows
.iter()
.map(|r| social_actor_from_row(r, base_url))
.map(|r| social_actor_from_row(r, &self.instance))
.collect())
}
async fn get_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
async fn get_followers(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
@@ -223,21 +225,20 @@ impl domain::ports::FollowQuery for PostgresFederationRepository {
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $2 AND f.status = 'accepted'",
)
.bind(base_url)
.bind(self.instance.base_url())
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(infra_err)?;
Ok(rows
.iter()
.map(|r| social_actor_from_row(r, base_url))
.map(|r| social_actor_from_row(r, &self.instance))
.collect())
}
async fn get_pending_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
@@ -249,14 +250,39 @@ impl domain::ports::FollowQuery for PostgresFederationRepository {
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $2 AND f.status = 'pending'",
)
.bind(base_url)
.bind(self.instance.base_url())
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(infra_err)?;
Ok(rows
.iter()
.map(|r| social_actor_from_row(r, base_url))
.map(|r| social_actor_from_row(r, &self.instance))
.collect())
}
async fn get_pending_following(
&self,
user_id: uuid::Uuid,
) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_following f
LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $2 AND f.status = 'pending'",
)
.bind(self.instance.base_url())
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(infra_err)?;
Ok(rows
.iter()
.map(|r| social_actor_from_row(r, &self.instance))
.collect())
}
@@ -284,20 +310,45 @@ impl domain::ports::FollowQuery for PostgresFederationRepository {
Ok(count as usize)
}
async fn is_following(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<bool, DomainError> {
let uid = follower_id.to_string();
async fn count_pending_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2 AND status = 'accepted'",
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'pending'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count as usize)
}
async fn get_relation(
&self,
viewer_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<FollowRelation, DomainError> {
let uid = viewer_id.to_string();
let row = sqlx::query(
"SELECT (SELECT status FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2) AS following,
(SELECT status FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2) AS followed_by",
)
.bind(&uid)
.bind(target_actor_url)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count > 0)
Ok(FollowRelation {
following: row
.try_get::<Option<String>, _>("following")
.map_err(infra_err)?
.as_deref()
.and_then(follow_status_from_str),
followed_by: row
.try_get::<Option<String>, _>("followed_by")
.map_err(infra_err)?
.as_deref()
.and_then(follow_status_from_str),
})
}
}

View File

@@ -0,0 +1,38 @@
mod federated_profile;
mod follow_repository;
mod social;
mod watchlist;
pub mod ap_content;
pub mod remote_goals;
pub use ap_content::PostgresApContentQuery;
pub use remote_goals::PostgresRemoteGoalRepository;
use sqlx::PgPool;
/// Postgres-backed implementations of the *domain* social ports.
///
/// Deliberately separate from `postgres-federation`: this crate knows nothing
/// about ActivityPub, which is what allows a build with the `federation`
/// feature off to exclude the federation stack entirely. See ADR-0009.
///
/// Shares the `ap_followers` / `ap_following` tables with
/// `postgres-federation`; neither crate owns migrations.
pub struct PostgresSocialRepository {
pub(crate) pool: PgPool,
pub(crate) instance: domain::value_objects::InstanceIdentity,
}
impl PostgresSocialRepository {
pub fn new(pool: PgPool, instance: domain::value_objects::InstanceIdentity) -> Self {
Self { pool, instance }
}
}
pub fn create_federated_profile_query(
pool: PgPool,
instance: domain::value_objects::InstanceIdentity,
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
std::sync::Arc::new(PostgresSocialRepository::new(pool, instance))
}

View File

@@ -1,10 +1,10 @@
use async_trait::async_trait;
use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery};
use super::PostgresFederationRepository;
use super::PostgresSocialRepository;
#[async_trait]
impl FederationAdminQuery for PostgresFederationRepository {
impl FederationAdminQuery for PostgresSocialRepository {
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
"SELECT DISTINCT ar.url, ar.handle, ar.display_name FROM ap_remote_actors ar JOIN ap_following f ON f.remote_actor_url = ar.url WHERE f.status = 'accepted'",

View File

@@ -2,10 +2,10 @@ use async_trait::async_trait;
use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository};
use sqlx::Row;
use super::PostgresFederationRepository;
use super::PostgresSocialRepository;
#[async_trait]
impl RemoteWatchlistRepository for PostgresFederationRepository {
impl RemoteWatchlistRepository for PostgresSocialRepository {
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO ap_remote_watchlist_entries \

View File

@@ -13,7 +13,7 @@ sqlx = { version = "0.8.6", features = [
] }
adapter-common = { workspace = true }
domain = { workspace = true }
postgres-federation = { workspace = true }
postgres-social = { workspace = true }
anyhow = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }

View File

@@ -28,7 +28,7 @@ pub use import_session::PostgresImportSessionRepository;
pub use movie::PostgresMovieRepository;
pub use movie_dedup::PostgresMovieDeduplicator;
pub use persons::{PostgresPersonAdapter, create_person_adapter};
pub use postgres_federation::PostgresApContentQuery;
pub use postgres_social::PostgresApContentQuery;
pub use profile::PostgresMovieProfileRepository;
pub use profile_fields::PostgresProfileFieldsRepository;
pub use refresh_sessions::PostgresRefreshSessionAdapter;
@@ -115,7 +115,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
goal_query: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _,
user_settings: std::sync::Arc::clone(&user_settings_repo) as _,
federation_settings: user_settings_repo as _,
remote_goal: std::sync::Arc::new(postgres_federation::PostgresRemoteGoalRepository::new(
remote_goal: std::sync::Arc::new(postgres_social::PostgresRemoteGoalRepository::new(
pool.clone(),
)) as _,
deduplicator: std::sync::Arc::new(PostgresMovieDeduplicator::new(pool)) as _,

View File

@@ -192,7 +192,10 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
name: r.try_get("name").unwrap_or_default(),
character: r.try_get("character").unwrap_or_default(),
billing_order: r.try_get::<i32, _>("billing_order").unwrap_or(0) as u32,
profile_path: r.try_get("profile_path").ok(),
profile_path: r
.try_get::<Option<String>, _>("profile_path")
.ok()
.flatten(),
})
.collect();
@@ -210,31 +213,40 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
name: r.try_get("name").unwrap_or_default(),
job: r.try_get("job").unwrap_or_default(),
department: r.try_get("department").unwrap_or_default(),
profile_path: r.try_get("profile_path").ok(),
profile_path: r
.try_get::<Option<String>, _>("profile_path")
.ok()
.flatten(),
})
.collect();
Ok(Some(MovieProfile {
movie_id: id.clone(),
tmdb_id: row.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u64,
imdb_id: row.try_get("imdb_id").ok(),
overview: row.try_get("overview").ok(),
tagline: row.try_get("tagline").ok(),
imdb_id: row.try_get::<Option<String>, _>("imdb_id").ok().flatten(),
overview: row.try_get::<Option<String>, _>("overview").ok().flatten(),
tagline: row.try_get::<Option<String>, _>("tagline").ok().flatten(),
runtime_minutes: row
.try_get::<Option<i32>, _>("runtime_minutes")
.ok()
.flatten()
.map(|v| v as u32),
budget_usd: row.try_get("budget_usd").ok(),
revenue_usd: row.try_get("revenue_usd").ok(),
vote_average: row.try_get("vote_average").ok(),
budget_usd: row.try_get::<Option<i64>, _>("budget_usd").ok().flatten(),
revenue_usd: row.try_get::<Option<i64>, _>("revenue_usd").ok().flatten(),
vote_average: row.try_get::<Option<f64>, _>("vote_average").ok().flatten(),
vote_count: row
.try_get::<Option<i32>, _>("vote_count")
.ok()
.flatten()
.map(|v| v as u32),
original_language: row.try_get("original_language").ok(),
collection_name: row.try_get("collection_name").ok(),
original_language: row
.try_get::<Option<String>, _>("original_language")
.ok()
.flatten(),
collection_name: row
.try_get::<Option<String>, _>("collection_name")
.ok()
.flatten(),
genres,
keywords,
cast,

View File

@@ -8,6 +8,7 @@ sqlx = { workspace = true }
activitypub = { workspace = true }
adapter-common = { workspace = true }
k-ap = { version = "0.5.0", registry = "gitea" }
sqlite-social = { workspace = true }
domain = { workspace = true }
anyhow = { workspace = true }
serde_json = { workspace = true }

View File

@@ -1,18 +1,8 @@
mod activity;
mod actor;
mod blocklist;
mod federated_profile;
mod follow;
mod follow_repository;
mod review;
mod social;
mod watchlist;
pub mod ap_content;
pub mod remote_goals;
pub use ap_content::SqliteApContentQuery;
pub use remote_goals::SqliteRemoteGoalRepository;
use k_ap::{FollowerStatus, RemoteActor};
use sqlx::SqlitePool;
@@ -84,24 +74,22 @@ impl SqliteFederationRepository {
}
}
pub fn create_federated_profile_query(
pub fn wire(
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));
instance: domain::value_objects::InstanceIdentity,
) -> activitypub::FederationRepos {
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool.clone()));
let social = std::sync::Arc::new(sqlite_social::SqliteSocialRepository::new(pool, instance));
activitypub::FederationRepos {
activity: std::sync::Arc::clone(&fed) as _,
follow: std::sync::Arc::clone(&fed) as _,
actor: std::sync::Arc::clone(&fed) as _,
blocklist: std::sync::Arc::clone(&fed) as _,
admin_query: std::sync::Arc::clone(&fed) as _,
review_store: std::sync::Arc::clone(&fed) as _,
remote_watchlist: std::sync::Arc::clone(&fed) as _,
follow_command: std::sync::Arc::clone(&fed) as _,
follow_query: fed as _,
review_store: fed as _,
admin_query: std::sync::Arc::clone(&social) as _,
remote_watchlist: std::sync::Arc::clone(&social) as _,
follow_command: std::sync::Arc::clone(&social) as _,
follow_query: social as _,
}
}

View File

@@ -1,6 +1,5 @@
use super::*;
use chrono::Utc;
use domain::ports::FederationAdminQuery;
use k_ap::AnnounceRepository;
use sqlx::SqlitePool;
@@ -48,65 +47,3 @@ async fn duplicate_announce_is_ignored() {
.unwrap();
assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1);
}
async fn setup_db(pool: &SqlitePool) {
sqlx::query(
"CREATE TABLE IF NOT EXISTS ap_remote_actors (
url TEXT PRIMARY KEY,
handle TEXT NOT NULL,
inbox_url TEXT NOT NULL,
shared_inbox_url TEXT,
display_name TEXT,
avatar_url TEXT,
fetched_at TEXT NOT NULL
)",
)
.execute(pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE IF NOT EXISTS ap_following (
local_user_id TEXT NOT NULL,
remote_actor_url TEXT NOT NULL,
follow_activity_id TEXT NOT NULL,
status TEXT NOT NULL,
PRIMARY KEY (local_user_id, remote_actor_url)
)",
)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn test_list_all_followed_remote_actors_deduplicates() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup_db(&pool).await;
let repo = SqliteFederationRepository::new(pool.clone());
let user1 = uuid::Uuid::new_v4();
let user2 = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO ap_remote_actors (url, handle, inbox_url, fetched_at, display_name)
VALUES ('https://other.social/users/alice', 'alice@other.social', 'https://other.social/inbox', '2024-01-01', 'Alice')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?, 'https://other.social/users/alice', 'act1', 'accepted'),
(?, 'https://other.social/users/alice', 'act2', 'accepted')",
)
.bind(user1.to_string())
.bind(user2.to_string())
.execute(&pool)
.await
.unwrap();
let actors = repo.list_all_followed_remote_actors().await.unwrap();
assert_eq!(actors.len(), 1);
assert_eq!(actors[0].handle, "alice@other.social");
}

View File

@@ -0,0 +1,15 @@
[package]
name = "sqlite-social"
version = "0.1.0"
edition = "2024"
[dependencies]
sqlx = { workspace = true }
adapter-common = { workspace = true }
domain = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
async-trait = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -2,10 +2,10 @@ use async_trait::async_trait;
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
use sqlx::Row;
use super::SqliteFederationRepository;
use super::SqliteSocialRepository;
#[async_trait]
impl FederatedProfileQuery for SqliteFederationRepository {
impl FederatedProfileQuery for SqliteSocialRepository {
async fn get_federated_profile(
&self,
synthetic_user_id: uuid::Uuid,

View File

@@ -2,11 +2,11 @@ use async_trait::async_trait;
use chrono::Utc;
use domain::{
errors::DomainError,
value_objects::{FollowStatus, SocialActor, SocialIdentity},
value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity},
};
use sqlx::Row;
use crate::SqliteFederationRepository;
use crate::SqliteSocialRepository;
use adapter_common::datetime_to_str;
fn follow_status_to_str(status: &FollowStatus) -> &'static str {
@@ -21,8 +21,17 @@ fn infra_err(e: impl std::fmt::Display) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
fn follow_status_from_str(status: &str) -> Option<FollowStatus> {
match status {
"pending" => Some(FollowStatus::Pending),
"accepted" => Some(FollowStatus::Accepted),
"rejected" => Some(FollowStatus::Rejected),
_ => None,
}
}
#[async_trait]
impl domain::ports::FollowCommand for SqliteFederationRepository {
impl domain::ports::FollowCommand for SqliteSocialRepository {
async fn add_follow(
&self,
follower_id: uuid::Uuid,
@@ -142,9 +151,12 @@ impl domain::ports::FollowCommand for SqliteFederationRepository {
}
}
fn social_actor_from_row(row: &sqlx::sqlite::SqliteRow, base_url: &str) -> SocialActor {
fn social_actor_from_row(
row: &sqlx::sqlite::SqliteRow,
instance: &InstanceIdentity,
) -> SocialActor {
let actor_url: String = row.get("remote_actor_url");
let identity = SocialIdentity::from_actor_url(&actor_url, base_url);
let identity = instance.identify(&actor_url);
let (handle, display_name, avatar_url) = match &identity {
SocialIdentity::Local(_) => {
@@ -154,17 +166,18 @@ fn social_actor_from_row(row: &sqlx::sqlite::SqliteRow, base_url: &str) -> Socia
.try_get::<Option<String>, _>("local_avatar_path")
.ok()
.flatten()
.map(|p| format!("{}/images/{}", base_url, p));
.map(|p| instance.image_url_for(&p));
let handle = username
.as_deref()
.map(|u| SocialIdentity::format_local_handle(u, base_url))
.map(|u| instance.handle_for(u))
.unwrap_or_else(|| actor_url.clone());
(handle, display, avatar)
}
SocialIdentity::Remote { .. } => {
let handle: String = row
.try_get("remote_handle")
.try_get::<Option<String>, _>("remote_handle")
.ok()
.flatten()
.unwrap_or_else(|| actor_url.clone());
let display: Option<String> = row.try_get("remote_display").ok().flatten();
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
@@ -181,12 +194,8 @@ fn social_actor_from_row(row: &sqlx::sqlite::SqliteRow, base_url: &str) -> Socia
}
#[async_trait]
impl domain::ports::FollowQuery for SqliteFederationRepository {
async fn get_following(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
impl domain::ports::FollowQuery for SqliteSocialRepository {
async fn get_following(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
@@ -197,22 +206,18 @@ impl domain::ports::FollowQuery for SqliteFederationRepository {
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
)
.bind(base_url)
.bind(self.instance.base_url())
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(infra_err)?;
Ok(rows
.iter()
.map(|r| social_actor_from_row(r, base_url))
.map(|r| social_actor_from_row(r, &self.instance))
.collect())
}
async fn get_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
async fn get_followers(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
@@ -223,21 +228,20 @@ impl domain::ports::FollowQuery for SqliteFederationRepository {
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
)
.bind(base_url)
.bind(self.instance.base_url())
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(infra_err)?;
Ok(rows
.iter()
.map(|r| social_actor_from_row(r, base_url))
.map(|r| social_actor_from_row(r, &self.instance))
.collect())
}
async fn get_pending_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
@@ -249,14 +253,39 @@ impl domain::ports::FollowQuery for SqliteFederationRepository {
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?2 AND f.status = 'pending'",
)
.bind(base_url)
.bind(self.instance.base_url())
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(infra_err)?;
Ok(rows
.iter()
.map(|r| social_actor_from_row(r, base_url))
.map(|r| social_actor_from_row(r, &self.instance))
.collect())
}
async fn get_pending_following(
&self,
user_id: uuid::Uuid,
) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_following f
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?2 AND f.status = 'pending'",
)
.bind(self.instance.base_url())
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(infra_err)?;
Ok(rows
.iter()
.map(|r| social_actor_from_row(r, &self.instance))
.collect())
}
@@ -284,20 +313,45 @@ impl domain::ports::FollowQuery for SqliteFederationRepository {
Ok(count as usize)
}
async fn is_following(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<bool, DomainError> {
let uid = follower_id.to_string();
async fn count_pending_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ? AND status = 'accepted'",
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'pending'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count as usize)
}
async fn get_relation(
&self,
viewer_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<FollowRelation, DomainError> {
let uid = viewer_id.to_string();
let row = sqlx::query(
"SELECT (SELECT status FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2) AS following,
(SELECT status FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2) AS followed_by",
)
.bind(&uid)
.bind(target_actor_url)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count > 0)
Ok(FollowRelation {
following: row
.try_get::<Option<String>, _>("following")
.map_err(infra_err)?
.as_deref()
.and_then(follow_status_from_str),
followed_by: row
.try_get::<Option<String>, _>("followed_by")
.map_err(infra_err)?
.as_deref()
.and_then(follow_status_from_str),
})
}
}

View File

@@ -0,0 +1,42 @@
mod federated_profile;
mod follow_repository;
mod social;
mod watchlist;
pub mod ap_content;
pub mod remote_goals;
pub use ap_content::SqliteApContentQuery;
pub use remote_goals::SqliteRemoteGoalRepository;
use sqlx::SqlitePool;
/// SQLite-backed implementations of the *domain* social ports.
///
/// Deliberately separate from `sqlite-federation`: this crate knows nothing
/// about ActivityPub, which is what allows a build with the `federation`
/// feature off to exclude the federation stack entirely. See ADR-0009.
///
/// Shares the `ap_followers` / `ap_following` tables with
/// `sqlite-federation`; neither crate owns migrations.
pub struct SqliteSocialRepository {
pub(crate) pool: SqlitePool,
pub(crate) instance: domain::value_objects::InstanceIdentity,
}
impl SqliteSocialRepository {
pub fn new(pool: SqlitePool, instance: domain::value_objects::InstanceIdentity) -> Self {
Self { pool, instance }
}
}
pub fn create_federated_profile_query(
pool: SqlitePool,
instance: domain::value_objects::InstanceIdentity,
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
std::sync::Arc::new(SqliteSocialRepository::new(pool, instance))
}
#[cfg(test)]
#[path = "tests/follow_relation_tests.rs"]
mod follow_relation_tests;

View File

@@ -1,10 +1,10 @@
use async_trait::async_trait;
use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery};
use super::SqliteFederationRepository;
use super::SqliteSocialRepository;
#[async_trait]
impl FederationAdminQuery for SqliteFederationRepository {
impl FederationAdminQuery for SqliteSocialRepository {
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
"SELECT DISTINCT ar.url, ar.handle, ar.display_name

View File

@@ -0,0 +1,253 @@
use super::*;
use domain::ports::{FederationAdminQuery, FollowQuery};
use domain::value_objects::{FollowStatus, InstanceIdentity, SocialIdentity};
use sqlx::SqlitePool;
async fn test_pool() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
for ddl in [
"CREATE TABLE ap_following (local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL,
follow_activity_id TEXT NOT NULL, created_at TEXT, status TEXT NOT NULL,
PRIMARY KEY (local_user_id, remote_actor_url))",
"CREATE TABLE ap_followers (local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL,
follow_activity_id TEXT NOT NULL, created_at TEXT, status TEXT NOT NULL,
PRIMARY KEY (local_user_id, remote_actor_url))",
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL,
display_name TEXT, avatar_path TEXT)",
"CREATE TABLE ap_remote_actors (url TEXT PRIMARY KEY, handle TEXT NOT NULL,
display_name TEXT, avatar_url TEXT)",
] {
sqlx::query(ddl).execute(&pool).await.unwrap();
}
pool
}
fn repo(pool: SqlitePool) -> SqliteSocialRepository {
SqliteSocialRepository::new(pool, InstanceIdentity::new("https://md.example"))
}
#[tokio::test]
async fn get_relation_returns_no_edges_for_strangers() {
let r = repo(test_pool().await);
let rel = r
.get_relation(uuid::Uuid::new_v4(), "https://other.example/users/bob")
.await
.unwrap();
assert_eq!(rel.following, None);
assert_eq!(rel.followed_by, None);
}
#[tokio::test]
async fn get_relation_reads_following_direction_only_from_ap_following() {
let pool = test_pool().await;
let viewer = uuid::Uuid::new_v4();
let target = "https://other.example/users/bob";
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?1, ?2, '', 'pending')",
)
.bind(viewer.to_string())
.bind(target)
.execute(&pool)
.await
.unwrap();
let rel = repo(pool).get_relation(viewer, target).await.unwrap();
assert_eq!(rel.following, Some(FollowStatus::Pending));
assert_eq!(
rel.followed_by, None,
"an ap_following row must not populate followed_by"
);
}
#[tokio::test]
async fn get_relation_reads_followed_by_from_ap_followers_including_rejected() {
let pool = test_pool().await;
let owner = uuid::Uuid::new_v4();
let requester = "https://other.example/users/carol";
sqlx::query(
"INSERT INTO ap_followers (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?1, ?2, '', 'rejected')",
)
.bind(owner.to_string())
.bind(requester)
.execute(&pool)
.await
.unwrap();
let rel = repo(pool).get_relation(owner, requester).await.unwrap();
assert_eq!(rel.followed_by, Some(FollowStatus::Rejected));
assert_eq!(rel.following, None);
}
#[tokio::test]
async fn get_relation_treats_unknown_status_as_no_edge() {
let pool = test_pool().await;
let viewer = uuid::Uuid::new_v4();
let target = "https://other.example/users/dave";
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?1, ?2, '', 'not-a-real-status')",
)
.bind(viewer.to_string())
.bind(target)
.execute(&pool)
.await
.unwrap();
let rel = repo(pool).get_relation(viewer, target).await.unwrap();
assert_eq!(rel.following, None);
}
#[tokio::test]
async fn get_pending_following_returns_only_pending_rows() {
let pool = test_pool().await;
let viewer = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?1, 'https://other.example/users/pending', '', 'pending'),
(?1, 'https://other.example/users/accepted', '', 'accepted')",
)
.bind(viewer.to_string())
.execute(&pool)
.await
.unwrap();
let actors = FollowQuery::get_pending_following(&repo(pool), viewer)
.await
.unwrap();
assert_eq!(
actors.len(),
1,
"accepted rows must not appear in pending_following"
);
// The handle-fallback-on-join-miss behavior is covered by
// `remote_actor_with_no_cached_row_falls_back_to_its_actor_url`; this test
// only needs to check pending-row filtering, so it asserts on identity.
assert_eq!(
actors[0].identity,
SocialIdentity::Remote {
actor_url: "https://other.example/users/pending".to_string()
}
);
}
#[tokio::test]
async fn remote_actor_with_no_cached_row_falls_back_to_its_actor_url() {
let pool = test_pool().await;
let viewer = uuid::Uuid::new_v4();
let orphan = "https://other.example/users/uncached";
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?1, ?2, '', 'pending')",
)
.bind(viewer.to_string())
.bind(orphan)
.execute(&pool)
.await
.unwrap();
// deliberately NO ap_remote_actors row for `orphan`
let actors = FollowQuery::get_pending_following(&repo(pool), viewer)
.await
.unwrap();
assert_eq!(actors.len(), 1);
assert_eq!(
actors[0].handle, orphan,
"with no cached actor, handle must fall back to the actor url, not render empty"
);
}
#[tokio::test]
async fn count_pending_followers_counts_only_pending() {
let pool = test_pool().await;
let owner = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO ap_followers (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?1, 'https://other.example/users/a', '', 'pending'),
(?1, 'https://other.example/users/b', '', 'pending'),
(?1, 'https://other.example/users/c', '', 'accepted'),
(?1, 'https://other.example/users/d', '', 'rejected')",
)
.bind(owner.to_string())
.execute(&pool)
.await
.unwrap();
let n = FollowQuery::count_pending_followers(&repo(pool), owner)
.await
.unwrap();
assert_eq!(
n, 2,
"only pending rows count; accepted and rejected must not"
);
}
async fn setup_admin_query_db(pool: &SqlitePool) {
sqlx::query(
"CREATE TABLE IF NOT EXISTS ap_remote_actors (
url TEXT PRIMARY KEY,
handle TEXT NOT NULL,
inbox_url TEXT NOT NULL,
shared_inbox_url TEXT,
display_name TEXT,
avatar_url TEXT,
fetched_at TEXT NOT NULL
)",
)
.execute(pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE IF NOT EXISTS ap_following (
local_user_id TEXT NOT NULL,
remote_actor_url TEXT NOT NULL,
follow_activity_id TEXT NOT NULL,
status TEXT NOT NULL,
PRIMARY KEY (local_user_id, remote_actor_url)
)",
)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn test_list_all_followed_remote_actors_deduplicates() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup_admin_query_db(&pool).await;
let repo =
SqliteSocialRepository::new(pool.clone(), InstanceIdentity::new("https://localhost"));
let user1 = uuid::Uuid::new_v4();
let user2 = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO ap_remote_actors (url, handle, inbox_url, fetched_at, display_name)
VALUES ('https://other.social/users/alice', 'alice@other.social', 'https://other.social/inbox', '2024-01-01', 'Alice')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?, 'https://other.social/users/alice', 'act1', 'accepted'),
(?, 'https://other.social/users/alice', 'act2', 'accepted')",
)
.bind(user1.to_string())
.bind(user2.to_string())
.execute(&pool)
.await
.unwrap();
let actors = repo.list_all_followed_remote_actors().await.unwrap();
assert_eq!(actors.len(), 1);
assert_eq!(actors[0].handle, "alice@other.social");
}

View File

@@ -2,10 +2,10 @@ use async_trait::async_trait;
use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository};
use sqlx::Row;
use super::SqliteFederationRepository;
use super::SqliteSocialRepository;
#[async_trait]
impl RemoteWatchlistRepository for SqliteFederationRepository {
impl RemoteWatchlistRepository for SqliteSocialRepository {
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO ap_remote_watchlist_entries \

View File

@@ -13,7 +13,7 @@ sqlx = { version = "0.8.6", features = [
adapter-common = { workspace = true }
domain = { workspace = true }
sqlite-federation = { workspace = true }
sqlite-social = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
anyhow = { workspace = true }

View File

@@ -32,7 +32,7 @@ pub use profile::SqliteMovieProfileRepository;
pub use profile_fields::SqliteProfileFieldsRepository;
pub use refresh_sessions::SqliteRefreshSessionAdapter;
pub use review::SqliteReviewRepository;
pub use sqlite_federation::SqliteApContentQuery;
pub use sqlite_social::SqliteApContentQuery;
pub use stats::SqliteStatsRepository;
pub use users::SqliteUserRepository;
pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository};
@@ -118,7 +118,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
goal_query: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _,
user_settings: std::sync::Arc::clone(&user_settings_repo) as _,
federation_settings: user_settings_repo as _,
remote_goal: std::sync::Arc::new(sqlite_federation::SqliteRemoteGoalRepository::new(
remote_goal: std::sync::Arc::new(sqlite_social::SqliteRemoteGoalRepository::new(
pool.clone(),
)) as _,
deduplicator: std::sync::Arc::new(SqliteMovieDeduplicator::new(pool)) as _,

View File

@@ -208,7 +208,10 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
name: r.try_get("name").unwrap_or_default(),
character: r.try_get("character").unwrap_or_default(),
billing_order: r.try_get::<i64, _>("billing_order").unwrap_or(0) as u32,
profile_path: r.try_get("profile_path").ok(),
profile_path: r
.try_get::<Option<String>, _>("profile_path")
.ok()
.flatten(),
})
.collect();
@@ -226,31 +229,40 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
name: r.try_get("name").unwrap_or_default(),
job: r.try_get("job").unwrap_or_default(),
department: r.try_get("department").unwrap_or_default(),
profile_path: r.try_get("profile_path").ok(),
profile_path: r
.try_get::<Option<String>, _>("profile_path")
.ok()
.flatten(),
})
.collect();
Ok(Some(MovieProfile {
movie_id: id.clone(),
tmdb_id: row.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u64,
imdb_id: row.try_get("imdb_id").ok(),
overview: row.try_get("overview").ok(),
tagline: row.try_get("tagline").ok(),
imdb_id: row.try_get::<Option<String>, _>("imdb_id").ok().flatten(),
overview: row.try_get::<Option<String>, _>("overview").ok().flatten(),
tagline: row.try_get::<Option<String>, _>("tagline").ok().flatten(),
runtime_minutes: row
.try_get::<Option<i64>, _>("runtime_minutes")
.ok()
.flatten()
.map(|v| v as u32),
budget_usd: row.try_get("budget_usd").ok(),
revenue_usd: row.try_get("revenue_usd").ok(),
vote_average: row.try_get("vote_average").ok(),
budget_usd: row.try_get::<Option<i64>, _>("budget_usd").ok().flatten(),
revenue_usd: row.try_get::<Option<i64>, _>("revenue_usd").ok().flatten(),
vote_average: row.try_get::<Option<f64>, _>("vote_average").ok().flatten(),
vote_count: row
.try_get::<Option<i64>, _>("vote_count")
.ok()
.flatten()
.map(|v| v as u32),
original_language: row.try_get("original_language").ok(),
collection_name: row.try_get("collection_name").ok(),
original_language: row
.try_get::<Option<String>, _>("original_language")
.ok()
.flatten(),
collection_name: row
.try_get::<Option<String>, _>("collection_name")
.ok()
.flatten(),
genres,
keywords,
cast,
@@ -286,3 +298,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.collect())
}
}
#[cfg(test)]
#[path = "tests/profile.rs"]
mod tests;

View File

@@ -0,0 +1,95 @@
use super::super::profile::SqliteMovieProfileRepository;
use domain::{ports::MovieProfileRepository, value_objects::MovieId};
use sqlx::SqlitePool;
async fn pool_with_schema() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::query(
"CREATE TABLE movie_profiles (
movie_id TEXT PRIMARY KEY, tmdb_id INTEGER, imdb_id TEXT,
overview TEXT, tagline TEXT, runtime_minutes INTEGER,
budget_usd INTEGER, revenue_usd INTEGER, vote_average REAL,
vote_count INTEGER, original_language TEXT, collection_name TEXT,
enriched_at TEXT NOT NULL
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("CREATE TABLE movie_genres (movie_id TEXT, tmdb_id INTEGER, name TEXT)")
.execute(&pool)
.await
.unwrap();
sqlx::query("CREATE TABLE movie_keywords (movie_id TEXT, tmdb_id INTEGER, name TEXT)")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE movie_cast (movie_id TEXT, tmdb_person_id INTEGER,
name TEXT, character TEXT, billing_order INTEGER, profile_path TEXT)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE movie_crew (movie_id TEXT, tmdb_person_id INTEGER,
name TEXT, job TEXT, department TEXT, profile_path TEXT)",
)
.execute(&pool)
.await
.unwrap();
pool
}
async fn insert_bare_profile(pool: &SqlitePool, movie_id: &str) {
sqlx::query("INSERT INTO movie_profiles (movie_id, tmdb_id, enriched_at) VALUES (?, 1, ?)")
.bind(movie_id)
.bind(chrono::Utc::now().to_rfc3339())
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn null_cast_profile_path_becomes_none_not_empty_string() {
let pool = pool_with_schema().await;
let movie_id = MovieId::generate();
let movie_id_str = movie_id.value().to_string();
insert_bare_profile(&pool, &movie_id_str).await;
sqlx::query(
"INSERT INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path)
VALUES (?, 1, 'Alice', 'Hero', 0, NULL)",
)
.bind(&movie_id_str)
.execute(&pool)
.await
.unwrap();
let adapter = SqliteMovieProfileRepository::new(pool);
let profile = adapter.get_by_movie_id(&movie_id).await.unwrap().unwrap();
assert_eq!(profile.cast.len(), 1);
assert_eq!(
profile.cast[0].profile_path, None,
"NULL profile_path must decode to None, not Some(\"\")"
);
}
#[tokio::test]
async fn null_budget_usd_becomes_none_not_some_zero() {
let pool = pool_with_schema().await;
let movie_id = MovieId::generate();
let movie_id_str = movie_id.value().to_string();
insert_bare_profile(&pool, &movie_id_str).await;
let adapter = SqliteMovieProfileRepository::new(pool);
let profile = adapter.get_by_movie_id(&movie_id).await.unwrap().unwrap();
assert_eq!(
profile.budget_usd, None,
"NULL budget_usd must decode to None, not Some(0)"
);
}

View File

@@ -296,6 +296,7 @@ pub struct FollowingTemplate {
pub ctx: HtmlPageContext,
pub user_id: uuid::Uuid,
pub actors: Vec<RemoteActorData>,
pub pending_actors: Vec<RemoteActorData>,
pub error: Option<String>,
}

View File

@@ -34,7 +34,7 @@
<a href="/">Feed</a>
<a href="/users">Users</a>
{% if let Some(uid) = ctx.user_id %}
<a href="/users/{{ uid }}">Profile</a>
<a href="/users/{{ uid }}">Profile{% if ctx.pending_follow_count > 0 %} ({{ ctx.pending_follow_count }}){% endif %}</a>
<a href="/reviews/new">Add Review</a>
<a href="/import">Import</a>
<a href="/watch-queue">Queue</a>

View File

@@ -10,6 +10,28 @@
<input type="text" name="handle" placeholder="@user@instance.tld" required>
<button type="submit">Follow</button>
</form>
{% if !pending_actors.is_empty() %}
<h3>Requested ({{ pending_actors.len() }})</h3>
<ul class="following-list">
{% for actor in pending_actors %}
<li class="following-item">
{% if let Some(avatar) = actor.avatar_url %}
<img src="{{ avatar }}" alt="" style="width:32px;height:32px;border-radius:50%;vertical-align:middle;margin-right:6px" />
{% endif %}
<strong>{{ actor.handle }}</strong>
{% if let Some(name) = actor.display_name %}
({{ name }})
{% endif %}
<a href="{{ actor.url }}" target="_blank" rel="noopener noreferrer">View profile ↗</a>
<form method="POST" action="/users/{{ user_id }}/unfollow" style="display:inline">
<input type="hidden" name="actor_url" value="{{ actor.url }}">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit">Cancel request</button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
{% if actors.is_empty() %}
<p>Not following anyone yet. Follow remote users from your <a href="/users/{{ user_id }}">profile page</a>.</p>
{% else %}