v0.5.0 — codebase refinement, flexible API, architecture cleanup
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:
24
src/handlers/actor.rs
Normal file
24
src/handlers/actor.rs
Normal 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
37
src/handlers/featured.rs
Normal 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
89
src/handlers/followers.rs
Normal 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
35
src/handlers/inbox.rs
Normal 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
7
src/handlers/mod.rs
Normal 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
95
src/handlers/nodeinfo.rs
Normal 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
162
src/handlers/outbox.rs
Normal 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()
|
||||
}
|
||||
49
src/handlers/tests/nodeinfo.rs
Normal file
49
src/handlers/tests/nodeinfo.rs
Normal 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
68
src/handlers/webfinger.rs
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user