From 8602614e7cb5b0ba0d69b3d2d83d3535f98b2bcf Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 14 May 2026 19:34:43 +0200 Subject: [PATCH] =?UTF-8?q?fix(ap):=20visibility-aware=20addressing=20?= =?UTF-8?q?=E2=80=94=20correct=20to/cc=20outbound,=20parse=20inbound=20to/?= =?UTF-8?q?cc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/activitypub-base/src/service.rs | 21 +++++++++++++++++-- crates/adapters/activitypub/src/handler.rs | 19 +++++++++++++++++ crates/adapters/postgres/src/activitypub.rs | 5 ++++- crates/domain/src/ports.rs | 2 ++ crates/domain/src/testing.rs | 1 + 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/adapters/activitypub-base/src/service.rs b/crates/adapters/activitypub-base/src/service.rs index abc14ec..6149bc1 100644 --- a/crates/adapters/activitypub-base/src/service.rs +++ b/crates/adapters/activitypub-base/src/service.rs @@ -36,6 +36,23 @@ fn thought_note_json( base_url: &str, ) -> anyhow::Result<(url::Url, serde_json::Value)> { let ap_id = url::Url::parse(&format!("{}/thoughts/{}", base_url, thought.id))?; + + // Build to/cc based on visibility per AP spec. + let (to, cc) = match thought.visibility { + domain::models::thought::Visibility::Public => ( + vec![crate::urls::AS_PUBLIC.to_string()], + vec![local_actor.followers_url.to_string()], + ), + domain::models::thought::Visibility::Unlisted => ( + vec![local_actor.followers_url.to_string()], + vec![crate::urls::AS_PUBLIC.to_string()], + ), + domain::models::thought::Visibility::Followers => { + (vec![local_actor.followers_url.to_string()], vec![]) + } + domain::models::thought::Visibility::Direct => (vec![], vec![]), + }; + let mut note = serde_json::json!({ "type": "Note", "id": ap_id.to_string(), @@ -43,8 +60,8 @@ fn thought_note_json( "attributedTo": local_actor.ap_id.to_string(), "content": thought.content.as_str(), "published": thought.created_at.to_rfc3339(), - "to": [crate::urls::AS_PUBLIC], - "cc": [local_actor.followers_url.to_string()], + "to": to, + "cc": cc, "sensitive": thought.sensitive, }); if let Some(ref cw) = thought.content_warning { diff --git a/crates/adapters/activitypub/src/handler.rs b/crates/adapters/activitypub/src/handler.rs index 38748c3..54000cc 100644 --- a/crates/adapters/activitypub/src/handler.rs +++ b/crates/adapters/activitypub/src/handler.rs @@ -111,6 +111,24 @@ impl ApObjectHandler for ThoughtsObjectHandler { .intern_remote_actor(actor_url) .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); + let has_followers = note.to.iter().any(|s| s.ends_with("/followers")) + || note.cc.iter().any(|s| s.ends_with("/followers")); + + let visibility = if in_to { + "public" + } else if in_cc { + "unlisted" + } else if has_followers { + "followers" + } else { + "direct" + }; + self.repo .accept_note( ap_id, @@ -119,6 +137,7 @@ impl ApObjectHandler for ThoughtsObjectHandler { note.published, note.sensitive, note.summary, + visibility, ) .await .map_err(|e| anyhow!("{e}")) diff --git a/crates/adapters/postgres/src/activitypub.rs b/crates/adapters/postgres/src/activitypub.rs index 06538ae..f0b3688 100644 --- a/crates/adapters/postgres/src/activitypub.rs +++ b/crates/adapters/postgres/src/activitypub.rs @@ -187,11 +187,12 @@ impl ActivityPubRepository for PgActivityPubRepository { published: DateTime, sensitive: bool, content_warning: Option, + visibility: &str, ) -> Result<(), DomainError> { let capped: String = content.chars().take(500).collect(); sqlx::query( "INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at) - VALUES($1,$2,$3,$4,'public',$5,false,$6,$7) ON CONFLICT(ap_id) DO NOTHING", + VALUES($1,$2,$3,$4,$8,$5,false,$6,$7) ON CONFLICT(ap_id) DO NOTHING", ) .bind(uuid::Uuid::new_v4()) .bind(author_id.as_uuid()) @@ -200,6 +201,7 @@ impl ActivityPubRepository for PgActivityPubRepository { .bind(sensitive) .bind(content_warning) .bind(published) + .bind(visibility) .execute(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string())) @@ -275,6 +277,7 @@ mod tests { chrono::Utc::now(), false, None, + "public", ) .await .unwrap(); diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index ba9a08d..6ffc407 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -288,6 +288,7 @@ pub trait ActivityPubRepository: Send + Sync { // ── Inbox processing (remote → local) ─────────────────────────── /// Persist an incoming remote Note. Idempotent on ap_id. + #[allow(clippy::too_many_arguments)] async fn accept_note( &self, ap_id: &url::Url, @@ -296,6 +297,7 @@ pub trait ActivityPubRepository: Send + Sync { published: chrono::DateTime, sensitive: bool, content_warning: Option, + visibility: &str, ) -> Result<(), DomainError>; /// Apply an Update to a previously accepted remote Note. diff --git a/crates/domain/src/testing.rs b/crates/domain/src/testing.rs index 39d7b47..38078ec 100644 --- a/crates/domain/src/testing.rs +++ b/crates/domain/src/testing.rs @@ -698,6 +698,7 @@ impl ActivityPubRepository for TestStore { _published: chrono::DateTime, _sensitive: bool, _content_warning: Option, + _visibility: &str, ) -> Result<(), DomainError> { Ok(()) }