actor.rs 317 lignes · 10311 octets
//! Person actor handler with `Accept` content negotiation.
//!
//! Mounted at `GET /users/{username}`.

use axum::{
    Extension, Json,
    extract::Path,
    http::{HeaderMap, HeaderValue, StatusCode, header},
    response::{IntoResponse, Response},
};
use url::Url;

use super::error::FediverseError;
use super::resolver::UserSummary;
use super::state::FediverseState;
use super::types::{EndpointsJson, PersonJson, PublicKeyJson, actor_context};

/// `GET /users/{username}`.
///
/// Negotiates between ActivityPub JSON and HTML based on the `Accept` header:
///
/// | `Accept` value contains… | Response |
/// |---|---|
/// | `application/activity+json` | `200` JSON Person |
/// | `application/ld+json` | `200` JSON Person |
/// | `*/*` (e.g. `curl` default) | `200` JSON Person |
/// | (no `Accept` header) | `200` JSON Person (Mastodon convention) |
/// | only `text/html` (and UI not mounted at this layer) | `406 Not Acceptable` |
pub async fn handle(
    Path(username): Path<String>,
    headers: HeaderMap,
    Extension(state): Extension<FediverseState>,
) -> Result<Response, FediverseError> {
    if !prefers_activitypub(&headers) {
        return Err(FediverseError::NotAcceptable);
    }

    let user = state
        .resolver
        .find_local_user(&username)
        .await?
        .ok_or(FediverseError::UserNotFound)?;

    let person = build_person_json(&state, &user)?;

    Ok((
        StatusCode::OK,
        [(
            header::CONTENT_TYPE,
            HeaderValue::from_static("application/activity+json; charset=utf-8"),
        )],
        Json(person),
    )
        .into_response())
}

/// Inspect an `Accept` header and decide whether the caller will accept the
/// ActivityPub JSON representation.
///
/// Per Mastodon convention, an absent `Accept` header is treated as a
/// preference for ActivityPub (AP `id` URLs are the canonical resource).
pub(crate) fn prefers_activitypub(headers: &HeaderMap) -> bool {
    let Some(value) = headers.get(header::ACCEPT).and_then(|v| v.to_str().ok()) else {
        return true;
    };

    let lower = value.to_ascii_lowercase();
    lower.contains("application/activity+json")
        || lower.contains("application/ld+json")
        || lower.contains("*/*")
}

/// Build a [`PersonJson`] for a local user, using base URLs derived from the
/// federation config.
pub(crate) fn build_person_json(
    state: &FediverseState,
    user: &UserSummary,
) -> Result<PersonJson, FediverseError> {
    let base = state.config.base_url();
    let actor_url = url_join(base, &format!("users/{}", user.username))?;
    let inbox = url_join(base, &format!("users/{}/inbox", user.username))?;
    let outbox = url_join(base, &format!("users/{}/outbox", user.username))?;
    let followers = url_join(base, &format!("users/{}/followers", user.username))?;
    let following = url_join(base, &format!("users/{}/following", user.username))?;
    let profile = url_join(base, &format!("@{}", user.username))?;
    let shared_inbox = url_join(base, "inbox")?;

    let public_key = user.ap_public_key_pem.as_ref().map(|pem| PublicKeyJson {
        id: append_fragment(&actor_url, "main-key"),
        owner: actor_url.clone(),
        public_key_pem: pem.clone(),
    });

    Ok(PersonJson {
        context: actor_context(),
        kind: "Person".to_string(),
        id: actor_url,
        preferred_username: user.username.clone(),
        name: user.display_name.clone(),
        summary: user.bio.clone(),
        url: Some(profile),
        inbox,
        outbox,
        followers,
        following,
        endpoints: Some(EndpointsJson { shared_inbox }),
        public_key,
        manually_approves_followers: false,
        discoverable: true,
    })
}

fn url_join(base: &Url, path: &str) -> Result<Url, FediverseError> {
    base.join(path).map_err(|e| {
        FediverseError::App(rustwarden_core::error::AppError::Internal(format!(
            "failed to build actor URL from {base} + {path}: {e}"
        )))
    })
}

fn append_fragment(url: &Url, fragment: &str) -> Url {
    let mut copy = url.clone();
    copy.set_fragment(Some(fragment));
    copy
}

#[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;

    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("/users/{username}", get(handle))
            .layer(Extension(state))
    }

    fn alice() -> UserSummary {
        UserSummary {
            id: Uuid::new_v4(),
            username: "alice".into(),
            display_name: Some("Alice".into()),
            bio: Some("<p>Hello</p>".into()),
            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 actor_returns_person_json_with_ap_accept() {
        let app = router_with_resolver("example.org", Some(alice()));

        let response = app
            .oneshot(
                Request::builder()
                    .uri("/users/alice")
                    .header(header::ACCEPT, "application/activity+json")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_eq!(response.status(), StatusCode::OK);
        let ct = response
            .headers()
            .get(header::CONTENT_TYPE)
            .and_then(|v| v.to_str().ok())
            .unwrap_or_default()
            .to_string();
        assert!(
            ct.contains("application/activity+json"),
            "content-type={ct}"
        );

        let body = body_json(response).await;
        assert_eq!(body["type"], "Person");
        assert_eq!(body["id"], "https://example.org/users/alice");
        assert_eq!(body["preferredUsername"], "alice");
        assert_eq!(body["inbox"], "https://example.org/users/alice/inbox");
        assert_eq!(body["outbox"], "https://example.org/users/alice/outbox");
        assert_eq!(
            body["followers"],
            "https://example.org/users/alice/followers"
        );
        assert_eq!(
            body["following"],
            "https://example.org/users/alice/following"
        );
        assert!(body.get("publicKey").is_none() || body["publicKey"].is_null());
    }

    #[tokio::test]
    async fn actor_returns_person_json_with_ld_json_accept() {
        let app = router_with_resolver("example.org", Some(alice()));
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/users/alice")
                    .header(
                        header::ACCEPT,
                        "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
                    )
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let body = body_json(response).await;
        assert_eq!(body["type"], "Person");
    }

    #[tokio::test]
    async fn actor_returns_404_for_unknown_user() {
        let app = router_with_resolver("example.org", None);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/users/ghost")
                    .header(header::ACCEPT, "application/activity+json")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn actor_returns_406_for_html_when_no_ui_handler() {
        let app = router_with_resolver("example.org", Some(alice()));
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/users/alice")
                    .header(header::ACCEPT, "text/html")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE);
    }

    #[tokio::test]
    async fn actor_default_accept_returns_ap_json() {
        let app = router_with_resolver("example.org", Some(alice()));
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/users/alice")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let body = body_json(response).await;
        assert_eq!(body["type"], "Person");
    }

    #[test]
    fn prefers_ap_for_explicit_accept() {
        let mut h = HeaderMap::new();
        h.insert(
            header::ACCEPT,
            HeaderValue::from_static("application/activity+json"),
        );
        assert!(prefers_activitypub(&h));
    }
}