diff --git a/Dockerfile b/Dockerfile index a566726..bd9498f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ COPY crates/adapters/event-publisher/Cargo.toml crates/adapters/event-publishe COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml COPY crates/adapters/metadata/Cargo.toml crates/adapters/metadata/Cargo.toml COPY crates/adapters/poster-fetcher/Cargo.toml crates/adapters/poster-fetcher/Cargo.toml -COPY crates/adapters/poster-storage/Cargo.toml crates/adapters/poster-storage/Cargo.toml +COPY crates/adapters/image-storage/Cargo.toml crates/adapters/image-storage/Cargo.toml COPY crates/adapters/poster-sync/Cargo.toml crates/adapters/poster-sync/Cargo.toml COPY crates/adapters/export/Cargo.toml crates/adapters/export/Cargo.toml COPY crates/adapters/importer/Cargo.toml crates/adapters/importer/Cargo.toml diff --git a/README.md b/README.md index 4e32aab..ca36459 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ A self-hosted, server-side rendered movie logging system with a full REST API. B - Background poster fetching and storage (local filesystem or S3-compatible) - RSS/Atom feed for public subscription (global and per-user) - JWT authentication via cookie (HTML) or Bearer token (REST API) -- ActivityPub federation — follow/unfollow remote users on any compatible server, accept/reject/remove followers, pending follow request management +- ActivityPub federation — follow/unfollow remote users, accept/reject/remove followers, federated reviews broadcast as `Note` objects with `#MoviesDiary` + `#MovieTitle` hashtags, paginated outbox, boost/Announce tracking, NodeInfo discovery endpoint, shared inbox delivery, actor profile sync (bio, avatar, discoverable) +- Federation moderation — instance-level domain blocking (admin-managed), per-user actor blocking with `Block` activity, delivery filter excludes blocked actors and blocked-domain inboxes - CSV and JSON diary export - File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports - REST API v1 (`/api/v1/`) with full feature parity with the HTML interface @@ -33,7 +34,7 @@ adapters/ postgres — PostgreSQL repository + connection factory metadata — TMDB / OMDb HTTP client poster-fetcher — downloads poster images - poster-storage — stores posters on local filesystem or S3-compatible storage + image-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage poster-sync — event handler: triggers poster fetch+store on MovieDiscovered template-askama — Askama HTML rendering rss — RSS/Atom feed generation diff --git a/crates/adapters/activitypub-base/src/activities.rs b/crates/adapters/activitypub-base/src/activities.rs index a2d50cd..206b642 100644 --- a/crates/adapters/activitypub-base/src/activities.rs +++ b/crates/adapters/activitypub-base/src/activities.rs @@ -63,9 +63,22 @@ impl Activity for FollowActivity { } async fn receive(self, data: &Data) -> Result<(), Self::Error> { + let domain = self.actor().host_str().unwrap_or(""); + if data.federation_repo.is_domain_blocked(domain).await? { + tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain"); + return Ok(()); + } let _follower = self.actor.dereference(data).await?; let local_actor = self.object.dereference(data).await?; + if data.federation_repo + .is_actor_blocked(local_actor.user_id, self.actor.inner().as_str()) + .await? + { + tracing::info!(actor = %self.actor.inner(), "ignoring follow from blocked actor"); + return Ok(()); + } + data.federation_repo .add_follower( local_actor.user_id, @@ -114,6 +127,11 @@ impl Activity for AcceptActivity { } async fn receive(self, data: &Data) -> Result<(), Self::Error> { + let domain = self.actor().host_str().unwrap_or(""); + if data.federation_repo.is_domain_blocked(domain).await? { + tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain"); + return Ok(()); + } let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner()) .ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?; data.federation_repo @@ -158,6 +176,11 @@ impl Activity for RejectActivity { } async fn receive(self, data: &Data) -> Result<(), Self::Error> { + let domain = self.actor().host_str().unwrap_or(""); + if data.federation_repo.is_domain_blocked(domain).await? { + tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain"); + return Ok(()); + } if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.actor.inner()) { data.federation_repo .remove_following(user_id, self.actor.inner().as_str()) @@ -198,6 +221,11 @@ impl Activity for UndoActivity { } async fn receive(self, data: &Data) -> Result<(), Self::Error> { + let domain = self.actor().host_str().unwrap_or(""); + if data.federation_repo.is_domain_blocked(domain).await? { + tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain"); + return Ok(()); + } if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.object.inner()) { data.federation_repo .remove_follower(user_id, self.actor.inner().as_str()) @@ -242,6 +270,11 @@ impl Activity for CreateActivity { } async fn receive(self, data: &Data) -> Result<(), Self::Error> { + let domain = self.actor().host_str().unwrap_or(""); + if data.federation_repo.is_domain_blocked(domain).await? { + tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain"); + return Ok(()); + } let ap_id = self.id.clone(); let actor_url = self.actor.inner().clone(); data.object_handler @@ -283,6 +316,11 @@ impl Activity for DeleteActivity { } async fn receive(self, data: &Data) -> Result<(), Self::Error> { + let domain = self.actor().host_str().unwrap_or(""); + if data.federation_repo.is_domain_blocked(domain).await? { + tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain"); + return Ok(()); + } let actor_url = self.actor.inner().clone(); data.object_handler .on_delete(&self.object, &actor_url) @@ -323,6 +361,11 @@ impl Activity for UpdateActivity { } async fn receive(self, data: &Data) -> Result<(), Self::Error> { + let domain = self.actor().host_str().unwrap_or(""); + if data.federation_repo.is_domain_blocked(domain).await? { + tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain"); + return Ok(()); + } let ap_id = self.id.clone(); let actor_url = self.actor.inner().clone(); data.object_handler @@ -365,6 +408,11 @@ impl Activity for AnnounceActivity { } async fn receive(self, data: &Data) -> Result<(), Self::Error> { + let domain = self.actor().host_str().unwrap_or(""); + if data.federation_repo.is_domain_blocked(domain).await? { + tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain"); + return Ok(()); + } let object_domain = self.object.host_str().unwrap_or(""); if object_domain != data.domain { return Ok(()); @@ -382,6 +430,57 @@ impl Activity for AnnounceActivity { } } +// --- Block --- + +#[derive(Clone, Default, Debug, Serialize, Deserialize)] +#[serde(rename = "Block")] +pub struct BlockType; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BlockActivity { + pub(crate) id: Url, + #[serde(rename = "type", default)] + pub(crate) kind: BlockType, + pub(crate) actor: ObjectId, + pub(crate) object: Url, +} + +#[async_trait::async_trait] +impl Activity for BlockActivity { + type DataType = FederationData; + type Error = Error; + + fn id(&self) -> &Url { + &self.id + } + + fn actor(&self) -> &Url { + self.actor.inner() + } + + async fn verify(&self, _data: &Data) -> Result<(), Self::Error> { + Ok(()) + } + + async fn receive(self, data: &Data) -> Result<(), Self::Error> { + let domain = self.actor().host_str().unwrap_or(""); + if data.federation_repo.is_domain_blocked(domain).await? { + tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain"); + return Ok(()); + } + // They blocked us — remove them from our following list + if let Some(local_user_id) = crate::urls::extract_user_id_from_url(&self.object) { + let _ = data + .federation_repo + .remove_following(local_user_id, self.actor.inner().as_str()) + .await; + } + tracing::info!(actor = %self.actor.inner(), "received block"); + Ok(()) + } +} + // --- Inbox dispatch enum --- #[derive(Debug, Deserialize, Serialize)] @@ -404,4 +503,6 @@ pub enum InboxActivities { Update(UpdateActivity), #[serde(rename = "Announce")] Announce(AnnounceActivity), + #[serde(rename = "Block")] + Block(BlockActivity), } diff --git a/crates/adapters/activitypub-base/src/actors.rs b/crates/adapters/activitypub-base/src/actors.rs index 3d5e107..448251c 100644 --- a/crates/adapters/activitypub-base/src/actors.rs +++ b/crates/adapters/activitypub-base/src/actors.rs @@ -27,7 +27,8 @@ pub struct DbActor { pub ap_id: Url, pub last_refreshed_at: DateTime, pub bio: Option, - pub avatar_path: Option, + pub avatar_url: Option, + pub profile_url: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -105,7 +106,8 @@ pub async fn get_local_actor( ap_id, last_refreshed_at: Utc::now(), bio: user.bio, - avatar_path: user.avatar_path, + avatar_url: user.avatar_url, + profile_url: user.profile_url, }) } @@ -164,7 +166,8 @@ impl Object for DbActor { ap_id, last_refreshed_at: Utc::now(), bio: None, - avatar_path: None, + avatar_url: None, + profile_url: None, })) } @@ -175,14 +178,11 @@ impl Object for DbActor { public_key_pem: self.public_key_pem.clone(), }; - let icon = self.avatar_path.as_ref().map(|p| ApImageObject { + let icon = self.avatar_url.map(|url| ApImageObject { kind: "Image".to_string(), - url: Url::parse(&format!("{}/images/{}", data.base_url, p)) - .expect("valid avatar url"), + url, }); - let profile_url = - Url::parse(&format!("{}/u/{}", data.base_url, self.username)) - .expect("valid profile url"); + let profile_url = self.profile_url; Ok(Person { kind: Default::default(), @@ -196,7 +196,7 @@ impl Object for DbActor { name: Some(self.username.clone()), summary: self.bio.clone(), icon, - url: Some(profile_url), + url: profile_url, discoverable: Some(true), manually_approves_followers: false, }) @@ -242,7 +242,8 @@ impl Object for DbActor { ap_id, last_refreshed_at: Utc::now(), bio: None, - avatar_path: None, + avatar_url: None, + profile_url: None, }) } } diff --git a/crates/adapters/activitypub-base/src/content.rs b/crates/adapters/activitypub-base/src/content.rs index 4aa93dc..83aad72 100644 --- a/crates/adapters/activitypub-base/src/content.rs +++ b/crates/adapters/activitypub-base/src/content.rs @@ -41,4 +41,7 @@ pub trait ApObjectHandler: Send + Sync { /// Actor unfollowed/was removed — clean up all their remote content. async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()>; + + /// Total number of locally-authored posts across all users. + async fn count_local_posts(&self) -> anyhow::Result; } diff --git a/crates/adapters/activitypub-base/src/data.rs b/crates/adapters/activitypub-base/src/data.rs index cf2793a..885d08e 100644 --- a/crates/adapters/activitypub-base/src/data.rs +++ b/crates/adapters/activitypub-base/src/data.rs @@ -11,6 +11,8 @@ pub struct FederationData { pub(crate) object_handler: Arc, pub(crate) base_url: String, pub(crate) domain: String, + pub(crate) allow_registration: bool, + pub(crate) software_name: String, } impl FederationData { @@ -19,6 +21,8 @@ impl FederationData { user_repo: Arc, object_handler: Arc, base_url: String, + allow_registration: bool, + software_name: String, ) -> Self { let domain = base_url .trim_start_matches("https://") @@ -33,6 +37,8 @@ impl FederationData { object_handler, base_url, domain, + allow_registration, + software_name, } } } diff --git a/crates/adapters/activitypub-base/src/lib.rs b/crates/adapters/activitypub-base/src/lib.rs index 3c741e3..0d9f986 100644 --- a/crates/adapters/activitypub-base/src/lib.rs +++ b/crates/adapters/activitypub-base/src/lib.rs @@ -7,6 +7,7 @@ pub mod error; pub mod federation; pub mod followers_handler; pub mod inbox; +pub mod nodeinfo; pub mod outbox; pub mod repository; pub mod service; @@ -19,7 +20,7 @@ pub use data::FederationData; pub use error::Error; pub use federation::ApFederationConfig; pub use repository::{ - FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, + BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, }; pub use service::ActivityPubService; pub use user::{ApUser, ApUserRepository}; diff --git a/crates/adapters/activitypub-base/src/nodeinfo.rs b/crates/adapters/activitypub-base/src/nodeinfo.rs new file mode 100644 index 0000000..1154237 --- /dev/null +++ b/crates/adapters/activitypub-base/src/nodeinfo.rs @@ -0,0 +1,119 @@ +use activitypub_federation::config::Data; +use axum::Json; +use serde::Serialize; + +use crate::data::FederationData; +use crate::error::Error; + +#[derive(Serialize)] +pub struct NodeInfoWellKnown { + pub links: Vec, +} + +#[derive(Serialize)] +pub struct NodeInfoLink { + pub rel: String, + pub href: String, +} + +#[derive(Serialize)] +pub struct NodeInfoSoftware { + pub name: String, + pub version: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NodeInfoUsage { + pub users: NodeInfoUsers, + pub local_posts: u64, +} + +#[derive(Serialize)] +pub struct NodeInfoUsers { + pub total: usize, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NodeInfo { + pub version: String, + pub software: NodeInfoSoftware, + pub protocols: Vec, + pub usage: NodeInfoUsage, + pub open_registrations: bool, +} + +pub async fn nodeinfo_well_known_handler( + data: Data, +) -> Result, Error> { + let href = format!("{}/nodeinfo/2.0", data.base_url); + Ok(Json(NodeInfoWellKnown { + links: vec![NodeInfoLink { + rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(), + href, + }], + })) +} + +pub async fn nodeinfo_handler( + data: Data, +) -> Result, Error> { + let user_count = data.user_repo.count_users().await.unwrap_or(0); + let local_posts = data.object_handler.count_local_posts().await.unwrap_or(0); + + Ok(Json(NodeInfo { + version: "2.0".to_string(), + software: NodeInfoSoftware { + name: data.software_name.clone(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + protocols: vec!["activitypub".to_string()], + usage: NodeInfoUsage { + users: NodeInfoUsers { total: user_count }, + local_posts, + }, + open_registrations: data.allow_registration, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nodeinfo_well_known_serializes_correctly() { + let doc = NodeInfoWellKnown { + links: vec![NodeInfoLink { + rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(), + href: "https://example.com/nodeinfo/2.0".to_string(), + }], + }; + let json = serde_json::to_value(&doc).unwrap(); + assert_eq!(json["links"][0]["rel"], "http://nodeinfo.diaspora.software/ns/schema/2.0"); + assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0"); + } + + #[test] + fn nodeinfo_serializes_camel_case() { + let doc = NodeInfo { + version: "2.0".to_string(), + software: NodeInfoSoftware { + name: "my-app".to_string(), + version: "0.1.0".to_string(), + }, + protocols: vec!["activitypub".to_string()], + usage: NodeInfoUsage { + users: NodeInfoUsers { total: 3 }, + local_posts: 42, + }, + open_registrations: false, + }; + let json = serde_json::to_value(&doc).unwrap(); + assert_eq!(json["version"], "2.0"); + assert_eq!(json["software"]["name"], "my-app"); + assert_eq!(json["usage"]["users"]["total"], 3); + assert_eq!(json["usage"]["localPosts"], 42); + assert_eq!(json["openRegistrations"], false); + } +} diff --git a/crates/adapters/activitypub-base/src/repository.rs b/crates/adapters/activitypub-base/src/repository.rs index 90c9f74..12015a7 100644 --- a/crates/adapters/activitypub-base/src/repository.rs +++ b/crates/adapters/activitypub-base/src/repository.rs @@ -30,6 +30,13 @@ pub struct Follower { pub status: FollowerStatus, } +#[derive(Debug, Clone)] +pub struct BlockedDomain { + pub domain: String, + pub reason: Option, + pub blocked_at: String, +} + #[async_trait] pub trait FederationRepository: Send + Sync { async fn add_follower( @@ -97,4 +104,12 @@ pub trait FederationRepository: Send + Sync { announced_at: chrono::DateTime, ) -> Result<()>; async fn count_announces(&self, object_url: &str) -> Result; + async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()>; + async fn remove_blocked_domain(&self, domain: &str) -> Result<()>; + async fn get_blocked_domains(&self) -> Result>; + async fn is_domain_blocked(&self, domain: &str) -> Result; + async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>; + async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>; + async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result>; + async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result; } diff --git a/crates/adapters/activitypub-base/src/service.rs b/crates/adapters/activitypub-base/src/service.rs index fb1a919..d227d8d 100644 --- a/crates/adapters/activitypub-base/src/service.rs +++ b/crates/adapters/activitypub-base/src/service.rs @@ -18,9 +18,10 @@ use crate::{ followers_handler::{followers_handler, following_handler}, inbox::inbox_handler, outbox::outbox_handler, - repository::{FederationRepository, FollowerStatus, FollowingStatus, RemoteActor}, + repository::{BlockedDomain, FederationRepository, FollowerStatus, FollowingStatus, RemoteActor}, urls::activity_url, user::ApUserRepository, + nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler}, webfinger::webfinger_handler, }; @@ -78,9 +79,11 @@ impl ActivityPubService { user_repo: Arc, object_handler: Arc, base_url: String, + allow_registration: bool, + software_name: String, debug: bool, ) -> anyhow::Result { - let data = FederationData::new(repo, user_repo, object_handler, base_url.clone()); + let data = FederationData::new(repo, user_repo, object_handler, base_url.clone(), allow_registration, software_name); let federation_config = ApFederationConfig::new(data, debug).await?; Ok(Self { federation_config, @@ -112,6 +115,8 @@ impl ActivityPubService { pub fn router(&self) -> Router { Router::new() + .route("/.well-known/nodeinfo", get(nodeinfo_well_known_handler)) + .route("/nodeinfo/2.0", get(nodeinfo_handler)) .route("/.well-known/webfinger", get(webfinger_handler)) .route("/users/{id}/inbox", post(inbox_handler)) .route("/users/{id}/outbox", get(outbox_handler)) @@ -443,9 +448,30 @@ impl ActivityPubService { .map_err(|e| anyhow::anyhow!("{e}"))?; let followers = data.federation_repo.get_followers(local_user_id).await?; + let blocked = data + .federation_repo + .get_blocked_actors(local_user_id) + .await + .unwrap_or_default(); + let blocked_set: std::collections::HashSet = blocked.into_iter().collect(); + let blocked_domains = data + .federation_repo + .get_blocked_domains() + .await + .unwrap_or_default(); + let blocked_domain_set: std::collections::HashSet = + blocked_domains.into_iter().map(|d| d.domain).collect(); let accepted: Vec<_> = followers .into_iter() .filter(|f| f.status == FollowerStatus::Accepted) + .filter(|f| !blocked_set.contains(&f.actor.url)) + .filter(|f| { + let domain = url::Url::parse(&f.actor.inbox_url) + .ok() + .and_then(|u| u.host_str().map(|s| s.to_string())) + .unwrap_or_default(); + !blocked_domain_set.contains(&domain) + }) .collect(); if accepted.is_empty() { @@ -526,6 +552,88 @@ impl ActivityPubService { Ok(()) } + pub async fn block_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + + data.federation_repo + .add_blocked_actor(local_user_id, actor_url) + .await?; + let _ = data.federation_repo.remove_follower(local_user_id, actor_url).await; + let _ = data.federation_repo.remove_following(local_user_id, actor_url).await; + + let local_actor = get_local_actor(local_user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + if let Ok(Some(remote_actor)) = data.federation_repo.get_remote_actor(actor_url).await { + let block_id = crate::urls::activity_url(&self.base_url) + .map_err(|e| anyhow::anyhow!("{e}"))?; + let block = crate::activities::BlockActivity { + id: block_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: Url::parse(actor_url)?, + }; + let inbox = Url::parse(&remote_actor.inbox_url)?; + let sends = SendActivityTask::prepare( + &WithContext::new_default(block), + &local_actor, + vec![inbox], + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!(actor = %actor_url, "failed to deliver Block activity"); + } + } + + Ok(()) + } + + pub async fn unblock_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + data.federation_repo + .remove_blocked_actor(local_user_id, actor_url) + .await + } + + pub async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> anyhow::Result> { + let data = self.federation_config.to_request_data(); + let actor_urls = data.federation_repo.get_blocked_actors(local_user_id).await?; + let mut actors = Vec::new(); + for url in actor_urls { + let actor = match data.federation_repo.get_remote_actor(&url).await { + Ok(Some(a)) => a, + _ => RemoteActor { + url: url.clone(), + handle: url.clone(), + inbox_url: url.clone(), + shared_inbox_url: None, + display_name: None, + avatar_url: None, + }, + }; + actors.push(actor); + } + Ok(actors) + } + + pub async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + data.federation_repo.add_blocked_domain(domain, reason).await + } + + pub async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + data.federation_repo.remove_blocked_domain(domain).await + } + + pub async fn get_blocked_domains(&self) -> anyhow::Result> { + let data = self.federation_config.to_request_data(); + data.federation_repo.get_blocked_domains().await + } + async fn follow_local( &self, local_user_id: uuid::Uuid, diff --git a/crates/adapters/activitypub-base/src/user.rs b/crates/adapters/activitypub-base/src/user.rs index 2a72147..1c00361 100644 --- a/crates/adapters/activitypub-base/src/user.rs +++ b/crates/adapters/activitypub-base/src/user.rs @@ -1,15 +1,18 @@ use async_trait::async_trait; +use url::Url; #[derive(Debug, Clone)] pub struct ApUser { pub id: uuid::Uuid, pub username: String, pub bio: Option, - pub avatar_path: Option, + pub avatar_url: Option, + pub profile_url: Option, } #[async_trait] pub trait ApUserRepository: Send + Sync { async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result>; async fn find_by_username(&self, username: &str) -> anyhow::Result>; + async fn count_users(&self) -> anyhow::Result; } diff --git a/crates/adapters/activitypub/src/event_handler.rs b/crates/adapters/activitypub/src/event_handler.rs index 5e8a8ae..c8dcca5 100644 --- a/crates/adapters/activitypub/src/event_handler.rs +++ b/crates/adapters/activitypub/src/event_handler.rs @@ -92,6 +92,7 @@ impl ActivityPubEventHandler { movie_title, release_year, poster_url, + &self.base_url, ); let json = serde_json::to_value(obj)?; diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index fcc2fae..c6484c6 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -25,18 +25,19 @@ pub struct ActivityPubWire { } pub async fn wire( - federation_repo: std::sync::Arc, - review_store: std::sync::Arc, - user_repo: std::sync::Arc, - movie_repo: std::sync::Arc, - review_repo: std::sync::Arc, - diary_repo: std::sync::Arc, - base_url: String, + federation_repo: std::sync::Arc, + review_store: std::sync::Arc, + user_repo: std::sync::Arc, + movie_repo: std::sync::Arc, + review_repo: std::sync::Arc, + diary_repo: std::sync::Arc, + base_url: String, + allow_registration: bool, ) -> anyhow::Result { let concrete = std::sync::Arc::new( ActivityPubService::new( federation_repo, - std::sync::Arc::new(DomainUserRepoAdapter(user_repo)), + std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, base_url.clone())), std::sync::Arc::new(ReviewObjectHandler { movie_repository: std::sync::Arc::clone(&movie_repo), diary_repository: diary_repo, @@ -44,6 +45,8 @@ pub async fn wire( base_url: base_url.clone(), }), base_url.clone(), + allow_registration, + "movies-diary".to_string(), cfg!(debug_assertions), ) .await?, diff --git a/crates/adapters/activitypub/src/objects.rs b/crates/adapters/activitypub/src/objects.rs index 4ba6bb5..3f04a53 100644 --- a/crates/adapters/activitypub/src/objects.rs +++ b/crates/adapters/activitypub/src/objects.rs @@ -5,6 +5,18 @@ use url::Url; use domain::models::Review; +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ApHashtag { + #[serde(rename = "type")] + pub(crate) kind: String, + pub(crate) href: Url, + pub(crate) name: String, +} + +pub(crate) fn normalize_hashtag(title: &str) -> String { + title.chars().filter(|c| c.is_alphanumeric()).collect() +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ReviewObject { @@ -22,6 +34,8 @@ pub struct ReviewObject { pub(crate) rating: u8, pub(crate) comment: Option, pub(crate) watched_at: DateTime, + #[serde(default)] + pub(crate) tag: Vec, } /// Serialize a local Review into a ReviewObject for AP delivery. @@ -33,6 +47,7 @@ pub fn review_to_ap_object( movie_title: String, release_year: u16, poster_url: Option, + base_url: &str, ) -> ReviewObject { let stars: String = "\u{2B50}".repeat(review.rating().value() as usize); let comment_text = review.comment().map(|c| c.value().to_string()); @@ -50,6 +65,22 @@ pub fn review_to_ap_object( None => format!("{} {}{}\n{}", stars, movie_title, year_str, watched_str), }; + let normalized = normalize_hashtag(&movie_title); + let tag = vec![ + ApHashtag { + kind: "Hashtag".to_string(), + href: Url::parse(&format!("{}/tags/moviesdiary", base_url)) + .expect("valid base_url"), + name: "#MoviesDiary".to_string(), + }, + ApHashtag { + kind: "Hashtag".to_string(), + href: Url::parse(&format!("{}/tags/{}", base_url, normalized.to_lowercase())) + .expect("valid base_url"), + name: format!("#{}", normalized), + }, + ]; + ReviewObject { kind: NoteType::default(), id: ap_id, @@ -62,5 +93,51 @@ pub fn review_to_ap_object( rating: review.rating().value(), comment: comment_text, watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc), + tag, + } +} + +#[cfg(test)] +mod tests { + 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::{Review, ReviewSource}, + value_objects::{MovieId, Rating, ReviewId, UserId}, + }; + + let review = Review::from_persistence( + ReviewId::generate(), + MovieId::from_uuid(uuid::Uuid::new_v4()), + UserId::from_uuid(uuid::Uuid::new_v4()), + Rating::new(4).unwrap(), + None, + NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(), + NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(), + ReviewSource::Local, + ); + let obj = review_to_ap_object( + &review, + "https://example.com/reviews/1".parse().unwrap(), + "https://example.com/users/1".parse().unwrap(), + "Dune".to_string(), + 2021, + None, + "https://example.com", + ); + 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")); } } diff --git a/crates/adapters/activitypub/src/port.rs b/crates/adapters/activitypub/src/port.rs index 65c1521..dea8b31 100644 --- a/crates/adapters/activitypub/src/port.rs +++ b/crates/adapters/activitypub/src/port.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use uuid::Uuid; -use activitypub_base::{ActivityPubService, RemoteActor}; +use activitypub_base::{ActivityPubService, BlockedDomain, RemoteActor}; #[async_trait] pub trait ActivityPubPort: Send + Sync { @@ -25,6 +25,12 @@ pub trait ActivityPubPort: Send + Sync { async fn get_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result>; 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>; + 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>; } #[async_trait] @@ -73,6 +79,24 @@ impl ActivityPubPort for ActivityPubService { 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> { + 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> { + self.get_blocked_domains().await + } } pub struct NoopActivityPubService; @@ -112,4 +136,22 @@ impl ActivityPubPort for NoopActivityPubService { 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> { + 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> { + Ok(vec![]) + } } diff --git a/crates/adapters/activitypub/src/review_handler.rs b/crates/adapters/activitypub/src/review_handler.rs index ac81691..b5d20a3 100644 --- a/crates/adapters/activitypub/src/review_handler.rs +++ b/crates/adapters/activitypub/src/review_handler.rs @@ -59,7 +59,7 @@ impl ApObjectHandler for ReviewObjectHandler { let poster_url = movie .as_ref() .and_then(|m| m.poster_path()) - .map(|p| format!("{}/posters/{}", self.base_url, p.value())); + .map(|p| format!("{}/images/{}", self.base_url, p.value())); let obj = review_to_ap_object( review, @@ -68,6 +68,7 @@ impl ApObjectHandler for ReviewObjectHandler { movie_title, release_year, poster_url, + &self.base_url, ); let json = serde_json::to_value(obj)?; results.push((ap_id, json)); @@ -122,7 +123,7 @@ impl ApObjectHandler for ReviewObjectHandler { let poster_url = movie .as_ref() .and_then(|m| m.poster_path()) - .map(|p| format!("{}/posters/{}", self.base_url, p.value())); + .map(|p| format!("{}/images/{}", self.base_url, p.value())); let obj = review_to_ap_object( review, @@ -131,6 +132,7 @@ impl ApObjectHandler for ReviewObjectHandler { movie_title, release_year, poster_url, + &self.base_url, ); let json = serde_json::to_value(obj)?; results.push((ap_id, json, published)); @@ -235,4 +237,11 @@ impl ApObjectHandler for ReviewObjectHandler { async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> { self.review_store.delete_by_actor(actor_url.as_str()).await } + + async fn count_local_posts(&self) -> anyhow::Result { + self.diary_repository + .count_local_posts() + .await + .map_err(|e| anyhow::anyhow!(e.to_string())) + } } diff --git a/crates/adapters/activitypub/src/user_adapter.rs b/crates/adapters/activitypub/src/user_adapter.rs index 1dd72e4..747a5e4 100644 --- a/crates/adapters/activitypub/src/user_adapter.rs +++ b/crates/adapters/activitypub/src/user_adapter.rs @@ -3,30 +3,49 @@ use std::sync::Arc; use activitypub_base::{ApUser, ApUserRepository}; use async_trait::async_trait; use domain::{ports::UserRepository, value_objects::UserId}; +use url::Url; -pub struct DomainUserRepoAdapter(pub Arc); +pub struct DomainUserRepoAdapter { + pub repo: Arc, + pub base_url: String, +} + +impl DomainUserRepoAdapter { + pub fn new(repo: Arc, base_url: String) -> Self { + Self { repo, base_url } + } + + 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() + }); + let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok(); + ApUser { + id: u.id().value(), + username: u.username().value().to_string(), + bio: u.bio().map(|s| s.to_string()), + avatar_url, + profile_url, + } + } +} #[async_trait] impl ApUserRepository for DomainUserRepoAdapter { async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result> { let user_id = UserId::from_uuid(id); - Ok(self.0.find_by_id(&user_id).await?.map(|u| ApUser { - id: u.id().value(), - username: u.username().value().to_string(), - bio: u.bio().map(|s| s.to_string()), - avatar_path: u.avatar_path().map(|s| s.to_string()), - })) + Ok(self.repo.find_by_id(&user_id).await?.as_ref().map(|u| self.build_user(u))) } async fn find_by_username(&self, username: &str) -> anyhow::Result> { use domain::value_objects::Username; - let uname = - Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?; - Ok(self.0.find_by_username(&uname).await?.map(|u| ApUser { - id: u.id().value(), - username: u.username().value().to_string(), - bio: u.bio().map(|s| s.to_string()), - avatar_path: u.avatar_path().map(|s| s.to_string()), - })) + let uname = Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?; + Ok(self.repo.find_by_username(&uname).await?.as_ref().map(|u| self.build_user(u))) + } + + async fn count_users(&self) -> anyhow::Result { + Ok(self.repo.list_with_stats().await + .map_err(|e| anyhow::anyhow!(e.to_string()))? + .len()) } } diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs index 8b8eef6..33683bf 100644 --- a/crates/adapters/postgres-federation/src/lib.rs +++ b/crates/adapters/postgres-federation/src/lib.rs @@ -5,7 +5,7 @@ use sqlx::{PgPool, Row}; use activitypub::RemoteReviewRepository; use activitypub_base::{ - FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, + BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, }; use domain::models::{Review, ReviewSource}; @@ -381,6 +381,106 @@ impl FederationRepository for PostgresFederationRepository { .await?; Ok(row.get::("cnt") as usize) } + + async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> { + let now = Utc::now().naive_utc(); + let ts = datetime_to_str(&now); + sqlx::query( + "INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES ($1, $2, $3) + ON CONFLICT(domain) DO UPDATE SET reason = EXCLUDED.reason", + ) + .bind(domain) + .bind(reason) + .bind(&ts) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn remove_blocked_domain(&self, domain: &str) -> Result<()> { + sqlx::query("DELETE FROM blocked_domains WHERE domain = $1") + .bind(domain) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_blocked_domains(&self) -> Result> { + let rows = sqlx::query( + "SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC", + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .iter() + .map(|r| BlockedDomain { + domain: r.get("domain"), + reason: r.get("reason"), + blocked_at: r.get("blocked_at"), + }) + .collect()) + } + + async fn is_domain_blocked(&self, domain: &str) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM blocked_domains WHERE domain = $1", + ) + .bind(domain) + .fetch_one(&self.pool) + .await?; + Ok(count > 0) + } + + async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { + let uid = local_user_id.to_string(); + let ts = datetime_to_str(&Utc::now().naive_utc()); + sqlx::query( + "INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING", + ) + .bind(&uid) + .bind(actor_url) + .bind(&ts) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { + let uid = local_user_id.to_string(); + sqlx::query( + "DELETE FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2", + ) + .bind(&uid) + .bind(actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result> { + let uid = local_user_id.to_string(); + let rows = sqlx::query( + "SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1 ORDER BY blocked_at DESC", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await?; + Ok(rows.iter().map(|r| r.get::("remote_actor_url")).collect()) + } + + async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result { + let uid = local_user_id.to_string(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2", + ) + .bind(&uid) + .bind(actor_url) + .fetch_one(&self.pool) + .await?; + Ok(count > 0) + } } #[async_trait] diff --git a/crates/adapters/postgres/migrations/0012_blocked_domains.sql b/crates/adapters/postgres/migrations/0012_blocked_domains.sql new file mode 100644 index 0000000..4680c89 --- /dev/null +++ b/crates/adapters/postgres/migrations/0012_blocked_domains.sql @@ -0,0 +1,5 @@ +CREATE TABLE blocked_domains ( + domain TEXT PRIMARY KEY, + reason TEXT, + blocked_at TEXT NOT NULL +); diff --git a/crates/adapters/postgres/migrations/0013_blocked_actors.sql b/crates/adapters/postgres/migrations/0013_blocked_actors.sql new file mode 100644 index 0000000..0b1245e --- /dev/null +++ b/crates/adapters/postgres/migrations/0013_blocked_actors.sql @@ -0,0 +1,6 @@ +CREATE TABLE blocked_actors ( + local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + remote_actor_url TEXT NOT NULL, + blocked_at TEXT NOT NULL, + PRIMARY KEY (local_user_id, remote_actor_url) +); diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs index e458141..c92355f 100644 --- a/crates/adapters/postgres/src/lib.rs +++ b/crates/adapters/postgres/src/lib.rs @@ -766,6 +766,16 @@ impl DiaryRepository for PostgresRepository { offset: page.offset, }) } + + async fn count_local_posts(&self) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL" + ) + .fetch_one(&self.pool) + .await + .map_err(Self::map_err)?; + Ok(count as u64) + } } #[async_trait] diff --git a/crates/adapters/sqlite-federation/src/lib.rs b/crates/adapters/sqlite-federation/src/lib.rs index 3d77cad..88e10bc 100644 --- a/crates/adapters/sqlite-federation/src/lib.rs +++ b/crates/adapters/sqlite-federation/src/lib.rs @@ -5,7 +5,7 @@ use sqlx::{Row, SqlitePool}; use activitypub::RemoteReviewRepository; use activitypub_base::{ - FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, + BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, }; use domain::models::{Review, ReviewSource}; @@ -428,6 +428,105 @@ impl FederationRepository for SqliteFederationRepository { .await?; Ok(row.get::("cnt") as usize) } + + async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> { + let now = Utc::now().naive_utc(); + let ts = datetime_to_str(&now); + sqlx::query( + "INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES (?1, ?2, ?3) + ON CONFLICT(domain) DO UPDATE SET reason = excluded.reason", + ) + .bind(domain) + .bind(reason) + .bind(&ts) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn remove_blocked_domain(&self, domain: &str) -> Result<()> { + sqlx::query("DELETE FROM blocked_domains WHERE domain = ?1") + .bind(domain) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_blocked_domains(&self) -> Result> { + let rows = sqlx::query( + "SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC", + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .iter() + .map(|r| BlockedDomain { + domain: r.get("domain"), + reason: r.get("reason"), + blocked_at: r.get("blocked_at"), + }) + .collect()) + } + + async fn is_domain_blocked(&self, domain: &str) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM blocked_domains WHERE domain = ?1", + ) + .bind(domain) + .fetch_one(&self.pool) + .await?; + Ok(count > 0) + } + + async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { + let uid = local_user_id.to_string(); + let ts = datetime_to_str(&Utc::now().naive_utc()); + sqlx::query( + "INSERT OR IGNORE INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) + VALUES (?1, ?2, ?3)", + ) + .bind(&uid) + .bind(actor_url) + .bind(&ts) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { + let uid = local_user_id.to_string(); + sqlx::query( + "DELETE FROM blocked_actors WHERE local_user_id = ?1 AND remote_actor_url = ?2", + ) + .bind(&uid) + .bind(actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result> { + let uid = local_user_id.to_string(); + let rows = sqlx::query( + "SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = ?1 ORDER BY blocked_at DESC", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await?; + Ok(rows.iter().map(|r| r.get::("remote_actor_url")).collect()) + } + + async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result { + let uid = local_user_id.to_string(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = ?1 AND remote_actor_url = ?2", + ) + .bind(&uid) + .bind(actor_url) + .fetch_one(&self.pool) + .await?; + Ok(count > 0) + } } // --- Content-specific repository (movies-diary) --- @@ -586,6 +685,84 @@ pub fn wire(pool: sqlx::SqlitePool) -> ( ) } +#[cfg(test)] +mod actor_block_tests { + use super::*; + use sqlx::SqlitePool; + + async fn test_pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::query("CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, password_hash TEXT, created_at TEXT)") + .execute(&pool).await.unwrap(); + sqlx::query("CREATE TABLE blocked_actors (local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, remote_actor_url TEXT NOT NULL, blocked_at TEXT NOT NULL, PRIMARY KEY (local_user_id, remote_actor_url))") + .execute(&pool).await.unwrap(); + let uid = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)") + .bind(&uid).bind("a@b.com").bind("hash").bind("2024-01-01") + .execute(&pool).await.unwrap(); + pool + } + + #[tokio::test] + async fn block_and_check_actor() { + let pool = test_pool().await; + let user_id = uuid::Uuid::parse_str( + &sqlx::query_scalar::<_, String>("SELECT id FROM users LIMIT 1") + .fetch_one(&pool).await.unwrap() + ).unwrap(); + let repo = SqliteFederationRepository::new(pool); + let actor_url = "https://mastodon.social/users/alice"; + assert!(!repo.is_actor_blocked(user_id, actor_url).await.unwrap()); + repo.add_blocked_actor(user_id, actor_url).await.unwrap(); + assert!(repo.is_actor_blocked(user_id, actor_url).await.unwrap()); + let list = repo.get_blocked_actors(user_id).await.unwrap(); + assert_eq!(list, vec![actor_url.to_string()]); + repo.remove_blocked_actor(user_id, actor_url).await.unwrap(); + assert!(!repo.is_actor_blocked(user_id, actor_url).await.unwrap()); + } +} + +#[cfg(test)] +mod domain_block_tests { + use super::*; + use sqlx::SqlitePool; + + async fn test_pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::query("CREATE TABLE blocked_domains (domain TEXT PRIMARY KEY, reason TEXT, blocked_at TEXT NOT NULL)") + .execute(&pool).await.unwrap(); + pool + } + + #[tokio::test] + async fn blocked_domain_is_detected() { + let pool = test_pool().await; + let repo = SqliteFederationRepository::new(pool); + assert!(!repo.is_domain_blocked("mastodon.social").await.unwrap()); + repo.add_blocked_domain("mastodon.social", Some("spam")).await.unwrap(); + assert!(repo.is_domain_blocked("mastodon.social").await.unwrap()); + } + + #[tokio::test] + async fn remove_unblocks_domain() { + let pool = test_pool().await; + let repo = SqliteFederationRepository::new(pool); + repo.add_blocked_domain("spam.xyz", None).await.unwrap(); + repo.remove_blocked_domain("spam.xyz").await.unwrap(); + assert!(!repo.is_domain_blocked("spam.xyz").await.unwrap()); + } + + #[tokio::test] + async fn get_blocked_domains_returns_all() { + let pool = test_pool().await; + let repo = SqliteFederationRepository::new(pool); + repo.add_blocked_domain("a.com", Some("reason a")).await.unwrap(); + repo.add_blocked_domain("b.com", None).await.unwrap(); + let domains = repo.get_blocked_domains().await.unwrap(); + assert_eq!(domains.len(), 2); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/adapters/sqlite/migrations/0012_blocked_domains.sql b/crates/adapters/sqlite/migrations/0012_blocked_domains.sql new file mode 100644 index 0000000..4680c89 --- /dev/null +++ b/crates/adapters/sqlite/migrations/0012_blocked_domains.sql @@ -0,0 +1,5 @@ +CREATE TABLE blocked_domains ( + domain TEXT PRIMARY KEY, + reason TEXT, + blocked_at TEXT NOT NULL +); diff --git a/crates/adapters/sqlite/migrations/0013_blocked_actors.sql b/crates/adapters/sqlite/migrations/0013_blocked_actors.sql new file mode 100644 index 0000000..0b1245e --- /dev/null +++ b/crates/adapters/sqlite/migrations/0013_blocked_actors.sql @@ -0,0 +1,6 @@ +CREATE TABLE blocked_actors ( + local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + remote_actor_url TEXT NOT NULL, + blocked_at TEXT NOT NULL, + PRIMARY KEY (local_user_id, remote_actor_url) +); diff --git a/crates/adapters/sqlite/src/federation.rs b/crates/adapters/sqlite/src/federation.rs deleted file mode 100644 index dc418bc..0000000 --- a/crates/adapters/sqlite/src/federation.rs +++ /dev/null @@ -1,492 +0,0 @@ -use anyhow::{anyhow, Result}; -use async_trait::async_trait; -use chrono::Utc; -use sqlx::{Row, SqlitePool}; - -use activitypub_base::{FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor}; -use activitypub::RemoteReviewRepository; -use domain::models::{Review, ReviewSource}; - -use crate::models::datetime_to_str; - -pub struct SqliteFederationRepository { - pool: SqlitePool, -} - -impl SqliteFederationRepository { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } -} - -fn status_to_str(status: &FollowerStatus) -> &'static str { - match status { - FollowerStatus::Pending => "pending", - FollowerStatus::Accepted => "accepted", - FollowerStatus::Rejected => "rejected", - } -} - -fn str_to_status(s: &str) -> FollowerStatus { - match s { - "accepted" => FollowerStatus::Accepted, - "rejected" => FollowerStatus::Rejected, - _ => FollowerStatus::Pending, - } -} - -#[async_trait] -impl FederationRepository for SqliteFederationRepository { - async fn add_follower( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowerStatus, - follow_activity_id: &str, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let status_str = status_to_str(&status); - let now = Utc::now().naive_utc(); - let created_at = datetime_to_str(&now); - - sqlx::query( - "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id) - VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET - status = excluded.status, - follow_activity_id = excluded.follow_activity_id", - ) - .bind(&uid) - .bind(remote_actor_url) - .bind(status_str) - .bind(&created_at) - .bind(follow_activity_id) - .execute(&self.pool) - .await?; - - Ok(()) - } - - async fn get_follower_follow_activity_id( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - ) -> Result> { - let uid = local_user_id.to_string(); - let row: Option> = sqlx::query_scalar( - "SELECT follow_activity_id FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?", - ) - .bind(&uid) - .bind(remote_actor_url) - .fetch_optional(&self.pool) - .await?; - Ok(row.flatten()) - } - - async fn remove_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<()> { - let uid = local_user_id.to_string(); - sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?") - .bind(&uid) - .bind(remote_actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - - let rows = sqlx::query( - "SELECT f.remote_actor_url, f.status, - a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url - FROM ap_followers f - LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ?", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await?; - - let followers = rows - .into_iter() - .map(|row| { - let url: String = row.get("remote_actor_url"); - let status_str: String = row.get("status"); - let handle: String = row.try_get("handle").unwrap_or_default(); - let inbox_url: String = row.try_get("inbox_url").unwrap_or_default(); - let shared_inbox_url: Option = row.try_get("shared_inbox_url").ok().flatten(); - let display_name: Option = row.try_get("display_name").ok().flatten(); - let avatar_url: Option = row.try_get("avatar_url").ok().flatten(); - - Follower { - actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url }, - status: str_to_status(&status_str), - } - }) - .collect(); - - Ok(followers) - } - - async fn update_follower_status( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowerStatus, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let status_str = status_to_str(&status); - - let result = sqlx::query( - "UPDATE ap_followers SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?", - ) - .bind(status_str) - .bind(&uid) - .bind(remote_actor_url) - .execute(&self.pool) - .await?; - - if result.rows_affected() == 0 { - tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found"); - } - - Ok(()) - } - - async fn add_following(&self, local_user_id: uuid::Uuid, actor: RemoteActor, follow_activity_id: &str) -> Result<()> { - let uid = local_user_id.to_string(); - let now = Utc::now().naive_utc(); - let created_at = datetime_to_str(&now); - - self.upsert_remote_actor(actor.clone()).await?; - - sqlx::query( - "INSERT OR IGNORE INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at) - VALUES (?, ?, ?, ?)", - ) - .bind(&uid) - .bind(&actor.url) - .bind(follow_activity_id) - .bind(&created_at) - .execute(&self.pool) - .await?; - - Ok(()) - } - - async fn get_follow_activity_id(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result> { - let uid = local_user_id.to_string(); - let row: Option> = sqlx::query_scalar( - "SELECT follow_activity_id FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?", - ) - .bind(&uid) - .bind(remote_actor_url) - .fetch_optional(&self.pool) - .await?; - Ok(row.flatten()) - } - - async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { - let uid = local_user_id.to_string(); - sqlx::query("DELETE FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?") - .bind(&uid) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn get_following(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - - let rows = sqlx::query( - "SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url - FROM ap_following f - INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ? AND f.status = 'accepted'", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await?; - - Ok(rows.into_iter().map(|row| RemoteActor { - url: row.get("url"), - handle: row.get("handle"), - inbox_url: row.get("inbox_url"), - shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), - display_name: row.try_get("display_name").ok().flatten(), - avatar_url: row.try_get("avatar_url").ok().flatten(), - }).collect()) - } - - async fn count_following(&self, local_user_id: uuid::Uuid) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await?; - Ok(count as usize) - } - - async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> { - let now = Utc::now().naive_utc(); - let fetched_at = datetime_to_str(&now); - - sqlx::query( - "INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, fetched_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(url) DO UPDATE SET - handle = excluded.handle, - inbox_url = excluded.inbox_url, - shared_inbox_url = excluded.shared_inbox_url, - display_name = excluded.display_name, - avatar_url = excluded.avatar_url, - fetched_at = excluded.fetched_at", - ) - .bind(&actor.url) - .bind(&actor.handle) - .bind(&actor.inbox_url) - .bind(&actor.shared_inbox_url) - .bind(&actor.display_name) - .bind(&actor.avatar_url) - .bind(&fetched_at) - .execute(&self.pool) - .await?; - - Ok(()) - } - - async fn get_remote_actor(&self, actor_url: &str) -> Result> { - let row = sqlx::query( - "SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url - FROM ap_remote_actors WHERE url = ?", - ) - .bind(actor_url) - .fetch_optional(&self.pool) - .await?; - - Ok(row.map(|row| RemoteActor { - url: row.get("url"), - handle: row.get("handle"), - inbox_url: row.get("inbox_url"), - shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), - display_name: row.try_get("display_name").ok().flatten(), - avatar_url: row.try_get("avatar_url").ok().flatten(), - })) - } - - async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result> { - let uid = user_id.to_string(); - let row = sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?") - .bind(&uid) - .fetch_optional(&self.pool) - .await?; - Ok(row.map(|r| (r.get("public_key"), r.get("private_key")))) - } - - async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, public_key: String, private_key: String) -> Result<()> { - let uid = user_id.to_string(); - let now = Utc::now().naive_utc(); - let created_at = datetime_to_str(&now); - - sqlx::query( - "INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(user_id) DO UPDATE SET - public_key = excluded.public_key, - private_key = excluded.private_key", - ) - .bind(&uid) - .bind(&public_key) - .bind(&private_key) - .bind(&created_at) - .execute(&self.pool) - .await?; - - Ok(()) - } - - async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - - let rows = sqlx::query( - "SELECT f.remote_actor_url, - a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url - FROM ap_followers f - LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ? AND f.status = 'pending'", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await?; - - Ok(rows.into_iter().map(|row| RemoteActor { - url: row.get("remote_actor_url"), - handle: row.try_get("handle").unwrap_or_default(), - inbox_url: row.try_get("inbox_url").unwrap_or_default(), - shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), - display_name: row.try_get("display_name").ok().flatten(), - avatar_url: row.try_get("avatar_url").ok().flatten(), - }).collect()) - } - - async fn update_following_status( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowingStatus, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let status_str = match status { - FollowingStatus::Pending => "pending", - FollowingStatus::Accepted => "accepted", - }; - - let result = sqlx::query( - "UPDATE ap_following SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?", - ) - .bind(status_str) - .bind(&uid) - .bind(remote_actor_url) - .execute(&self.pool) - .await?; - - if result.rows_affected() == 0 { - tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found"); - } - - Ok(()) - } - - async fn add_announce( - &self, - activity_id: &str, - object_url: &str, - actor_url: &str, - announced_at: chrono::DateTime, - ) -> Result<()> { - let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string(); - sqlx::query( - "INSERT OR IGNORE INTO ap_announces (id, object_url, actor_url, announced_at) - VALUES (?1, ?2, ?3, ?4)", - ) - .bind(activity_id) - .bind(object_url) - .bind(actor_url) - .bind(&ts) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn count_announces(&self, object_url: &str) -> Result { - let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1") - .bind(object_url) - .fetch_one(&self.pool) - .await?; - Ok(row.get::("cnt") as usize) - } -} - -// --- Content-specific repository (movies-diary) --- - -#[async_trait] -impl RemoteReviewRepository for SqliteFederationRepository { - async fn save_remote_review( - &self, - review: &Review, - ap_id: &str, - movie_title: &str, - release_year: u16, - poster_url: Option<&str>, - ) -> Result<()> { - let actor_url = match review.source() { - ReviewSource::Remote { actor_url } => actor_url.clone(), - ReviewSource::Local => { - return Err(anyhow!("save_remote_review called with a local review")); - } - }; - - let movie_id = review.movie_id().value().to_string(); - - let _ = sqlx::query( - "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) - VALUES (?, NULL, ?, ?, NULL, ?) - ON CONFLICT(id) DO UPDATE SET - poster_path = COALESCE(excluded.poster_path, movies.poster_path)", - ) - .bind(&movie_id) - .bind(movie_title) - .bind(release_year.max(1888) as i64) - .bind(poster_url) - .execute(&self.pool) - .await?; - - let id = review.id().value().to_string(); - let user_id = review.user_id().value().to_string(); - let rating = review.rating().value() as i64; - let comment = review.comment().map(|c| c.value().to_string()); - let watched_at = datetime_to_str(review.watched_at()); - let created_at = datetime_to_str(review.created_at()); - - sqlx::query( - "INSERT OR IGNORE INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(&id) - .bind(&movie_id) - .bind(&user_id) - .bind(rating) - .bind(&comment) - .bind(&watched_at) - .bind(&created_at) - .bind(&actor_url) - .bind(ap_id) - .execute(&self.pool) - .await?; - - Ok(()) - } - - async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()> { - sqlx::query("DELETE FROM reviews WHERE ap_id = ? AND remote_actor_url = ?") - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn update_remote_review( - &self, - ap_id: &str, - actor_url: &str, - rating: u8, - comment: Option<&str>, - watched_at: chrono::NaiveDateTime, - ) -> Result<()> { - let watched_at_str = datetime_to_str(&watched_at); - sqlx::query( - "UPDATE reviews SET rating = ?, comment = ?, watched_at = ? - WHERE ap_id = ? AND remote_actor_url = ?", - ) - .bind(rating as i64) - .bind(comment) - .bind(&watched_at_str) - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn delete_by_actor(&self, actor_url: &str) -> Result<()> { - sqlx::query("DELETE FROM reviews WHERE remote_actor_url = ?") - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } -} diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 5bb6b77..44d3fcd 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -752,6 +752,16 @@ impl DiaryRepository for SqliteMovieRepository { offset: page.offset, }) } + + async fn count_local_posts(&self) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL" + ) + .fetch_one(&self.pool) + .await + .map_err(Self::map_err)?; + Ok(count as u64) + } } #[async_trait] @@ -1080,3 +1090,48 @@ mod feed_filter_tests { assert_eq!(stats.rating_histogram[4], 0); // 5★ bucket } } + +#[cfg(test)] +mod diary_count_tests { + use super::*; + use sqlx::SqlitePool; + + async fn test_pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + pool + } + + #[tokio::test] + async fn count_local_posts_excludes_remote_reviews() { + use domain::ports::DiaryRepository; + let pool = test_pool().await; + let repo = SqliteMovieRepository::new(pool.clone()); + + let user_id = uuid::Uuid::new_v4().to_string(); + let movie_id = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO users (id, email, password_hash, created_at, username) VALUES (?, ?, ?, ?, ?)") + .bind(&user_id).bind("a@b.com").bind("hash").bind("2024-01-01 00:00:00").bind("alice") + .execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)") + .bind(&movie_id).bind("Test Movie").bind(2024i32) + .execute(&pool).await.unwrap(); + + // Local review (remote_actor_url IS NULL) + let r1 = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) VALUES (?, ?, ?, ?, ?, ?)") + .bind(&r1).bind(&movie_id).bind(&user_id).bind(4i32) + .bind("2024-01-01 00:00:00").bind("2024-01-01 00:00:00") + .execute(&pool).await.unwrap(); + + // Remote review (remote_actor_url IS NOT NULL) + let r2 = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at, remote_actor_url) VALUES (?, ?, ?, ?, ?, ?, ?)") + .bind(&r2).bind(&movie_id).bind(&user_id).bind(3i32) + .bind("2024-01-01 00:00:00").bind("2024-01-01 00:00:00").bind("https://remote/user") + .execute(&pool).await.unwrap(); + + let count = repo.count_local_posts().await.unwrap(); + assert_eq!(count, 1); + } +} diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index 1ba7986..4c2482e 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -1,5 +1,6 @@ use application::ports::{ - ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, + ActivityFeedPageData, BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry, + BlockedDomainsPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView, ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData, ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData, @@ -224,6 +225,20 @@ struct FollowersTemplate { error: Option, } +#[derive(Template)] +#[template(path = "blocked_domains.html")] +struct BlockedDomainsTemplate<'a> { + ctx: &'a HtmlPageContext, + domains: &'a [BlockedDomainEntry], +} + +#[derive(Template)] +#[template(path = "blocked_actors.html")] +struct BlockedActorsTemplate<'a> { + ctx: &'a HtmlPageContext, + actors: &'a [BlockedActorEntry], +} + struct HeatmapCell { month_label: String, count: i64, @@ -672,4 +687,22 @@ impl HtmlRenderer for AskamaHtmlRenderer { .render() .map_err(|e| e.to_string()) } + + fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result { + BlockedDomainsTemplate { + ctx: &data.ctx, + domains: &data.domains, + } + .render() + .map_err(|e| e.to_string()) + } + + fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result { + BlockedActorsTemplate { + ctx: &data.ctx, + actors: &data.actors, + } + .render() + .map_err(|e| e.to_string()) + } } diff --git a/crates/adapters/template-askama/templates/blocked_actors.html b/crates/adapters/template-askama/templates/blocked_actors.html new file mode 100644 index 0000000..832aa6a --- /dev/null +++ b/crates/adapters/template-askama/templates/blocked_actors.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block content %} +

Blocked Users

+ +{% if actors.is_empty() %} +

No users blocked.

+{% else %} +
    + {% for a in actors %} +
  • + {% if let Some(avatar) = a.avatar_url %} + avatar + {% endif %} + {{ a.handle }}{% if let Some(name) = a.display_name %} ({{ name }}){% endif %} +
    + + + +
    +
  • + {% endfor %} +
+{% endif %} +{% endblock %} diff --git a/crates/adapters/template-askama/templates/blocked_domains.html b/crates/adapters/template-askama/templates/blocked_domains.html new file mode 100644 index 0000000..67e2cd0 --- /dev/null +++ b/crates/adapters/template-askama/templates/blocked_domains.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block content %} +

Blocked Domains

+ +
+ +
+ + +
+
+ + +
+ +
+ +{% if domains.is_empty() %} +

No domains blocked.

+{% else %} +
    + {% for d in domains %} +
  • + {{ d.domain }}{% if let Some(r) = d.reason %} — {{ r }}{% endif %} + ({{ d.blocked_at }}) +
    + + + +
    +
  • + {% endfor %} +
+{% endif %} +{% endblock %} diff --git a/crates/adapters/template-askama/templates/followers.html b/crates/adapters/template-askama/templates/followers.html index cf104a3..a35d4c1 100644 --- a/crates/adapters/template-askama/templates/followers.html +++ b/crates/adapters/template-askama/templates/followers.html @@ -20,6 +20,11 @@ +
+ + + +
{% endfor %} diff --git a/crates/adapters/template-askama/templates/following.html b/crates/adapters/template-askama/templates/following.html index 96074ac..18d64f2 100644 --- a/crates/adapters/template-askama/templates/following.html +++ b/crates/adapters/template-askama/templates/following.html @@ -20,6 +20,11 @@ +
+ + + +
{% endfor %} diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs index ca95ca6..1cb92d2 100644 --- a/crates/application/src/ports.rs +++ b/crates/application/src/ports.rs @@ -152,6 +152,29 @@ pub struct ProfileSettingsPageData { pub saved: bool, } +pub struct BlockedDomainEntry { + pub domain: String, + pub reason: Option, + pub blocked_at: String, +} + +pub struct BlockedDomainsPageData { + pub ctx: HtmlPageContext, + pub domains: Vec, +} + +pub struct BlockedActorEntry { + pub url: String, + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, +} + +pub struct BlockedActorsPageData { + pub ctx: HtmlPageContext, + pub actors: Vec, +} + pub trait HtmlRenderer: Send + Sync { fn render_diary_page( &self, @@ -174,6 +197,8 @@ pub trait HtmlRenderer: Send + Sync { &self, data: ProfileSettingsPageData, ) -> Result; + fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result; + fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result; } pub trait RssFeedRenderer: Send + Sync { diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 3f3fb01..9dc0ba8 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -116,6 +116,7 @@ pub trait DiaryRepository: Send + Sync { movie_id: &MovieId, page: &PageParams, ) -> Result, DomainError>; + async fn count_local_posts(&self) -> Result; } #[async_trait] diff --git a/crates/presentation/src/dtos.rs b/crates/presentation/src/dtos.rs index d261862..3ce1288 100644 --- a/crates/presentation/src/dtos.rs +++ b/crates/presentation/src/dtos.rs @@ -278,6 +278,29 @@ pub struct FollowerActionForm { pub csrf_token: String, } +#[derive(Deserialize)] +pub struct BlockDomainForm { + pub domain: String, + #[serde(default)] + pub reason: Option, + #[serde(rename = "_csrf", default)] + pub csrf_token: String, +} + +#[derive(Deserialize)] +pub struct RemoveDomainForm { + pub domain: String, + #[serde(rename = "_csrf", default)] + pub csrf_token: String, +} + +#[derive(Deserialize)] +pub struct ActorUrlForm { + pub actor_url: String, + #[serde(rename = "_csrf", default)] + pub csrf_token: String, +} + #[derive(serde::Deserialize, Default)] pub struct ProfileQueryParams { pub view: Option, @@ -472,6 +495,27 @@ pub struct MovieDetailResponse { pub reviews: SocialFeedResponse, } +#[derive(serde::Serialize)] +pub struct BlockedDomainResponse { + pub domain: String, + pub reason: Option, + pub blocked_at: String, +} + +#[derive(serde::Deserialize)] +pub struct AddBlockedDomainRequest { + pub domain: String, + pub reason: Option, +} + +#[derive(serde::Serialize)] +pub struct BlockedActorResponse { + pub url: String, + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index a2c7a26..b6df723 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -232,6 +232,9 @@ mod tests { ) -> Result, DomainError> { panic!() } + async fn count_local_posts(&self) -> Result { + panic!() + } } #[cfg(feature = "federation")] #[async_trait::async_trait] @@ -440,6 +443,8 @@ mod tests { fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result { panic!() } fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result { panic!() } fn render_profile_settings_page(&self, _: application::ports::ProfileSettingsPageData) -> Result { panic!() } + fn render_blocked_domains_page(&self, _: application::ports::BlockedDomainsPageData) -> Result { panic!() } + fn render_blocked_actors_page(&self, _: application::ports::BlockedActorsPageData) -> Result { panic!() } } impl crate::ports::RssFeedRenderer for Panic { fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result { diff --git a/crates/presentation/src/handlers/api.rs b/crates/presentation/src/handlers/api.rs index fb6e089..cfedacf 100644 --- a/crates/presentation/src/handlers/api.rs +++ b/crates/presentation/src/handlers/api.rs @@ -411,6 +411,97 @@ fn entry_to_dto(entry: &DiaryEntry) -> DiaryEntryDto { } } +#[cfg(feature = "federation")] +pub async fn get_blocked_domains_admin( + State(state): State, + _admin: crate::extractors::AdminUser, +) -> impl IntoResponse { + match state.ap_service.get_blocked_domains().await { + Ok(domains) => { + let response: Vec = domains + .into_iter() + .map(|d| crate::dtos::BlockedDomainResponse { + domain: d.domain, + reason: d.reason, + blocked_at: d.blocked_at, + }) + .collect(); + axum::Json(response).into_response() + } + Err(e) => ap_err(e).into_response(), + } +} + +#[cfg(feature = "federation")] +pub async fn add_blocked_domain_admin( + State(state): State, + _admin: crate::extractors::AdminUser, + axum::Json(body): axum::Json, +) -> impl IntoResponse { + match state.ap_service.add_blocked_domain(&body.domain, body.reason.as_deref()).await { + Ok(()) => StatusCode::CREATED.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[cfg(feature = "federation")] +pub async fn remove_blocked_domain_admin( + State(state): State, + _admin: crate::extractors::AdminUser, + axum::extract::Path(domain): axum::extract::Path, +) -> impl IntoResponse { + match state.ap_service.remove_blocked_domain(&domain).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[cfg(feature = "federation")] +pub async fn block_actor_api( + State(state): State, + user: AuthenticatedUser, + axum::Json(body): axum::Json, +) -> impl IntoResponse { + match state.ap_service.block_actor(user.0.value(), &body.actor_url).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[cfg(feature = "federation")] +pub async fn unblock_actor_api( + State(state): State, + user: AuthenticatedUser, + axum::Json(body): axum::Json, +) -> impl IntoResponse { + match state.ap_service.unblock_actor(user.0.value(), &body.actor_url).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[cfg(feature = "federation")] +pub async fn get_blocked_actors_api( + State(state): State, + user: AuthenticatedUser, +) -> impl IntoResponse { + match state.ap_service.get_blocked_actors(user.0.value()).await { + Ok(actors) => { + let response: Vec = actors + .into_iter() + .map(|a| crate::dtos::BlockedActorResponse { + url: a.url, + handle: a.handle, + display_name: a.display_name, + avatar_url: a.avatar_url, + }) + .collect(); + axum::Json(response).into_response() + } + Err(e) => ap_err(e).into_response(), + } +} + #[cfg(feature = "federation")] fn ap_err(e: anyhow::Error) -> impl IntoResponse { tracing::error!("ActivityPub error: {:?}", e); diff --git a/crates/presentation/src/handlers/html.rs b/crates/presentation/src/handlers/html.rs index f421688..8f6e03a 100644 --- a/crates/presentation/src/handlers/html.rs +++ b/crates/presentation/src/handlers/html.rs @@ -10,7 +10,10 @@ use chrono::Utc; use uuid::Uuid; #[cfg(feature = "federation")] -use application::ports::{FollowersPageData, FollowingPageData}; +use application::ports::{ + BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry, BlockedDomainsPageData, + FollowersPageData, FollowingPageData, +}; use application::{ commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand}, ports::{ @@ -27,13 +30,13 @@ use domain::models::ExportFormat; use domain::{errors::DomainError, value_objects::UserId}; #[cfg(feature = "federation")] -use crate::dtos::{FollowForm, FollowerActionForm, UnfollowForm}; +use crate::dtos::{ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, UnfollowForm}; use crate::{ csrf::CsrfToken, dtos::{ ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm, }, - extractors::{OptionalCookieUser, RequiredCookieUser}, + extractors::{AdminUser, OptionalCookieUser, RequiredCookieUser}, state::AppState, }; @@ -1019,6 +1022,160 @@ pub async fn get_profile_settings( } } +pub async fn get_tag(Path(tag): Path) -> impl IntoResponse { + if tag.eq_ignore_ascii_case("moviesdiary") { + Redirect::temporary("/") + } else { + Redirect::temporary(&format!("/?search={}", tag)) + } +} + +#[cfg(feature = "federation")] +pub async fn get_blocked_domains_page( + AdminUser(user_id): AdminUser, + State(state): State, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await; + ctx.page_title = "Blocked Domains — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/admin/blocked-domains", state.app_ctx.config.base_url); + match state.ap_service.get_blocked_domains().await { + Ok(domains) => { + let data = BlockedDomainsPageData { + ctx, + domains: domains + .into_iter() + .map(|d| BlockedDomainEntry { + domain: d.domain, + reason: d.reason, + blocked_at: d.blocked_at, + }) + .collect(), + }; + match state.html_renderer.render_blocked_domains_page(data) { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } + } + Err(e) => { + tracing::error!("get_blocked_domains error: {:?}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Failed to load blocked domains").into_response() + } + } +} + +#[cfg(feature = "federation")] +pub async fn post_blocked_domain( + AdminUser(_): AdminUser, + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let reason = form.reason.as_deref().filter(|s| !s.trim().is_empty()); + match state.ap_service.add_blocked_domain(&form.domain, reason).await { + Ok(()) => Redirect::to("/admin/blocked-domains").into_response(), + Err(e) => { + tracing::error!("add_blocked_domain error: {:?}", e); + Redirect::to("/admin/blocked-domains").into_response() + } + } +} + +#[cfg(feature = "federation")] +pub async fn post_remove_blocked_domain( + AdminUser(_): AdminUser, + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match state.ap_service.remove_blocked_domain(&form.domain).await { + Ok(()) => Redirect::to("/admin/blocked-domains").into_response(), + Err(e) => { + tracing::error!("remove_blocked_domain error: {:?}", e); + Redirect::to("/admin/blocked-domains").into_response() + } + } +} + +#[cfg(feature = "federation")] +pub async fn get_blocked_actors_page( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; + ctx.page_title = "Blocked Users — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/social/blocked", state.app_ctx.config.base_url); + match state.ap_service.get_blocked_actors(user_id.value()).await { + Ok(actors) => { + let data = BlockedActorsPageData { + ctx, + actors: actors + .into_iter() + .map(|a| BlockedActorEntry { + url: a.url, + handle: a.handle, + display_name: a.display_name, + avatar_url: a.avatar_url, + }) + .collect(), + }; + match state.html_renderer.render_blocked_actors_page(data) { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } + } + Err(e) => { + tracing::error!("get_blocked_actors error: {:?}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Failed to load blocked users").into_response() + } + } +} + +#[cfg(feature = "federation")] +pub async fn post_block_actor_html( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match state.ap_service.block_actor(user_id.value(), &form.actor_url).await { + Ok(()) => Redirect::to("/social/blocked").into_response(), + Err(e) => { + tracing::error!("block_actor html error: {:?}", e); + Redirect::to("/social/blocked").into_response() + } + } +} + +#[cfg(feature = "federation")] +pub async fn post_unblock_actor( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match state.ap_service.unblock_actor(user_id.value(), &form.actor_url).await { + Ok(()) => Redirect::to("/social/blocked").into_response(), + Err(e) => { + tracing::error!("unblock_actor error: {:?}", e); + Redirect::to("/social/blocked").into_response() + } + } +} + pub async fn post_profile_settings( RequiredCookieUser(user_id): RequiredCookieUser, State(state): State, diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index 9b93314..8023ddc 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -89,6 +89,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { Arc::clone(&review_repository), Arc::clone(&diary_repository), app_config.base_url.clone(), + app_config.allow_registration, ).await?; let ap_router = ap.router; let ap_service_arc = ap.service; diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 116b774..6bf038b 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -100,7 +100,8 @@ fn html_routes(rate_limit: u64) -> Router { "/settings/profile", routing::get(handlers::html::get_profile_settings) .post(handlers::html::post_profile_settings), - ); + ) + .route("/tags/{tag}", routing::get(handlers::html::get_tag)); #[cfg(feature = "federation")] let base = base.merge(federation_html_routes()); @@ -139,6 +140,21 @@ fn federation_html_routes() -> Router { "/users/{id}/followers/remove", routing::post(handlers::html::remove_follower), ) + .route( + "/admin/blocked-domains", + routing::get(handlers::html::get_blocked_domains_page) + .post(handlers::html::post_blocked_domain), + ) + .route( + "/admin/blocked-domains/remove", + routing::post(handlers::html::post_remove_blocked_domain), + ) + .route( + "/social/blocked", + routing::get(handlers::html::get_blocked_actors_page), + ) + .route("/social/block", routing::post(handlers::html::post_block_actor_html)) + .route("/social/unblock", routing::post(handlers::html::post_unblock_actor)) } fn api_routes(rate_limit: u64) -> Router { @@ -220,4 +236,16 @@ fn federation_api_routes() -> Router { "/social/followers/remove", routing::post(handlers::api::remove_follower), ) + .route( + "/admin/blocked-domains", + routing::get(handlers::api::get_blocked_domains_admin) + .post(handlers::api::add_blocked_domain_admin), + ) + .route( + "/admin/blocked-domains/{domain}", + routing::delete(handlers::api::remove_blocked_domain_admin), + ) + .route("/social/block", routing::post(handlers::api::block_actor_api)) + .route("/social/unblock", routing::post(handlers::api::unblock_actor_api)) + .route("/social/blocked", routing::get(handlers::api::get_blocked_actors_api)) } diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index 0991c56..f6d6e52 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -302,6 +302,38 @@ async fn get_api_movie_detail_returns_404_for_unknown_id() { assert_eq!(response.status(), StatusCode::NOT_FOUND); } +#[tokio::test] +async fn tags_moviesdiary_redirects_to_home() { + let app = test_app().await; + let response = app + .oneshot(with_ip( + Request::builder() + .uri("/tags/moviesdiary") + .body(Body::empty()) + .unwrap(), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!(response.headers().get("location").unwrap(), "/"); +} + +#[tokio::test] +async fn tags_other_redirects_to_search() { + let app = test_app().await; + let response = app + .oneshot(with_ip( + Request::builder() + .uri("/tags/batman") + .body(Body::empty()) + .unwrap(), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!(response.headers().get("location").unwrap(), "/?search=batman"); +} + #[tokio::test] async fn get_movie_detail_html_returns_404_for_unknown_id() { let app = test_app().await;