refactor(domain): remove ap_id/inbox_url from User and Thought; use ActivityPubRepository lookups

This commit is contained in:
2026-05-15 13:21:21 +02:00
parent bf3e336d0f
commit e935c8973e
13 changed files with 131 additions and 135 deletions

View File

@@ -74,6 +74,7 @@ fn thought_note_json(
thought: &domain::models::thought::Thought, thought: &domain::models::thought::Thought,
local_actor: &crate::actors::DbActor, local_actor: &crate::actors::DbActor,
base_url: &str, base_url: &str,
in_reply_to_url: Option<&str>,
) -> anyhow::Result<(url::Url, serde_json::Value)> { ) -> anyhow::Result<(url::Url, serde_json::Value)> {
let ap_id = url::Url::parse(&format!("{}/thoughts/{}", base_url, thought.id))?; let ap_id = url::Url::parse(&format!("{}/thoughts/{}", base_url, thought.id))?;
@@ -107,7 +108,7 @@ fn thought_note_json(
if let Some(ref cw) = thought.content_warning { if let Some(ref cw) = thought.content_warning {
note["summary"] = serde_json::json!(cw); note["summary"] = serde_json::json!(cw);
} }
if let Some(ref reply_url) = thought.in_reply_to_url { if let Some(reply_url) = in_reply_to_url {
note["inReplyTo"] = serde_json::json!(reply_url); note["inReplyTo"] = serde_json::json!(reply_url);
} }
if let Some(updated_at) = thought.updated_at { if let Some(updated_at) = thought.updated_at {
@@ -1415,6 +1416,7 @@ impl domain::ports::OutboundFederationPort for ActivityPubService {
author_user_id: &domain::value_objects::UserId, author_user_id: &domain::value_objects::UserId,
thought: &domain::models::thought::Thought, thought: &domain::models::thought::Thought,
_author_username: &str, _author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), domain::errors::DomainError> { ) -> Result<(), domain::errors::DomainError> {
let user_uuid = author_user_id.as_uuid(); let user_uuid = author_user_id.as_uuid();
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
@@ -1426,8 +1428,9 @@ impl domain::ports::OutboundFederationPort for ActivityPubService {
return Ok(()); return Ok(());
}; };
let (ap_id, note) = thought_note_json(thought, &local_actor, &self.base_url) let (ap_id, note) =
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; thought_note_json(thought, &local_actor, &self.base_url, in_reply_to_url)
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?;
let create = crate::activities::CreateActivity { let create = crate::activities::CreateActivity {
id: ap_id, id: ap_id,
@@ -1476,6 +1479,7 @@ impl domain::ports::OutboundFederationPort for ActivityPubService {
author_user_id: &domain::value_objects::UserId, author_user_id: &domain::value_objects::UserId,
thought: &domain::models::thought::Thought, thought: &domain::models::thought::Thought,
_author_username: &str, _author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), domain::errors::DomainError> { ) -> Result<(), domain::errors::DomainError> {
let user_uuid = author_user_id.as_uuid(); let user_uuid = author_user_id.as_uuid();
let data = self.federation_config.to_request_data(); let data = self.federation_config.to_request_data();
@@ -1487,8 +1491,9 @@ impl domain::ports::OutboundFederationPort for ActivityPubService {
return Ok(()); return Ok(());
}; };
let (_ap_id, note) = thought_note_json(thought, &local_actor, &self.base_url) let (_ap_id, note) =
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; thought_note_json(thought, &local_actor, &self.base_url, in_reply_to_url)
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?;
let update_id = url::Url::parse(&format!( let update_id = url::Url::parse(&format!(
"{}/activities/update/{}", "{}/activities/update/{}",

View File

@@ -29,8 +29,6 @@ struct FeedRow {
t_user_id: uuid::Uuid, t_user_id: uuid::Uuid,
content: String, content: String,
in_reply_to_id: Option<uuid::Uuid>, in_reply_to_id: Option<uuid::Uuid>,
in_reply_to_url: Option<String>,
t_ap_id: Option<String>,
visibility: String, visibility: String,
content_warning: Option<String>, content_warning: Option<String>,
sensitive: bool, sensitive: bool,
@@ -47,8 +45,6 @@ struct FeedRow {
header_url: Option<String>, header_url: Option<String>,
custom_css: Option<String>, custom_css: Option<String>,
author_local: bool, author_local: bool,
u_ap_id: Option<String>,
inbox_url: Option<String>,
author_created_at: DateTime<Utc>, author_created_at: DateTime<Utc>,
author_updated_at: DateTime<Utc>, author_updated_at: DateTime<Utc>,
like_count: i64, like_count: i64,
@@ -59,12 +55,12 @@ struct FeedRow {
const FEED_SELECT: &str = " const FEED_SELECT: &str = "
SELECT SELECT
t.id AS thought_id, t.user_id AS t_user_id, t.content, t.id AS thought_id, t.user_id AS t_user_id, t.content,
t.in_reply_to_id, t.in_reply_to_url, t.ap_id AS t_ap_id, t.in_reply_to_id,
t.visibility, t.content_warning, t.sensitive, t.local AS t_local, t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
t.created_at AS thought_created_at, t.updated_at, t.created_at AS thought_created_at, t.updated_at,
u.id AS author_id, u.username, u.email, u.password_hash, u.id AS author_id, u.username, u.email, u.password_hash,
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css, u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,
u.local AS author_local, u.ap_id AS u_ap_id, u.inbox_url, u.local AS author_local,
u.created_at AS author_created_at, u.updated_at AS author_updated_at, u.created_at AS author_created_at, u.updated_at AS author_updated_at,
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count, (SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count, (SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
@@ -77,8 +73,6 @@ fn row_to_entry(r: FeedRow) -> FeedEntry {
user_id: UserId::from_uuid(r.t_user_id), user_id: UserId::from_uuid(r.t_user_id),
content: Content::new_remote(r.content), content: Content::new_remote(r.content),
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid), in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
in_reply_to_url: r.in_reply_to_url,
ap_id: r.t_ap_id,
visibility: Visibility::from_db_str(&r.visibility), visibility: Visibility::from_db_str(&r.visibility),
content_warning: r.content_warning, content_warning: r.content_warning,
sensitive: r.sensitive, sensitive: r.sensitive,
@@ -97,8 +91,6 @@ fn row_to_entry(r: FeedRow) -> FeedEntry {
header_url: r.header_url, header_url: r.header_url,
custom_css: r.custom_css, custom_css: r.custom_css,
local: r.author_local, local: r.author_local,
ap_id: r.u_ap_id,
inbox_url: r.inbox_url,
created_at: r.author_created_at, created_at: r.author_created_at,
updated_at: r.author_updated_at, updated_at: r.author_updated_at,
}; };

View File

@@ -60,8 +60,6 @@ impl ActivityPubRepository for PgActivityPubRepository {
user_id: UserId::from_uuid(r.user_id), user_id: UserId::from_uuid(r.user_id),
content: Content::new_remote(r.content), content: Content::new_remote(r.content),
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid), in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
in_reply_to_url: None,
ap_id: None,
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: r.content_warning, content_warning: r.content_warning,
sensitive: r.sensitive, sensitive: r.sensitive,
@@ -127,8 +125,6 @@ impl ActivityPubRepository for PgActivityPubRepository {
user_id: UserId::from_uuid(r.user_id), user_id: UserId::from_uuid(r.user_id),
content: Content::new_remote(r.content), content: Content::new_remote(r.content),
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid), in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
in_reply_to_url: None,
ap_id: None,
visibility: Visibility::Public, visibility: Visibility::Public,
content_warning: r.content_warning, content_warning: r.content_warning,
sensitive: r.sensitive, sensitive: r.sensitive,

View File

@@ -29,8 +29,6 @@ struct FeedRow {
t_user_id: uuid::Uuid, t_user_id: uuid::Uuid,
content: String, content: String,
in_reply_to_id: Option<uuid::Uuid>, in_reply_to_id: Option<uuid::Uuid>,
in_reply_to_url: Option<String>,
t_ap_id: Option<String>,
visibility: String, visibility: String,
content_warning: Option<String>, content_warning: Option<String>,
sensitive: bool, sensitive: bool,
@@ -47,8 +45,6 @@ struct FeedRow {
header_url: Option<String>, header_url: Option<String>,
custom_css: Option<String>, custom_css: Option<String>,
author_local: bool, author_local: bool,
u_ap_id: Option<String>,
inbox_url: Option<String>,
author_created_at: DateTime<Utc>, author_created_at: DateTime<Utc>,
author_updated_at: DateTime<Utc>, author_updated_at: DateTime<Utc>,
like_count: i64, like_count: i64,
@@ -83,7 +79,7 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
" "
SELECT SELECT
t.id AS thought_id, t.user_id AS t_user_id, t.content, t.id AS thought_id, t.user_id AS t_user_id, t.content,
t.in_reply_to_id, t.in_reply_to_url, t.ap_id AS t_ap_id, t.in_reply_to_id,
t.visibility, t.content_warning, t.sensitive, t.local AS t_local, t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
t.created_at AS thought_created_at, t.updated_at, t.created_at AS thought_created_at, t.updated_at,
u.id AS author_id, u.id AS author_id,
@@ -98,7 +94,7 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
u.bio, u.bio,
COALESCE(ra.avatar_url, u.avatar_url) AS avatar_url, COALESCE(ra.avatar_url, u.avatar_url) AS avatar_url,
u.header_url, u.custom_css, u.header_url, u.custom_css,
u.local AS author_local, u.ap_id AS u_ap_id, u.inbox_url, u.local AS author_local,
u.created_at AS author_created_at, u.updated_at AS author_updated_at, u.created_at AS author_created_at, u.updated_at AS author_updated_at,
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count, (SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count, (SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
@@ -116,8 +112,6 @@ fn row_to_entry(r: FeedRow) -> FeedEntry {
user_id: UserId::from_uuid(r.t_user_id), user_id: UserId::from_uuid(r.t_user_id),
content: Content::new_remote(r.content), content: Content::new_remote(r.content),
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid), in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
in_reply_to_url: r.in_reply_to_url,
ap_id: r.t_ap_id,
visibility: Visibility::from_db_str(&r.visibility), visibility: Visibility::from_db_str(&r.visibility),
content_warning: r.content_warning, content_warning: r.content_warning,
sensitive: r.sensitive, sensitive: r.sensitive,
@@ -136,8 +130,6 @@ fn row_to_entry(r: FeedRow) -> FeedEntry {
header_url: r.header_url, header_url: r.header_url,
custom_css: r.custom_css, custom_css: r.custom_css,
local: r.author_local, local: r.author_local,
ap_id: r.u_ap_id,
inbox_url: r.inbox_url,
created_at: r.author_created_at, created_at: r.author_created_at,
updated_at: r.author_updated_at, updated_at: r.author_updated_at,
}; };

View File

@@ -28,8 +28,6 @@ pub(crate) struct ThoughtRow {
pub user_id: uuid::Uuid, pub user_id: uuid::Uuid,
pub content: String, pub content: String,
pub in_reply_to_id: Option<uuid::Uuid>, pub in_reply_to_id: Option<uuid::Uuid>,
pub in_reply_to_url: Option<String>,
pub ap_id: Option<String>,
pub visibility: String, pub visibility: String,
pub content_warning: Option<String>, pub content_warning: Option<String>,
pub sensitive: bool, pub sensitive: bool,
@@ -45,8 +43,6 @@ impl From<ThoughtRow> for Thought {
user_id: UserId::from_uuid(r.user_id), user_id: UserId::from_uuid(r.user_id),
content: Content::new_remote(r.content), content: Content::new_remote(r.content),
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid), in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
in_reply_to_url: r.in_reply_to_url,
ap_id: r.ap_id,
visibility: Visibility::from_db_str(&r.visibility), visibility: Visibility::from_db_str(&r.visibility),
content_warning: r.content_warning, content_warning: r.content_warning,
sensitive: r.sensitive, sensitive: r.sensitive,
@@ -58,22 +54,20 @@ impl From<ThoughtRow> for Thought {
} }
const THOUGHT_SELECT: &str = const THOUGHT_SELECT: &str =
"SELECT id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,visibility,content_warning,sensitive,local,created_at,updated_at FROM thoughts"; "SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at FROM thoughts";
#[async_trait] #[async_trait]
impl ThoughtRepository for PgThoughtRepository { impl ThoughtRepository for PgThoughtRepository {
async fn save(&self, t: &Thought) -> Result<(), DomainError> { async fn save(&self, t: &Thought) -> Result<(), DomainError> {
sqlx::query( sqlx::query(
"INSERT INTO thoughts(id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,visibility,content_warning,sensitive,local,created_at) "INSERT INTO thoughts(id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()" ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()"
) )
.bind(t.id.as_uuid()) .bind(t.id.as_uuid())
.bind(t.user_id.as_uuid()) .bind(t.user_id.as_uuid())
.bind(t.content.as_str()) .bind(t.content.as_str())
.bind(t.in_reply_to_id.as_ref().map(|x| x.as_uuid())) .bind(t.in_reply_to_id.as_ref().map(|x| x.as_uuid()))
.bind(&t.in_reply_to_url)
.bind(&t.ap_id)
.bind(t.visibility.as_str()) .bind(t.visibility.as_str())
.bind(&t.content_warning) .bind(&t.content_warning)
.bind(t.sensitive) .bind(t.sensitive)
@@ -121,11 +115,11 @@ impl ThoughtRepository for PgThoughtRepository {
// Recursive CTE: fetches the root thought and all nested replies at any depth. // Recursive CTE: fetches the root thought and all nested replies at any depth.
sqlx::query_as::<_, ThoughtRow>( sqlx::query_as::<_, ThoughtRow>(
"WITH RECURSIVE thread AS ( "WITH RECURSIVE thread AS (
SELECT id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id, SELECT id,user_id,content,in_reply_to_id,
visibility,content_warning,sensitive,local,created_at,updated_at visibility,content_warning,sensitive,local,created_at,updated_at
FROM thoughts WHERE id = $1 FROM thoughts WHERE id = $1
UNION ALL UNION ALL
SELECT t.id,t.user_id,t.content,t.in_reply_to_id,t.in_reply_to_url,t.ap_id, SELECT t.id,t.user_id,t.content,t.in_reply_to_id,
t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at
FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id
) )

View File

@@ -58,15 +58,13 @@ impl TopFriendRepository for PgTopFriendRepository {
header_url: Option<String>, header_url: Option<String>,
custom_css: Option<String>, custom_css: Option<String>,
local: bool, local: bool,
ap_id: Option<String>,
inbox_url: Option<String>,
created_at: chrono::DateTime<chrono::Utc>, created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>, updated_at: chrono::DateTime<chrono::Utc>,
} }
let rows = sqlx::query_as::<_, Row>( let rows = sqlx::query_as::<_, Row>(
"SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position, "SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position,
u.id, u.username, u.email, u.password_hash, u.display_name, u.bio, u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
u.avatar_url, u.header_url, u.custom_css, u.local, u.ap_id, u.inbox_url, u.avatar_url, u.header_url, u.custom_css, u.local,
u.created_at, u.updated_at u.created_at, u.updated_at
FROM top_friends tf JOIN users u ON u.id=tf.friend_id FROM top_friends tf JOIN users u ON u.id=tf.friend_id
WHERE tf.user_id=$1 ORDER BY tf.position", WHERE tf.user_id=$1 ORDER BY tf.position",
@@ -96,8 +94,6 @@ impl TopFriendRepository for PgTopFriendRepository {
header_url: r.header_url, header_url: r.header_url,
custom_css: r.custom_css, custom_css: r.custom_css,
local: r.local, local: r.local,
ap_id: r.ap_id,
inbox_url: r.inbox_url,
created_at: r.created_at, created_at: r.created_at,
updated_at: r.updated_at, updated_at: r.updated_at,
}; };

View File

@@ -30,8 +30,6 @@ pub struct UserRow {
pub header_url: Option<String>, pub header_url: Option<String>,
pub custom_css: Option<String>, pub custom_css: Option<String>,
pub local: bool, pub local: bool,
pub ap_id: Option<String>,
pub inbox_url: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
@@ -49,15 +47,15 @@ impl From<UserRow> for User {
header_url: r.header_url, header_url: r.header_url,
custom_css: r.custom_css, custom_css: r.custom_css,
local: r.local, local: r.local,
ap_id: r.ap_id,
inbox_url: r.inbox_url,
created_at: r.created_at, created_at: r.created_at,
updated_at: r.updated_at, updated_at: r.updated_at,
} }
} }
} }
pub const USER_SELECT: &str = "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,created_at,updated_at FROM users"; pub const USER_SELECT: &str =
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
custom_css,local,created_at,updated_at FROM users";
#[async_trait] #[async_trait]
impl UserRepository for PgUserRepository { impl UserRepository for PgUserRepository {
@@ -90,14 +88,14 @@ impl UserRepository for PgUserRepository {
async fn save(&self, user: &User) -> Result<(), DomainError> { async fn save(&self, user: &User) -> Result<(), DomainError> {
sqlx::query( sqlx::query(
"INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,created_at,updated_at) "INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,created_at,updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
username=EXCLUDED.username, email=EXCLUDED.email, username=EXCLUDED.username, email=EXCLUDED.email,
password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name, password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name,
bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url, bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url,
header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css, header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css,
local=EXCLUDED.local, ap_id=EXCLUDED.ap_id, inbox_url=EXCLUDED.inbox_url, local=EXCLUDED.local,
updated_at=NOW()" updated_at=NOW()"
) )
.bind(user.id.as_uuid()) .bind(user.id.as_uuid())
@@ -110,8 +108,6 @@ impl UserRepository for PgUserRepository {
.bind(&user.header_url) .bind(&user.header_url)
.bind(&user.custom_css) .bind(&user.custom_css)
.bind(user.local) .bind(user.local)
.bind(&user.ap_id)
.bind(&user.inbox_url)
.bind(user.created_at) .bind(user.created_at)
.bind(user.updated_at) .bind(user.updated_at)
.execute(&self.pool) .execute(&self.pool)

View File

@@ -1,7 +1,7 @@
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::thought::{Thought, Visibility}, models::thought::Visibility,
ports::{ActivityPubRepository, OutboundFederationPort, ThoughtRepository, UserRepository}, ports::{ActivityPubRepository, OutboundFederationPort, ThoughtRepository, UserRepository},
value_objects::ThoughtId, value_objects::ThoughtId,
}; };
@@ -18,11 +18,11 @@ pub struct FederationEventService {
} }
impl FederationEventService { impl FederationEventService {
fn object_ap_id(&self, thought: &Thought, thought_id: &ThoughtId) -> String { async fn object_ap_id(&self, thought_id: &ThoughtId) -> Result<String, DomainError> {
thought if let Some(ap_id) = self.ap_repo.get_thought_ap_id(thought_id).await? {
.ap_id return Ok(ap_id);
.clone() }
.unwrap_or_else(|| format!("{}/thoughts/{}", self.base_url, thought_id)) Ok(format!("{}/thoughts/{}", self.base_url, thought_id))
} }
pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> { pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> {
@@ -48,31 +48,24 @@ impl FederationEventService {
Some(u) => u, Some(u) => u,
None => return Ok(()), None => return Ok(()),
}; };
// For replies to remote posts: in_reply_to_url is None but in_reply_to_id // Resolve in_reply_to_url for the parent thought via AP repo.
// points to the locally-stored remote thought. Resolve its ap_id so the let in_reply_to_url = if let Some(ref reply_id) = thought.in_reply_to_id {
// outbound Note includes inReplyTo and Mastodon threads it correctly. let ap_id = self
let thought = if thought.in_reply_to_url.is_none() { .ap_repo
if let Some(ref reply_id) = thought.in_reply_to_id { .get_thought_ap_id(reply_id)
match self.thoughts.find_by_id(reply_id).await? { .await?
Some(parent) => { .unwrap_or_else(|| format!("{}/thoughts/{}", self.base_url, reply_id));
let parent_ap_url = parent.ap_id.unwrap_or_else(|| { Some(ap_id)
format!("{}/thoughts/{}", self.base_url, reply_id)
});
domain::models::thought::Thought {
in_reply_to_url: Some(parent_ap_url),
..thought
}
}
None => thought,
}
} else {
thought
}
} else { } else {
thought None
}; };
self.ap self.ap
.broadcast_create(user_id, &thought, user.username.as_str()) .broadcast_create(
user_id,
&thought,
user.username.as_str(),
in_reply_to_url.as_deref(),
)
.await .await
} }
@@ -106,8 +99,21 @@ impl FederationEventService {
Some(u) => u, Some(u) => u,
None => return Ok(()), None => return Ok(()),
}; };
let in_reply_to_url = if let Some(ref reply_id) = thought.in_reply_to_id {
self.ap_repo
.get_thought_ap_id(reply_id)
.await?
.or_else(|| Some(format!("{}/thoughts/{}", self.base_url, reply_id)))
} else {
None
};
self.ap self.ap
.broadcast_update(user_id, &thought, user.username.as_str()) .broadcast_update(
user_id,
&thought,
user.username.as_str(),
in_reply_to_url.as_deref(),
)
.await .await
} }
@@ -122,11 +128,10 @@ impl FederationEventService {
_ => return Ok(()), _ => return Ok(()),
}; };
let _ = booster; let _ = booster;
let thought = match self.thoughts.find_by_id(thought_id).await? { if self.thoughts.find_by_id(thought_id).await?.is_none() {
Some(t) => t, return Ok(());
None => return Ok(()), }
}; let object_ap_id = self.object_ap_id(thought_id).await?;
let object_ap_id = self.object_ap_id(&thought, thought_id);
self.ap.broadcast_announce(user_id, &object_ap_id).await self.ap.broadcast_announce(user_id, &object_ap_id).await
} }
@@ -134,11 +139,10 @@ impl FederationEventService {
user_id, user_id,
thought_id, thought_id,
} => { } => {
let thought = match self.thoughts.find_by_id(thought_id).await? { if self.thoughts.find_by_id(thought_id).await?.is_none() {
Some(t) => t, return Ok(());
None => return Ok(()), }
}; let object_ap_id = self.object_ap_id(thought_id).await?;
let object_ap_id = self.object_ap_id(&thought, thought_id);
self.ap self.ap
.broadcast_undo_announce(user_id, &object_ap_id) .broadcast_undo_announce(user_id, &object_ap_id)
.await .await
@@ -262,17 +266,19 @@ impl FederationEventService {
}; };
let _ = liker; let _ = liker;
let thought = match self.thoughts.find_by_id(thought_id).await? { let thought = match self.thoughts.find_by_id(thought_id).await? {
Some(t) if t.ap_id.is_some() => t, Some(t) => t,
_ => return Ok(()), _ => return Ok(()),
}; };
let author = match self.users.find_by_id(&thought.user_id).await? { let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? {
Some(u) if u.inbox_url.is_some() => u, Some(id) => id,
_ => return Ok(()), None => return Ok(()), // local thought — no federation needed
};
let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? {
Some(u) => u,
None => return Ok(()),
}; };
let object_ap_id = thought.ap_id.unwrap();
let inbox_url = author.inbox_url.unwrap();
self.ap self.ap
.broadcast_like(user_id, &object_ap_id, &inbox_url) .broadcast_like(user_id, &thought_ap_id, &actor_urls.inbox_url)
.await .await
} }
@@ -286,17 +292,19 @@ impl FederationEventService {
}; };
let _ = liker; let _ = liker;
let thought = match self.thoughts.find_by_id(thought_id).await? { let thought = match self.thoughts.find_by_id(thought_id).await? {
Some(t) if t.ap_id.is_some() => t, Some(t) => t,
_ => return Ok(()), _ => return Ok(()),
}; };
let author = match self.users.find_by_id(&thought.user_id).await? { let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? {
Some(u) if u.inbox_url.is_some() => u, Some(id) => id,
_ => return Ok(()), None => return Ok(()),
};
let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? {
Some(u) => u,
None => return Ok(()),
}; };
let object_ap_id = thought.ap_id.unwrap();
let inbox_url = author.inbox_url.unwrap();
self.ap self.ap
.broadcast_undo_like(user_id, &object_ap_id, &inbox_url) .broadcast_undo_like(user_id, &thought_ap_id, &actor_urls.inbox_url)
.await .await
} }
@@ -345,6 +353,7 @@ mod tests {
_: &UserId, _: &UserId,
thought: &Thought, thought: &Thought,
_: &str, _: &str,
_in_reply_to_url: Option<&str>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
self.created.lock().unwrap().push(thought.id.clone()); self.created.lock().unwrap().push(thought.id.clone());
Ok(()) Ok(())
@@ -358,6 +367,7 @@ mod tests {
_: &UserId, _: &UserId,
thought: &Thought, thought: &Thought,
_: &str, _: &str,
_in_reply_to_url: Option<&str>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
self.updated.lock().unwrap().push(thought.id.clone()); self.updated.lock().unwrap().push(thought.id.clone());
Ok(()) Ok(())
@@ -460,10 +470,9 @@ mod tests {
async fn remote_thought_created_does_not_broadcast() { async fn remote_thought_created_does_not_broadcast() {
let store = TestStore::default(); let store = TestStore::default();
let alice = alice(); let alice = alice();
// Remote thought: local = false, ap_id = Some(...) // Remote thought: local = false
let mut thought = local_thought(alice.id.clone()); let mut thought = local_thought(alice.id.clone());
thought.local = false; thought.local = false;
thought.ap_id = Some("https://remote.example/notes/1".into());
store.users.lock().unwrap().push(alice.clone()); store.users.lock().unwrap().push(alice.clone());
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
@@ -553,7 +562,10 @@ mod tests {
let alice = alice(); let alice = alice();
let mut thought = local_thought(alice.id.clone()); let mut thought = local_thought(alice.id.clone());
thought.local = false; thought.local = false;
thought.ap_id = Some("https://mastodon.social/users/bob/statuses/123".into()); store.thought_ap_ids.lock().unwrap().insert(
thought.id.clone(),
"https://mastodon.social/users/bob/statuses/123".into(),
);
store.users.lock().unwrap().push(alice.clone()); store.users.lock().unwrap().push(alice.clone());
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
@@ -702,7 +714,10 @@ mod tests {
let alice = alice(); let alice = alice();
let mut thought = local_thought(alice.id.clone()); let mut thought = local_thought(alice.id.clone());
thought.local = false; thought.local = false;
thought.ap_id = Some("https://mastodon.social/users/bob/statuses/456".into()); store.thought_ap_ids.lock().unwrap().insert(
thought.id.clone(),
"https://mastodon.social/users/bob/statuses/456".into(),
);
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
let spy = Arc::new(SpyPort::default()); let spy = Arc::new(SpyPort::default());
@@ -797,10 +812,19 @@ mod tests {
PasswordHash("h".into()), PasswordHash("h".into()),
); );
author.local = false; author.local = false;
author.inbox_url = Some("https://mastodon.social/users/author/inbox".into()); store.actor_ap_urls.lock().unwrap().insert(
author.id.clone(),
domain::ports::ActorApUrls {
ap_id: "https://mastodon.social/users/author".into(),
inbox_url: "https://mastodon.social/users/author/inbox".into(),
},
);
let mut thought = local_thought(author.id.clone()); let thought = local_thought(author.id.clone());
thought.ap_id = Some("https://mastodon.social/posts/123".into()); store.thought_ap_ids.lock().unwrap().insert(
thought.id.clone(),
"https://mastodon.social/posts/123".into(),
);
let liker = alice(); let liker = alice();

View File

@@ -15,8 +15,6 @@ pub struct Thought {
pub user_id: UserId, pub user_id: UserId,
pub content: Content, pub content: Content,
pub in_reply_to_id: Option<ThoughtId>, pub in_reply_to_id: Option<ThoughtId>,
pub in_reply_to_url: Option<String>,
pub ap_id: Option<String>,
pub visibility: Visibility, pub visibility: Visibility,
pub content_warning: Option<String>, pub content_warning: Option<String>,
pub sensitive: bool, pub sensitive: bool,
@@ -60,8 +58,6 @@ impl Thought {
user_id, user_id,
content, content,
in_reply_to_id, in_reply_to_id,
in_reply_to_url: None,
ap_id: None,
visibility, visibility,
content_warning, content_warning,
sensitive, sensitive,

View File

@@ -13,8 +13,6 @@ pub struct User {
pub header_url: Option<String>, pub header_url: Option<String>,
pub custom_css: Option<String>, pub custom_css: Option<String>,
pub local: bool, pub local: bool,
pub ap_id: Option<String>,
pub inbox_url: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
@@ -38,8 +36,6 @@ impl User {
header_url: None, header_url: None,
custom_css: None, custom_css: None,
local: true, local: true,
ap_id: None,
inbox_url: None,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
} }

View File

@@ -442,6 +442,7 @@ pub trait OutboundFederationPort: Send + Sync {
author_user_id: &UserId, author_user_id: &UserId,
thought: &Thought, thought: &Thought,
author_username: &str, author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), DomainError>; ) -> Result<(), DomainError>;
/// Fan out a Delete tombstone for a now-deleted local Note. /// Fan out a Delete tombstone for a now-deleted local Note.
@@ -459,6 +460,7 @@ pub trait OutboundFederationPort: Send + Sync {
author_user_id: &UserId, author_user_id: &UserId,
thought: &Thought, thought: &Thought,
author_username: &str, author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), DomainError>; ) -> Result<(), DomainError>;
/// Fan out an Announce(object_ap_id) for a boost. /// Fan out an Announce(object_ap_id) for a boost.

View File

@@ -19,6 +19,7 @@ use crate::{
}; };
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use url; use url;
@@ -35,6 +36,12 @@ pub struct TestStore {
pub top_friends: Arc<Mutex<Vec<TopFriend>>>, pub top_friends: Arc<Mutex<Vec<TopFriend>>>,
pub notifications: Arc<Mutex<Vec<Notification>>>, pub notifications: Arc<Mutex<Vec<Notification>>>,
pub events: Arc<Mutex<Vec<DomainEvent>>>, pub events: Arc<Mutex<Vec<DomainEvent>>>,
/// AP URL → UserId for remote actors (used by find_remote_actor_id / intern_remote_actor)
pub actor_ap_ids: Arc<Mutex<HashMap<String, UserId>>>,
/// ThoughtId → AP object URL (used by get_thought_ap_id)
pub thought_ap_ids: Arc<Mutex<HashMap<ThoughtId, String>>>,
/// UserId → ActorApUrls (used by get_actor_ap_urls)
pub actor_ap_urls: Arc<Mutex<HashMap<UserId, ActorApUrls>>>,
} }
#[async_trait] #[async_trait]
@@ -802,14 +809,12 @@ impl ActivityPubRepository for TestStore {
&self, &self,
actor_ap_url: &url::Url, actor_ap_url: &url::Url,
) -> Result<Option<UserId>, DomainError> { ) -> Result<Option<UserId>, DomainError> {
let url = actor_ap_url.to_string();
Ok(self Ok(self
.users .actor_ap_ids
.lock() .lock()
.unwrap() .unwrap()
.iter() .get(actor_ap_url.as_str())
.find(|u| u.ap_id.as_deref() == Some(&url)) .cloned())
.map(|u| u.id.clone()))
} }
async fn intern_remote_actor(&self, actor_ap_url: &url::Url) -> Result<UserId, DomainError> { async fn intern_remote_actor(&self, actor_ap_url: &url::Url) -> Result<UserId, DomainError> {
if let Some(uid) = self.find_remote_actor_id(actor_ap_url).await? { if let Some(uid) = self.find_remote_actor_id(actor_ap_url).await? {
@@ -831,12 +836,14 @@ impl ActivityPubRepository for TestStore {
header_url: None, header_url: None,
custom_css: None, custom_css: None,
local: false, local: false,
ap_id: Some(actor_ap_url.to_string()),
inbox_url: None,
created_at: chrono::Utc::now(), created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(), updated_at: chrono::Utc::now(),
}; };
self.users.lock().unwrap().push(user); self.users.lock().unwrap().push(user);
self.actor_ap_ids
.lock()
.unwrap()
.insert(actor_ap_url.to_string(), uid.clone());
Ok(uid) Ok(uid)
} }
async fn update_remote_actor_display( async fn update_remote_actor_display(
@@ -884,15 +891,15 @@ impl ActivityPubRepository for TestStore {
} }
async fn get_thought_ap_id( async fn get_thought_ap_id(
&self, &self,
_thought_id: &ThoughtId, thought_id: &ThoughtId,
) -> Result<Option<String>, DomainError> { ) -> Result<Option<String>, DomainError> {
Ok(None) Ok(self.thought_ap_ids.lock().unwrap().get(thought_id).cloned())
} }
async fn get_actor_ap_urls( async fn get_actor_ap_urls(
&self, &self,
_user_id: &UserId, user_id: &UserId,
) -> Result<Option<ActorApUrls>, DomainError> { ) -> Result<Option<ActorApUrls>, DomainError> {
Ok(None) Ok(self.actor_ap_urls.lock().unwrap().get(user_id).cloned())
} }
} }

View File

@@ -26,7 +26,7 @@ pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtRespon
content: e.thought.content.as_str().to_string(), content: e.thought.content.as_str().to_string(),
author: to_user_response(&e.author), author: to_user_response(&e.author),
in_reply_to_id: e.thought.in_reply_to_id.as_ref().map(|id| id.as_uuid()), in_reply_to_id: e.thought.in_reply_to_id.as_ref().map(|id| id.as_uuid()),
in_reply_to_url: e.thought.in_reply_to_url.clone(), in_reply_to_url: None,
visibility: e.thought.visibility.as_str().to_string(), visibility: e.thought.visibility.as_str().to_string(),
content_warning: e.thought.content_warning.clone(), content_warning: e.thought.content_warning.clone(),
sensitive: e.thought.sensitive, sensitive: e.thought.sensitive,