code refactor
Some checks failed
CI / Check / Test (push) Has been cancelled

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
2026-10-26 09:36:23 +00:00
parent 2ce7db12c2
commit 017b58d6fa
804 changed files with 33423 additions and 28955 deletions

View File

@@ -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"] }

View 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()))
}
}

View File

@@ -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

View File

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

View File

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

View File

@@ -306,7 +306,3 @@ pub fn goal_to_ap_object(
cc: vec![format!("{}/followers", actor_url)],
}
}
#[cfg(test)]
#[path = "tests/objects.rs"]
mod tests;

View File

@@ -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<()>;

View File

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

View File

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

View File

@@ -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"]);
}

View 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()
}

View File

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

View 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");
}

View 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;

View 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());
}

View File

@@ -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"])
);
}

View 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);
}