webfinger.rs
283 lignes · 8976 octets
//! WebFinger handler (RFC 7033). //! //! Resolves `@user@domain` handles to ActivityPub actor URLs. Mounted at //! `GET /.well-known/webfinger`. use axum::{ Extension, Json, extract::Query, http::{HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, }; use serde::Deserialize; use super::error::FediverseError; use super::state::FediverseState; use super::types::{WebFingerLink, WebFingerResponse}; /// Query parameters for the WebFinger handler. #[derive(Debug, Deserialize)] pub struct WebFingerQuery { /// Handle being queried, e.g. `acct:alice@example.org`. pub resource: String, } /// `GET /.well-known/webfinger?resource=acct:user@domain`. /// /// Returns a JRD describing how to find the actor's ActivityPub /// representation. Sets `Access-Control-Allow-Origin: *` (RFC 7033 §3.2). pub async fn handle( Query(query): Query<WebFingerQuery>, Extension(state): Extension<FediverseState>, ) -> Result<Response, FediverseError> { let (username, domain) = parse_acct_resource(&query.resource)?; if domain != state.config.domain() { return Err(FediverseError::DomainMismatch); } let user = state .resolver .find_local_user(&username) .await? .ok_or(FediverseError::UserNotFound)?; let actor_url = format!("{}users/{}", state.config.base_url(), user.username); let profile_url = format!("{}@{}", state.config.base_url(), user.username); let body = WebFingerResponse { subject: query.resource.clone(), aliases: vec![actor_url.clone(), profile_url.clone()], links: vec![ WebFingerLink { rel: "self".to_string(), media_type: Some("application/activity+json".to_string()), href: Some(actor_url), template: None, }, WebFingerLink { rel: "http://webfinger.net/rel/profile-page".to_string(), media_type: Some("text/html".to_string()), href: Some(profile_url), template: None, }, ], }; Ok(( StatusCode::OK, [ ( header::CONTENT_TYPE, HeaderValue::from_static("application/jrd+json"), ), ( header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"), ), ], Json(body), ) .into_response()) } /// Parse a `resource=acct:user@domain` value into `(username, domain)`. /// /// Returns [`FediverseError::InvalidResource`] if the prefix is missing or /// the `@` separator is absent or any side is empty. pub(crate) fn parse_acct_resource(resource: &str) -> Result<(String, String), FediverseError> { let acct = resource.strip_prefix("acct:").ok_or_else(|| { FediverseError::InvalidResource(format!("missing acct: prefix in {resource}")) })?; let (user, domain) = acct.split_once('@').ok_or_else(|| { FediverseError::InvalidResource(format!("missing @ separator in {resource}")) })?; if user.is_empty() || domain.is_empty() { return Err(FediverseError::InvalidResource(format!( "empty user or domain in {resource}" ))); } Ok((user.to_string(), domain.to_string())) } #[cfg(test)] mod tests { use super::*; use crate::config::FediverseConfig; use crate::resolver::{LocalUserResolver, UserSummary}; use async_trait::async_trait; use axum::Router; use axum::body::Body; use axum::http::Request; use axum::routing::get; use std::sync::Arc; use tower::ServiceExt; use uuid::Uuid; /// Stub resolver returning predefined users. struct StubResolver { user: Option<UserSummary>, } #[async_trait] impl LocalUserResolver for StubResolver { async fn find_local_user( &self, _username: &str, ) -> Result<Option<UserSummary>, rustwarden_core::error::AppError> { Ok(self.user.clone()) } async fn count_users_total(&self) -> Result<u64, rustwarden_core::error::AppError> { Ok(0) } async fn count_users_active_since( &self, _days: u32, ) -> Result<u64, rustwarden_core::error::AppError> { Ok(0) } async fn count_local_posts(&self) -> Result<u64, rustwarden_core::error::AppError> { Ok(0) } } fn router_with_resolver(domain: &str, user: Option<UserSummary>) -> Router { let state = FediverseState { config: FediverseConfig::for_tests(domain), resolver: Arc::new(StubResolver { user }), }; Router::new() .route("/.well-known/webfinger", get(handle)) .layer(Extension(state)) } fn alice() -> UserSummary { UserSummary { id: Uuid::new_v4(), username: "alice".into(), display_name: Some("Alice".into()), bio: None, ap_public_key_pem: None, } } async fn body_json(response: Response) -> serde_json::Value { let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) .await .expect("body bytes"); serde_json::from_slice(&bytes).expect("body is valid JSON") } #[tokio::test] async fn webfinger_returns_jrd_for_existing_local_user() { let app = router_with_resolver("example.org", Some(alice())); let response = app .oneshot( Request::builder() .uri("/.well-known/webfinger?resource=acct:alice@example.org") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get(header::CONTENT_TYPE) .map(HeaderValue::to_str) .transpose() .unwrap() .unwrap_or_default(), "application/jrd+json" ); assert_eq!( response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN), Some(&HeaderValue::from_static("*")) ); let body = body_json(response).await; assert_eq!(body["subject"], "acct:alice@example.org"); let self_link = body["links"] .as_array() .expect("links array") .iter() .find(|l| l["rel"] == "self") .expect("self link present"); assert_eq!(self_link["type"], "application/activity+json"); assert_eq!(self_link["href"], "https://example.org/users/alice"); } #[tokio::test] async fn webfinger_returns_404_for_unknown_user() { let app = router_with_resolver("example.org", None); let response = app .oneshot( Request::builder() .uri("/.well-known/webfinger?resource=acct:bob@example.org") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn webfinger_returns_404_when_domain_mismatches() { let app = router_with_resolver("example.org", Some(alice())); let response = app .oneshot( Request::builder() .uri("/.well-known/webfinger?resource=acct:alice@other.tld") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn webfinger_returns_400_for_malformed_resource() { let app = router_with_resolver("example.org", Some(alice())); for bad in [ "/.well-known/webfinger?resource=alice@example.org", "/.well-known/webfinger?resource=acct:noatdomain", "/.well-known/webfinger?resource=acct:", ] { let response = app .clone() .oneshot(Request::builder().uri(bad).body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST, "bad uri={bad}"); } } #[test] fn parse_acct_resource_accepts_valid_handle() { let (user, domain) = parse_acct_resource("acct:alice@example.org").expect("valid"); assert_eq!(user, "alice"); assert_eq!(domain, "example.org"); } #[test] fn parse_acct_resource_rejects_missing_prefix() { let err = parse_acct_resource("alice@example.org").expect_err("must fail"); assert!( matches!(err, FediverseError::InvalidResource(_)), "got {err:?}" ); } }
GitRust