fix(federation): fix 27 AP bugs, gaps, and inconsistencies
Round 1 — 18 bug fixes:
- remote likes/boosts now persist in engagement tables
- intern_remote_actor uses name@domain, expanded username to VARCHAR(255)
- PgRemoteActorRepository upsert/find now handles all fields
- update_following_status no longer a no-op, count_followers counts all
- accept/reject follow publishes event before DB mark (atomicity)
- fetch_outbox_page follows pagination via next links
- actor URL canonicalized to /users/{uuid}
- content_to_html escapes single quotes
- WebFinger accepts application/ld+json type
- try_from_ap accepts Article and Page object types
- feed SQL uses parameterized viewer UUID instead of format!
- content cap raised from 500 to 5000 chars
- also_known_as changed from Option<String> to Vec<String>
- connections fetch always triggers from page 1
Round 2 — 9 gap fixes:
- on_announce_removed handler deletes boost row on Undo(Announce)
- on_update handles Person/Service/Group actor profile updates
- sync_remote_actor_to_user syncs remote_actors → users on create/update
- FederationBlockPort: block_by_username sends Block activity to remote
- domain RemoteActor gains inbox_url, shared_inbox_url fields
- remote_actors attachment column (JSONB) with read/write
- .well-known/host-meta endpoint
- 256KB body limit on AP inbox routes
- outbox cleanup job (7-day retention, hourly sweep)
This commit is contained in:
@@ -10,7 +10,7 @@ use url::Url;
|
||||
use crate::note::{ThoughtNote, ThoughtNoteInput};
|
||||
use crate::port::{AcceptNoteInput, ActivityPubRepository};
|
||||
use crate::urls::ThoughtsUrls;
|
||||
use domain::ports::{EventPublisher, TagRepository};
|
||||
use domain::ports::{BoostRepository, EventPublisher, LikeRepository, TagRepository};
|
||||
use domain::value_objects::UserId;
|
||||
use k_ap::{ApContentReader, ApObjectHandler};
|
||||
|
||||
@@ -19,6 +19,8 @@ pub struct ThoughtsObjectHandler {
|
||||
urls: ThoughtsUrls,
|
||||
event_publisher: Option<Arc<dyn EventPublisher>>,
|
||||
tag_repo: Arc<dyn TagRepository>,
|
||||
likes: Arc<dyn LikeRepository>,
|
||||
boosts: Arc<dyn BoostRepository>,
|
||||
}
|
||||
|
||||
impl ThoughtsObjectHandler {
|
||||
@@ -27,12 +29,16 @@ impl ThoughtsObjectHandler {
|
||||
base_url: &str,
|
||||
event_publisher: Option<Arc<dyn EventPublisher>>,
|
||||
tag_repo: Arc<dyn TagRepository>,
|
||||
likes: Arc<dyn LikeRepository>,
|
||||
boosts: Arc<dyn BoostRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
urls: ThoughtsUrls::new(base_url),
|
||||
event_publisher,
|
||||
tag_repo,
|
||||
likes,
|
||||
boosts,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +112,10 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
.intern_remote_actor(actor_url.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
let _ = self
|
||||
.repo
|
||||
.sync_remote_actor_to_user(actor_url.as_str())
|
||||
.await;
|
||||
|
||||
let as_public = "https://www.w3.org/ns/activitystreams#Public";
|
||||
let in_to = note.to.iter().any(|s| s == as_public);
|
||||
@@ -194,17 +204,50 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
async fn on_update(
|
||||
&self,
|
||||
ap_id: &Url,
|
||||
_actor_url: &Url,
|
||||
actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let Some((note, _)) = ThoughtNote::try_from_ap(object) else {
|
||||
tracing::debug!(ap_id = %ap_id, "on_update: skipping non-Note object");
|
||||
return Ok(());
|
||||
};
|
||||
self.repo
|
||||
.apply_note_update(ap_id.as_str(), ¬e.content)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
let obj_type = object.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match obj_type {
|
||||
"Note" | "Article" | "Page" => {
|
||||
let Some((note, _)) = ThoughtNote::try_from_ap(object) else {
|
||||
return Ok(());
|
||||
};
|
||||
self.repo
|
||||
.apply_note_update(ap_id.as_str(), ¬e.content)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
"Person" | "Service" | "Application" | "Group" | "Organization" => {
|
||||
let display_name = object.get("name").and_then(|v| v.as_str());
|
||||
let avatar_url = object
|
||||
.get("icon")
|
||||
.and_then(|v| v.get("url"))
|
||||
.and_then(|v| v.as_str());
|
||||
self.repo
|
||||
.update_remote_actor_display(
|
||||
&self
|
||||
.repo
|
||||
.find_remote_actor_id(actor_url.as_str())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?
|
||||
.ok_or_else(|| anyhow!("unknown actor"))?,
|
||||
display_name,
|
||||
avatar_url,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
let _ = self
|
||||
.repo
|
||||
.sync_remote_actor_to_user(actor_url.as_str())
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!(ap_id = %ap_id, obj_type, "on_update: skipping");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
|
||||
@@ -245,14 +288,24 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
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");
|
||||
tracing::debug!(actor = %actor_url, "on_like: remote actor not interned, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
|
||||
let like_id = domain::value_objects::LikeId::new();
|
||||
|
||||
let like = domain::models::social::Like {
|
||||
id: like_id.clone(),
|
||||
user_id: actor_user_id.clone(),
|
||||
thought_id: thought_id.clone(),
|
||||
ap_id: Some(object_url.to_string()),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
let _ = self.likes.save(&like).await;
|
||||
|
||||
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,
|
||||
@@ -294,10 +347,13 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
}
|
||||
};
|
||||
|
||||
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
|
||||
let _ = self.likes.delete(&actor_user_id, &thought_id).await;
|
||||
|
||||
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),
|
||||
thought_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
@@ -369,9 +425,19 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
|
||||
let boost_id = domain::value_objects::BoostId::new();
|
||||
|
||||
let boost = domain::models::social::Boost {
|
||||
id: boost_id.clone(),
|
||||
user_id: actor_user_id.clone(),
|
||||
thought_id: thought_id.clone(),
|
||||
ap_id: Some(object_url.to_string()),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
let _ = self.boosts.save(&boost).await;
|
||||
|
||||
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,
|
||||
@@ -384,6 +450,44 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_announce_removed(&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(()),
|
||||
};
|
||||
|
||||
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
|
||||
let _ = self.boosts.delete(&actor_user_id, &thought_id).await;
|
||||
|
||||
if let Some(ep) = &self.event_publisher {
|
||||
ep.publish(&domain::events::DomainEvent::BoostRemoved {
|
||||
user_id: actor_user_id,
|
||||
thought_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_announce_of_remote(&self, _object_url: &Url, _actor_url: &Url) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -74,11 +74,15 @@ pub struct ThoughtNoteInput {
|
||||
|
||||
impl ThoughtNote {
|
||||
/// Returns `(note, extensions)` if `value` is a Note object, `None` otherwise.
|
||||
pub fn try_from_ap(value: serde_json::Value) -> Option<(Self, Option<serde_json::Value>)> {
|
||||
if value.get("type").and_then(|v| v.as_str()) != Some("Note") {
|
||||
pub fn try_from_ap(mut value: serde_json::Value) -> Option<(Self, Option<serde_json::Value>)> {
|
||||
let obj_type = value.get("type").and_then(|v| v.as_str());
|
||||
if !matches!(obj_type, Some("Note" | "Article" | "Page")) {
|
||||
return None;
|
||||
}
|
||||
let extensions = extract_extensions(&value);
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert("type".to_string(), serde_json::json!("Note"));
|
||||
}
|
||||
serde_json::from_value(value)
|
||||
.ok()
|
||||
.map(|note| (note, extensions))
|
||||
|
||||
@@ -101,6 +101,9 @@ pub trait ActivityPubRepository: Send + Sync {
|
||||
/// Returns None for users that have not been federated.
|
||||
async fn get_actor_ap_urls(&self, user_id: &UserId)
|
||||
-> Result<Option<ActorApUrls>, DomainError>;
|
||||
|
||||
/// Sync display_name + avatar_url from remote_actors to users table.
|
||||
async fn sync_remote_actor_to_user(&self, actor_ap_url: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -23,7 +23,8 @@ fn content_to_html(text: &str) -> String {
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """);
|
||||
.replace('"', """)
|
||||
.replace('\'', "'");
|
||||
let paragraphs: Vec<&str> = escaped.split('\n').filter(|s| !s.is_empty()).collect();
|
||||
if paragraphs.is_empty() {
|
||||
format!("<p>{}</p>", escaped)
|
||||
@@ -116,13 +117,17 @@ fn k_ap_actor_to_domain(a: k_ap::RemoteActor) -> DomainRemoteActor {
|
||||
last_fetched_at: chrono::Utc::now(),
|
||||
bio: a.bio,
|
||||
banner_url: a.banner_url,
|
||||
also_known_as: a.also_known_as.into_iter().next(),
|
||||
also_known_as: a.also_known_as,
|
||||
followers_url: a.followers_url,
|
||||
following_url: a.following_url,
|
||||
inbox_url: Some(a.inbox_url),
|
||||
shared_inbox_url: a.shared_inbox_url,
|
||||
attachment: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: these fetches are unsigned — fails on instances with authorized-fetch (Secure Mode).
|
||||
// Fix requires exposing k-ap's signed HTTP client.
|
||||
async fn resolve_actor_profiles_from_urls(
|
||||
urls: Vec<String>,
|
||||
) -> Vec<domain::models::actor_connection_summary::ActorConnectionSummary> {
|
||||
@@ -201,7 +206,9 @@ async fn webfinger_resolve_actor_url(handle: &str) -> anyhow::Result<String> {
|
||||
.and_then(|links| {
|
||||
links.iter().find(|l| {
|
||||
l["rel"].as_str() == Some("self")
|
||||
&& l["type"].as_str() == Some("application/activity+json")
|
||||
&& l["type"].as_str().is_some_and(|t| {
|
||||
t == "application/activity+json" || t.starts_with("application/ld+json")
|
||||
})
|
||||
})
|
||||
})
|
||||
.and_then(|l| l["href"].as_str())
|
||||
@@ -415,11 +422,8 @@ impl FederationSchedulerPort for ApFederationAdapter {
|
||||
actor_ap_url: &str,
|
||||
collection_url: &str,
|
||||
connection_type: &str,
|
||||
page: u32,
|
||||
_page: u32,
|
||||
) -> Result<(), DomainError> {
|
||||
if page != 1 {
|
||||
return Ok(());
|
||||
}
|
||||
let actor = actor_ap_url.to_string();
|
||||
let collection = collection_url.to_string();
|
||||
let conn_type = connection_type.to_string();
|
||||
@@ -536,9 +540,15 @@ impl FederationLookupPort for ApFederationAdapter {
|
||||
last_fetched_at: chrono::Utc::now(),
|
||||
bio: actor.bio,
|
||||
banner_url: actor.banner_url.as_ref().map(|u| u.to_string()),
|
||||
also_known_as: actor.also_known_as.into_iter().next(),
|
||||
also_known_as: actor
|
||||
.also_known_as
|
||||
.into_iter()
|
||||
.map(|u| u.to_string())
|
||||
.collect(),
|
||||
followers_url: actor.followers_url.as_ref().map(|u| u.to_string()),
|
||||
following_url: actor.following_url.as_ref().map(|u| u.to_string()),
|
||||
inbox_url: None,
|
||||
shared_inbox_url: None,
|
||||
attachment: actor
|
||||
.attachment
|
||||
.into_iter()
|
||||
@@ -599,20 +609,36 @@ impl FederationFetchPort for ApFederationAdapter {
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
|
||||
let url = base["first"]
|
||||
let first_url = base["first"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("{}?page={}", outbox_url, page));
|
||||
.unwrap_or_else(|| format!("{}?page=1", outbox_url));
|
||||
|
||||
let resp: serde_json::Value = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/activity+json, application/ld+json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
let mut current_url = first_url;
|
||||
let mut hops = 0u32;
|
||||
let target_page = page.max(1);
|
||||
let max_hops = 10u32;
|
||||
|
||||
let resp: serde_json::Value = loop {
|
||||
let page_resp: serde_json::Value = client
|
||||
.get(¤t_url)
|
||||
.header("Accept", "application/activity+json, application/ld+json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
|
||||
hops += 1;
|
||||
if hops >= target_page || hops >= max_hops {
|
||||
break page_resp;
|
||||
}
|
||||
match page_resp["next"].as_str() {
|
||||
Some(next) => current_url = next.to_string(),
|
||||
None => break page_resp,
|
||||
}
|
||||
};
|
||||
|
||||
let empty = vec![];
|
||||
let items = resp["orderedItems"].as_array().unwrap_or(&empty);
|
||||
@@ -850,4 +876,33 @@ impl FederationFollowRequestPort for ApFederationAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
// ── FederationBlockPort ──────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::FederationBlockPort for ApFederationAdapter {
|
||||
async fn block_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError> {
|
||||
let actor_url = webfinger_resolve_actor_url(handle)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
self.inner
|
||||
.block_actor(local_user_id.as_uuid(), &actor_url)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
|
||||
async fn unblock_remote(
|
||||
&self,
|
||||
local_user_id: &UserId,
|
||||
handle: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = webfinger_resolve_actor_url(handle)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
self.inner
|
||||
.unblock_actor(local_user_id.as_uuid(), &actor_url)
|
||||
.await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// FederationActionPort is a blanket supertrait; no explicit impl needed.
|
||||
|
||||
@@ -11,24 +11,24 @@ impl ThoughtsUrls {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_url(&self, username: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}", self.base_url, username)).expect("valid URL")
|
||||
pub fn user_url(&self, id: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}", self.base_url, id)).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_inbox(&self, id: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/inbox", self.base_url, id)).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_outbox(&self, id: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/outbox", self.base_url, id)).expect("valid URL")
|
||||
}
|
||||
|
||||
pub fn user_followers(&self, username: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/followers", self.base_url, username)).expect("valid URL")
|
||||
pub fn user_followers(&self, id: &str) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/followers", self.base_url, id)).expect("valid URL")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user