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:?}"
        );
    }
}