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:
@@ -1,7 +1,7 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
|
||||
const MAX_REMOTE_CONTENT_CHARS: usize = 500;
|
||||
const MAX_REMOTE_CONTENT_CHARS: usize = 5000;
|
||||
const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
@@ -155,24 +155,28 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
||||
return Ok(id);
|
||||
}
|
||||
let new_id = uuid::Uuid::new_v4();
|
||||
// Use the last path segment as username (e.g. /users/alice → "alice").
|
||||
// Falls back to a random short id for long segments (e.g. UUID-based actor URLs).
|
||||
// username column is VARCHAR(32).
|
||||
let last_seg = url::Url::parse(actor_ap_url)
|
||||
.ok()
|
||||
let parsed = url::Url::parse(actor_ap_url).ok();
|
||||
let domain_str = parsed
|
||||
.as_ref()
|
||||
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
let last_seg = parsed
|
||||
.and_then(|u| {
|
||||
u.path_segments()
|
||||
.and_then(|mut s| s.next_back().map(|s| s.to_string()))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let handle = if last_seg.is_empty() {
|
||||
format!("remote_{}", &new_id.to_string()[..13])
|
||||
} else if last_seg.len() <= 32 {
|
||||
last_seg
|
||||
let handle = if last_seg.is_empty() || domain_str.is_empty() {
|
||||
format!("r_{}", &new_id.to_string()[..13])
|
||||
} else {
|
||||
format!("remote_{}", &new_id.to_string()[..13])
|
||||
let candidate = format!("{}@{}", last_seg, domain_str);
|
||||
if candidate.len() <= 255 {
|
||||
candidate
|
||||
} else {
|
||||
format!("r_{}", &new_id.to_string()[..13])
|
||||
}
|
||||
};
|
||||
sqlx::query(
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
|
||||
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING",
|
||||
)
|
||||
@@ -181,9 +185,24 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
||||
.bind(format!("{}@remote", new_id))
|
||||
.bind(actor_ap_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
// Re-fetch to get whichever id won the race
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let fallback = format!("r_{}", &new_id.to_string()[..13]);
|
||||
let new_id2 = uuid::Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
|
||||
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING",
|
||||
)
|
||||
.bind(new_id2)
|
||||
.bind(&fallback)
|
||||
.bind(format!("{}@remote", new_id2))
|
||||
.bind(actor_ap_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
}
|
||||
|
||||
self.find_remote_actor_id(actor_ap_url)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
@@ -345,6 +364,19 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
||||
.into_domain()
|
||||
.map(|opt| opt.map(|(ap_id, inbox_url)| ActorApUrls { ap_id, inbox_url }))
|
||||
}
|
||||
|
||||
async fn sync_remote_actor_to_user(&self, actor_ap_url: &str) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE users SET display_name = ra.display_name, avatar_url = ra.avatar_url, updated_at = NOW()
|
||||
FROM remote_actors ra
|
||||
WHERE users.ap_id = ra.url AND users.ap_id = $1 AND users.local = false",
|
||||
)
|
||||
.bind(actor_ap_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -113,14 +113,14 @@ impl<'a> FeedSqlBuilder<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn select(&self) -> String {
|
||||
fn select(&self, viewer_param: &str) -> String {
|
||||
let (viewer_cols, viewer_joins) = match self.viewer {
|
||||
Some(uid) => (
|
||||
Some(_) => (
|
||||
"(lv.thought_id IS NOT NULL) AS liked_by_viewer,
|
||||
(bv.thought_id IS NOT NULL) AS boosted_by_viewer".to_string(),
|
||||
format!(
|
||||
"LEFT JOIN (SELECT thought_id FROM likes WHERE user_id='{uid}') lv ON lv.thought_id = t.id
|
||||
LEFT JOIN (SELECT thought_id FROM boosts WHERE user_id='{uid}') bv ON bv.thought_id = t.id"
|
||||
"LEFT JOIN (SELECT thought_id FROM likes WHERE user_id={viewer_param}) lv ON lv.thought_id = t.id
|
||||
LEFT JOIN (SELECT thought_id FROM boosts WHERE user_id={viewer_param}) bv ON bv.thought_id = t.id"
|
||||
),
|
||||
),
|
||||
None => (
|
||||
@@ -164,13 +164,13 @@ impl<'a> FeedSqlBuilder<'a> {
|
||||
)
|
||||
}
|
||||
|
||||
fn fed_clause(&self) -> String {
|
||||
fn fed_clause(&self, viewer_param: &str) -> String {
|
||||
match self.viewer {
|
||||
Some(fid) => format!(
|
||||
Some(_) => format!(
|
||||
" OR t.user_id IN (
|
||||
SELECT u2.id FROM users u2
|
||||
JOIN federation_following ff ON u2.ap_id = ff.remote_actor_url
|
||||
WHERE ff.local_user_id = '{fid}'
|
||||
WHERE ff.local_user_id = {viewer_param}
|
||||
)"
|
||||
),
|
||||
None => String::new(),
|
||||
@@ -217,7 +217,7 @@ impl<'a> FeedSqlBuilder<'a> {
|
||||
);
|
||||
let data = format!(
|
||||
"{} WHERE t.local=true AND t.visibility='public'{} {} LIMIT $1 OFFSET $2",
|
||||
self.select(),
|
||||
self.select("$3"),
|
||||
filter,
|
||||
order
|
||||
);
|
||||
@@ -225,17 +225,16 @@ impl<'a> FeedSqlBuilder<'a> {
|
||||
}
|
||||
|
||||
fn home(&self) -> (String, String) {
|
||||
let fed = self.fed_clause();
|
||||
let filter = self.filter_sql();
|
||||
let order = self.order_sql();
|
||||
let count = format!(
|
||||
let count = format!(
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'{}",
|
||||
fed, filter
|
||||
self.fed_clause("$2"), filter
|
||||
);
|
||||
let data =
|
||||
format!(
|
||||
"{} WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'{} {} LIMIT $2 OFFSET $3",
|
||||
self.select(), fed, filter, order
|
||||
self.select("$4"), self.fed_clause("$4"), filter, order
|
||||
);
|
||||
(count, data)
|
||||
}
|
||||
@@ -249,7 +248,7 @@ impl<'a> FeedSqlBuilder<'a> {
|
||||
);
|
||||
let data = format!(
|
||||
"{} WHERE t.content % $1 AND t.visibility='public'{} {} LIMIT $2 OFFSET $3",
|
||||
self.select(),
|
||||
self.select("$4"),
|
||||
filter,
|
||||
order
|
||||
);
|
||||
@@ -271,7 +270,7 @@ impl<'a> FeedSqlBuilder<'a> {
|
||||
JOIN thought_tags tt ON tt.thought_id = t.id
|
||||
JOIN tags tg ON tg.id = tt.tag_id
|
||||
WHERE tg.name = $1 AND t.visibility = 'public'{} {} LIMIT $2 OFFSET $3",
|
||||
self.select(),
|
||||
self.select("$4"),
|
||||
filter,
|
||||
order
|
||||
);
|
||||
@@ -287,7 +286,7 @@ impl<'a> FeedSqlBuilder<'a> {
|
||||
);
|
||||
let data = format!(
|
||||
"{} WHERE t.user_id = $1 AND ($4::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $4 AND following_id = $1 AND state = 'accepted'))))){} {} LIMIT $2 OFFSET $3",
|
||||
self.select(), filter, order
|
||||
self.select("$4"), filter, order
|
||||
);
|
||||
(count, data)
|
||||
}
|
||||
@@ -300,12 +299,15 @@ impl FeedRepository for PgFeedRepository {
|
||||
let page = &req.query.page;
|
||||
let builder = FeedSqlBuilder::new(&req.options, &req.query.scope, viewer);
|
||||
|
||||
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
|
||||
|
||||
match &req.query.scope {
|
||||
FeedScope::Home { following_ids } => {
|
||||
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
|
||||
let (count_sql, data_sql) = builder.home();
|
||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||
.bind(&ids)
|
||||
.bind(viewer_uuid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
@@ -313,6 +315,7 @@ impl FeedRepository for PgFeedRepository {
|
||||
.bind(&ids)
|
||||
.bind(page.limit())
|
||||
.bind(page.offset())
|
||||
.bind(viewer_uuid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
@@ -336,6 +339,7 @@ impl FeedRepository for PgFeedRepository {
|
||||
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
|
||||
.bind(page.limit())
|
||||
.bind(page.offset())
|
||||
.bind(viewer_uuid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
@@ -361,6 +365,7 @@ impl FeedRepository for PgFeedRepository {
|
||||
.bind(query)
|
||||
.bind(page.limit())
|
||||
.bind(page.offset())
|
||||
.bind(viewer_uuid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
@@ -386,6 +391,7 @@ impl FeedRepository for PgFeedRepository {
|
||||
.bind(tag_name)
|
||||
.bind(page.limit())
|
||||
.bind(page.offset())
|
||||
.bind(viewer_uuid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
@@ -402,7 +408,6 @@ impl FeedRepository for PgFeedRepository {
|
||||
|
||||
FeedScope::User { user_id } => {
|
||||
let uid = user_id.as_uuid();
|
||||
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
|
||||
let (count_sql, data_sql) = builder.user();
|
||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||
.bind(uid)
|
||||
|
||||
@@ -18,14 +18,44 @@ impl PgRemoteActorRepository {
|
||||
#[async_trait]
|
||||
impl RemoteActorRepository for PgRemoteActorRepository {
|
||||
async fn upsert(&self, a: &RemoteActor) -> Result<(), DomainError> {
|
||||
let also_known_as: Option<Vec<&str>> = if a.also_known_as.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(a.also_known_as.iter().map(|s| s.as_str()).collect())
|
||||
};
|
||||
let attachment_json: serde_json::Value = a
|
||||
.attachment
|
||||
.iter()
|
||||
.map(|(n, v)| serde_json::json!({"name": n, "value": v}))
|
||||
.collect();
|
||||
sqlx::query(
|
||||
"INSERT INTO remote_actors(url,handle,display_name,avatar_url,last_fetched_at)
|
||||
VALUES($1,$2,$3,$4,$5)
|
||||
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
|
||||
avatar_url=EXCLUDED.avatar_url,last_fetched_at=EXCLUDED.last_fetched_at"
|
||||
"INSERT INTO remote_actors(url,handle,display_name,avatar_url,last_fetched_at,
|
||||
bio,banner_url,outbox_url,followers_url,following_url,also_known_as,attachment)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
ON CONFLICT(url) DO UPDATE SET
|
||||
handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
|
||||
avatar_url=EXCLUDED.avatar_url,last_fetched_at=EXCLUDED.last_fetched_at,
|
||||
bio=EXCLUDED.bio,banner_url=EXCLUDED.banner_url,
|
||||
outbox_url=EXCLUDED.outbox_url,followers_url=EXCLUDED.followers_url,
|
||||
following_url=EXCLUDED.following_url,also_known_as=EXCLUDED.also_known_as,
|
||||
attachment=EXCLUDED.attachment",
|
||||
)
|
||||
.bind(&a.url).bind(&a.handle).bind(&a.display_name).bind(&a.avatar_url).bind(a.last_fetched_at)
|
||||
.execute(&self.pool).await.into_domain().map(|_| ())
|
||||
.bind(&a.url)
|
||||
.bind(&a.handle)
|
||||
.bind(&a.display_name)
|
||||
.bind(&a.avatar_url)
|
||||
.bind(a.last_fetched_at)
|
||||
.bind(&a.bio)
|
||||
.bind(&a.banner_url)
|
||||
.bind(&a.outbox_url)
|
||||
.bind(&a.followers_url)
|
||||
.bind(&a.following_url)
|
||||
.bind(also_known_as.as_deref())
|
||||
.bind(&attachment_json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn find_by_url(&self, url: &str) -> Result<Option<RemoteActor>, DomainError> {
|
||||
@@ -36,24 +66,55 @@ impl RemoteActorRepository for PgRemoteActorRepository {
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
last_fetched_at: DateTime<Utc>,
|
||||
bio: Option<String>,
|
||||
banner_url: Option<String>,
|
||||
outbox_url: Option<String>,
|
||||
followers_url: Option<String>,
|
||||
following_url: Option<String>,
|
||||
also_known_as: Option<Vec<String>>,
|
||||
inbox_url: Option<String>,
|
||||
shared_inbox_url: Option<String>,
|
||||
attachment: Option<serde_json::Value>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT url,handle,display_name,avatar_url,last_fetched_at FROM remote_actors WHERE url=$1"
|
||||
).bind(url).fetch_optional(&self.pool).await
|
||||
"SELECT url,handle,display_name,avatar_url,last_fetched_at,
|
||||
bio,banner_url,outbox_url,followers_url,following_url,also_known_as,
|
||||
inbox_url,shared_inbox_url,attachment
|
||||
FROM remote_actors WHERE url=$1",
|
||||
)
|
||||
.bind(url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|o| o.map(|r| RemoteActor {
|
||||
url: r.url,
|
||||
handle: r.handle,
|
||||
display_name: r.display_name,
|
||||
avatar_url: r.avatar_url,
|
||||
last_fetched_at: r.last_fetched_at,
|
||||
bio: None,
|
||||
banner_url: None,
|
||||
also_known_as: None,
|
||||
outbox_url: None,
|
||||
followers_url: None,
|
||||
following_url: None,
|
||||
attachment: vec![],
|
||||
}))
|
||||
.map(|o| {
|
||||
o.map(|r| RemoteActor {
|
||||
url: r.url,
|
||||
handle: r.handle,
|
||||
display_name: r.display_name,
|
||||
avatar_url: r.avatar_url,
|
||||
last_fetched_at: r.last_fetched_at,
|
||||
bio: r.bio,
|
||||
banner_url: r.banner_url,
|
||||
also_known_as: r.also_known_as.unwrap_or_default(),
|
||||
outbox_url: r.outbox_url,
|
||||
followers_url: r.followers_url,
|
||||
following_url: r.following_url,
|
||||
inbox_url: r.inbox_url,
|
||||
shared_inbox_url: r.shared_inbox_url,
|
||||
attachment: r
|
||||
.attachment
|
||||
.and_then(|v| v.as_array().cloned())
|
||||
.map(|arr| {
|
||||
arr.into_iter()
|
||||
.filter_map(|item| {
|
||||
let name = item.get("name")?.as_str()?.to_string();
|
||||
let value = item.get("value")?.as_str()?.to_string();
|
||||
Some((name, value))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user