remote_identity_service.rs
429 lignes · 16645 octets
//! Remote ActivityPub identity service (RFC 0003). //! //! Caches remote actors in the `remote_identities` table and exposes //! `resolve_handle` which performs WebFinger + actor fetch when the cache //! is stale or empty. //! use std::sync::Arc; use std::time::Duration; use chrono::{DateTime, Utc}; use sea_orm::DatabaseConnection; use uuid::Uuid; use crate::config::FediverseConfig; use crate::error::FediverseError; use crate::fetcher::RemoteFetcher; use crate::models::remote_identity; use crate::types::PersonJson; use rustwarden_core::error::AppError; /// Service orchestrating remote actor resolution and caching. /// /// Constructed by consumers (RFCs 0008+ for invitations / Identity /// abstraction will inject one). Cheap to clone — the DB handle is /// `Arc`-backed and the fetcher is behind `Arc<dyn RemoteFetcher>`. #[derive(Clone)] pub struct RemoteIdentityService { db: DatabaseConnection, fetcher: Arc<dyn RemoteFetcher>, cache_ttl: Duration, } impl RemoteIdentityService { /// Build a new service with the given storage backend, fetcher, and /// federation config. pub fn new( db: DatabaseConnection, fetcher: Arc<dyn RemoteFetcher>, config: &FediverseConfig, ) -> Self { Self { db, fetcher, cache_ttl: config.fetch_actor_cache_ttl(), } } /// Return `true` when the cache row is older than the configured TTL /// and should be refreshed. pub fn is_stale(&self, last_fetched_at: DateTime<Utc>) -> bool { let elapsed = Utc::now().signed_duration_since(last_fetched_at); match elapsed.to_std() { Ok(d) => d >= self.cache_ttl, // Negative durations: clock skew, treat as fresh. Err(_) => false, } } /// Resolve a `@user@domain` handle. Returns the cached row if fresh; /// otherwise calls the fetcher (WebFinger + actor JSON) and upserts. pub async fn resolve_handle( &self, handle: &str, ) -> Result<remote_identity::Model, FediverseError> { let (username, domain) = parse_handle(handle)?; // Fast path: cache hit and fresh. if let Some(cached) = self.find_by_handle(&username, &domain).await? { if !self.is_stale(cached.last_fetched_at) { return Ok(cached); } } // Slow path: fetch via WebFinger then by actor URL, then upsert. let webfinger = self.fetcher.webfinger(handle).await?; let actor_url = webfinger .links .iter() .find(|l| { l.rel == "self" && l.media_type .as_deref() .is_some_and(|t| t.contains("activity+json") || t.contains("ld+json")) }) .and_then(|l| l.href.as_ref()) .ok_or_else(|| { FediverseError::InvalidResource(format!( "WebFinger response for {handle} has no self/AP link" )) })?; let actor_url = url::Url::parse(actor_url).map_err(|e| { FediverseError::InvalidResource(format!("invalid self link in WebFinger: {e}")) })?; let person = self.fetcher.fetch_actor(&actor_url).await?; self.upsert_from_person(person, &domain).await } /// Direct DB lookup by AP id. Returns `None` if no row matches. pub async fn find_by_uri( &self, ap_id: &str, ) -> Result<Option<remote_identity::Model>, AppError> { use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; Ok(remote_identity::Entity::find() .filter(remote_identity::Column::ApId.eq(ap_id)) .one(&self.db) .await?) } /// Direct DB lookup by handle. Returns `None` if no row matches. pub async fn find_by_handle( &self, username: &str, domain: &str, ) -> Result<Option<remote_identity::Model>, AppError> { use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; Ok(remote_identity::Entity::find() .filter(remote_identity::Column::Username.eq(username)) .filter(remote_identity::Column::Domain.eq(domain)) .one(&self.db) .await?) } /// Insert or update a remote identity from a freshly fetched PersonJson. /// /// `expected_domain` MUST match the host of `person.id`. Mismatches yield /// [`FediverseError::DomainMismatch`] without touching the database, to /// prevent trivial spoofing. pub async fn upsert_from_person( &self, person: PersonJson, expected_domain: &str, ) -> Result<remote_identity::Model, FediverseError> { use sea_orm::{ActiveModelTrait, Set}; let actor_host = person .id .host_str() .ok_or_else(|| FediverseError::InvalidResource("actor id has no host".into()))?; if !actor_host.eq_ignore_ascii_case(expected_domain) { return Err(FediverseError::DomainMismatch); } let raw_json = serde_json::to_value(&person).ok(); let now = Utc::now(); let public_key_pem = person .public_key .as_ref() .map(|k| k.public_key_pem.clone()) .unwrap_or_default(); if let Some(existing) = self .find_by_uri(person.id.as_str()) .await .map_err(FediverseError::App)? { let mut active: remote_identity::ActiveModel = existing.into(); active.username = Set(person.preferred_username.clone()); active.domain = Set(actor_host.to_string()); active.display_name = Set(person.name.clone()); active.summary_html = Set(person.summary.clone()); active.inbox_url = Set(person.inbox.to_string()); active.shared_inbox_url = Set(person .endpoints .as_ref() .map(|e| e.shared_inbox.to_string())); active.public_key_pem = Set(public_key_pem); active.actor_type = Set(person.kind.clone()); active.raw_json = Set(raw_json); active.last_fetched_at = Set(now); active.updated_at = Set(now); Ok(active.update(&self.db).await.map_err(AppError::from)?) } else { let active = remote_identity::ActiveModel { id: Set(Uuid::new_v4()), ap_id: Set(person.id.to_string()), username: Set(person.preferred_username.clone()), domain: Set(actor_host.to_string()), display_name: Set(person.name.clone()), summary_html: Set(person.summary.clone()), inbox_url: Set(person.inbox.to_string()), shared_inbox_url: Set(person .endpoints .as_ref() .map(|e| e.shared_inbox.to_string())), public_key_pem: Set(public_key_pem), icon_url: Set(None), actor_type: Set(person.kind.clone()), raw_json: Set(raw_json), last_fetched_at: Set(now), disabled_at: Set(None), created_at: Set(now), updated_at: Set(now), }; Ok(active.insert(&self.db).await.map_err(AppError::from)?) } } /// Mark an identity as disabled (used by `Delete{Actor}` in RFC 0010). pub async fn mark_disabled(&self, id: Uuid) -> Result<(), AppError> { use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set, sea_query::Expr}; let now = Utc::now(); remote_identity::Entity::update_many() .col_expr(remote_identity::Column::DisabledAt, Expr::value(now)) .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now)) .filter(remote_identity::Column::Id.eq(id)) .exec(&self.db) .await?; // Touch unused-import lint let _ = Set::<Option<DateTime<Utc>>>; Ok(()) } } /// Parse a `@user@domain` handle into `(username, domain)`. /// /// Accepts both `@user@domain` (Mastodon convention) and bare `user@domain`. /// Returns [`FediverseError::InvalidResource`] if the format is malformed. pub(crate) fn parse_handle(handle: &str) -> Result<(String, String), FediverseError> { let trimmed = handle.trim(); let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed); let (user, domain) = stripped.split_once('@').ok_or_else(|| { FediverseError::InvalidResource(format!("missing @ separator in handle {handle}")) })?; if user.is_empty() || domain.is_empty() { return Err(FediverseError::InvalidResource(format!( "empty user or domain in handle {handle}" ))); } Ok((user.to_string(), domain.to_string())) } #[cfg(test)] mod tests { use super::*; use crate::fetcher::UnconfiguredFetcher; use chrono::Duration as ChronoDuration; use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; fn config_for_tests() -> FediverseConfig { FediverseConfig::for_tests("example.org") } fn make_service(db: DatabaseConnection) -> RemoteIdentityService { RemoteIdentityService::new(db, Arc::new(UnconfiguredFetcher), &config_for_tests()) } fn sample_remote(domain: &str, username: &str) -> remote_identity::Model { let now = Utc::now(); let id = Uuid::new_v4(); remote_identity::Model { id, ap_id: format!("https://{domain}/users/{username}"), username: username.to_string(), domain: domain.to_string(), display_name: Some(username.to_string()), summary_html: None, inbox_url: format!("https://{domain}/users/{username}/inbox"), shared_inbox_url: Some(format!("https://{domain}/inbox")), public_key_pem: "-----BEGIN PUBLIC KEY-----\nFAKE\n-----END PUBLIC KEY-----" .to_string(), icon_url: None, actor_type: "Person".to_string(), raw_json: None, last_fetched_at: now, disabled_at: None, created_at: now, updated_at: now, } } // ─── Test 1 — is_stale fresh ───────────────────────────────────── #[test] fn is_stale_returns_false_within_ttl() { let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); let svc = make_service(db); let recent = Utc::now() - ChronoDuration::seconds(60); assert!(!svc.is_stale(recent)); } // ─── Test 2 — is_stale stale ───────────────────────────────────── #[test] fn is_stale_returns_true_past_ttl() { let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); let svc = make_service(db); // Default TTL is 86 400 s. Use 2 days to be safely past. let old = Utc::now() - ChronoDuration::days(2); assert!(svc.is_stale(old)); } // ─── Test 3 — parse_handle ─────────────────────────────────────── #[test] fn parse_handle_accepts_mastodon_form() { let (user, domain) = parse_handle("@alice@example.org").expect("valid"); assert_eq!(user, "alice"); assert_eq!(domain, "example.org"); } #[test] fn parse_handle_accepts_bare_form() { let (user, domain) = parse_handle("alice@example.org").expect("valid"); assert_eq!(user, "alice"); assert_eq!(domain, "example.org"); } #[test] fn parse_handle_rejects_missing_at() { let err = parse_handle("alice").expect_err("must fail"); assert!(matches!(err, FediverseError::InvalidResource(_))); } // ─── Test 4 — find_by_uri none ─────────────────────────────────── #[tokio::test] async fn find_by_uri_returns_none_when_absent() { let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([Vec::<remote_identity::Model>::new()]) .into_connection(); let svc = make_service(db); let result = svc .find_by_uri("https://other.tld/users/ghost") .await .unwrap(); assert!(result.is_none()); } // ─── Test 5 — find_by_uri some ─────────────────────────────────── #[tokio::test] async fn find_by_uri_returns_record_when_present() { let row = sample_remote("other.tld", "alice"); let expected_id = row.id; let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row]]) .into_connection(); let svc = make_service(db); let result = svc .find_by_uri("https://other.tld/users/alice") .await .unwrap() .expect("must be present"); assert_eq!(result.id, expected_id); } // ─── Test 6 — find_by_handle none ──────────────────────────────── #[tokio::test] async fn find_by_handle_returns_none_when_absent() { let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([Vec::<remote_identity::Model>::new()]) .into_connection(); let svc = make_service(db); let result = svc.find_by_handle("ghost", "other.tld").await.unwrap(); assert!(result.is_none()); } // ─── Test 7 — upsert rejects domain mismatch ───────────────────── #[tokio::test] async fn upsert_rejects_domain_mismatch() { let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); let svc = make_service(db); let person = PersonJson { context: crate::types::actor_context(), kind: "Person".into(), id: url::Url::parse("https://attacker.tld/users/alice").unwrap(), preferred_username: "alice".into(), name: None, summary: None, url: None, inbox: url::Url::parse("https://attacker.tld/users/alice/inbox").unwrap(), outbox: url::Url::parse("https://attacker.tld/users/alice/outbox").unwrap(), followers: url::Url::parse("https://attacker.tld/users/alice/followers").unwrap(), following: url::Url::parse("https://attacker.tld/users/alice/following").unwrap(), endpoints: None, public_key: None, manually_approves_followers: false, discoverable: true, }; let err = svc .upsert_from_person(person, "expected.tld") .await .expect_err("must fail"); assert!(matches!(err, FediverseError::DomainMismatch), "got {err:?}"); } // ─── Test 8 — mark_disabled ────────────────────────────────────── #[tokio::test] async fn mark_disabled_sets_disabled_at() { let id = Uuid::new_v4(); let db = MockDatabase::new(DatabaseBackend::Postgres) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let svc = make_service(db); svc.mark_disabled(id).await.unwrap(); } // ─── Test 9 — resolve_handle returns cached when fresh ─────────── #[tokio::test] async fn resolve_handle_returns_cached_when_fresh() { let row = sample_remote("other.tld", "alice"); let expected_id = row.id; let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row]]) .into_connection(); // UnconfiguredFetcher would return Err if invoked — fresh cache must // not invoke it. let svc = RemoteIdentityService::new(db, Arc::new(UnconfiguredFetcher), &config_for_tests()); let resolved = svc .resolve_handle("@alice@other.tld") .await .expect("cached row"); assert_eq!(resolved.id, expected_id); } }
GitRust