use std::sync::Arc;
use actpub_nodeinfo::{Discovery, NodeInfo, Version};
use axum::Router;
use axum::extract::State;
use axum::http::{StatusCode, header};
use axum::response::IntoResponse;
use axum::routing::get;
use url::Url;
pub const NODEINFO_CONTENT_TYPE: &str = "application/json; charset=utf-8";
#[derive(Debug, Clone)]
pub struct NodeInfoState {
pub base_url: Url,
pub v2_0: Option<NodeInfo>,
pub v2_1: Option<NodeInfo>,
}
impl NodeInfoState {
#[must_use]
pub const fn dual(base_url: Url, v2_0: NodeInfo, v2_1: NodeInfo) -> Self {
Self {
base_url,
v2_0: Some(v2_0),
v2_1: Some(v2_1),
}
}
#[must_use]
pub const fn only_v2_1(base_url: Url, v2_1: NodeInfo) -> Self {
Self {
base_url,
v2_0: None,
v2_1: Some(v2_1),
}
}
fn discovery(&self) -> Discovery {
let mut disco = Discovery::default();
if self.v2_0.is_some()
&& let Ok(href) = self.versioned_href(Version::V2_0)
{
disco = disco.with_version(Version::V2_0, href);
}
if self.v2_1.is_some()
&& let Ok(href) = self.versioned_href(Version::V2_1)
{
disco = disco.with_version(Version::V2_1, href);
}
disco
}
fn versioned_href(&self, version: Version) -> Result<Url, url::ParseError> {
self.base_url
.join(&format!("/nodeinfo/{}", version.as_str()))
}
}
pub fn nodeinfo_router(state: NodeInfoState) -> Router {
Router::new()
.route("/.well-known/nodeinfo", get(handle_discovery))
.route("/nodeinfo/2.0", get(handle_v2_0))
.route("/nodeinfo/2.1", get(handle_v2_1))
.with_state(Arc::new(state))
}
async fn handle_discovery(State(state): State<Arc<NodeInfoState>>) -> impl IntoResponse {
json_response(state.discovery())
}
async fn handle_v2_0(State(state): State<Arc<NodeInfoState>>) -> impl IntoResponse {
state.v2_0.as_ref().map_or_else(
|| StatusCode::NOT_FOUND.into_response(),
|info| json_response_with_schema(info, Version::V2_0).into_response(),
)
}
async fn handle_v2_1(State(state): State<Arc<NodeInfoState>>) -> impl IntoResponse {
state.v2_1.as_ref().map_or_else(
|| StatusCode::NOT_FOUND.into_response(),
|info| json_response_with_schema(info, Version::V2_1).into_response(),
)
}
fn json_response<T: serde::Serialize>(value: T) -> axum::response::Response {
match serde_json::to_vec(&value) {
Ok(bytes) => (
StatusCode::OK,
[(header::CONTENT_TYPE, NODEINFO_CONTENT_TYPE)],
bytes,
)
.into_response(),
Err(err) => {
tracing::error!(target: "actpub::axum::nodeinfo", %err, "JSON serialise failed");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
fn json_response_with_schema<T: serde::Serialize>(
value: T,
version: Version,
) -> axum::response::Response {
let content_type = format!(
r#"application/json; charset=utf-8; profile="{}""#,
version.schema_uri(),
);
match serde_json::to_vec(&value) {
Ok(bytes) => (
StatusCode::OK,
[(header::CONTENT_TYPE, content_type.as_str())],
bytes,
)
.into_response(),
Err(err) => {
tracing::error!(target: "actpub::axum::nodeinfo", %err, "JSON serialise failed");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
#[cfg(test)]
mod tests {
use actpub_nodeinfo::{NodeInfo, Protocol, Software, Version};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use serde_json::Value;
use tower::ServiceExt;
use super::*;
fn sample_node_info(version: Version) -> NodeInfo {
NodeInfo::builder(version, Software::new("test-server", "0.1.0"))
.protocol(Protocol::ActivityPub)
.build()
}
fn dual_state() -> NodeInfoState {
NodeInfoState::dual(
"https://example.com".parse().unwrap(),
sample_node_info(Version::V2_0),
sample_node_info(Version::V2_1),
)
}
#[tokio::test]
async fn discovery_lists_both_versions() {
let app = nodeinfo_router(dual_state());
let req = Request::builder()
.uri("/.well-known/nodeinfo")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let links = v["links"].as_array().unwrap();
assert_eq!(links.len(), 2);
assert!(
links
.iter()
.any(|l| l["href"].as_str().unwrap_or("").ends_with("/nodeinfo/2.0"))
);
assert!(
links
.iter()
.any(|l| l["href"].as_str().unwrap_or("").ends_with("/nodeinfo/2.1"))
);
}
#[tokio::test]
async fn schema_endpoint_returns_node_info_with_versioned_profile() {
let app = nodeinfo_router(dual_state());
let req = Request::builder()
.uri("/nodeinfo/2.0")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
ct.contains(r#"profile="http://nodeinfo.diaspora.software/ns/schema/2.0""#),
"missing schema profile parameter: {ct}",
);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["version"], serde_json::json!("2.0"));
assert_eq!(v["software"]["name"], serde_json::json!("test-server"));
}
#[tokio::test]
async fn schema_endpoint_returns_404_for_a_disabled_version() {
let state = NodeInfoState::only_v2_1(
"https://example.com".parse().unwrap(),
sample_node_info(Version::V2_1),
);
let app = nodeinfo_router(state);
let req = Request::builder()
.uri("/nodeinfo/2.0")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
}