feat: discoverability (NodeInfo, hashtags) and moderation (domain/actor blocking)

- NodeInfo at /.well-known/nodeinfo + /nodeinfo/2.0
- Hashtags #MoviesDiary + #MovieTitle on review posts; /tags/{tag} redirect
- Domain blocking: blocked_domains table, admin API + HTML, inbox enforcement
- Per-actor blocking: blocked_actors table, user API + HTML, BlockActivity send/receive
- Delivery filter excludes blocked actors and blocked-domain inboxes
This commit is contained in:
2026-05-12 00:49:30 +02:00
parent 80f620c840
commit f0620f5aa1
40 changed files with 1410 additions and 543 deletions

View File

@@ -92,6 +92,7 @@ impl ActivityPubEventHandler {
movie_title,
release_year,
poster_url,
&self.base_url,
);
let json = serde_json::to_value(obj)?;

View File

@@ -25,18 +25,19 @@ pub struct ActivityPubWire {
}
pub async fn wire(
federation_repo: std::sync::Arc<dyn FederationRepository>,
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
base_url: String,
federation_repo: std::sync::Arc<dyn FederationRepository>,
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
base_url: String,
allow_registration: bool,
) -> anyhow::Result<ActivityPubWire> {
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?,

View File

@@ -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<String>,
pub(crate) watched_at: DateTime<Utc>,
#[serde(default)]
pub(crate) tag: Vec<ApHashtag>,
}
/// 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<String>,
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"));
}
}

View File

@@ -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<Vec<RemoteActor>>;
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn get_blocked_actors(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()>;
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()>;
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>>;
}
#[async_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<Vec<RemoteActor>> {
self.get_blocked_actors(local_user_id).await
}
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()> {
self.add_blocked_domain(domain, reason).await
}
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
self.remove_blocked_domain(domain).await
}
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
self.get_blocked_domains().await
}
}
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<Vec<RemoteActor>> {
Ok(vec![])
}
async fn add_blocked_domain(&self, _: &str, _: Option<&str>) -> anyhow::Result<()> {
Ok(())
}
async fn remove_blocked_domain(&self, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
Ok(vec![])
}
}

View File

@@ -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<u64> {
self.diary_repository
.count_local_posts()
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
}

View File

@@ -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<dyn UserRepository>);
pub struct DomainUserRepoAdapter {
pub repo: Arc<dyn UserRepository>,
pub base_url: String,
}
impl DomainUserRepoAdapter {
pub fn new(repo: Arc<dyn UserRepository>, 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<Option<ApUser>> {
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<Option<ApUser>> {
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<usize> {
Ok(self.repo.list_with_stats().await
.map_err(|e| anyhow::anyhow!(e.to_string()))?
.len())
}
}