Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
@@ -1,19 +1,26 @@
|
||||
[package]
|
||||
name = "activitypub"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
k-ap = { version = "0.5.0", registry = "gitea" }
|
||||
config = { workspace = true }
|
||||
k-ap = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v4", "serde"] }
|
||||
chrono = { workspace = true, features = ["clock", "serde"] }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
url = { version = "2", features = ["serde"] }
|
||||
ammonia = "4"
|
||||
url = { workspace = true, features = ["serde"] }
|
||||
ammonia = { workspace = true }
|
||||
|
||||
tokio = { workspace = true, features = ["time", "sync"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt", "macros"] }
|
||||
domain = { workspace = true, features = ["test-helpers"] }
|
||||
|
||||
33
crates/adapters/activitypub/src/deletion_notifier.rs
Normal file
33
crates/adapters/activitypub/src/deletion_notifier.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::FederationDeletionNotifier,
|
||||
value_objects::{InstanceIdentity, UserId},
|
||||
};
|
||||
use k_ap::ActivityPubService;
|
||||
|
||||
use crate::urls::actor_url;
|
||||
|
||||
pub struct ApDeletionNotifier {
|
||||
service: Arc<ActivityPubService>,
|
||||
instance: InstanceIdentity,
|
||||
}
|
||||
|
||||
impl ApDeletionNotifier {
|
||||
pub fn new(service: Arc<ActivityPubService>, instance: InstanceIdentity) -> Self {
|
||||
Self { service, instance }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FederationDeletionNotifier for ApDeletionNotifier {
|
||||
async fn notify_deleted(&self, user: &UserId) -> Result<(), DomainError> {
|
||||
let ap_id = actor_url(&self.instance, user.value());
|
||||
self.service
|
||||
.broadcast_delete_to_followers(user.value(), ap_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use domain::{
|
||||
events::DomainEvent,
|
||||
ports::{
|
||||
GoalQuery, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository,
|
||||
UserFederationSettingsQuery,
|
||||
UserFederationSettingsQuery, UserRepository,
|
||||
},
|
||||
value_objects::{InstanceIdentity, MovieId, ReviewId, UserId},
|
||||
};
|
||||
@@ -25,6 +25,7 @@ pub struct ActivityPubEventHandler {
|
||||
goal_repo: Arc<dyn GoalQuery>,
|
||||
stats_repo: Arc<dyn StatsRepository>,
|
||||
federation_settings: Arc<dyn UserFederationSettingsQuery>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
instance: InstanceIdentity,
|
||||
}
|
||||
|
||||
@@ -38,6 +39,7 @@ impl ActivityPubEventHandler {
|
||||
goal_repo: Arc<dyn GoalQuery>,
|
||||
stats_repo: Arc<dyn StatsRepository>,
|
||||
federation_settings: Arc<dyn UserFederationSettingsQuery>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
instance: InstanceIdentity,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -48,6 +50,7 @@ impl ActivityPubEventHandler {
|
||||
goal_repo,
|
||||
stats_repo,
|
||||
federation_settings,
|
||||
user_repo,
|
||||
instance,
|
||||
}
|
||||
}
|
||||
@@ -55,7 +58,17 @@ impl ActivityPubEventHandler {
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for ActivityPubEventHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"activitypub"
|
||||
}
|
||||
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
if let Some(author) = self.outbound_author(event)
|
||||
&& self.is_tombstoned(&author).await
|
||||
{
|
||||
tracing::debug!(user = %author.value(), "skipping federation event of a deleted account");
|
||||
return Ok(());
|
||||
}
|
||||
match event {
|
||||
DomainEvent::ReviewLogged {
|
||||
review_id, user_id, ..
|
||||
@@ -139,13 +152,6 @@ impl EventHandler for ActivityPubEventHandler {
|
||||
.on_goal_deleted(user_id, *year)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
||||
DomainEvent::UserDeleted { user_id } => {
|
||||
let ap_id = actor_url(&self.instance, user_id.value());
|
||||
self.ap_service
|
||||
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
DomainEvent::UserAccountMoved {
|
||||
user_id,
|
||||
new_actor_url,
|
||||
@@ -164,6 +170,36 @@ impl EventHandler for ActivityPubEventHandler {
|
||||
}
|
||||
|
||||
impl ActivityPubEventHandler {
|
||||
fn outbound_author(&self, event: &DomainEvent) -> Option<UserId> {
|
||||
match event {
|
||||
DomainEvent::ReviewLogged { user_id, .. }
|
||||
| DomainEvent::ReviewUpdated { user_id, .. }
|
||||
| DomainEvent::ReviewDeleted { user_id, .. }
|
||||
| DomainEvent::UserUpdated { user_id }
|
||||
| DomainEvent::WatchlistEntryAdded { user_id, .. }
|
||||
| DomainEvent::WatchlistEntryRemoved { user_id, .. }
|
||||
| DomainEvent::GoalCreated { user_id, .. }
|
||||
| DomainEvent::GoalUpdated { user_id, .. }
|
||||
| DomainEvent::GoalDeleted { user_id, .. }
|
||||
| DomainEvent::UserAccountMoved { user_id, .. } => Some(user_id.clone()),
|
||||
DomainEvent::FederationDeliveryRequested {
|
||||
activity_json,
|
||||
signing_actor_id,
|
||||
..
|
||||
} if activity_json.get("type").and_then(|t| t.as_str()) != Some("Delete") => {
|
||||
Some(UserId::from_uuid(*signing_actor_id))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_tombstoned(&self, user: &UserId) -> bool {
|
||||
matches!(
|
||||
self.user_repo.find_tombstoned_by_id(user).await,
|
||||
Ok(Some(_))
|
||||
)
|
||||
}
|
||||
|
||||
async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
|
||||
let flags = self
|
||||
.federation_settings
|
||||
|
||||
@@ -4,18 +4,20 @@ use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{BlockedDomainInfo, FollowedActorInfo},
|
||||
ports::{ApBackfillPort, ApDocumentPort, InstanceBlocklistPort},
|
||||
ports::{ApBackfillPort, ApDocumentPort, InstanceBlocklistPort, UserRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
use k_ap::ActivityPubService;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct ApServiceAdapter {
|
||||
service: Arc<ActivityPubService>,
|
||||
users: Arc<dyn UserRepository>,
|
||||
}
|
||||
|
||||
impl ApServiceAdapter {
|
||||
pub fn new(service: Arc<ActivityPubService>) -> Self {
|
||||
Self { service }
|
||||
pub fn new(service: Arc<ActivityPubService>, users: Arc<dyn UserRepository>) -> Self {
|
||||
Self { service, users }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +28,13 @@ fn ap_err(e: anyhow::Error) -> DomainError {
|
||||
|
||||
#[async_trait]
|
||||
impl ApDocumentPort for ApServiceAdapter {
|
||||
async fn actor_is_gone(&self, user_id: Uuid) -> Result<bool, DomainError> {
|
||||
Ok(self
|
||||
.users
|
||||
.find_tombstoned_by_id(&UserId::from_uuid(user_id))
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
async fn actor_json(&self, user_id: &str) -> Result<String, DomainError> {
|
||||
self.service.actor_json(user_id).await.map_err(ap_err)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod composite_handler;
|
||||
pub mod deletion_notifier;
|
||||
pub mod event_handler;
|
||||
pub mod federation_event_bridge;
|
||||
pub mod federation_ports;
|
||||
@@ -7,12 +8,12 @@ pub mod objects;
|
||||
pub mod remote_review_repository;
|
||||
pub mod review_handler;
|
||||
pub mod social_adapter;
|
||||
pub mod tombstone_gate;
|
||||
pub(crate) mod urls;
|
||||
pub mod user_adapter;
|
||||
pub mod watchlist_handler;
|
||||
|
||||
pub const INSTANCE_ACTOR_ID: uuid::Uuid =
|
||||
uuid::Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0]);
|
||||
pub const INSTANCE_ACTOR_ID: uuid::Uuid = domain::value_objects::SERVICE_ACTOR_UUID;
|
||||
|
||||
pub use k_ap::{
|
||||
ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
|
||||
@@ -20,9 +21,10 @@ pub use k_ap::{
|
||||
FollowRepository, Follower, FollowerStatus, FollowingStatus, LocalObject, RemoteActor,
|
||||
};
|
||||
|
||||
pub use deletion_notifier::ApDeletionNotifier;
|
||||
pub use event_handler::ActivityPubEventHandler;
|
||||
pub use federation_ports::ApServiceAdapter;
|
||||
pub use remote_review_repository::{RemoteReviewRepository, RemoteReviewUpdate};
|
||||
pub use remote_review_repository::{NewRemoteReview, RemoteReviewRepository, RemoteReviewUpdate};
|
||||
pub use review_handler::ReviewObjectHandler;
|
||||
pub use social_adapter::CompositeSocialAdapter;
|
||||
pub use user_adapter::DomainUserRepoAdapter;
|
||||
@@ -46,6 +48,7 @@ pub struct ActivityPubWire {
|
||||
pub service: std::sync::Arc<ActivityPubService>,
|
||||
pub router: axum::Router,
|
||||
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
|
||||
pub deletion_notifier: std::sync::Arc<dyn domain::ports::FederationDeletionNotifier>,
|
||||
}
|
||||
|
||||
pub struct ActivityPubDeps {
|
||||
@@ -68,6 +71,7 @@ pub struct ActivityPubDeps {
|
||||
pub follow_query: std::sync::Arc<dyn domain::ports::FollowQuery>,
|
||||
pub instance: domain::value_objects::InstanceIdentity,
|
||||
pub allow_registration: bool,
|
||||
pub federation: config::FederationConfig,
|
||||
pub event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
|
||||
}
|
||||
|
||||
@@ -92,6 +96,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
follow_query: _,
|
||||
instance,
|
||||
allow_registration,
|
||||
federation,
|
||||
event_publisher,
|
||||
} = deps;
|
||||
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
|
||||
@@ -99,7 +104,6 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
movie_repo: std::sync::Arc::clone(&movie_repo),
|
||||
diary_repo,
|
||||
review_store,
|
||||
event_publisher: std::sync::Arc::clone(&event_publisher),
|
||||
instance: instance.clone(),
|
||||
});
|
||||
let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler {
|
||||
@@ -118,9 +122,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
goal: goal_handler,
|
||||
});
|
||||
|
||||
let federation_debug = std::env::var("FEDERATION_DEBUG")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
let federation_debug = federation.debug;
|
||||
|
||||
if federation_debug {
|
||||
tracing::warn!(
|
||||
@@ -140,7 +142,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
.actor_repo(actor_repo)
|
||||
.blocklist_repo(blocklist_repo)
|
||||
.user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new(
|
||||
user_repo,
|
||||
std::sync::Arc::clone(&user_repo),
|
||||
instance.clone(),
|
||||
)))
|
||||
.signed_fetch_actor_id(INSTANCE_ACTOR_ID)
|
||||
@@ -158,7 +160,16 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
.await?,
|
||||
);
|
||||
|
||||
let router = concrete.router();
|
||||
let router = concrete
|
||||
.router()
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
std::sync::Arc::clone(&user_repo),
|
||||
tombstone_gate::tombstone_gate,
|
||||
));
|
||||
let deletion_notifier = std::sync::Arc::new(ApDeletionNotifier::new(
|
||||
std::sync::Arc::clone(&concrete),
|
||||
instance.clone(),
|
||||
)) as std::sync::Arc<dyn domain::ports::FederationDeletionNotifier>;
|
||||
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
|
||||
std::sync::Arc::clone(&concrete),
|
||||
local_ap_content,
|
||||
@@ -167,11 +178,13 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
goal_repo,
|
||||
stats_repo,
|
||||
federation_settings,
|
||||
std::sync::Arc::clone(&user_repo),
|
||||
instance,
|
||||
)) as std::sync::Arc<dyn domain::ports::EventHandler>;
|
||||
|
||||
let ports = std::sync::Arc::new(federation_ports::ApServiceAdapter::new(
|
||||
std::sync::Arc::clone(&concrete),
|
||||
std::sync::Arc::clone(&user_repo),
|
||||
));
|
||||
|
||||
Ok(ActivityPubWire {
|
||||
@@ -183,5 +196,6 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
service: concrete,
|
||||
router,
|
||||
event_handler,
|
||||
deletion_notifier,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -306,7 +306,3 @@ pub fn goal_to_ap_object(
|
||||
cc: vec![format!("{}/followers", actor_url)],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/objects.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::models::Review;
|
||||
use domain::{events::DomainEvent, models::Review};
|
||||
|
||||
pub struct RemoteReviewUpdate<'a> {
|
||||
pub ap_id: &'a str,
|
||||
@@ -14,17 +14,19 @@ pub struct RemoteReviewUpdate<'a> {
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
pub struct NewRemoteReview<'a> {
|
||||
pub review: &'a Review,
|
||||
pub ap_id: &'a str,
|
||||
pub movie_title: &'a str,
|
||||
pub release_year: u16,
|
||||
pub external_metadata_id: Option<&'a str>,
|
||||
pub poster_url: Option<&'a str>,
|
||||
pub events: &'a [DomainEvent],
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RemoteReviewRepository: Send + Sync {
|
||||
async fn save_remote_review(
|
||||
&self,
|
||||
review: &Review,
|
||||
ap_id: &str,
|
||||
movie_title: &str,
|
||||
release_year: u16,
|
||||
external_metadata_id: Option<&str>,
|
||||
poster_url: Option<&str>,
|
||||
) -> Result<()>;
|
||||
async fn save_remote_review(&self, new: NewRemoteReview<'_>) -> Result<()>;
|
||||
|
||||
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
models::ReviewSource,
|
||||
ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery},
|
||||
ports::{DiaryQuery, LocalApContentQuery, MovieQuery},
|
||||
value_objects::{
|
||||
Comment, ExternalMetadataId, InstanceIdentity, MovieId, Rating, ReviewId, UserId,
|
||||
},
|
||||
@@ -13,7 +13,7 @@ use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
||||
use url::Url;
|
||||
|
||||
use crate::objects::{ReviewApInput, ReviewObject, review_to_ap_object};
|
||||
use crate::remote_review_repository::RemoteReviewRepository;
|
||||
use crate::remote_review_repository::{NewRemoteReview, RemoteReviewRepository};
|
||||
use crate::urls::{actor_url, review_url};
|
||||
|
||||
pub struct ReviewObjectHandler {
|
||||
@@ -21,7 +21,6 @@ pub struct ReviewObjectHandler {
|
||||
pub movie_repo: Arc<dyn MovieQuery>,
|
||||
pub diary_repo: Arc<dyn DiaryQuery>,
|
||||
pub review_store: Arc<dyn RemoteReviewRepository>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
pub instance: InstanceIdentity,
|
||||
}
|
||||
|
||||
@@ -160,28 +159,30 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
spoilers: obj.contains_spoilers,
|
||||
});
|
||||
|
||||
self.review_store
|
||||
.save_remote_review(
|
||||
&review,
|
||||
obj.id.as_str(),
|
||||
&obj.movie_title,
|
||||
obj.release_year,
|
||||
obj.external_metadata_id.as_deref(),
|
||||
obj.poster_url.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(ref ext_id_str) = obj.external_metadata_id
|
||||
&& let Ok(external_metadata_id) = ExternalMetadataId::new(ext_id_str.clone())
|
||||
{
|
||||
let _ = self
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::MovieEnrichmentRequested {
|
||||
let events: Vec<DomainEvent> = obj
|
||||
.external_metadata_id
|
||||
.as_ref()
|
||||
.and_then(|ext_id| ExternalMetadataId::new(ext_id.clone()).ok())
|
||||
.map(
|
||||
|external_metadata_id| DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id: movie_id.clone(),
|
||||
external_metadata_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
},
|
||||
)
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
self.review_store
|
||||
.save_remote_review(NewRemoteReview {
|
||||
review: &review,
|
||||
ap_id: obj.id.as_str(),
|
||||
movie_title: &obj.movie_title,
|
||||
release_year: obj.release_year,
|
||||
external_metadata_id: obj.external_metadata_id.as_deref(),
|
||||
poster_url: obj.poster_url.as_deref(),
|
||||
events: &events,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{BlockQuery, FollowGraphQuery, LocalSocial, SocialCommand, UserRepository},
|
||||
events::DomainEvent,
|
||||
ports::{
|
||||
BlockQuery, EventPublisher, FollowGraphQuery, LocalSocial, SocialCommand, UserRepository,
|
||||
},
|
||||
value_objects::{
|
||||
FollowRelation, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, UserId,
|
||||
},
|
||||
@@ -16,6 +19,7 @@ pub struct CompositeSocialAdapter {
|
||||
ap_service: Arc<ActivityPubService>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
instance: InstanceIdentity,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
impl CompositeSocialAdapter {
|
||||
@@ -24,12 +28,22 @@ impl CompositeSocialAdapter {
|
||||
ap_service: Arc<ActivityPubService>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
instance: InstanceIdentity,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
) -> Self {
|
||||
Self {
|
||||
local,
|
||||
ap_service,
|
||||
user_repo,
|
||||
instance,
|
||||
event_publisher,
|
||||
}
|
||||
}
|
||||
|
||||
async fn announce(&self, events: &[DomainEvent]) {
|
||||
for event in events {
|
||||
if let Err(e) = self.event_publisher.publish(event).await {
|
||||
tracing::warn!("failed to publish {event:?} after k_ap write: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,11 +54,19 @@ fn ap_err(e: anyhow::Error) -> DomainError {
|
||||
|
||||
#[async_trait]
|
||||
impl SocialCommand for CompositeSocialAdapter {
|
||||
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> {
|
||||
async fn follow(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &FollowTarget,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
let identity = self.local.resolve_target(target).await?;
|
||||
|
||||
if let SocialIdentity::Local(_) = identity {
|
||||
return self.local.follow_resolved(follower, &identity).await;
|
||||
return self
|
||||
.local
|
||||
.follow_resolved(follower, &identity, events)
|
||||
.await;
|
||||
}
|
||||
|
||||
let handle = match target {
|
||||
@@ -64,21 +86,27 @@ impl SocialCommand for CompositeSocialAdapter {
|
||||
self.ap_service
|
||||
.follow(follower.value(), &handle)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
.map_err(ap_err)?;
|
||||
self.announce(events).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unfollow(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
match target {
|
||||
SocialIdentity::Local(_) => self.local.unfollow(follower, target).await,
|
||||
SocialIdentity::Remote { .. } => self
|
||||
.ap_service
|
||||
.unfollow(follower.value(), &self.instance.actor_url_of(target))
|
||||
.await
|
||||
.map_err(ap_err),
|
||||
SocialIdentity::Local(_) => self.local.unfollow(follower, target, events).await,
|
||||
SocialIdentity::Remote { .. } => {
|
||||
self.ap_service
|
||||
.unfollow(follower.value(), &self.instance.actor_url_of(target))
|
||||
.await
|
||||
.map_err(ap_err)?;
|
||||
self.announce(events).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,14 +114,18 @@ impl SocialCommand for CompositeSocialAdapter {
|
||||
&self,
|
||||
owner: &UserId,
|
||||
requester: &SocialIdentity,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
match requester {
|
||||
SocialIdentity::Local(_) => self.local.accept_follow(owner, requester).await,
|
||||
SocialIdentity::Remote { .. } => self
|
||||
.ap_service
|
||||
.accept_follower(owner.value(), &self.instance.actor_url_of(requester))
|
||||
.await
|
||||
.map_err(ap_err),
|
||||
SocialIdentity::Local(_) => self.local.accept_follow(owner, requester, events).await,
|
||||
SocialIdentity::Remote { .. } => {
|
||||
self.ap_service
|
||||
.accept_follower(owner.value(), &self.instance.actor_url_of(requester))
|
||||
.await
|
||||
.map_err(ap_err)?;
|
||||
self.announce(events).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,14 +133,18 @@ impl SocialCommand for CompositeSocialAdapter {
|
||||
&self,
|
||||
owner: &UserId,
|
||||
requester: &SocialIdentity,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
match requester {
|
||||
SocialIdentity::Local(_) => self.local.reject_follow(owner, requester).await,
|
||||
SocialIdentity::Remote { .. } => self
|
||||
.ap_service
|
||||
.reject_follower(owner.value(), &self.instance.actor_url_of(requester))
|
||||
.await
|
||||
.map_err(ap_err),
|
||||
SocialIdentity::Local(_) => self.local.reject_follow(owner, requester, events).await,
|
||||
SocialIdentity::Remote { .. } => {
|
||||
self.ap_service
|
||||
.reject_follower(owner.value(), &self.instance.actor_url_of(requester))
|
||||
.await
|
||||
.map_err(ap_err)?;
|
||||
self.announce(events).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,31 +152,49 @@ impl SocialCommand for CompositeSocialAdapter {
|
||||
&self,
|
||||
owner: &UserId,
|
||||
follower: &SocialIdentity,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
match follower {
|
||||
SocialIdentity::Local(_) => self.local.remove_follower(owner, follower).await,
|
||||
SocialIdentity::Remote { .. } => self
|
||||
.ap_service
|
||||
.remove_follower(owner.value(), &self.instance.actor_url_of(follower))
|
||||
.await
|
||||
.map_err(ap_err),
|
||||
SocialIdentity::Local(_) => self.local.remove_follower(owner, follower, events).await,
|
||||
SocialIdentity::Remote { .. } => {
|
||||
self.ap_service
|
||||
.remove_follower(owner.value(), &self.instance.actor_url_of(follower))
|
||||
.await
|
||||
.map_err(ap_err)?;
|
||||
self.announce(events).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
||||
async fn block(
|
||||
&self,
|
||||
blocker: &UserId,
|
||||
target: &SocialIdentity,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(target);
|
||||
self.ap_service
|
||||
.block_actor(blocker.value(), &actor_url)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
.map_err(ap_err)?;
|
||||
self.announce(events).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
||||
async fn unblock(
|
||||
&self,
|
||||
blocker: &UserId,
|
||||
target: &SocialIdentity,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(target);
|
||||
self.ap_service
|
||||
.unblock_actor(blocker.value(), &actor_url)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
.map_err(ap_err)?;
|
||||
self.announce(events).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_hashtag_strips_non_alphanumeric() {
|
||||
assert_eq!(normalize_hashtag("The Dark Knight"), "TheDarkKnight");
|
||||
assert_eq!(normalize_hashtag("Schindler's List"), "SchindlersList");
|
||||
assert_eq!(
|
||||
normalize_hashtag("2001: A Space Odyssey"),
|
||||
"2001ASpaceOdyssey"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_to_ap_object_includes_two_hashtags() {
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
models::{PersistedReview, Review, ReviewSource},
|
||||
value_objects::{MovieId, Rating, ReviewId, UserId},
|
||||
};
|
||||
|
||||
let review = Review::from_persistence(PersistedReview {
|
||||
id: ReviewId::generate(),
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
|
||||
user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||
rating: Rating::new(4).unwrap(),
|
||||
comment: None,
|
||||
watched_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
});
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
ReviewApInput {
|
||||
ap_id: "https://example.com/reviews/1".parse().unwrap(),
|
||||
actor_url: "https://example.com/users/1".parse().unwrap(),
|
||||
movie_title: "Dune".to_string(),
|
||||
release_year: 2021,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
base_url: "https://example.com".to_string(),
|
||||
},
|
||||
);
|
||||
assert_eq!(obj.tag.len(), 2);
|
||||
let names: Vec<&str> = obj.tag.iter().map(|t| t.name.as_str()).collect();
|
||||
assert!(names.contains(&"#MoviesDiary"));
|
||||
assert!(names.contains(&"#Dune"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_to_ap_object_has_public_addressing() {
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
models::{PersistedReview, Review, ReviewSource},
|
||||
value_objects::{MovieId, Rating, ReviewId, UserId},
|
||||
};
|
||||
|
||||
let review = Review::from_persistence(PersistedReview {
|
||||
id: ReviewId::generate(),
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
|
||||
user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||
rating: Rating::new(3).unwrap(),
|
||||
comment: None,
|
||||
watched_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
created_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
spoilers: false,
|
||||
});
|
||||
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
ReviewApInput {
|
||||
ap_id: "https://example.com/reviews/1".parse().unwrap(),
|
||||
actor_url: actor_url.clone(),
|
||||
movie_title: "Dune".to_string(),
|
||||
release_year: 2021,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
base_url: "https://example.com".to_string(),
|
||||
},
|
||||
);
|
||||
assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]);
|
||||
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_to_ap_object_carries_spoiler_flag_and_summary() {
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
models::{PersistedReview, Review, ReviewSource},
|
||||
value_objects::{Comment, MovieId, Rating, ReviewId, UserId},
|
||||
};
|
||||
|
||||
let ts = NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
|
||||
let review = Review::from_persistence(PersistedReview {
|
||||
id: ReviewId::generate(),
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
|
||||
user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||
rating: Rating::new(4).unwrap(),
|
||||
comment: Some(Comment::new("the mentor is the villain".into()).unwrap()),
|
||||
watched_at: ts,
|
||||
created_at: ts,
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
spoilers: true,
|
||||
});
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
ReviewApInput {
|
||||
ap_id: "https://example.com/reviews/1".parse().unwrap(),
|
||||
actor_url: "https://example.com/users/1".parse().unwrap(),
|
||||
movie_title: "Dune".to_string(),
|
||||
release_year: 2021,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
base_url: "https://example.com".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(obj.contains_spoilers);
|
||||
assert_eq!(obj.summary.as_deref(), Some("Contains spoilers"));
|
||||
let json = serde_json::to_value(&obj).unwrap();
|
||||
assert_eq!(json["containsSpoilers"], serde_json::Value::Bool(true));
|
||||
assert_eq!(json["summary"], "Contains spoilers");
|
||||
|
||||
// A flag without a comment never travels: there is nothing to veil.
|
||||
let review_no_comment = Review::from_persistence(PersistedReview {
|
||||
id: ReviewId::generate(),
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
|
||||
user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||
rating: Rating::new(4).unwrap(),
|
||||
comment: None,
|
||||
watched_at: ts,
|
||||
created_at: ts,
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
spoilers: true,
|
||||
});
|
||||
let obj2 = review_to_ap_object(
|
||||
&review_no_comment,
|
||||
ReviewApInput {
|
||||
ap_id: "https://example.com/reviews/2".parse().unwrap(),
|
||||
actor_url: "https://example.com/users/1".parse().unwrap(),
|
||||
movie_title: "Dune".to_string(),
|
||||
release_year: 2021,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
base_url: "https://example.com".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(!obj2.contains_spoilers);
|
||||
assert!(obj2.summary.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchlist_to_ap_object_has_public_addressing() {
|
||||
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
||||
let obj = watchlist_to_ap_object(WatchlistApInput {
|
||||
ap_id: "https://example.com/watchlist/1".parse().unwrap(),
|
||||
actor_url: actor_url.clone(),
|
||||
movie_title: "Alien".to_string(),
|
||||
release_year: 1979,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
added_at: chrono::Utc::now(),
|
||||
base_url: "https://example.com".to_string(),
|
||||
});
|
||||
assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]);
|
||||
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
|
||||
}
|
||||
32
crates/adapters/activitypub/src/tombstone_gate.rs
Normal file
32
crates/adapters/activitypub/src/tombstone_gate.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use domain::{ports::UserRepository, value_objects::UserId};
|
||||
|
||||
pub async fn tombstone_gate(
|
||||
State(users): State<Arc<dyn UserRepository>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
if let Some(id) = gated_user(request.uri().path())
|
||||
&& matches!(
|
||||
users.find_tombstoned_by_id(&UserId::from_uuid(id)).await,
|
||||
Ok(Some(_))
|
||||
)
|
||||
{
|
||||
return StatusCode::GONE.into_response();
|
||||
}
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
pub fn gated_user(path: &str) -> Option<uuid::Uuid> {
|
||||
let rest = path.strip_prefix("/users/")?;
|
||||
let (id, endpoint) = rest.split_once('/')?;
|
||||
(!endpoint.is_empty()).then_some(())?;
|
||||
uuid::Uuid::parse_str(id).ok()
|
||||
}
|
||||
@@ -66,7 +66,10 @@ impl ApUserRepository for DomainUserRepoAdapter {
|
||||
let user_id = UserId::from_uuid(id);
|
||||
let user = match self.repo.find_by_id(&user_id).await? {
|
||||
Some(u) => u,
|
||||
None => return Ok(None),
|
||||
None => match self.repo.find_tombstoned_by_id(&user_id).await? {
|
||||
Some(u) => u,
|
||||
None => return Ok(None),
|
||||
},
|
||||
};
|
||||
Ok(Some(self.build_user(&user)))
|
||||
}
|
||||
|
||||
253
crates/adapters/activitypub/tests/activity_ids.rs
Normal file
253
crates/adapters/activitypub/tests/activity_ids.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use activitypub::ActivityPubEventHandler;
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDate;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{DiaryEntry, WatchlistWithMovie},
|
||||
ports::{
|
||||
EventHandler, FederationDeletionNotifier, LocalApContentQuery, MovieCommand, UserRepository,
|
||||
},
|
||||
testing::{
|
||||
InMemoryStore, PanicGoalQuery, PanicReviewRepository, PanicStatsRepository, test_movie,
|
||||
},
|
||||
value_objects::{InstanceIdentity, MovieId, UserId},
|
||||
};
|
||||
use k_ap::{
|
||||
ActivityPubService, ApUser, FederationEvent,
|
||||
testing::{MockActorRepoBuilder, MockFollowRepoBuilder, MockUserRepoBuilder},
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
struct NoContent;
|
||||
|
||||
#[async_trait]
|
||||
impl LocalApContentQuery for NoContent {
|
||||
async fn get_local_watchlist_for_user(
|
||||
&self,
|
||||
_: &UserId,
|
||||
) -> Result<Vec<WatchlistWithMovie>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_local_reviews_for_movie(
|
||||
&self,
|
||||
_: &MovieId,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_local_reviews_page(
|
||||
&self,
|
||||
_: &UserId,
|
||||
_: Option<chrono::NaiveDateTime>,
|
||||
_: usize,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
struct Capture(mpsc::UnboundedSender<serde_json::Value>);
|
||||
|
||||
#[async_trait]
|
||||
impl k_ap::EventPublisher for Capture {
|
||||
async fn publish(&self, event: FederationEvent) -> anyhow::Result<()> {
|
||||
if let FederationEvent::DeliveryRequested { activity, .. } = event {
|
||||
let _ = self.0.send(activity);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
handler: ActivityPubEventHandler,
|
||||
delivered: mpsc::UnboundedReceiver<serde_json::Value>,
|
||||
user: UserId,
|
||||
movie_id: MovieId,
|
||||
store: Arc<InMemoryStore>,
|
||||
service: Arc<ActivityPubService>,
|
||||
}
|
||||
|
||||
async fn harness() -> Harness {
|
||||
let user = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
let uid = user.value();
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let service = ActivityPubService::builder("https://diary.example")
|
||||
.activity_repo(k_ap::testing::MockActivityRepoBuilder::new().build())
|
||||
.follow_repo(
|
||||
MockFollowRepoBuilder::new()
|
||||
.on_get_accepted_follower_inboxes(|_| {
|
||||
Ok(vec!["https://remote.example/inbox".to_string()])
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.actor_repo(MockActorRepoBuilder::new().build())
|
||||
.blocklist_repo(k_ap::testing::MockBlocklistRepoBuilder::new().build())
|
||||
.user_repo(
|
||||
MockUserRepoBuilder::new()
|
||||
.on_find_by_id(move |id| {
|
||||
Ok((id == uid).then(|| ApUser {
|
||||
id,
|
||||
username: "alice".into(),
|
||||
display_name: None,
|
||||
bio: None,
|
||||
avatar_url: None,
|
||||
banner_url: None,
|
||||
also_known_as: vec![],
|
||||
profile_url: None,
|
||||
attachment: vec![],
|
||||
manually_approves_followers: false,
|
||||
discoverable: true,
|
||||
actor_type: Default::default(),
|
||||
featured_url: None,
|
||||
}))
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.content_reader(k_ap::testing::MockContentReaderBuilder::new().build())
|
||||
.object_handler(k_ap::testing::MockObjectHandlerBuilder::new().build())
|
||||
.event_publisher(Arc::new(Capture(tx)))
|
||||
.build()
|
||||
.await
|
||||
.expect("service builds");
|
||||
|
||||
let store = InMemoryStore::new();
|
||||
let movie = test_movie("Heat");
|
||||
store.upsert_movie(&movie, &[]).await.unwrap();
|
||||
let service = Arc::new(service);
|
||||
store
|
||||
.save(&domain::testing::test_user_with_role(
|
||||
user.clone(),
|
||||
domain::models::UserRole::Standard,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let handler = ActivityPubEventHandler::new(
|
||||
Arc::clone(&service),
|
||||
Arc::new(NoContent),
|
||||
Arc::new(PanicReviewRepository),
|
||||
store.clone(),
|
||||
Arc::new(PanicGoalQuery),
|
||||
Arc::new(PanicStatsRepository),
|
||||
store.clone(),
|
||||
store.clone(),
|
||||
InstanceIdentity::new("https://diary.example"),
|
||||
);
|
||||
Harness {
|
||||
handler,
|
||||
delivered: rx,
|
||||
user,
|
||||
movie_id: movie.id().clone(),
|
||||
store,
|
||||
service,
|
||||
}
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
async fn next_activity_id(&mut self) -> String {
|
||||
let activity = tokio::time::timeout(Duration::from_secs(5), self.delivered.recv())
|
||||
.await
|
||||
.expect("an activity was delivered")
|
||||
.expect("channel open");
|
||||
activity["id"]
|
||||
.as_str()
|
||||
.expect("activity has an id")
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activitypub_is_idempotent_when_run_twice() {
|
||||
let mut h = harness().await;
|
||||
let event = DomainEvent::WatchlistEntryAdded {
|
||||
user_id: h.user.clone(),
|
||||
movie_id: h.movie_id.clone(),
|
||||
movie_title: "Heat".into(),
|
||||
release_year: 1995,
|
||||
external_metadata_id: None,
|
||||
added_at: NaiveDate::from_ymd_opt(2026, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap(),
|
||||
};
|
||||
|
||||
h.handler.handle(&event).await.unwrap();
|
||||
let first = h.next_activity_id().await;
|
||||
h.handler.handle(&event).await.unwrap();
|
||||
let second = h.next_activity_id().await;
|
||||
|
||||
assert!(first.starts_with("https://diary.example/activities/create/"));
|
||||
assert_eq!(first, second, "a redelivered Create keeps its activity id");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn redelivered_delete_gets_a_fresh_activity_id_known_k_ap_limitation() {
|
||||
let mut h = harness().await;
|
||||
let event = DomainEvent::WatchlistEntryRemoved {
|
||||
user_id: h.user.clone(),
|
||||
movie_id: h.movie_id.clone(),
|
||||
};
|
||||
|
||||
h.handler.handle(&event).await.unwrap();
|
||||
let first = h.next_activity_id().await;
|
||||
h.handler.handle(&event).await.unwrap();
|
||||
let second = h.next_activity_id().await;
|
||||
|
||||
assert_ne!(first, second);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_deleted_account_sends_nothing_but_its_delete() {
|
||||
let mut h = harness().await;
|
||||
h.store.mark_deleted(&h.user, &[]).await.unwrap();
|
||||
let logged = DomainEvent::ReviewLogged {
|
||||
review_id: domain::value_objects::ReviewId::generate(),
|
||||
user_id: h.user.clone(),
|
||||
movie_id: h.movie_id.clone(),
|
||||
rating: domain::value_objects::Rating::new(4).unwrap(),
|
||||
watched_at: NaiveDate::from_ymd_opt(2026, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap(),
|
||||
};
|
||||
let added = DomainEvent::WatchlistEntryAdded {
|
||||
user_id: h.user.clone(),
|
||||
movie_id: h.movie_id.clone(),
|
||||
movie_title: "Heat".into(),
|
||||
release_year: 1995,
|
||||
external_metadata_id: None,
|
||||
added_at: NaiveDate::from_ymd_opt(2026, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap(),
|
||||
};
|
||||
let queued = DomainEvent::FederationDeliveryRequested {
|
||||
inbox_url: "https://remote.example/inbox".into(),
|
||||
activity_json: serde_json::json!({"type": "Create"}),
|
||||
signing_actor_id: h.user.value(),
|
||||
};
|
||||
|
||||
for event in [&logged, &added, &queued] {
|
||||
h.handler.handle(event).await.unwrap();
|
||||
}
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(300), h.delivered.recv())
|
||||
.await
|
||||
.is_err(),
|
||||
"nothing is delivered for a deleted account"
|
||||
);
|
||||
|
||||
activitypub::ApDeletionNotifier::new(
|
||||
Arc::clone(&h.service),
|
||||
InstanceIdentity::new("https://diary.example"),
|
||||
)
|
||||
.notify_deleted(&h.user)
|
||||
.await
|
||||
.unwrap();
|
||||
let delete = tokio::time::timeout(Duration::from_secs(5), h.delivered.recv())
|
||||
.await
|
||||
.expect("the Delete is delivered")
|
||||
.unwrap();
|
||||
assert_eq!(delete["type"], "Delete");
|
||||
}
|
||||
4
crates/adapters/activitypub/tests/objects.rs
Normal file
4
crates/adapters/activitypub/tests/objects.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
#[path = "objects/review_object_test.rs"]
|
||||
mod review_object_test;
|
||||
#[path = "objects/watchlist_object_test.rs"]
|
||||
mod watchlist_object_test;
|
||||
112
crates/adapters/activitypub/tests/objects/review_object_test.rs
Normal file
112
crates/adapters/activitypub/tests/objects/review_object_test.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use activitypub::objects::{ReviewApInput, review_to_ap_object};
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::models::{PersistedReview, Review, ReviewSource};
|
||||
use domain::value_objects::{Comment, MovieId, Rating, ReviewId, UserId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const PUBLIC: &str = "https://www.w3.org/ns/activitystreams#Public";
|
||||
|
||||
fn timestamp() -> NaiveDateTime {
|
||||
NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
|
||||
}
|
||||
|
||||
fn review(rating: u8, comment: Option<&str>, spoilers: bool) -> Review {
|
||||
Review::from_persistence(PersistedReview {
|
||||
id: ReviewId::generate(),
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
|
||||
user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||
rating: Rating::new(rating).unwrap(),
|
||||
comment: comment.map(|c| Comment::new(c.into()).unwrap()),
|
||||
watched_at: timestamp(),
|
||||
created_at: timestamp(),
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
spoilers,
|
||||
})
|
||||
}
|
||||
|
||||
fn input(movie_title: &str, actor_url: &str) -> ReviewApInput {
|
||||
ReviewApInput {
|
||||
ap_id: "https://example.com/reviews/1".parse().unwrap(),
|
||||
actor_url: actor_url.parse().unwrap(),
|
||||
movie_title: movie_title.to_string(),
|
||||
release_year: 2021,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
base_url: "https://example.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render(review: &Review, input: ReviewApInput) -> Value {
|
||||
serde_json::to_value(review_to_ap_object(review, input)).unwrap()
|
||||
}
|
||||
|
||||
fn tag_names(json: &Value) -> Vec<&str> {
|
||||
json["tag"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| t["name"].as_str().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_object_tags_movie_title_with_non_alphanumerics_stripped() {
|
||||
for (title, hashtag) in [
|
||||
("The Dark Knight", "#TheDarkKnight"),
|
||||
("Schindler's List", "#SchindlersList"),
|
||||
("2001: A Space Odyssey", "#2001ASpaceOdyssey"),
|
||||
] {
|
||||
let json = render(
|
||||
&review(4, None, false),
|
||||
input(title, "https://example.com/users/1"),
|
||||
);
|
||||
assert!(
|
||||
tag_names(&json).contains(&hashtag),
|
||||
"{title}: {:?}",
|
||||
tag_names(&json)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_object_includes_two_hashtags() {
|
||||
let json = render(
|
||||
&review(4, None, false),
|
||||
input("Dune", "https://example.com/users/1"),
|
||||
);
|
||||
|
||||
assert_eq!(tag_names(&json), vec!["#MoviesDiary", "#Dune"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_object_is_publicly_addressed_with_followers_in_cc() {
|
||||
let json = render(
|
||||
&review(3, None, false),
|
||||
input("Dune", "https://example.com/users/abc"),
|
||||
);
|
||||
|
||||
assert_eq!(json["to"], json!([PUBLIC]));
|
||||
assert_eq!(
|
||||
json["cc"],
|
||||
json!(["https://example.com/users/abc/followers"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_object_carries_spoiler_flag_and_summary_only_with_a_comment() {
|
||||
let json = render(
|
||||
&review(4, Some("the mentor is the villain"), true),
|
||||
input("Dune", "https://example.com/users/1"),
|
||||
);
|
||||
assert_eq!(json["containsSpoilers"], Value::Bool(true));
|
||||
assert_eq!(json["summary"], "Contains spoilers");
|
||||
|
||||
// A flag without a comment never travels: there is nothing to veil.
|
||||
let json = render(
|
||||
&review(4, None, true),
|
||||
input("Dune", "https://example.com/users/1"),
|
||||
);
|
||||
assert!(json.get("containsSpoilers").is_none());
|
||||
assert!(json.get("summary").is_none());
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use activitypub::objects::{WatchlistApInput, watchlist_to_ap_object};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn watchlist_object_is_publicly_addressed_with_followers_in_cc() {
|
||||
let obj = watchlist_to_ap_object(WatchlistApInput {
|
||||
ap_id: "https://example.com/watchlist/1".parse().unwrap(),
|
||||
actor_url: "https://example.com/users/abc".parse().unwrap(),
|
||||
movie_title: "Alien".to_string(),
|
||||
release_year: 1979,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
added_at: chrono::Utc::now(),
|
||||
base_url: "https://example.com".to_string(),
|
||||
});
|
||||
let json = serde_json::to_value(&obj).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
json["to"],
|
||||
json!(["https://www.w3.org/ns/activitystreams#Public"])
|
||||
);
|
||||
assert_eq!(
|
||||
json["cc"],
|
||||
json!(["https://example.com/users/abc/followers"])
|
||||
);
|
||||
}
|
||||
67
crates/adapters/activitypub/tests/user_adapter.rs
Normal file
67
crates/adapters/activitypub/tests/user_adapter.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use domain::{
|
||||
ports::UserRepository,
|
||||
testing::{InMemoryStore, test_user},
|
||||
value_objects::InstanceIdentity,
|
||||
};
|
||||
use k_ap::ApUserRepository;
|
||||
|
||||
use activitypub::DomainUserRepoAdapter;
|
||||
|
||||
async fn adapter_with_deleted_alice() -> (DomainUserRepoAdapter, uuid::Uuid) {
|
||||
let store = InMemoryStore::new();
|
||||
let alice = test_user("alice");
|
||||
store.save(&alice).await.unwrap();
|
||||
store.mark_deleted(alice.id(), &[]).await.unwrap();
|
||||
let adapter = DomainUserRepoAdapter::new(store, InstanceIdentity::new("https://md.example"));
|
||||
(adapter, alice.id().value())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tombstoned_actor_can_still_sign() {
|
||||
let (adapter, id) = adapter_with_deleted_alice().await;
|
||||
|
||||
let user = adapter.find_by_id(id).await.unwrap();
|
||||
|
||||
assert_eq!(user.expect("k-ap resolves the tombstone").username, "alice");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_tombstone_is_not_found_by_username() {
|
||||
let (adapter, _) = adapter_with_deleted_alice().await;
|
||||
|
||||
assert!(adapter.find_by_username("alice").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_user_is_none() {
|
||||
let (adapter, _) = adapter_with_deleted_alice().await;
|
||||
|
||||
assert!(
|
||||
adapter
|
||||
.find_by_id(uuid::Uuid::new_v4())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_per_user_path_is_gated() {
|
||||
use activitypub::tombstone_gate::gated_user;
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let rid = uuid::Uuid::new_v4();
|
||||
for endpoint in [
|
||||
"outbox",
|
||||
"featured",
|
||||
"inbox",
|
||||
"followers",
|
||||
"following",
|
||||
&format!("reviews/{rid}"),
|
||||
] {
|
||||
assert_eq!(gated_user(&format!("/users/{id}/{endpoint}")), Some(id));
|
||||
}
|
||||
assert_eq!(gated_user(&format!("/users/{id}")), None);
|
||||
assert_eq!(gated_user(&format!("/users/{id}/")), None);
|
||||
assert_eq!(gated_user("/inbox"), None);
|
||||
assert_eq!(gated_user("/users/not-a-uuid/outbox"), None);
|
||||
}
|
||||
Reference in New Issue
Block a user