3 Commits

Author SHA1 Message Date
9412a9739a fix: allow www. apex equivalence in actor domain verification
Threads serves actors at threads.net but their id field uses www.threads.net.
Extract apex_domain() helper and fall back to apex comparison when the
strict verify_domains_match check fails.
2026-05-27 22:49:30 +02:00
13111c10b9 chore: bump version to 0.1.5 2026-05-27 22:37:55 +02:00
2e3b6d5cd4 fix: accept optional outbox/followers/following and any AP actor type
Person struct now deserializes gracefully when outbox, followers, or
following are absent (Threads omits them for some actors). Accepts
Service/Application/Organization/Group in addition to Person.
manually_approves_followers defaults to false when absent.
2026-05-27 22:37:49 +02:00
5 changed files with 57 additions and 23 deletions

2
Cargo.lock generated
View File

@@ -1368,7 +1368,7 @@ dependencies = [
[[package]] [[package]]
name = "k-ap" name = "k-ap"
version = "0.1.0" version = "0.1.5"
dependencies = [ dependencies = [
"activitypub_federation", "activitypub_federation",
"anyhow", "anyhow",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "k-ap" name = "k-ap"
version = "0.1.4" version = "0.1.6"
edition = "2024" edition = "2024"
description = "Generic ActivityPub protocol layer" description = "Generic ActivityPub protocol layer"
license = "MIT" license = "MIT"

View File

@@ -2,7 +2,6 @@ use activitypub_federation::{
config::Data, config::Data,
fetch::object_id::ObjectId, fetch::object_id::ObjectId,
http_signatures::generate_actor_keypair, http_signatures::generate_actor_keypair,
kinds::actor::PersonType,
protocol::{public_key::PublicKey, verification::verify_domains_match}, protocol::{public_key::PublicKey, verification::verify_domains_match},
traits::{Actor, Object}, traits::{Actor, Object},
}; };
@@ -58,18 +57,39 @@ pub struct ProfileFieldObject {
pub value: String, pub value: String,
} }
/// Accepts any AP actor type on inbound JSON; always serializes as "Person" for local actors.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ApActorType {
Person,
Service,
Application,
Organization,
Group,
}
impl Default for ApActorType {
fn default() -> Self {
Self::Person
}
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Person { pub struct Person {
#[serde(rename = "type")] #[serde(rename = "type")]
kind: PersonType, kind: ApActorType,
id: ObjectId<DbActor>, id: ObjectId<DbActor>,
#[serde(default)]
preferred_username: String, preferred_username: String,
inbox: Url, inbox: Url,
outbox: Url, #[serde(default)]
followers: Url, outbox: Option<Url>,
following: Url, #[serde(default)]
public_key: PublicKey, followers: Option<Url>,
#[serde(default)]
following: Option<Url>,
pub public_key: PublicKey,
#[serde(default)]
name: Option<String>, name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>, summary: Option<String>,
@@ -79,6 +99,7 @@ pub struct Person {
url: Option<Url>, url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
discoverable: Option<bool>, discoverable: Option<bool>,
#[serde(default)]
manually_approves_followers: bool, manually_approves_followers: bool,
#[serde(skip_serializing_if = "Option::is_none", default)] #[serde(skip_serializing_if = "Option::is_none", default)]
updated: Option<DateTime<Utc>>, updated: Option<DateTime<Utc>>,
@@ -172,6 +193,11 @@ pub async fn get_local_actor(
}) })
} }
fn apex_domain(url: &Url) -> String {
let host = url.host_str().unwrap_or("");
host.strip_prefix("www.").unwrap_or(host).to_owned()
}
#[async_trait::async_trait] #[async_trait::async_trait]
impl Object for DbActor { impl Object for DbActor {
type DataType = FederationData; type DataType = FederationData;
@@ -275,9 +301,9 @@ impl Object for DbActor {
id: self.ap_id.clone().into(), id: self.ap_id.clone().into(),
preferred_username: self.username.clone(), preferred_username: self.username.clone(),
inbox: self.inbox_url.clone(), inbox: self.inbox_url.clone(),
outbox: self.outbox_url.clone(), outbox: Some(self.outbox_url.clone()),
followers: self.followers_url.clone(), followers: Some(self.followers_url.clone()),
following: self.following_url.clone(), following: Some(self.following_url.clone()),
public_key, public_key,
name: Some(self.username.clone()), name: Some(self.username.clone()),
summary: self.bio.clone(), summary: self.bio.clone(),
@@ -298,8 +324,13 @@ impl Object for DbActor {
expected_domain: &Url, expected_domain: &Url,
_data: &Data<Self::DataType>, _data: &Data<Self::DataType>,
) -> Result<(), Self::Error> { ) -> Result<(), Self::Error> {
verify_domains_match(json.id.inner(), expected_domain)?; if verify_domains_match(json.id.inner(), expected_domain).is_ok() {
Ok(()) return Ok(());
}
if apex_domain(json.id.inner()) == apex_domain(expected_domain) {
return Ok(());
}
verify_domains_match(json.id.inner(), expected_domain).map_err(Error::from)
} }
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> { async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
@@ -311,7 +342,7 @@ impl Object for DbActor {
shared_inbox_url, shared_inbox_url,
display_name: json.name.clone(), display_name: json.name.clone(),
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()), avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
outbox_url: Some(json.outbox.to_string()), outbox_url: json.outbox.as_ref().map(|u| u.to_string()),
}; };
data.federation_repo.upsert_remote_actor(actor).await?; data.federation_repo.upsert_remote_actor(actor).await?;
@@ -323,9 +354,12 @@ impl Object for DbActor {
.endpoints .endpoints
.as_ref() .as_ref()
.and_then(|e| Url::parse(e.shared_inbox.as_str()).ok()); .and_then(|e| Url::parse(e.shared_inbox.as_str()).ok());
let outbox_url = json.outbox.clone(); let fallback = |suffix: &str| {
let followers_url = json.followers.clone(); Url::parse(&format!("{}{}", ap_id, suffix)).unwrap_or_else(|_| ap_id.clone())
let following_url = json.following.clone(); };
let outbox_url = json.outbox.clone().unwrap_or_else(|| fallback("/outbox"));
let followers_url = json.followers.clone().unwrap_or_else(|| fallback("/followers"));
let following_url = json.following.clone().unwrap_or_else(|| fallback("/following"));
Ok(DbActor { Ok(DbActor {
user_id, user_id,

View File

@@ -348,9 +348,9 @@ impl ActivityPubService {
avatar_url: actor.avatar_url, avatar_url: actor.avatar_url,
banner_url: actor.banner_url, banner_url: actor.banner_url,
ap_url: actor.ap_id, ap_url: actor.ap_id,
outbox_url: actor.outbox_url, outbox_url: Some(actor.outbox_url),
followers_url: actor.followers_url, followers_url: Some(actor.followers_url),
following_url: actor.following_url, following_url: Some(actor.following_url),
also_known_as: actor.also_known_as, also_known_as: actor.also_known_as,
profile_url: actor.profile_url, profile_url: actor.profile_url,
attachment: actor.attachment, attachment: actor.attachment,

View File

@@ -17,9 +17,9 @@ pub struct LookedUpActor {
pub avatar_url: Option<Url>, pub avatar_url: Option<Url>,
pub banner_url: Option<Url>, pub banner_url: Option<Url>,
pub ap_url: Url, pub ap_url: Url,
pub outbox_url: Url, pub outbox_url: Option<Url>,
pub followers_url: Url, pub followers_url: Option<Url>,
pub following_url: Url, pub following_url: Option<Url>,
pub also_known_as: Option<String>, pub also_known_as: Option<String>,
pub profile_url: Option<Url>, pub profile_url: Option<Url>,
pub attachment: Vec<ApProfileField>, pub attachment: Vec<ApProfileField>,