v0.5.0 — codebase refinement, flexible API, architecture cleanup
All checks were successful
CI / fmt (push) Successful in 31s
CI / clippy (push) Successful in 4m0s
CI / test (push) Successful in 5m44s

Error handling:
  thiserror enum (NotFound/BadRequest/Unauthorized/Forbidden/Internal)
  eliminates 41 boilerplate .map_err() calls
  signature failures return 401, not 500

Named types:
  Keypair, LocalObject (with to/cc/bto/bcc addressing), Addressing

Readability:
  descriptive names everywhere, small functions, breathing room
  noisy comments removed, intent-explicit error handling (no let _ =)
  types.rs per module separating data from behavior

File organization:
  handlers/ module (actor, featured, followers, inbox, nodeinfo, outbox, webfinger)
  actors/ split (mod.rs + person.rs + types.rs)
  service/ split (builder, broadcast, collections, delivery, fetch, follow, lookup, types)
  tests next to modules

Repository traits:
  FollowRepository → 5 sub-traits (FollowerWriter/Reader, FollowingWriter/Reader, FollowMigration)
  ActorRepository → 3 sub-traits (KeypairRepository, RemoteActorCache, AnnounceRepository)
  BlocklistRepository → 2 sub-traits (DomainBlocklist, ActorBlocklist)
  supertraits with blanket impls — existing consumers unchanged
  FollowMigration has default no-op
  delete dead get_following_outbox_url

Testing:
  mock_repo! macro generates mock builders from compact specs
  MockFollowRepo, MockActorRepo, MockBlocklistRepo, MockActivityRepo,
  MockUserRepo, MockContentReader, MockObjectHandler, MockEventPublisher
  all hand-written test stubs replaced

Flexibility:
  UrlScheme trait — configurable URL patterns (DefaultUrlScheme = /users/{uuid})
  on_unknown_activity hook for custom AP extensions
  broadcast_raw_to_followers for arbitrary activity JSON
  broadcast_create/broadcast_update (renamed from Note-centric names)
  internal modules locked to pub(crate), clean public re-exports
  actor_handler, followers_handler, following_handler re-exported for custom routers

Security:
  SSRF: block IPv6-mapped private IPv4, TEST-NET, benchmarking, reserved ranges
  verify_attributed_to rejects missing/array attributedTo
  remove .expect() from outbox handler

Architecture:
  handlers/followers.rs delegates to serialize_ordered_collection (no more UrlScheme bypass)
  extract dispatch_sends, prepare_addressed_broadcast (eliminate duplication)
  DbActor::object_id(), RemoteActor::from/from_ap_person/placeholder
  send_activity unifies prepare+dispatch, deterministic_activity_id helper
  pass-through wrappers grouped in lookup.rs
This commit is contained in:
2026-07-25 17:01:44 +02:00
parent 17b57fb9b5
commit b569efe715
73 changed files with 3583 additions and 3160 deletions

24
src/handlers/actor.rs Normal file
View File

@@ -0,0 +1,24 @@
use activitypub_federation::{
axum::json::FederationJson, config::Data, protocol::context::WithContext, traits::Object,
};
use axum::extract::Path;
use crate::actors::{Person, get_local_actor};
use crate::data::FederationData;
use crate::error::Error;
use crate::urls::actor_ap_context;
/// Serves the AP actor JSON for a local user.
/// The path parameter is the user's UUID (matching the canonical actor URL).
pub async fn actor_handler(
Path(user_id_str): Path<String>,
data: Data<FederationData>,
) -> Result<FederationJson<WithContext<Person>>, Error> {
let user_id =
uuid::Uuid::parse_str(&user_id_str).map_err(|_| Error::not_found("user not found"))?;
let db_actor = get_local_actor(user_id, &data).await?;
let person = db_actor.into_json(&data).await?;
Ok(FederationJson(WithContext::new(person, actor_ap_context())))
}

37
src/handlers/featured.rs Normal file
View File

@@ -0,0 +1,37 @@
use activitypub_federation::{axum::json::FederationJson, config::Data};
use axum::extract::Path;
use serde_json::json;
use crate::data::FederationData;
use crate::error::Error;
use crate::urls::AP_CONTEXT;
/// Serves the `featured` (pinned posts) `OrderedCollection` for a local user.
///
/// Remote servers follow the `featured` link from the actor JSON and expect
/// an `OrderedCollection` whose `orderedItems` are the AP URLs of pinned objects.
/// The handler calls [`ApContentReader::get_featured_objects`] — override that
/// method to expose your pinned posts.
pub async fn featured_handler(
Path(user_id_str): Path<String>,
data: Data<FederationData>,
) -> Result<FederationJson<serde_json::Value>, Error> {
let user_id =
uuid::Uuid::parse_str(&user_id_str).map_err(|_| Error::not_found("user not found"))?;
data.user_repo
.find_by_id(user_id)
.await?
.ok_or_else(|| Error::not_found("user not found"))?;
let featured_url = format!("{}/users/{}/featured", data.base_url, user_id_str);
let items = data.content_reader.get_featured_objects(user_id).await?;
Ok(FederationJson(json!({
"@context": AP_CONTEXT,
"type": "OrderedCollection",
"id": featured_url,
"totalItems": items.len(),
"orderedItems": items.iter().map(|url| url.as_str()).collect::<Vec<_>>(),
})))
}

89
src/handlers/followers.rs Normal file
View File

@@ -0,0 +1,89 @@
use activitypub_federation::{axum::json::FederationJson, config::Data};
use axum::extract::{Path, Query};
use serde::Deserialize;
use crate::data::FederationData;
use crate::error::Error;
use crate::service::collections::serialize_ordered_collection;
#[derive(Deserialize)]
pub struct PageQuery {
page: Option<u32>,
}
async fn collection_handler(
user_id_str: &str,
query: PageQuery,
data: Data<FederationData>,
collection_type: &str,
) -> Result<FederationJson<serde_json::Value>, Error> {
let user_id =
uuid::Uuid::parse_str(user_id_str).map_err(|_| Error::bad_request("invalid user id"))?;
data.user_repo
.find_by_id(user_id)
.await?
.ok_or_else(|| Error::not_found("user not found"))?;
let actor_url = data
.url_scheme
.actor_url(&data.base_url, user_id)
.map_err(Error::from)?;
let collection_url = match collection_type {
"followers" => data.url_scheme.followers_url(&actor_url),
_ => data.url_scheme.following_url(&actor_url),
}
.map_err(Error::from)?
.to_string();
let total = match collection_type {
"followers" => data.follow_repo.count_followers(user_id).await,
_ => data.follow_repo.count_following(user_id).await,
}
.map_err(Error::from)?;
let items_fn = |offset: u32, limit: usize| {
let data = data.clone();
async move {
Ok(match collection_type {
"followers" => data
.follow_repo
.get_followers_page(user_id, offset, limit)
.await?
.into_iter()
.map(|follower| follower.actor.url)
.collect(),
_ => data
.follow_repo
.get_following_page(user_id, offset, limit)
.await?
.into_iter()
.map(|actor| actor.url)
.collect(),
})
}
};
let json_str = serialize_ordered_collection(&collection_url, total, query.page, items_fn)
.await
.map_err(Error::from)?;
let value: serde_json::Value =
serde_json::from_str(&json_str).map_err(|e| Error::from(anyhow::anyhow!(e)))?;
Ok(FederationJson(value))
}
pub async fn followers_handler(
Path(user_id_str): Path<String>,
Query(query): Query<PageQuery>,
data: Data<FederationData>,
) -> Result<FederationJson<serde_json::Value>, Error> {
collection_handler(&user_id_str, query, data, "followers").await
}
pub async fn following_handler(
Path(user_id_str): Path<String>,
Query(query): Query<PageQuery>,
data: Data<FederationData>,
) -> Result<FederationJson<serde_json::Value>, Error> {
collection_handler(&user_id_str, query, data, "following").await
}

35
src/handlers/inbox.rs Normal file
View File

@@ -0,0 +1,35 @@
use activitypub_federation::{
axum::inbox::{ActivityData, receive_activity},
config::Data,
protocol::context::WithContext,
};
use crate::activities::InboxActivities;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
pub async fn inbox_handler(
data: Data<FederationData>,
activity_data: ActivityData,
) -> Result<(), Error> {
let result = receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>(
activity_data,
&data,
)
.await;
match result {
Ok(()) => Ok(()),
Err(Error::Internal(ref inner)) if is_unknown_activity_error(inner) => {
tracing::debug!(error = %inner, "unknown activity type, accepted without processing");
Ok(())
}
Err(error) => Err(error),
}
}
fn is_unknown_activity_error(error: &anyhow::Error) -> bool {
let message = error.to_string();
message.contains("unknown variant") || message.contains("does not match any variant")
}

7
src/handlers/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod actor;
pub mod featured;
pub mod followers;
pub mod inbox;
pub mod nodeinfo;
pub mod outbox;
pub mod webfinger;

95
src/handlers/nodeinfo.rs Normal file
View File

@@ -0,0 +1,95 @@
use activitypub_federation::config::Data;
use axum::Json;
use serde::Serialize;
use crate::data::FederationData;
use crate::error::Error;
const NODEINFO_2_0_REL: &str = "http://nodeinfo.diaspora.software/ns/schema/2.0";
#[derive(Serialize)]
pub struct NodeInfoWellKnown {
pub links: Vec<NodeInfoLink>,
}
#[derive(Serialize)]
pub struct NodeInfoLink {
pub rel: String,
pub href: String,
}
#[derive(Serialize)]
pub struct NodeInfoSoftware {
pub name: String,
pub version: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeInfoUsage {
pub users: NodeInfoUsers,
pub local_posts: u64,
}
#[derive(Serialize)]
pub struct NodeInfoUsers {
pub total: usize,
}
#[derive(Serialize)]
pub struct NodeInfoServices {
pub inbound: Vec<String>,
pub outbound: Vec<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeInfo {
pub version: String,
pub software: NodeInfoSoftware,
pub protocols: Vec<String>,
pub services: NodeInfoServices,
pub open_registrations: bool,
pub usage: NodeInfoUsage,
pub metadata: serde_json::Value,
}
pub async fn nodeinfo_well_known_handler(
data: Data<FederationData>,
) -> Result<Json<NodeInfoWellKnown>, Error> {
let href = format!("{}/nodeinfo/2.0", data.base_url);
Ok(Json(NodeInfoWellKnown {
links: vec![NodeInfoLink {
rel: NODEINFO_2_0_REL.to_string(),
href,
}],
}))
}
pub async fn nodeinfo_handler(data: Data<FederationData>) -> Result<Json<NodeInfo>, Error> {
let user_count = data.user_repo.count_users().await.unwrap_or(0);
let local_posts = data.content_reader.count_local_posts().await.unwrap_or(0);
Ok(Json(NodeInfo {
version: "2.0".to_string(),
software: NodeInfoSoftware {
name: data.software_name.clone(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
protocols: vec!["activitypub".to_string()],
services: NodeInfoServices {
inbound: data.nodeinfo_services_inbound.clone(),
outbound: data.nodeinfo_services_outbound.clone(),
},
open_registrations: data.allow_registration,
usage: NodeInfoUsage {
users: NodeInfoUsers { total: user_count },
local_posts,
},
metadata: data.nodeinfo_metadata.clone(),
}))
}
#[cfg(test)]
#[path = "tests/nodeinfo.rs"]
mod tests;

162
src/handlers/outbox.rs Normal file
View File

@@ -0,0 +1,162 @@
use axum::extract::{Path, Query};
use axum::response::IntoResponse;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType,
protocol::context::WithContext,
};
use crate::{
activities::CreateActivity, content::LocalObject, data::FederationData, error::Error,
urls::AP_PAGE_SIZE,
};
#[derive(Deserialize)]
pub struct OutboxQuery {
page: Option<bool>,
before: Option<String>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderedCollection {
#[serde(rename = "@context")]
context: String,
#[serde(rename = "type")]
kind: String,
id: String,
total_items: u64,
first: String,
last: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderedCollectionPage {
#[serde(rename = "@context")]
context: String,
#[serde(rename = "type")]
kind: String,
id: String,
part_of: String,
total_items: u64,
ordered_items: Vec<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
next: Option<String>,
}
pub async fn outbox_handler(
Path(user_id_str): Path<String>,
Query(query): Query<OutboxQuery>,
data: Data<FederationData>,
) -> Result<axum::response::Response, Error> {
let uuid =
uuid::Uuid::parse_str(&user_id_str).map_err(|_| Error::bad_request("invalid user id"))?;
data.user_repo
.find_by_id(uuid)
.await?
.ok_or_else(|| Error::not_found("user not found"))?;
let actor_url = data.url_scheme.actor_url(&data.base_url, uuid)?;
let outbox_url = data.url_scheme.outbox_url(&actor_url)?.to_string();
let total = data.content_reader.count_local_posts().await?;
if query.page.unwrap_or(false) {
build_outbox_page(uuid, &query, &outbox_url, total, &data).await
} else {
build_outbox_collection(&outbox_url, total)
}
}
async fn build_outbox_page(
user_id: uuid::Uuid,
query: &OutboxQuery,
outbox_url: &str,
total: u64,
data: &Data<FederationData>,
) -> Result<axum::response::Response, Error> {
let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok());
let items = data
.content_reader
.get_local_objects_page(user_id, before, AP_PAGE_SIZE)
.await?;
let actor_url: Url = data
.url_scheme
.actor_url(&data.base_url, user_id)
.map_err(|error| Error::bad_request(format!("invalid base_url: {error}")))?;
let has_more = items.len() == AP_PAGE_SIZE;
let oldest_timestamp = items.last().map(|item| item.published_at);
let ordered_items = wrap_items_as_create_activities(&items, &actor_url)?;
let page_id = match &query.before {
Some(before) => format!("{}?page=true&before={}", outbox_url, before),
None => format!("{}?page=true", outbox_url),
};
let next = if has_more {
oldest_timestamp.map(|timestamp| {
let formatted = timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
format!("{}?page=true&before={}", outbox_url, formatted)
})
} else {
None
};
Ok(axum::Json(OrderedCollectionPage {
context: crate::urls::AP_CONTEXT.to_string(),
kind: "OrderedCollectionPage".to_string(),
id: page_id,
part_of: outbox_url.to_string(),
total_items: total,
ordered_items,
next,
})
.into_response())
}
fn build_outbox_collection(
outbox_url: &str,
total: u64,
) -> Result<axum::response::Response, Error> {
Ok(axum::Json(OrderedCollection {
context: crate::urls::AP_CONTEXT.to_string(),
kind: "OrderedCollection".to_string(),
id: outbox_url.to_string(),
total_items: total,
first: format!("{}?page=true", outbox_url),
last: format!("{}?page=true&before=1970-01-01T00:00:00.000Z", outbox_url),
})
.into_response())
}
fn wrap_items_as_create_activities(
items: &[LocalObject],
actor_url: &Url,
) -> Result<Vec<serde_json::Value>, Error> {
items
.iter()
.map(|item| {
let create_id = Url::parse(&format!("{}/activity", item.ap_id))
.map_err(|error| anyhow::anyhow!(error))?;
let activity = WithContext::new_default(CreateActivity {
id: create_id,
kind: CreateType::default(),
actor: ObjectId::from(actor_url.clone()),
object: item.object.clone(),
to: item.to.clone(),
cc: item.cc.clone(),
bto: vec![],
bcc: vec![],
});
serde_json::to_value(activity).map_err(|error| anyhow::anyhow!(error).into())
})
.collect()
}

View File

@@ -0,0 +1,49 @@
use super::*;
#[test]
fn nodeinfo_well_known_serializes_correctly() {
let doc = NodeInfoWellKnown {
links: vec![NodeInfoLink {
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
href: "https://example.com/nodeinfo/2.0".to_string(),
}],
};
let json = serde_json::to_value(&doc).unwrap();
assert_eq!(
json["links"][0]["rel"],
"http://nodeinfo.diaspora.software/ns/schema/2.0"
);
assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0");
}
#[test]
fn nodeinfo_serializes_camel_case() {
let doc = NodeInfo {
version: "2.0".to_string(),
software: NodeInfoSoftware {
name: "my-app".to_string(),
version: "0.1.0".to_string(),
},
protocols: vec!["activitypub".to_string()],
services: NodeInfoServices {
inbound: vec![],
outbound: vec![],
},
open_registrations: false,
usage: NodeInfoUsage {
users: NodeInfoUsers { total: 3 },
local_posts: 42,
},
metadata: serde_json::json!({}),
};
let json = serde_json::to_value(&doc).unwrap();
assert!(json.get("$schema").is_none());
assert_eq!(json["version"], "2.0");
assert_eq!(json["software"]["name"], "my-app");
assert_eq!(json["usage"]["users"]["total"], 3);
assert_eq!(json["usage"]["localPosts"], 42);
assert_eq!(json["openRegistrations"], false);
assert_eq!(json["services"]["inbound"], serde_json::json!([]));
assert_eq!(json["services"]["outbound"], serde_json::json!([]));
assert_eq!(json["metadata"], serde_json::json!({}));
}

68
src/handlers/webfinger.rs Normal file
View File

@@ -0,0 +1,68 @@
use activitypub_federation::{config::Data, fetch::webfinger::extract_webfinger_name};
use axum::{
extract::Query,
http::header,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use crate::data::FederationData;
use crate::error::Error;
#[derive(Deserialize)]
pub struct WebfingerQuery {
resource: String,
}
#[derive(Serialize)]
struct WebfingerLink {
rel: String,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
href: Option<String>,
}
#[derive(Serialize)]
struct WebfingerResponse {
subject: String,
/// Canonical URIs for the same account (acct: URI + AP actor URL).
aliases: Vec<String>,
links: Vec<WebfingerLink>,
}
pub async fn webfinger_handler(
Query(query): Query<WebfingerQuery>,
data: Data<FederationData>,
) -> Result<Response, Error> {
let name = extract_webfinger_name(&query.resource, &data)?;
let user = data
.user_repo
.find_by_username(name)
.await?
.ok_or_else(|| Error::not_found("user not found"))?;
let ap_id = data.url_scheme.actor_url(&data.base_url, user.id)?;
let acct_uri = format!("acct:{}@{}", user.username, data.domain);
let response = WebfingerResponse {
subject: query.resource.clone(),
aliases: vec![acct_uri, ap_id.to_string()],
links: vec![
WebfingerLink {
rel: "http://webfinger.net/rel/profile-page".to_string(),
kind: Some("text/html".to_string()),
href: Some(ap_id.to_string()),
},
WebfingerLink {
rel: "self".to_string(),
kind: Some(crate::urls::AP_CONTENT_TYPE.to_string()),
href: Some(ap_id.to_string()),
},
],
};
let body = serde_json::to_string(&response).map_err(|error| anyhow::anyhow!(error))?;
Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response())
}