nodeinfo.rs 197 lignes · 6590 octets
//! NodeInfo discovery + 2.1 detail handlers.
//!
//! `GET /.well-known/nodeinfo` returns a discovery document linking to the
//! concrete schema versions we expose. `GET /nodeinfo/2.1` returns the
//! full metadata document used by Fediverse directories.

use axum::{Extension, Json};

use super::error::FediverseError;
use super::state::FediverseState;
use super::types::{
    NodeInfo, NodeInfoDiscovery, NodeInfoLink, NodeInfoServices, NodeInfoSoftware, NodeInfoUsage,
    NodeInfoUsageUsers,
};

const NODEINFO_2_1_REL: &str = "http://nodeinfo.diaspora.software/ns/schema/2.1";

/// `GET /.well-known/nodeinfo`.
pub async fn discovery(
    Extension(state): Extension<FediverseState>,
) -> Result<Json<NodeInfoDiscovery>, FediverseError> {
    let href = format!("{}nodeinfo/2.1", state.config.base_url());
    Ok(Json(NodeInfoDiscovery {
        links: vec![NodeInfoLink {
            rel: NODEINFO_2_1_REL.to_string(),
            href,
        }],
    }))
}

/// `GET /nodeinfo/2.1`.
pub async fn detail(
    Extension(state): Extension<FediverseState>,
) -> Result<Json<NodeInfo>, FediverseError> {
    let total = state.resolver.count_users_total().await?;
    let active_month = state.resolver.count_users_active_since(30).await?;
    let active_halfyear = state.resolver.count_users_active_since(180).await?;
    let local_posts = state.resolver.count_local_posts().await?;

    Ok(Json(NodeInfo {
        version: "2.1".to_string(),
        software: NodeInfoSoftware {
            name: "rustwarden".to_string(),
            version: env!("CARGO_PKG_VERSION").to_string(),
            repository: Some("https://github.com/EBO/rustwarden".to_string()),
            homepage: None,
        },
        protocols: vec!["activitypub".to_string()],
        services: NodeInfoServices::default(),
        usage: NodeInfoUsage {
            users: NodeInfoUsageUsers {
                total,
                active_month,
                active_halfyear,
            },
            local_posts,
        },
        // RFC 0004 will source this from `app_settings.allow_signup`. For now
        // a conservative default: closed registration.
        open_registrations: false,
        metadata: serde_json::json!({}),
    }))
}

#[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, StatusCode};
    use axum::routing::get;
    use std::sync::Arc;
    use tower::ServiceExt;

    struct StubResolver {
        total: u64,
        active_month: u64,
        active_halfyear: u64,
    }

    #[async_trait]
    impl LocalUserResolver for StubResolver {
        async fn find_local_user(
            &self,
            _username: &str,
        ) -> Result<Option<UserSummary>, rustwarden_core::error::AppError> {
            Ok(None)
        }
        async fn count_users_total(&self) -> Result<u64, rustwarden_core::error::AppError> {
            Ok(self.total)
        }
        async fn count_users_active_since(
            &self,
            days: u32,
        ) -> Result<u64, rustwarden_core::error::AppError> {
            Ok(if days <= 30 {
                self.active_month
            } else {
                self.active_halfyear
            })
        }
        async fn count_local_posts(&self) -> Result<u64, rustwarden_core::error::AppError> {
            Ok(0)
        }
    }

    fn router(domain: &str, total: u64, active_month: u64, active_halfyear: u64) -> Router {
        let state = FediverseState {
            config: FediverseConfig::for_tests(domain),
            resolver: Arc::new(StubResolver {
                total,
                active_month,
                active_halfyear,
            }),
        };
        Router::new()
            .route("/.well-known/nodeinfo", get(discovery))
            .route("/nodeinfo/2.1", get(detail))
            .layer(Extension(state))
    }

    async fn body_json(response: axum::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 nodeinfo_discovery_links_to_2_1() {
        let app = router("example.org", 1, 0, 0);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/.well-known/nodeinfo")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let body = body_json(response).await;
        let link = body["links"]
            .as_array()
            .expect("links")
            .iter()
            .find(|l| l["rel"] == "http://nodeinfo.diaspora.software/ns/schema/2.1")
            .expect("2.1 schema link");
        assert_eq!(link["href"], "https://example.org/nodeinfo/2.1");
    }

    #[tokio::test]
    async fn nodeinfo_detail_returns_software_metadata() {
        let app = router("example.org", 0, 0, 0);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/nodeinfo/2.1")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let body = body_json(response).await;
        assert_eq!(body["version"], "2.1");
        assert_eq!(body["software"]["name"], "rustwarden");
        assert_eq!(
            body["software"]["version"],
            env!("CARGO_PKG_VERSION"),
            "software.version must mirror CARGO_PKG_VERSION"
        );
        assert_eq!(body["protocols"][0], "activitypub");
    }

    #[tokio::test]
    async fn nodeinfo_detail_counts_users_via_resolver() {
        let app = router("example.org", 42, 7, 23);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/nodeinfo/2.1")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        let body = body_json(response).await;
        assert_eq!(body["usage"]["users"]["total"], 42);
        assert_eq!(body["usage"]["users"]["activeMonth"], 7);
        assert_eq!(body["usage"]["users"]["activeHalfyear"], 23);
        assert_eq!(body["usage"]["localPosts"], 0);
    }
}