feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready (#1)
This commit was merged in pull request #1.
This commit is contained in:
16
crates/adapters/activitypub/Cargo.toml
Normal file
16
crates/adapters/activitypub/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "activitypub"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
activitypub-base = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
url = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
408
crates/adapters/activitypub/src/handler.rs
Normal file
408
crates/adapters/activitypub/src/handler.rs
Normal file
@@ -0,0 +1,408 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
|
||||
const USERS_PATH_PREFIX: &str = "/users/";
|
||||
const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
|
||||
use crate::note::ThoughtNote;
|
||||
use crate::urls::ThoughtsUrls;
|
||||
use activitypub_base::{ActivityPubRepository, ApObjectHandler};
|
||||
use domain::ports::{EventPublisher, TagRepository};
|
||||
use domain::value_objects::UserId;
|
||||
|
||||
pub struct ThoughtsObjectHandler {
|
||||
repo: Arc<dyn ActivityPubRepository>,
|
||||
urls: ThoughtsUrls,
|
||||
event_publisher: Option<Arc<dyn EventPublisher>>,
|
||||
tag_repo: Arc<dyn TagRepository>,
|
||||
}
|
||||
|
||||
impl ThoughtsObjectHandler {
|
||||
pub fn new(
|
||||
repo: Arc<dyn ActivityPubRepository>,
|
||||
base_url: &str,
|
||||
event_publisher: Option<Arc<dyn EventPublisher>>,
|
||||
tag_repo: Arc<dyn TagRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
urls: ThoughtsUrls::new(base_url),
|
||||
event_publisher,
|
||||
tag_repo,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
async fn get_local_objects_for_user(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Vec<(Url, serde_json::Value)>> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let entries = self
|
||||
.repo
|
||||
.outbox_entries_for_actor(&uid)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
|
||||
let actor_url = self.urls.user_url(e.author_username.as_str());
|
||||
let followers = self.urls.user_followers(e.author_username.as_str());
|
||||
let in_reply_to = e
|
||||
.thought
|
||||
.in_reply_to_id
|
||||
.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||
let note = ThoughtNote::new_public(
|
||||
note_url.clone(),
|
||||
actor_url,
|
||||
e.thought.content.as_str().to_owned(),
|
||||
e.thought.created_at,
|
||||
in_reply_to,
|
||||
e.thought.sensitive,
|
||||
e.thought.content_warning,
|
||||
followers,
|
||||
);
|
||||
Ok((note_url, serde_json::to_value(¬e)?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_local_objects_page(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
before: Option<DateTime<Utc>>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let entries = self
|
||||
.repo
|
||||
.outbox_page_for_actor(&uid, before, limit)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let created_at = e.thought.created_at;
|
||||
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
|
||||
let actor_url = self.urls.user_url(e.author_username.as_str());
|
||||
let followers = self.urls.user_followers(e.author_username.as_str());
|
||||
let in_reply_to = e
|
||||
.thought
|
||||
.in_reply_to_id
|
||||
.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||
let note = ThoughtNote::new_public(
|
||||
note_url.clone(),
|
||||
actor_url,
|
||||
e.thought.content.as_str().to_owned(),
|
||||
created_at,
|
||||
in_reply_to,
|
||||
e.thought.sensitive,
|
||||
e.thought.content_warning,
|
||||
followers,
|
||||
);
|
||||
Ok((note_url, serde_json::to_value(¬e)?, created_at))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn on_create(
|
||||
&self,
|
||||
ap_id: &Url,
|
||||
actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let note: ThoughtNote = serde_json::from_value(object)?;
|
||||
let author_id = self
|
||||
.repo
|
||||
.intern_remote_actor(actor_url.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
// Derive visibility from AP addressing conventions.
|
||||
let as_public = "https://www.w3.org/ns/activitystreams#Public";
|
||||
let in_to = note.to.iter().any(|s| s == as_public);
|
||||
let in_cc = note.cc.iter().any(|s| s == as_public);
|
||||
let has_followers = note.to.iter().any(|s| s.ends_with("/followers"))
|
||||
|| note.cc.iter().any(|s| s.ends_with("/followers"));
|
||||
|
||||
let visibility = if in_to {
|
||||
"public"
|
||||
} else if in_cc {
|
||||
"unlisted"
|
||||
} else if has_followers {
|
||||
"followers"
|
||||
} else {
|
||||
"direct"
|
||||
};
|
||||
|
||||
let thought_id = self.repo
|
||||
.accept_note(
|
||||
ap_id.as_str(),
|
||||
&author_id,
|
||||
¬e.content,
|
||||
note.published,
|
||||
note.sensitive,
|
||||
note.summary,
|
||||
visibility,
|
||||
note.in_reply_to.as_ref().map(|u| u.as_str()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
// Extract and index hashtags from the AP tag array.
|
||||
let hashtag_names: Vec<String> = note
|
||||
.tag
|
||||
.iter()
|
||||
.filter(|t| t.get("type").and_then(|v| v.as_str()) == Some("Hashtag"))
|
||||
.filter_map(|t| t.get("name").and_then(|v| v.as_str()))
|
||||
.map(|name| name.trim_start_matches('#').to_lowercase())
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect();
|
||||
|
||||
for name in hashtag_names {
|
||||
if let Ok(tag) = self.tag_repo.find_or_create(&name).await {
|
||||
let _ = self.tag_repo.attach_to_thought(&thought_id, tag.id).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Fire mention notifications for local @mentions in the note's tag array.
|
||||
let base_url = url::Url::parse(&self.urls.base_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|h| h.to_string()))
|
||||
.unwrap_or_default();
|
||||
|
||||
for tag in ¬e.tag {
|
||||
if tag.get("type").and_then(|t| t.as_str()) != Some("Mention") {
|
||||
continue;
|
||||
}
|
||||
let href = match tag.get("href").and_then(|h| h.as_str()) {
|
||||
Some(h) => h,
|
||||
None => continue,
|
||||
};
|
||||
let href_url = match url::Url::parse(href) {
|
||||
Ok(u) => u,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if href_url.host_str().unwrap_or("") != base_url {
|
||||
continue;
|
||||
}
|
||||
let user_uuid = href_url
|
||||
.path()
|
||||
.strip_prefix(USERS_PATH_PREFIX)
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
if let Some(uuid) = user_uuid {
|
||||
self.on_mention(ap_id, uuid, actor_url)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "failed to process mention notification");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_update(
|
||||
&self,
|
||||
ap_id: &Url,
|
||||
_actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let note: ThoughtNote = serde_json::from_value(object)?;
|
||||
self.repo
|
||||
.apply_note_update(ap_id.as_str(), ¬e.content)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
|
||||
self.repo
|
||||
.retract_note(ap_id.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> {
|
||||
self.repo
|
||||
.retract_actor_notes(actor_url.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
async fn on_like(&self, object_url: &Url, actor_url: &Url) -> Result<()> {
|
||||
let thought_uuid = object_url
|
||||
.path()
|
||||
.strip_prefix(THOUGHTS_PATH_PREFIX)
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
|
||||
let thought_uuid = match thought_uuid {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
tracing::debug!(object = %object_url, "on_like: not a local thought URL, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let actor_user_id = self
|
||||
.repo
|
||||
.find_remote_actor_id(actor_url.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
let actor_user_id = match actor_user_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
tracing::debug!(actor = %actor_url, "on_like: remote actor not interned, skipping notification");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ep) = &self.event_publisher {
|
||||
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
|
||||
let like_id = domain::value_objects::LikeId::new();
|
||||
ep.publish(&domain::events::DomainEvent::LikeAdded {
|
||||
like_id,
|
||||
user_id: actor_user_id,
|
||||
thought_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_unlike(&self, object_url: &url::Url, actor_url: &url::Url) -> anyhow::Result<()> {
|
||||
let thought_uuid = object_url
|
||||
.path()
|
||||
.strip_prefix(THOUGHTS_PATH_PREFIX)
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
|
||||
let thought_uuid = match thought_uuid {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
tracing::debug!(object = %object_url, "on_unlike: not a local thought URL, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let actor_user_id = self
|
||||
.repo
|
||||
.find_remote_actor_id(actor_url.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
let actor_user_id = match actor_user_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
tracing::debug!(actor = %actor_url, "on_unlike: remote actor not interned, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ep) = &self.event_publisher {
|
||||
ep.publish(&domain::events::DomainEvent::LikeRemoved {
|
||||
user_id: actor_user_id,
|
||||
thought_id: domain::value_objects::ThoughtId::from_uuid(thought_uuid),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_mention(
|
||||
&self,
|
||||
thought_ap_id: &url::Url,
|
||||
mentioned_user_uuid: uuid::Uuid,
|
||||
actor_url: &url::Url,
|
||||
) -> anyhow::Result<()> {
|
||||
let author_user_id = match self
|
||||
.repo
|
||||
.find_remote_actor_id(actor_url.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?
|
||||
{
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let thought_uuid = thought_ap_id
|
||||
.path()
|
||||
.strip_prefix(THOUGHTS_PATH_PREFIX)
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
|
||||
let thought_uuid = match thought_uuid {
|
||||
Some(u) => u,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if let Some(ep) = &self.event_publisher {
|
||||
ep.publish(&domain::events::DomainEvent::MentionReceived {
|
||||
thought_id: domain::value_objects::ThoughtId::from_uuid(thought_uuid),
|
||||
mentioned_user_id: domain::value_objects::UserId::from_uuid(mentioned_user_uuid),
|
||||
author_user_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> Result<()> {
|
||||
let thought_uuid = object_url
|
||||
.path()
|
||||
.strip_prefix(THOUGHTS_PATH_PREFIX)
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
|
||||
let thought_uuid = match thought_uuid {
|
||||
Some(u) => u,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let actor_user_id = self
|
||||
.repo
|
||||
.find_remote_actor_id(actor_url.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
let actor_user_id = match actor_user_id {
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if let Some(ep) = &self.event_publisher {
|
||||
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
|
||||
let boost_id = domain::value_objects::BoostId::new();
|
||||
ep.publish(&domain::events::DomainEvent::BoostAdded {
|
||||
boost_id,
|
||||
user_id: actor_user_id,
|
||||
thought_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64> {
|
||||
self.repo
|
||||
.count_local_notes()
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
}
|
||||
7
crates/adapters/activitypub/src/lib.rs
Normal file
7
crates/adapters/activitypub/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod handler;
|
||||
pub mod note;
|
||||
pub mod urls;
|
||||
|
||||
pub use handler::ThoughtsObjectHandler;
|
||||
pub use note::ThoughtNote;
|
||||
pub use urls::ThoughtsUrls;
|
||||
81
crates/adapters/activitypub/src/note.rs
Normal file
81
crates/adapters/activitypub/src/note.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use activitypub_base::NoteType;
|
||||
use activitypub_base::AS_PUBLIC;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
/// AP Note representing a Thought.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThoughtNote {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: NoteType,
|
||||
pub id: Url,
|
||||
pub url: Url, // Mastodon uses this as the clickable link
|
||||
pub attributed_to: Url,
|
||||
pub content: String,
|
||||
pub published: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub to: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub cc: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub in_reply_to: Option<Url>,
|
||||
pub sensitive: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub tag: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ThoughtNote {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_public(
|
||||
id: Url,
|
||||
actor_url: Url,
|
||||
content: String,
|
||||
published: DateTime<Utc>,
|
||||
in_reply_to: Option<Url>,
|
||||
sensitive: bool,
|
||||
summary: Option<String>,
|
||||
followers_url: Url,
|
||||
) -> Self {
|
||||
Self {
|
||||
kind: Default::default(),
|
||||
url: id.clone(),
|
||||
id,
|
||||
attributed_to: actor_url,
|
||||
content,
|
||||
published,
|
||||
to: vec![AS_PUBLIC.to_string()],
|
||||
cc: vec![followers_url.to_string()],
|
||||
in_reply_to,
|
||||
sensitive,
|
||||
summary,
|
||||
tag: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn note_serializes_with_public_audience() {
|
||||
let note = ThoughtNote::new_public(
|
||||
"https://example.com/thoughts/1".parse().unwrap(),
|
||||
"https://example.com/users/alice".parse().unwrap(),
|
||||
"Hello world".to_string(),
|
||||
chrono::Utc::now(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
"https://example.com/users/alice/followers".parse().unwrap(),
|
||||
);
|
||||
let json = serde_json::to_string(¬e).unwrap();
|
||||
assert!(json.contains(AS_PUBLIC));
|
||||
assert!(json.contains("Hello world"));
|
||||
assert!(json.contains("\"url\""));
|
||||
}
|
||||
}
|
||||
57
crates/adapters/activitypub/src/urls.rs
Normal file
57
crates/adapters/activitypub/src/urls.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use url::Url;
|
||||
|
||||
pub struct ThoughtsUrls {
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl ThoughtsUrls {
|
||||
pub fn new(base_url: &str) -> Self {
|
||||
Self {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_url(&self, username: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}", self.base_url, username)).expect("valid URL")
|
||||
}
|
||||
|
||||
pub fn thought_url(&self, thought_id: uuid::Uuid) -> Url {
|
||||
Url::parse(&format!("{}/thoughts/{}", self.base_url, thought_id)).expect("valid URL")
|
||||
}
|
||||
|
||||
pub fn user_inbox(&self, username: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/inbox", self.base_url, username)).expect("valid URL")
|
||||
}
|
||||
|
||||
pub fn user_outbox(&self, username: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/outbox", self.base_url, username)).expect("valid URL")
|
||||
}
|
||||
|
||||
pub fn user_followers(&self, username: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/followers", self.base_url, username)).expect("valid URL")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn user_url_format() {
|
||||
let urls = ThoughtsUrls::new("https://example.com");
|
||||
assert_eq!(
|
||||
urls.user_url("alice").as_str(),
|
||||
"https://example.com/users/alice"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thought_url_format() {
|
||||
let urls = ThoughtsUrls::new("https://example.com");
|
||||
let id = uuid::Uuid::nil();
|
||||
assert!(urls
|
||||
.thought_url(id)
|
||||
.as_str()
|
||||
.starts_with("https://example.com/thoughts/"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user