feat: add federation processed activities table and update dependencies
- Created a new SQL migration to add the `federation_processed_activities` table with an index on `processed_at`. - Updated dependencies in `Cargo.toml` files across `bootstrap` and `worker` crates, including version updates for `k-ap`. - Enhanced the event publishing mechanism in the `factory.rs` file to include a new `KapPublisher` for handling federation events. - Refactored the `build` function in `factory.rs` to accommodate the new event publisher and improve ActivityPub service initialization. - Modified the worker's main loop to handle new federation event types and improved error handling for event processing. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -4,18 +4,17 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.10" }
|
||||
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 }
|
||||
activitypub_federation = "0.7.0-beta.11"
|
||||
reqwest = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
k-ap = { version = "0.3.0", registry = "gitea" }
|
||||
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 }
|
||||
reqwest = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::port::{AcceptNoteInput, ActivityPubRepository};
|
||||
use crate::urls::ThoughtsUrls;
|
||||
use domain::ports::{EventPublisher, TagRepository};
|
||||
use domain::value_objects::UserId;
|
||||
use k_ap::ApObjectHandler;
|
||||
use k_ap::{ApContentReader, ApObjectHandler};
|
||||
|
||||
pub struct ThoughtsObjectHandler {
|
||||
repo: Arc<dyn ActivityPubRepository>,
|
||||
@@ -37,43 +37,10 @@ impl ThoughtsObjectHandler {
|
||||
}
|
||||
}
|
||||
|
||||
#[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(&user_id.to_string());
|
||||
let followers = self.urls.user_followers(&user_id.to_string());
|
||||
let in_reply_to = e
|
||||
.thought
|
||||
.in_reply_to_id
|
||||
.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||
let note = ThoughtNote::new_public(ThoughtNoteInput {
|
||||
id: note_url.clone(),
|
||||
actor_url,
|
||||
content: e.thought.content.as_str().to_owned(),
|
||||
published: e.thought.created_at,
|
||||
in_reply_to,
|
||||
sensitive: e.thought.sensitive,
|
||||
summary: e.thought.content_warning,
|
||||
followers_url: followers,
|
||||
});
|
||||
Ok((note_url, serde_json::to_value(¬e)?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
// ── ApContentReader ───────────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl ApContentReader for ThoughtsObjectHandler {
|
||||
async fn get_local_objects_page(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
@@ -112,6 +79,18 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64> {
|
||||
self.repo
|
||||
.count_local_notes()
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
}
|
||||
|
||||
// ── ApObjectHandler ───────────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
async fn on_create(
|
||||
&self,
|
||||
ap_id: &Url,
|
||||
@@ -128,7 +107,6 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
.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);
|
||||
@@ -161,7 +139,6 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
// Extract and index hashtags from the AP tag array.
|
||||
let hashtag_names: Vec<String> = note
|
||||
.tag
|
||||
.iter()
|
||||
@@ -177,7 +154,6 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()))
|
||||
@@ -408,10 +384,7 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64> {
|
||||
self.repo
|
||||
.count_local_notes()
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
async fn on_announce_of_remote(&self, _object_url: &Url, _actor_url: &Url) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,17 @@ fn build_note_json(
|
||||
note
|
||||
}
|
||||
|
||||
fn thought_to_ap_visibility(
|
||||
v: &domain::models::thought::Visibility,
|
||||
) -> k_ap::ApVisibility {
|
||||
match v {
|
||||
domain::models::thought::Visibility::Public => k_ap::ApVisibility::Public,
|
||||
domain::models::thought::Visibility::Unlisted => k_ap::ApVisibility::Public,
|
||||
domain::models::thought::Visibility::Followers => k_ap::ApVisibility::FollowersOnly,
|
||||
domain::models::thought::Visibility::Direct => k_ap::ApVisibility::Private,
|
||||
}
|
||||
}
|
||||
|
||||
fn k_ap_actor_to_domain(a: k_ap::RemoteActor) -> DomainRemoteActor {
|
||||
DomainRemoteActor {
|
||||
url: a.url,
|
||||
@@ -264,7 +275,12 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
|
||||
in_reply_to_url,
|
||||
);
|
||||
self.inner
|
||||
.broadcast_create_note(user_uuid, note)
|
||||
.broadcast_create_note(
|
||||
user_uuid,
|
||||
note,
|
||||
thought_to_ap_visibility(&thought.visibility),
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
@@ -300,7 +316,12 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
|
||||
in_reply_to_url,
|
||||
);
|
||||
self.inner
|
||||
.broadcast_update_note(user_uuid, note)
|
||||
.broadcast_update_note(
|
||||
user_uuid,
|
||||
note,
|
||||
thought_to_ap_visibility(&thought.visibility),
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
@@ -384,7 +405,7 @@ impl FederationSchedulerPort for ApFederationAdapter {
|
||||
let actor = actor_ap_url.to_string();
|
||||
let outbox = outbox_url.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = service.backfill_outbox(&outbox, &actor).await {
|
||||
if let Err(e) = service.import_remote_outbox(&outbox, &actor).await {
|
||||
tracing::warn!(actor = %actor, error = %e, "posts backfill failed");
|
||||
}
|
||||
});
|
||||
@@ -517,7 +538,7 @@ 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,
|
||||
also_known_as: actor.also_known_as.into_iter().next(),
|
||||
followers_url: actor.followers_url.as_ref().map(|u| u.to_string()),
|
||||
following_url: actor.following_url.as_ref().map(|u| u.to_string()),
|
||||
attachment: actor
|
||||
|
||||
@@ -88,6 +88,15 @@ pub enum EventPayload {
|
||||
mentioned_user_id: String,
|
||||
author_user_id: String,
|
||||
},
|
||||
FederationDeliveryRequested {
|
||||
inbox: String,
|
||||
activity: serde_json::Value,
|
||||
signing_actor_id: String,
|
||||
},
|
||||
FederationBackfillRequested {
|
||||
owner_user_id: String,
|
||||
follower_inbox_url: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl EventPayload {
|
||||
@@ -113,6 +122,8 @@ impl EventPayload {
|
||||
Self::RemoteFollowRejected { .. } => "federation.follow.rejected",
|
||||
Self::ActorMoved { .. } => "federation.actor.moved",
|
||||
Self::MentionReceived { .. } => "mentions.received",
|
||||
Self::FederationDeliveryRequested { .. } => "federation.delivery.requested",
|
||||
Self::FederationBackfillRequested { .. } => "federation.backfill.requested",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -409,6 +420,12 @@ impl TryFrom<EventPayload> for DomainEvent {
|
||||
)?),
|
||||
author_user_id: UserId::from_uuid(parse_uuid(&author_user_id, "author_user_id")?),
|
||||
},
|
||||
EventPayload::FederationDeliveryRequested { .. }
|
||||
| EventPayload::FederationBackfillRequested { .. } => {
|
||||
return Err(DomainError::Internal(
|
||||
"federation infrastructure event — not a domain event".into(),
|
||||
));
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,15 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.10" }
|
||||
sqlx = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
k-ap = { version = "0.3.0", registry = "gitea" }
|
||||
sqlx = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
url = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
sqlx = { workspace = true, features = ["migrate"] }
|
||||
sqlx = { workspace = true, features = ["migrate"] }
|
||||
|
||||
@@ -4,8 +4,8 @@ use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use k_ap::{
|
||||
ApUser, ApUserRepository, BlockedDomain, FederationRepository, Follower, FollowerStatus,
|
||||
FollowingStatus, RemoteActor,
|
||||
ActivityRepository, ActorRepository, ApActorType, ApUser, ApUserRepository, BlockedDomain,
|
||||
BlocklistRepository, Follower, FollowerStatus, FollowingStatus, FollowRepository, RemoteActor,
|
||||
};
|
||||
|
||||
// ── PostgresFederationRepository ─────────────────────────────────────────────
|
||||
@@ -55,8 +55,37 @@ fn map_remote_actor(
|
||||
}
|
||||
}
|
||||
|
||||
// ── ActivityRepository ────────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl FederationRepository for PostgresFederationRepository {
|
||||
impl ActivityRepository for PostgresFederationRepository {
|
||||
async fn is_activity_processed(&self, activity_id: &str) -> Result<bool> {
|
||||
let n: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM federation_processed_activities WHERE activity_id=$1",
|
||||
)
|
||||
.bind(activity_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
async fn mark_activity_processed(&self, activity_id: &str) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO federation_processed_activities(activity_id) VALUES($1) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(activity_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
// ── FollowRepository ──────────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl FollowRepository for PostgresFederationRepository {
|
||||
async fn add_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
@@ -68,10 +97,16 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
"INSERT INTO federation_followers(local_user_id,remote_actor_url,status,follow_activity_id)
|
||||
VALUES($1,$2,$3,$4)
|
||||
ON CONFLICT(local_user_id,remote_actor_url) DO UPDATE
|
||||
SET status=EXCLUDED.status, follow_activity_id=EXCLUDED.follow_activity_id"
|
||||
SET status=EXCLUDED.status, follow_activity_id=EXCLUDED.follow_activity_id",
|
||||
)
|
||||
.bind(local_user_id).bind(remote_actor_url).bind(status_str(&status)).bind(follow_activity_id)
|
||||
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||
.bind(local_user_id)
|
||||
.bind(remote_actor_url)
|
||||
.bind(status_str(&status))
|
||||
.bind(follow_activity_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn get_follower_follow_activity_id(
|
||||
@@ -80,8 +115,13 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>> {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT follow_activity_id FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2"
|
||||
).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
|
||||
"SELECT follow_activity_id FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.bind(remote_actor_url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
async fn remove_follower(
|
||||
@@ -117,11 +157,28 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
||||
FROM federation_followers f
|
||||
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||
WHERE f.local_user_id=$1 AND f.status='accepted'"
|
||||
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| Follower {
|
||||
actor: map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url),
|
||||
status: str_status(&r.status),
|
||||
}).collect())
|
||||
WHERE f.local_user_id=$1 AND f.status='accepted'",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
.map(|r| Follower {
|
||||
actor: map_remote_actor(
|
||||
r.remote_actor_url,
|
||||
r.handle,
|
||||
r.inbox_url,
|
||||
r.shared_inbox_url,
|
||||
r.display_name,
|
||||
r.avatar_url,
|
||||
r.outbox_url,
|
||||
),
|
||||
status: str_status(&r.status),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_followers_page(
|
||||
@@ -147,20 +204,126 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
FROM federation_followers f
|
||||
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||
WHERE f.local_user_id=$1 AND f.status='accepted'
|
||||
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
||||
).bind(local_user_id).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| Follower {
|
||||
actor: map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url),
|
||||
status: str_status(&r.status),
|
||||
}).collect())
|
||||
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
.map(|r| Follower {
|
||||
actor: map_remote_actor(
|
||||
r.remote_actor_url,
|
||||
r.handle,
|
||||
r.inbox_url,
|
||||
r.shared_inbox_url,
|
||||
r.display_name,
|
||||
r.avatar_url,
|
||||
r.outbox_url,
|
||||
),
|
||||
status: str_status(&r.status),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
let n: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM federation_followers WHERE local_user_id=$1 AND status='accepted'"
|
||||
).bind(local_user_id).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
|
||||
"SELECT COUNT(*) FROM federation_followers WHERE local_user_id=$1 AND status='accepted'",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
let n: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM federation_followers WHERE local_user_id=$1 AND status='accepted'",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
async fn get_accepted_followers_page(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
offset: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<RemoteActor>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
remote_actor_url: String,
|
||||
handle: String,
|
||||
inbox_url: String,
|
||||
shared_inbox_url: Option<String>,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
outbox_url: Option<String>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
|
||||
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
||||
FROM federation_followers f
|
||||
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||
WHERE f.local_user_id=$1 AND f.status='accepted'
|
||||
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
map_remote_actor(
|
||||
r.remote_actor_url,
|
||||
r.handle,
|
||||
r.inbox_url,
|
||||
r.shared_inbox_url,
|
||||
r.display_name,
|
||||
r.avatar_url,
|
||||
r.outbox_url,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_accepted_follower_inboxes(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
) -> Result<Vec<String>> {
|
||||
let rows: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT COALESCE(r.shared_inbox_url, r.inbox_url)
|
||||
FROM federation_followers f
|
||||
JOIN remote_actors r ON r.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $1
|
||||
AND f.status = 'accepted'
|
||||
AND f.remote_actor_url NOT IN (
|
||||
SELECT actor_url FROM federation_blocked_actors WHERE local_user_id = $1
|
||||
)
|
||||
AND SUBSTRING(f.remote_actor_url FROM 'https?://([^/]+)')
|
||||
NOT IN (SELECT domain FROM federation_blocked_domains)
|
||||
AND COALESCE(r.shared_inbox_url, r.inbox_url) IS NOT NULL
|
||||
AND COALESCE(r.shared_inbox_url, r.inbox_url) <> ''",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
@@ -177,10 +340,27 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
||||
FROM federation_followers f
|
||||
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||
WHERE f.local_user_id=$1 AND f.status='pending'"
|
||||
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
|
||||
map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
|
||||
).collect())
|
||||
WHERE f.local_user_id=$1 AND f.status='pending'",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
map_remote_actor(
|
||||
r.remote_actor_url,
|
||||
r.handle,
|
||||
r.inbox_url,
|
||||
r.shared_inbox_url,
|
||||
r.display_name,
|
||||
r.avatar_url,
|
||||
r.outbox_url,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_follower_status(
|
||||
@@ -189,9 +369,16 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
remote_actor_url: &str,
|
||||
status: FollowerStatus,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE federation_followers SET status=$3 WHERE local_user_id=$1 AND remote_actor_url=$2")
|
||||
.bind(local_user_id).bind(remote_actor_url).bind(status_str(&status))
|
||||
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||
sqlx::query(
|
||||
"UPDATE federation_followers SET status=$3 WHERE local_user_id=$1 AND remote_actor_url=$2",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.bind(remote_actor_url)
|
||||
.bind(status_str(&status))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn add_following(
|
||||
@@ -205,10 +392,16 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
"INSERT INTO federation_following(local_user_id,remote_actor_url,follow_activity_id,outbox_url)
|
||||
VALUES($1,$2,$3,$4)
|
||||
ON CONFLICT(local_user_id,remote_actor_url) DO UPDATE
|
||||
SET follow_activity_id=EXCLUDED.follow_activity_id"
|
||||
SET follow_activity_id=EXCLUDED.follow_activity_id",
|
||||
)
|
||||
.bind(local_user_id).bind(&actor.url).bind(follow_activity_id).bind(&actor.outbox_url)
|
||||
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||
.bind(local_user_id)
|
||||
.bind(&actor.url)
|
||||
.bind(follow_activity_id)
|
||||
.bind(&actor.outbox_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn get_follow_activity_id(
|
||||
@@ -217,8 +410,13 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>> {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT follow_activity_id FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2"
|
||||
).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
|
||||
"SELECT follow_activity_id FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.bind(remote_actor_url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
@@ -249,10 +447,27 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
||||
FROM federation_following f
|
||||
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||
WHERE f.local_user_id=$1"
|
||||
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
|
||||
map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
|
||||
).collect())
|
||||
WHERE f.local_user_id=$1",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
map_remote_actor(
|
||||
r.remote_actor_url,
|
||||
r.handle,
|
||||
r.inbox_url,
|
||||
r.shared_inbox_url,
|
||||
r.display_name,
|
||||
r.avatar_url,
|
||||
r.outbox_url,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_following_page(
|
||||
@@ -276,20 +491,40 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
||||
FROM federation_following f
|
||||
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
|
||||
WHERE f.local_user_id=$1 AND f.status='accepted'
|
||||
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
||||
).bind(local_user_id).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
|
||||
map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
|
||||
).collect())
|
||||
WHERE f.local_user_id=$1
|
||||
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
map_remote_actor(
|
||||
r.remote_actor_url,
|
||||
r.handle,
|
||||
r.inbox_url,
|
||||
r.shared_inbox_url,
|
||||
r.display_name,
|
||||
r.avatar_url,
|
||||
r.outbox_url,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
let n: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM federation_following WHERE local_user_id=$1 AND status='accepted'")
|
||||
.bind(local_user_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
let n: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM federation_following WHERE local_user_id=$1",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
@@ -308,42 +543,51 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>> {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT outbox_url FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2"
|
||||
).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,outbox_url,last_fetched_at)
|
||||
VALUES($1,$2,$3,$4,$5,'',$6,$7,NOW())
|
||||
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
|
||||
inbox_url=EXCLUDED.inbox_url,shared_inbox_url=EXCLUDED.shared_inbox_url,
|
||||
avatar_url=EXCLUDED.avatar_url,outbox_url=EXCLUDED.outbox_url,last_fetched_at=NOW()"
|
||||
"SELECT outbox_url FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2",
|
||||
)
|
||||
.bind(&actor.url).bind(&actor.handle).bind(&actor.display_name)
|
||||
.bind(&actor.inbox_url).bind(&actor.shared_inbox_url)
|
||||
.bind(&actor.avatar_url).bind(&actor.outbox_url)
|
||||
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||
.bind(local_user_id)
|
||||
.bind(remote_actor_url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
url: String,
|
||||
handle: String,
|
||||
inbox_url: String,
|
||||
shared_inbox_url: Option<String>,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
outbox_url: Option<String>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT url,handle,inbox_url,shared_inbox_url,display_name,avatar_url,outbox_url FROM remote_actors WHERE url=$1"
|
||||
).bind(actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e)).map(|o| o.map(|r|
|
||||
map_remote_actor(r.url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
|
||||
))
|
||||
}
|
||||
async fn migrate_follower_actor(
|
||||
&self,
|
||||
old_actor_url: &str,
|
||||
new_actor_url: &str,
|
||||
) -> Result<Vec<uuid::Uuid>> {
|
||||
let mut tx = self.pool.begin().await.map_err(|e| anyhow!(e))?;
|
||||
|
||||
let affected: Vec<uuid::Uuid> = sqlx::query_scalar(
|
||||
"INSERT INTO federation_following(local_user_id, remote_actor_url, follow_activity_id, outbox_url)
|
||||
SELECT local_user_id, $2, follow_activity_id, outbox_url
|
||||
FROM federation_following
|
||||
WHERE remote_actor_url = $1
|
||||
ON CONFLICT (local_user_id, remote_actor_url) DO NOTHING
|
||||
RETURNING local_user_id",
|
||||
)
|
||||
.bind(old_actor_url)
|
||||
.bind(new_actor_url)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
|
||||
sqlx::query("DELETE FROM federation_following WHERE remote_actor_url = $1")
|
||||
.bind(old_actor_url)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
|
||||
tx.commit().await.map_err(|e| anyhow!(e))?;
|
||||
Ok(affected)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ActorRepository ───────────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl ActorRepository for PostgresFederationRepository {
|
||||
async fn get_local_actor_keypair(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
@@ -372,14 +616,70 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
public_key: String,
|
||||
private_key: String,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE users SET public_key=$2, private_key=$3, updated_at=NOW() WHERE id=$1")
|
||||
.bind(user_id)
|
||||
.bind(&public_key)
|
||||
.bind(&private_key)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
sqlx::query(
|
||||
"UPDATE users SET public_key=$2, private_key=$3, updated_at=NOW() WHERE id=$1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&public_key)
|
||||
.bind(&private_key)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,outbox_url,last_fetched_at)
|
||||
VALUES($1,$2,$3,$4,$5,'',$6,$7,NOW())
|
||||
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
|
||||
inbox_url=EXCLUDED.inbox_url,shared_inbox_url=EXCLUDED.shared_inbox_url,
|
||||
avatar_url=EXCLUDED.avatar_url,outbox_url=EXCLUDED.outbox_url,last_fetched_at=NOW()",
|
||||
)
|
||||
.bind(&actor.url)
|
||||
.bind(&actor.handle)
|
||||
.bind(&actor.display_name)
|
||||
.bind(&actor.inbox_url)
|
||||
.bind(&actor.shared_inbox_url)
|
||||
.bind(&actor.avatar_url)
|
||||
.bind(&actor.outbox_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
url: String,
|
||||
handle: String,
|
||||
inbox_url: String,
|
||||
shared_inbox_url: Option<String>,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
outbox_url: Option<String>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT url,handle,inbox_url,shared_inbox_url,display_name,avatar_url,outbox_url FROM remote_actors WHERE url=$1",
|
||||
)
|
||||
.bind(actor_url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|o| {
|
||||
o.map(|r| {
|
||||
map_remote_actor(
|
||||
r.url,
|
||||
r.handle,
|
||||
r.inbox_url,
|
||||
r.shared_inbox_url,
|
||||
r.display_name,
|
||||
r.avatar_url,
|
||||
r.outbox_url,
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fn add_announce(
|
||||
@@ -403,20 +703,44 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn count_announces(&self, object_url: &str) -> Result<usize> {
|
||||
let n: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM federation_announces WHERE object_url=$1")
|
||||
.bind(object_url)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok(n as usize)
|
||||
async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()> {
|
||||
sqlx::query(
|
||||
"DELETE FROM federation_announces WHERE activity_id=$1 AND actor_url=$2",
|
||||
)
|
||||
.bind(activity_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn count_announces(&self, object_url: &str) -> Result<usize> {
|
||||
let n: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM federation_announces WHERE object_url=$1",
|
||||
)
|
||||
.bind(object_url)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok(n as usize)
|
||||
}
|
||||
}
|
||||
|
||||
// ── BlocklistRepository ───────────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl BlocklistRepository for PostgresFederationRepository {
|
||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO federation_blocked_domains(domain,reason) VALUES($1,$2) ON CONFLICT(domain) DO NOTHING"
|
||||
).bind(domain).bind(reason).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||
"INSERT INTO federation_blocked_domains(domain,reason) VALUES($1,$2) ON CONFLICT(domain) DO NOTHING",
|
||||
)
|
||||
.bind(domain)
|
||||
.bind(reason)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
|
||||
@@ -453,77 +777,64 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
}
|
||||
|
||||
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
|
||||
let n: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM federation_blocked_domains WHERE domain=$1")
|
||||
.bind(domain)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
let n: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM federation_blocked_domains WHERE domain=$1",
|
||||
)
|
||||
.bind(domain)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO federation_blocked_actors(local_user_id,actor_url) VALUES($1,$2) ON CONFLICT DO NOTHING"
|
||||
).bind(local_user_id).bind(actor_url).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||
"INSERT INTO federation_blocked_actors(local_user_id,actor_url) VALUES($1,$2) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2")
|
||||
.bind(local_user_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
async fn remove_blocked_actor(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
actor_url: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"DELETE FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT actor_url FROM federation_blocked_actors WHERE local_user_id=$1 ORDER BY created_at DESC"
|
||||
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e))
|
||||
"SELECT actor_url FROM federation_blocked_actors WHERE local_user_id=$1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
|
||||
let n: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2"
|
||||
).bind(local_user_id).bind(actor_url).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
async fn migrate_follower_actor(
|
||||
&self,
|
||||
old_actor_url: &str,
|
||||
new_actor_url: &str,
|
||||
) -> Result<Vec<uuid::Uuid>> {
|
||||
let mut tx = self.pool.begin().await.map_err(|e| anyhow!(e))?;
|
||||
|
||||
// Copy rows to the new actor URL, carrying over existing data.
|
||||
// ON CONFLICT DO NOTHING skips users already following the new actor.
|
||||
// RETURNING gives us user IDs that actually need a re-follow.
|
||||
let affected: Vec<uuid::Uuid> = sqlx::query_scalar(
|
||||
"INSERT INTO federation_following(local_user_id, remote_actor_url, follow_activity_id, outbox_url)
|
||||
SELECT local_user_id, $2, follow_activity_id, outbox_url
|
||||
FROM federation_following
|
||||
WHERE remote_actor_url = $1
|
||||
ON CONFLICT (local_user_id, remote_actor_url) DO NOTHING
|
||||
RETURNING local_user_id",
|
||||
"SELECT COUNT(*) FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2",
|
||||
)
|
||||
.bind(old_actor_url)
|
||||
.bind(new_actor_url)
|
||||
.fetch_all(&mut *tx)
|
||||
.bind(local_user_id)
|
||||
.bind(actor_url)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
|
||||
// Delete the old rows.
|
||||
sqlx::query("DELETE FROM federation_following WHERE remote_actor_url = $1")
|
||||
.bind(old_actor_url)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
|
||||
tx.commit().await.map_err(|e| anyhow!(e))?;
|
||||
Ok(affected)
|
||||
Ok(n > 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,23 +854,30 @@ impl PostgresApUserRepository {
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
header_url: Option<String>,
|
||||
also_known_as: Option<String>,
|
||||
) -> ApUser {
|
||||
let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, username)).ok();
|
||||
let profile_url =
|
||||
url::Url::parse(&format!("{}/users/{}", self.base_url, username)).ok();
|
||||
let avatar_url = avatar_url.and_then(|u| url::Url::parse(&u).ok());
|
||||
let banner_url = header_url.and_then(|u| url::Url::parse(&u).ok());
|
||||
ApUser {
|
||||
id,
|
||||
username,
|
||||
display_name,
|
||||
bio,
|
||||
avatar_url,
|
||||
banner_url,
|
||||
also_known_as,
|
||||
also_known_as: also_known_as.into_iter().collect(),
|
||||
profile_url,
|
||||
attachment: vec![],
|
||||
manually_approves_followers: false,
|
||||
discoverable: true,
|
||||
actor_type: ApActorType::default(),
|
||||
featured_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -571,13 +889,14 @@ impl ApUserRepository for PostgresApUserRepository {
|
||||
struct Row {
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
header_url: Option<String>,
|
||||
also_known_as: Option<String>,
|
||||
}
|
||||
let row = sqlx::query_as::<_, Row>(
|
||||
"SELECT id,username,bio,avatar_url,header_url,also_known_as FROM users WHERE id=$1 AND local=true",
|
||||
"SELECT id,username,display_name,bio,avatar_url,header_url,also_known_as FROM users WHERE id=$1 AND local=true",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -587,6 +906,7 @@ impl ApUserRepository for PostgresApUserRepository {
|
||||
self.row_to_ap_user(
|
||||
r.id,
|
||||
r.username,
|
||||
r.display_name,
|
||||
r.bio,
|
||||
r.avatar_url,
|
||||
r.header_url,
|
||||
@@ -600,13 +920,14 @@ impl ApUserRepository for PostgresApUserRepository {
|
||||
struct Row {
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
header_url: Option<String>,
|
||||
also_known_as: Option<String>,
|
||||
}
|
||||
let row = sqlx::query_as::<_, Row>(
|
||||
"SELECT id,username,bio,avatar_url,header_url,also_known_as FROM users WHERE username=$1 AND local=true",
|
||||
"SELECT id,username,display_name,bio,avatar_url,header_url,also_known_as FROM users WHERE username=$1 AND local=true",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -616,6 +937,7 @@ impl ApUserRepository for PostgresApUserRepository {
|
||||
self.row_to_ap_user(
|
||||
r.id,
|
||||
r.username,
|
||||
r.display_name,
|
||||
r.bio,
|
||||
r.avatar_url,
|
||||
r.header_url,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS federation_processed_activities (
|
||||
activity_id TEXT PRIMARY KEY,
|
||||
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fed_processed_activities_at
|
||||
ON federation_processed_activities(processed_at);
|
||||
@@ -8,26 +8,29 @@ name = "thoughts"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
presentation = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
postgres = { workspace = true }
|
||||
postgres-search = { workspace = true }
|
||||
presentation = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
postgres = { workspace = true }
|
||||
postgres-search = { workspace = true }
|
||||
postgres-federation = { workspace = true }
|
||||
activitypub = { workspace = true }
|
||||
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.10" }
|
||||
nats = { workspace = true }
|
||||
event-transport = { workspace = true }
|
||||
auth = { workspace = true }
|
||||
storage = { workspace = true }
|
||||
application = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
async-nats = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
axum = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
tower_governor = "0.8"
|
||||
http = "1"
|
||||
activitypub = { workspace = true }
|
||||
k-ap = { version = "0.3.0", registry = "gitea" }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
nats = { workspace = true }
|
||||
event-transport = { workspace = true }
|
||||
event-payload = { workspace = true }
|
||||
auth = { workspace = true }
|
||||
storage = { workspace = true }
|
||||
application = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
async-nats = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
axum = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
tower_governor = "0.8"
|
||||
http = "1"
|
||||
|
||||
@@ -15,8 +15,8 @@ use domain::{
|
||||
events::DomainEvent,
|
||||
ports::{EventPublisher, OutboxWriter},
|
||||
};
|
||||
use event_transport::EventPublisherAdapter;
|
||||
use k_ap::ActivityPubService;
|
||||
use event_transport::{EventPublisherAdapter, Transport};
|
||||
use k_ap::{ActivityPubService, FederationEvent};
|
||||
use nats::NatsTransport;
|
||||
use postgres::activitypub::PgActivityPubRepository;
|
||||
use postgres::engagement::PgEngagementRepository;
|
||||
@@ -42,6 +42,39 @@ impl EventPublisher for NoOpEventPublisher {
|
||||
}
|
||||
}
|
||||
|
||||
struct KapPublisher(NatsTransport);
|
||||
|
||||
#[async_trait]
|
||||
impl k_ap::data::EventPublisher for KapPublisher {
|
||||
async fn publish(&self, event: FederationEvent) -> anyhow::Result<()> {
|
||||
let (subject, payload) = match event {
|
||||
FederationEvent::DeliveryRequested { inbox, activity, signing_actor_id } => (
|
||||
"federation.delivery.requested",
|
||||
serde_json::to_vec(&event_payload::EventPayload::FederationDeliveryRequested {
|
||||
inbox: inbox.to_string(),
|
||||
activity,
|
||||
signing_actor_id: signing_actor_id.to_string(),
|
||||
})?,
|
||||
),
|
||||
FederationEvent::BackfillRequested { owner_user_id, follower_inbox_url } => (
|
||||
"federation.backfill.requested",
|
||||
serde_json::to_vec(&event_payload::EventPayload::FederationBackfillRequested {
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
follower_inbox_url,
|
||||
})?,
|
||||
),
|
||||
FederationEvent::DeliveryFailed { inbox, error, .. } => {
|
||||
tracing::warn!(%inbox, %error, "AP delivery failed permanently");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
self.0
|
||||
.publish_bytes(subject, &payload)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn build(cfg: &Config) -> Infrastructure {
|
||||
// 1. Database connection + migrations
|
||||
let pool = PgPool::connect(&cfg.database_url)
|
||||
@@ -54,49 +87,64 @@ pub async fn build(cfg: &Config) -> Infrastructure {
|
||||
tracing::info!("Database connected and migrations applied");
|
||||
|
||||
// 2. Event publisher — real NATS or no-op fallback
|
||||
let event_publisher: Arc<dyn EventPublisher> = match &cfg.nats_url {
|
||||
let nats_client: Option<async_nats::Client> = match &cfg.nats_url {
|
||||
Some(url) => match async_nats::connect(url).await {
|
||||
Ok(client) => {
|
||||
tracing::info!("Connected to NATS at {url}");
|
||||
if let Err(e) = nats::ensure_stream(&client).await {
|
||||
tracing::warn!("JetStream stream setup failed: {e} — events may be lost");
|
||||
}
|
||||
Arc::new(EventPublisherAdapter::new(NatsTransport::new(client)))
|
||||
Some(client)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("NATS connect failed ({e}) — falling back to no-op publisher");
|
||||
Arc::new(NoOpEventPublisher)
|
||||
None
|
||||
}
|
||||
},
|
||||
None => {
|
||||
tracing::info!("NATS_URL not set — using no-op event publisher");
|
||||
Arc::new(NoOpEventPublisher)
|
||||
None
|
||||
}
|
||||
};
|
||||
let event_publisher: Arc<dyn EventPublisher> = match &nats_client {
|
||||
Some(client) => Arc::new(EventPublisherAdapter::new(NatsTransport::new(client.clone()))),
|
||||
None => Arc::new(NoOpEventPublisher),
|
||||
};
|
||||
let kap_publisher: Option<Arc<dyn k_ap::data::EventPublisher>> = nats_client
|
||||
.as_ref()
|
||||
.map(|c| Arc::new(KapPublisher(NatsTransport::new(c.clone()))) as _);
|
||||
|
||||
// 3. ActivityPub federation
|
||||
let connections_repo = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
|
||||
let raw_ap_service = Arc::new(
|
||||
ActivityPubService::builder(
|
||||
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
||||
Arc::new(PostgresApUserRepository::new(
|
||||
pool.clone(),
|
||||
cfg.base_url.clone(),
|
||||
)),
|
||||
Arc::new(ThoughtsObjectHandler::new(
|
||||
Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||
&cfg.base_url,
|
||||
Some(event_publisher.clone()),
|
||||
Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
|
||||
)),
|
||||
let fed_repo = Arc::new(PostgresFederationRepository::new(pool.clone()));
|
||||
let ap_handler = Arc::new(ThoughtsObjectHandler::new(
|
||||
Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||
&cfg.base_url,
|
||||
Some(event_publisher.clone()),
|
||||
Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
|
||||
));
|
||||
let mut ap_builder = ActivityPubService::builder(cfg.base_url.clone())
|
||||
.activity_repo(fed_repo.clone())
|
||||
.follow_repo(fed_repo.clone())
|
||||
.actor_repo(fed_repo.clone())
|
||||
.blocklist_repo(fed_repo.clone())
|
||||
.user_repo(Arc::new(PostgresApUserRepository::new(
|
||||
pool.clone(),
|
||||
cfg.base_url.clone(),
|
||||
)
|
||||
)))
|
||||
.content_reader(ap_handler.clone())
|
||||
.object_handler(ap_handler)
|
||||
.allow_registration(cfg.allow_registration)
|
||||
.software_name("thoughts")
|
||||
.debug(cfg.debug)
|
||||
.build()
|
||||
.await
|
||||
.expect("Failed to build ActivityPubService"),
|
||||
.debug(cfg.debug);
|
||||
if let Some(publisher) = kap_publisher {
|
||||
ap_builder = ap_builder.event_publisher(publisher);
|
||||
}
|
||||
let raw_ap_service = Arc::new(
|
||||
ap_builder
|
||||
.build()
|
||||
.await
|
||||
.expect("Failed to build ActivityPubService"),
|
||||
);
|
||||
let ap_service = Arc::new(ApFederationAdapter::new(raw_ap_service, connections_repo));
|
||||
|
||||
|
||||
@@ -8,23 +8,25 @@ name = "thoughts-worker"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
nats = { workspace = true }
|
||||
event-transport = { workspace = true }
|
||||
event-payload = { workspace = true }
|
||||
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.10" }
|
||||
activitypub = { workspace = true }
|
||||
postgres = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
nats = { workspace = true }
|
||||
event-transport = { workspace = true }
|
||||
event-payload = { workspace = true }
|
||||
k-ap = { version = "0.3.0", registry = "gitea" }
|
||||
activitypub = { workspace = true }
|
||||
postgres = { workspace = true }
|
||||
postgres-federation = { workspace = true }
|
||||
async-nats = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
futures = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
async-nats = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
futures = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
url = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
domain = { workspace = true, features = ["test-helpers"] }
|
||||
|
||||
@@ -23,10 +23,11 @@ pub struct WorkerHandlers {
|
||||
|
||||
pub struct WorkerInfra {
|
||||
pub pool: PgPool,
|
||||
pub consumer: event_transport::EventConsumerAdapter<nats::NatsMessageSource>,
|
||||
pub message_source: nats::NatsMessageSource,
|
||||
pub handlers: WorkerHandlers,
|
||||
pub dlq_store: Arc<PgFailedEventStore>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
pub raw_ap_service: Arc<k_ap::ActivityPubService>,
|
||||
}
|
||||
|
||||
pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> WorkerInfra {
|
||||
@@ -43,28 +44,32 @@ pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> Worker
|
||||
|
||||
// ActivityPub service (for federation fan-out)
|
||||
let connections_repo_worker = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
|
||||
let fed_repo_worker = Arc::new(PostgresFederationRepository::new(pool.clone()));
|
||||
let ap_handler_worker = Arc::new(ThoughtsObjectHandler::new(
|
||||
Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||
base_url,
|
||||
None,
|
||||
Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
|
||||
));
|
||||
let raw_ap_service = Arc::new(
|
||||
ActivityPubService::builder(
|
||||
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
||||
Arc::new(PostgresApUserRepository::new(
|
||||
ActivityPubService::builder(base_url.to_string())
|
||||
.activity_repo(fed_repo_worker.clone())
|
||||
.follow_repo(fed_repo_worker.clone())
|
||||
.actor_repo(fed_repo_worker.clone())
|
||||
.blocklist_repo(fed_repo_worker.clone())
|
||||
.user_repo(Arc::new(PostgresApUserRepository::new(
|
||||
pool.clone(),
|
||||
base_url.to_string(),
|
||||
)),
|
||||
Arc::new(ThoughtsObjectHandler::new(
|
||||
Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||
base_url,
|
||||
None,
|
||||
Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
|
||||
)),
|
||||
base_url,
|
||||
)
|
||||
.software_name("thoughts")
|
||||
.build()
|
||||
.await
|
||||
.expect("ActivityPubService build failed"),
|
||||
)))
|
||||
.content_reader(ap_handler_worker.clone())
|
||||
.object_handler(ap_handler_worker)
|
||||
.software_name("thoughts")
|
||||
.build()
|
||||
.await
|
||||
.expect("ActivityPubService build failed"),
|
||||
);
|
||||
let ap_service = Arc::new(ApFederationAdapter::new(
|
||||
raw_ap_service,
|
||||
raw_ap_service.clone(),
|
||||
connections_repo_worker,
|
||||
));
|
||||
let ap_outbound = ap_service.clone() as Arc<dyn OutboundFederationPort>;
|
||||
@@ -110,18 +115,17 @@ pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> Worker
|
||||
nats::ensure_stream(&nats_client)
|
||||
.await
|
||||
.expect("JetStream stream setup failed");
|
||||
let consumer = event_transport::EventConsumerAdapter::new(nats::NatsMessageSource::new(
|
||||
nats_client.clone(),
|
||||
));
|
||||
let message_source = nats::NatsMessageSource::new(nats_client.clone());
|
||||
let event_publisher: Arc<dyn EventPublisher> = Arc::new(
|
||||
event_transport::EventPublisherAdapter::new(nats::NatsTransport::new(nats_client)),
|
||||
);
|
||||
|
||||
WorkerInfra {
|
||||
pool,
|
||||
consumer,
|
||||
message_source,
|
||||
handlers,
|
||||
dlq_store,
|
||||
event_publisher,
|
||||
raw_ap_service,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ mod factory;
|
||||
mod handlers;
|
||||
mod outbox_relay;
|
||||
|
||||
use domain::ports::EventConsumer;
|
||||
use domain::{errors::DomainError, events::DomainEvent};
|
||||
use event_payload::EventPayload;
|
||||
use event_transport::MessageSource;
|
||||
use futures::StreamExt;
|
||||
use nats::CONSUMER_MAX_DELIVER;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -21,13 +25,11 @@ async fn main() {
|
||||
tracing::info!("Building worker...");
|
||||
let infra = factory::build(&database_url, &base_url, &nats_url).await;
|
||||
|
||||
// Spawn DLQ processor as a background task.
|
||||
tokio::spawn(dlq::run_dlq_processor(
|
||||
infra.dlq_store.clone(),
|
||||
infra.event_publisher.clone(),
|
||||
));
|
||||
|
||||
// Spawn outbox relay — polls DB for undelivered events and publishes them.
|
||||
tokio::spawn(
|
||||
outbox_relay::OutboxRelay {
|
||||
pool: infra.pool.clone(),
|
||||
@@ -38,71 +40,123 @@ async fn main() {
|
||||
);
|
||||
|
||||
tracing::info!("Worker started, consuming events...");
|
||||
let mut stream = infra.consumer.consume();
|
||||
let mut stream = infra.message_source.messages();
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(envelope) => {
|
||||
let event = &envelope.event;
|
||||
let event_type = event_payload::EventPayload::from(event).subject();
|
||||
tracing::info!(
|
||||
event_type,
|
||||
delivery = envelope.delivery_count,
|
||||
"received event"
|
||||
);
|
||||
|
||||
let n = infra.handlers.notification.handle(event).await;
|
||||
let f = infra.handlers.federation.handle(event).await;
|
||||
let fm = infra.handlers.federation_management.handle(event).await;
|
||||
|
||||
if n.is_ok() && f.is_ok() && fm.is_ok() {
|
||||
(envelope.ack)();
|
||||
tracing::info!(event_type, "event handled ok");
|
||||
} else {
|
||||
if let Err(e) = &n {
|
||||
tracing::error!("notification handler: {e}");
|
||||
}
|
||||
if let Err(e) = &f {
|
||||
tracing::error!("federation handler: {e}");
|
||||
}
|
||||
if let Err(e) = &fm {
|
||||
tracing::error!("federation management handler: {e}");
|
||||
Err(e) => tracing::error!("consumer error: {e}"),
|
||||
Ok(raw) => {
|
||||
let payload = match serde_json::from_slice::<EventPayload>(&raw.payload) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to deserialize event payload — acking: {e}");
|
||||
(raw.ack)();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Last delivery attempt -> move to DLQ then ack.
|
||||
// Earlier attempts -> nack so NATS retries.
|
||||
if envelope.delivery_count >= CONSUMER_MAX_DELIVER as u64 {
|
||||
let error_msg = n
|
||||
.err()
|
||||
.or(f.err())
|
||||
.or(fm.err())
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_else(|| "unknown error".into());
|
||||
let event_type = payload.subject();
|
||||
tracing::info!(event_type, delivery = raw.delivery_count, "received event");
|
||||
|
||||
// Serialize event back to payload for storage.
|
||||
let ep = event_payload::EventPayload::from(event);
|
||||
let event_type = ep.subject().to_string();
|
||||
let payload = serde_json::to_value(&ep).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
if let Err(e) = infra
|
||||
.dlq_store
|
||||
.insert(&event_type, &payload, &error_msg)
|
||||
.await
|
||||
{
|
||||
tracing::error!("DLQ insert failed: {e} — message lost");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
event_type,
|
||||
delivery_count = envelope.delivery_count,
|
||||
"event exhausted — moved to DLQ"
|
||||
);
|
||||
let outcome: Result<(), DomainError> = match payload {
|
||||
// ── k-ap federation events ────────────────────────────
|
||||
EventPayload::FederationDeliveryRequested {
|
||||
inbox,
|
||||
activity,
|
||||
signing_actor_id,
|
||||
} => {
|
||||
let result = async {
|
||||
let inbox_url = Url::parse(&inbox)
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
let actor_id = Uuid::parse_str(&signing_actor_id)
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
infra
|
||||
.raw_ap_service
|
||||
.deliver_to_inbox(inbox_url, activity, actor_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
.await;
|
||||
result
|
||||
}
|
||||
EventPayload::FederationBackfillRequested {
|
||||
owner_user_id,
|
||||
follower_inbox_url,
|
||||
} => {
|
||||
let result = async {
|
||||
let owner_id = Uuid::parse_str(&owner_user_id)
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
infra
|
||||
.raw_ap_service
|
||||
.run_backfill_for_follower(owner_id, follower_inbox_url)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
.await;
|
||||
result
|
||||
}
|
||||
|
||||
// ── domain events ──────────────────────────────────────
|
||||
p => match DomainEvent::try_from(p) {
|
||||
Err(e) => {
|
||||
tracing::warn!("unknown event type — acking: {e}");
|
||||
(raw.ack)();
|
||||
continue;
|
||||
}
|
||||
Ok(event) => {
|
||||
let n = infra.handlers.notification.handle(&event).await;
|
||||
let f = infra.handlers.federation.handle(&event).await;
|
||||
let fm = infra.handlers.federation_management.handle(&event).await;
|
||||
match (n, f, fm) {
|
||||
(Ok(()), Ok(()), Ok(())) => Ok(()),
|
||||
(n, f, fm) => {
|
||||
if let Err(e) = &n {
|
||||
tracing::error!("notification handler: {e}");
|
||||
}
|
||||
if let Err(e) = &f {
|
||||
tracing::error!("federation handler: {e}");
|
||||
}
|
||||
if let Err(e) = &fm {
|
||||
tracing::error!("federation management handler: {e}");
|
||||
}
|
||||
Err(n.err().or(f.err()).or(fm.err()).unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
match outcome {
|
||||
Ok(()) => {
|
||||
(raw.ack)();
|
||||
tracing::info!(event_type, "event handled ok");
|
||||
}
|
||||
Err(e) => {
|
||||
if raw.delivery_count >= CONSUMER_MAX_DELIVER as u64 {
|
||||
// Rebuild payload from raw bytes for DLQ storage.
|
||||
let payload_val = serde_json::from_slice::<serde_json::Value>(
|
||||
&raw.payload,
|
||||
)
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
if let Err(dlq_err) = infra
|
||||
.dlq_store
|
||||
.insert(event_type, &payload_val, &e.to_string())
|
||||
.await
|
||||
{
|
||||
tracing::error!("DLQ insert failed: {dlq_err} — message lost");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
event_type,
|
||||
delivery_count = raw.delivery_count,
|
||||
"event exhausted — moved to DLQ"
|
||||
);
|
||||
}
|
||||
(raw.ack)();
|
||||
} else {
|
||||
(raw.nack)();
|
||||
}
|
||||
(envelope.ack)(); // ack from NATS — DLQ owns it now
|
||||
} else {
|
||||
(envelope.nack)();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("consumer error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user