resolver.rs 147 lignes · 5819 octets
//! `LocalUserResolver` trait + SeaORM-backed default implementation.
//!
//! Federation handlers depend on this trait rather than directly on SeaORM,
//! which keeps unit tests simple (we inject a stub resolver) and lets future
//! deployments swap the backing store without touching the handler code.

use async_trait::async_trait;
use sea_orm::DatabaseConnection;
use uuid::Uuid;

use rustwarden_core::error::AppError;

/// Minimal projection of a local user usable to render WebFinger and
/// Person JSON. Extended by [RFC 0005](../../rfcs/0005-actor-key-storage.md)
/// (`actor-key-storage`) with the `ap_public_key_pem` field populated.
#[derive(Debug, Clone)]
pub struct UserSummary {
    /// Stable internal user identifier.
    pub id: Uuid,
    /// Local username (matches the `alice` part of `@alice@example.org`).
    pub username: String,
    /// Optional display name (Mastodon `name` field).
    pub display_name: Option<String>,
    /// Optional bio in HTML (Mastodon `summary` field).
    pub bio: Option<String>,
    /// PEM-encoded RSA public key. `None` until RFC 0005 lands.
    pub ap_public_key_pem: Option<String>,
}

/// Read-only projection of users used by federation handlers.
///
/// Concrete implementations provide a thin facade over the underlying user
/// store. The default is [`SeaOrmLocalUserResolver`]; tests can inject their
/// own implementation.
#[async_trait]
pub trait LocalUserResolver: Send + Sync {
    /// Look up a local user by username.
    ///
    /// Returns `Ok(None)` when no such user exists or the user is
    /// soft-deleted. Returns `Err(AppError::Database(_))` on transport errors.
    async fn find_local_user(&self, username: &str) -> Result<Option<UserSummary>, AppError>;

    /// Total number of registered users (NodeInfo `usage.users.total`).
    async fn count_users_total(&self) -> Result<u64, AppError>;

    /// Number of users active within the given window.
    ///
    /// "Active" is defined as having `last_login_at` within the past
    /// `days` days. Used to populate `usage.users.active_month` (`days = 30`)
    /// and `usage.users.active_halfyear` (`days = 180`).
    async fn count_users_active_since(&self, days: u32) -> Result<u64, AppError>;

    /// Total number of locally-authored posts (NodeInfo `usage.localPosts`).
    /// Returns `0` until later RFCs introduce post entities.
    async fn count_local_posts(&self) -> Result<u64, AppError>;
}

/// Default [`LocalUserResolver`] implementation backed by a SeaORM
/// [`DatabaseConnection`]. Cheap to clone (the connection is `Arc`-backed).
#[derive(Debug, Clone)]
pub struct SeaOrmLocalUserResolver {
    db: DatabaseConnection,
}

impl SeaOrmLocalUserResolver {
    /// Build a resolver around an existing SeaORM connection.
    pub fn new(db: DatabaseConnection) -> Self {
        Self { db }
    }
}

#[async_trait]
impl LocalUserResolver for SeaOrmLocalUserResolver {
    async fn find_local_user(&self, username: &str) -> Result<Option<UserSummary>, AppError> {
        use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};

        let row = rustwarden_core::models::user::Entity::find()
            .filter(rustwarden_core::models::user::Column::Username.eq(username))
            .one(&self.db)
            .await?;

        let Some(r) = row else { return Ok(None) };

        // RFC 0005: lazy-generate the actor key the first time someone
        // resolves the user, but only if encryption is configured. We
        // never block the resolution on a generation failure: better to
        // serve a key-less actor than to 500 the WebFinger flow.
        let public_pem = match &r.ap_public_key_pem {
            Some(pem) => Some(pem.clone()),
            None => {
                if rustwarden_core::services::crypto_service::CryptoService::is_available() {
                    match crate::services::actor_key_service::ActorKeyService::ensure_keys(
                        &self.db, r.id,
                    )
                    .await
                    {
                        Ok(material) => Some(material.public_pem),
                        Err(err) => {
                            tracing::warn!(
                                user_id = %r.id,
                                error = %err,
                                "actor key generation failed, serving key-less actor",
                            );
                            None
                        }
                    }
                } else {
                    None
                }
            }
        };

        Ok(Some(UserSummary {
            id: r.id,
            username: r.username,
            // `display_name` / `bio` columns are not in the schema yet — a
            // future RFC will add them.
            display_name: None,
            bio: None,
            ap_public_key_pem: public_pem,
        }))
    }

    async fn count_users_total(&self) -> Result<u64, AppError> {
        use sea_orm::{EntityTrait, PaginatorTrait};
        Ok(rustwarden_core::models::user::Entity::find()
            .count(&self.db)
            .await?)
    }

    async fn count_users_active_since(&self, days: u32) -> Result<u64, AppError> {
        use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter};

        let cutoff = chrono::Utc::now() - chrono::Duration::days(days as i64);
        Ok(rustwarden_core::models::user::Entity::find()
            .filter(rustwarden_core::models::user::Column::LastLoginAt.gte(cutoff))
            .count(&self.db)
            .await?)
    }

    async fn count_local_posts(&self) -> Result<u64, AppError> {
        // RFC 0002 — no post entity yet, always zero. Future RFCs that
        // introduce locally-authored posts will override this method or
        // ship a dedicated resolver.
        Ok(0)
    }
}