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)); } }
GitRust