fix(ap): visibility-aware addressing — correct to/cc outbound, parse inbound to/cc
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m25s
test / unit (pull_request) Successful in 16m39s
test / integration (pull_request) Failing after 17m35s

This commit is contained in:
2026-05-14 19:34:43 +02:00
parent a5ea97bbaa
commit 8602614e7c
5 changed files with 45 additions and 3 deletions

View File

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

View File

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

View File

@@ -187,11 +187,12 @@ impl ActivityPubRepository for PgActivityPubRepository {
published: DateTime<Utc>,
sensitive: bool,
content_warning: Option<String>,
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();

View File

@@ -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<chrono::Utc>,
sensitive: bool,
content_warning: Option<String>,
visibility: &str,
) -> Result<(), DomainError>;
/// Apply an Update to a previously accepted remote Note.

View File

@@ -698,6 +698,7 @@ impl ActivityPubRepository for TestStore {
_published: chrono::DateTime<chrono::Utc>,
_sensitive: bool,
_content_warning: Option<String>,
_visibility: &str,
) -> Result<(), DomainError> {
Ok(())
}