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