~ololduck/Frogue
feat(webui): expose nodeinfo 2.1 discovery and document
Browse the tree at this commit
4 files changed, +464, −0
frogue/Cargo.toml+1−0
@@ -2,6 +2,7 @@
name = "frogue"
version = "0.1.0+alpha.1"
edition = "2024"
+repository = "https://frogue.vit.am/~ololduck/Frogue"
[dependencies]
actix-web = "4.15.0"
frogue/src/webui/mod.rs+3−0
@@ -14,6 +14,7 @@ pub mod accounts;
pub mod federation;
pub mod git;
pub mod invites;
+pub mod nodeinfo;
pub mod remote_forge;
pub mod repositories;
pub mod ssh_keys;
@@ -28,6 +29,8 @@ pub(crate) fn configure(cfg: &mut web::ServiceConfig) {
.service(accounts::post_login)
.service(accounts::post_logout)
.service(federation::get_webfinger)
+ .service(nodeinfo::get_nodeinfo_jdd)
+ .service(nodeinfo::get_nodeinfo_21)
.service(
web::resource("/~{user_or_team}")
.route(
frogue/src/webui/nodeinfo.rs+176−0
@@ -0,0 +1,176 @@
+use std::sync::Arc;
+
+use actix_web::{HttpResponse, web};
+use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter};
+use serde::Serialize;
+use tokio::sync::RwLock;
+use tracing::instrument;
+
+use crate::entities::{ap_notes, comments, patch_proposals, repositories, tickets, users};
+use crate::state_read;
+use crate::{AppState, activitypub, registration};
+
+/// NodeInfo 2.1 JSON document structure
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct NodeInfo {
+ version: String,
+ software: Software,
+ protocols: Vec<String>,
+ services: Services,
+ open_registrations: bool,
+ usage: Usage,
+ metadata: Metadata,
+}
+
+#[derive(Serialize)]
+struct Software {
+ name: String,
+ version: String,
+ repository: Option<String>,
+}
+
+#[derive(Serialize)]
+struct Services {
+ inbound: Vec<String>,
+ outbound: Vec<String>,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Usage {
+ users: UserStats,
+ local_posts: u64,
+ local_comments: u64,
+}
+
+#[derive(Serialize)]
+struct UserStats {
+ total: u64,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Metadata {
+ repository_count: u64,
+}
+
+/// NodeInfo discovery document (JRD)
+#[derive(Serialize)]
+struct NodeInfoDiscovery {
+ links: Vec<NodeInfoLink>,
+}
+
+#[derive(Serialize)]
+struct NodeInfoLink {
+ rel: String,
+ href: String,
+}
+
+/// GET /.well-known/nodeinfo
+///
+/// Returns a JSON Resource Descriptor pointing to the NodeInfo 2.1 document.
+#[actix_web::get("/.well-known/nodeinfo")]
+#[instrument(skip(data))]
+pub async fn get_nodeinfo_jdd(data: web::Data<Arc<RwLock<AppState>>>) -> HttpResponse {
+ let state = state_read(&data).await;
+ let public_url = state.public_url().to_string();
+ drop(state);
+
+ let href = if public_url.is_empty() {
+ "/nodeinfo/2.1".to_string()
+ } else {
+ format!("{}/nodeinfo/2.1", public_url.trim_end_matches('/'))
+ };
+
+ let jrd = NodeInfoDiscovery {
+ links: vec![NodeInfoLink {
+ rel: "http://nodeinfo.diaspora.software/ns/schema/2.1#".to_string(),
+ href,
+ }],
+ };
+
+ HttpResponse::Ok()
+ .content_type("application/jrd+json")
+ .json(jrd)
+}
+
+/// GET /nodeinfo/2.1
+///
+/// Returns the NodeInfo 2.1 document with statistics about this instance.
+#[actix_web::get("/nodeinfo/2.1")]
+#[instrument(skip(data))]
+pub async fn get_nodeinfo_21(data: web::Data<Arc<RwLock<AppState>>>) -> HttpResponse {
+ let state = state_read(&data).await;
+ let db = state.db.clone();
+ let federation_enabled = activitypub::enabled(&state);
+ drop(state);
+
+ // Count users (excluding soft-deleted)
+ let user_count = users::Entity::find()
+ .filter(users::Column::DeletedAt.is_null())
+ .count(&db)
+ .await
+ .unwrap_or(0);
+
+ // Count repositories (excluding soft-deleted)
+ let repo_count = repositories::Entity::find()
+ .filter(repositories::Column::DeletedAt.is_null())
+ .count(&db)
+ .await
+ .unwrap_or(0);
+
+ // Count tickets
+ let ticket_count = tickets::Entity::find().count(&db).await.unwrap_or(0);
+
+ // Count patch proposals
+ let patch_count = patch_proposals::Entity::find()
+ .count(&db)
+ .await
+ .unwrap_or(0);
+
+ // Count notes
+ let note_count = ap_notes::Entity::find().count(&db).await.unwrap_or(0);
+
+ // Count comments
+ let comment_count = comments::Entity::find().count(&db).await.unwrap_or(0);
+
+ // Determine registration mode
+ let open_registrations = matches!(
+ registration::registration_mode(),
+ registration::RegistrationMode::Open
+ );
+
+ // Build protocols list
+ let mut protocols = vec!["forgefed".to_string()];
+ if federation_enabled {
+ protocols.push("activitypub".to_string());
+ }
+
+ let nodeinfo = NodeInfo {
+ version: "2.1".to_string(),
+ software: Software {
+ name: "frogue".to_string(),
+ version: env!("CARGO_PKG_VERSION").to_string(),
+ repository: Some(env!("CARGO_PKG_REPOSITORY").to_string()),
+ },
+ protocols,
+ services: Services {
+ inbound: vec![],
+ outbound: vec![],
+ },
+ open_registrations,
+ usage: Usage {
+ users: UserStats { total: user_count },
+ local_posts: ticket_count + patch_count + note_count,
+ local_comments: comment_count,
+ },
+ metadata: Metadata {
+ repository_count: repo_count,
+ },
+ };
+
+ HttpResponse::Ok()
+ .content_type("application/json")
+ .json(nodeinfo)
+}
frogue/tests/nodeinfo.rs+284−0
@@ -0,0 +1,284 @@
+//! Nodeinfo integration tests.
+//!
+//! These tests verify the nodeinfo 2.1 discovery and document endpoints:
+//! - /.well-known/nodeinfo (JDD)
+//! - /nodeinfo/2.1 (the actual document)
+
+mod support;
+
+use chrono::Utc;
+use miette::IntoDiagnostic;
+use sea_orm::{ActiveModelTrait, Database, Set};
+use support::{Client, Server, free_port};
+
+use frogue::entities::patch_proposals::{MergePolicy, Status};
+use frogue::entities::tickets::State;
+use frogue::entities::{comments, patch_proposals, repositories, tickets, users};
+
+/// Test 1: JDD at /.well-known/nodeinfo has correct structure
+#[tokio::test]
+async fn nodeinfo_discovery_returns_correct_jdd() -> miette::Result<()> {
+ let port = free_port();
+ let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
+ server.wait_for_connection(port);
+
+ let mut client = Client::new(port);
+ let response = client.get("/.well-known/nodeinfo");
+
+ assert_eq!(response.status, 200, "discovery should return 200");
+
+ let jdd: serde_json::Value = serde_json::from_str(&response.body).into_diagnostic()?;
+
+ assert!(jdd["links"].is_array(), "should have links array");
+ let links = jdd["links"].as_array().unwrap();
+ assert_eq!(links.len(), 1, "should have exactly one link");
+
+ let link = &links[0];
+ assert_eq!(
+ link["rel"], "http://nodeinfo.diaspora.software/ns/schema/2.1#",
+ "rel should match nodeinfo 2.1 schema"
+ );
+
+ let href = link["href"].as_str().unwrap();
+ assert!(
+ href.contains("/nodeinfo/2.1"),
+ "href should point to /nodeinfo/2.1"
+ );
+
+ Ok(())
+}
+
+/// Test 2: Document at /nodeinfo/2.1 has correct structure and content
+#[tokio::test]
+async fn nodeinfo_document_has_correct_fields() -> miette::Result<()> {
+ let port = free_port();
+ let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
+ server.wait_for_connection(port);
+
+ let db = Database::connect(server.database_url())
+ .await
+ .into_diagnostic()?;
+
+ // Insert some test data
+ let now = Utc::now();
+ let user = users::ActiveModel {
+ uuid: Set(uuid::Uuid::new_v4()),
+ name: Set("alice".to_string()),
+ email: Set("alice@example.org".to_string()),
+ password_hash: Set("irrelevant".to_string()),
+ ..Default::default()
+ };
+ let user = user.insert(&db).await.into_diagnostic()?;
+
+ // Create a repository
+ let repo = repositories::ActiveModel {
+ uuid: Set(uuid::Uuid::new_v4()),
+ owner_id: Set(user.id),
+ slug: Set("test-repo".to_string()),
+ visibility: Set(repositories::Visibility::Public),
+ default_branch: Set("main".to_string()),
+ next_ticket_number: Set(2),
+ next_patch_number: Set(2),
+ default_merge_policy: Set(repositories::MergePolicy::PreferFastForward),
+ created_at: Set(now.into()),
+ ..Default::default()
+ };
+ let repo = repo.insert(&db).await.into_diagnostic()?;
+
+ // Create a ticket
+ let ticket = tickets::ActiveModel {
+ uuid: Set(uuid::Uuid::new_v4()),
+ repository_id: Set(repo.id),
+ creator_id: Set(Some(user.id)),
+ number: Set(1),
+ summary: Set("Test ticket".to_string()),
+ state: Set(State::Open),
+ created_at: Set(now.into()),
+ updated_at: Set(now.into()),
+ ..Default::default()
+ };
+ let ticket = ticket.insert(&db).await.into_diagnostic()?;
+
+ // Create a patch proposal
+ let patch = patch_proposals::ActiveModel {
+ uuid: Set(uuid::Uuid::new_v4()),
+ repository_id: Set(repo.id),
+ creator_id: Set(Some(user.id)),
+ number: Set(1),
+ summary: Set("Test patch".to_string()),
+ source_ref: Set("feature".to_string()),
+ target_branch: Set("main".to_string()),
+ status: Set(Status::Open),
+ merge_policy: Set(MergePolicy::PreferFastForward),
+ created_at: Set(now.into()),
+ updated_at: Set(now.into()),
+ ..Default::default()
+ };
+ patch.insert(&db).await.into_diagnostic()?;
+
+ // Create a comment
+ let comment = comments::ActiveModel {
+ uuid: Set(uuid::Uuid::new_v4()),
+ author_id: Set(Some(user.id)),
+ ticket_id: Set(Some(ticket.id)),
+ content: Set("Test comment".to_string()),
+ content_format: Set("markdown".to_string()),
+ created_at: Set(now.into()),
+ ..Default::default()
+ };
+ comment.insert(&db).await.into_diagnostic()?;
+
+ let mut client = Client::new(port);
+ let response = client.get("/nodeinfo/2.1");
+
+ assert_eq!(response.status, 200, "document should return 200");
+ assert!(
+ response
+ .content_type
+ .as_ref()
+ .unwrap()
+ .contains("application/json"),
+ "should be JSON"
+ );
+
+ let doc: serde_json::Value = serde_json::from_str(&response.body).into_diagnostic()?;
+
+ // Check version
+ assert_eq!(doc["version"], "2.1", "version should be 2.1");
+
+ // Check software
+ assert_eq!(
+ doc["software"]["name"], "frogue",
+ "software name should be frogue"
+ );
+ assert!(
+ doc["software"]["version"].is_string(),
+ "software version should be present"
+ );
+
+ // Check protocols
+ let protocols = doc["protocols"].as_array().unwrap();
+ let protocol_strs: Vec<&str> = protocols.iter().map(|p| p.as_str().unwrap()).collect();
+ assert!(
+ protocol_strs.contains(&"activitypub"),
+ "should include activitypub protocol"
+ );
+ assert!(
+ protocol_strs.contains(&"forgefed"),
+ "should include forgefed protocol"
+ );
+
+ // Check services
+ assert!(
+ doc["services"]["inbound"].is_array(),
+ "services.inbound should be array"
+ );
+ assert!(
+ doc["services"]["outbound"].is_array(),
+ "services.outbound should be array"
+ );
+
+ // Check usage
+ assert!(
+ doc["usage"]["users"]["total"].is_number(),
+ "should have usage.users.total"
+ );
+ assert_eq!(doc["usage"]["users"]["total"], 1, "should count 1 user");
+
+ assert_eq!(
+ doc["usage"]["localPosts"], 2,
+ "localPosts should be tickets + patches (1 + 1 = 2)"
+ );
+
+ assert_eq!(
+ doc["usage"]["localComments"], 1,
+ "localComments should be 1"
+ );
+
+ // Check metadata
+ assert_eq!(
+ doc["metadata"]["repositoryCount"], 1,
+ "metadata should include repository count"
+ );
+
+ Ok(())
+}
+
+/// Test 3: Nodeinfo works when federation is disabled
+#[tokio::test]
+async fn nodeinfo_works_without_federation() -> miette::Result<()> {
+ let port = free_port();
+ let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
+ server.wait_for_connection(port);
+
+ let db = Database::connect(server.database_url())
+ .await
+ .into_diagnostic()?;
+
+ // Insert a user
+ let user = users::ActiveModel {
+ uuid: Set(uuid::Uuid::new_v4()),
+ name: Set("bob".to_string()),
+ email: Set("bob@example.org".to_string()),
+ password_hash: Set("irrelevant".to_string()),
+ ..Default::default()
+ };
+ user.insert(&db).await.into_diagnostic()?;
+
+ let mut client = Client::new(port);
+
+ // Discovery should still work
+ let response = client.get("/.well-known/nodeinfo");
+ assert_eq!(
+ response.status, 200,
+ "discovery should work without federation"
+ );
+
+ // Document should still work
+ let response = client.get("/nodeinfo/2.1");
+ assert_eq!(
+ response.status, 200,
+ "document should work without federation"
+ );
+
+ let doc: serde_json::Value = serde_json::from_str(&response.body).into_diagnostic()?;
+
+ // Should not include activitypub when federation disabled
+ let protocols = doc["protocols"].as_array().unwrap();
+ let protocol_strs: Vec<&str> = protocols.iter().map(|p| p.as_str().unwrap()).collect();
+
+ assert!(
+ !protocol_strs.contains(&"activitypub"),
+ "should NOT include activitypub when federation disabled"
+ );
+ assert!(
+ protocol_strs.contains(&"forgefed"),
+ "should still include forgefed"
+ );
+
+ Ok(())
+}
+
+/// Test 4: openRegistrations reflects registration mode
+#[tokio::test]
+async fn nodeinfo_reflects_registration_mode() -> miette::Result<()> {
+ // Test with invite-only mode
+ let port = free_port();
+ let mut server = Server::spawn_with_env(
+ &format!("127.0.0.1:{port}"),
+ &[("FROGUE_REGISTRATION_MODE", "invite_only")],
+ );
+ server.wait_for_connection(port);
+
+ let mut client = Client::new(port);
+ let response = client.get("/nodeinfo/2.1");
+ assert_eq!(response.status, 200);
+
+ let doc: serde_json::Value = serde_json::from_str(&response.body).into_diagnostic()?;
+ assert_eq!(
+ doc["openRegistrations"], false,
+ "should be false in invite-only mode"
+ );
+
+ Ok(())
+}