fix(federation): fix 27 AP bugs, gaps, and inconsistencies
Some checks failed
lint / lint (push) Failing after 9m26s
test / unit (push) Successful in 16m3s

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:
2026-05-29 11:28:40 +02:00
parent f9de21dcfa
commit 84edf58de6
32 changed files with 565 additions and 142 deletions

View File

@@ -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)