Skip to main content

actpub_axum/
nodeinfo.rs

1//! axum router serving the `/.well-known/nodeinfo` discovery
2//! document plus per-version schema endpoints.
3//!
4//! [`nodeinfo_router`] builds a router that exposes:
5//!
6//! - `GET /.well-known/nodeinfo` returning the [`Discovery`] document
7//!   pointing to one or more schema URLs (typically 2.0 and 2.1).
8//! - `GET /nodeinfo/2.0` and `GET /nodeinfo/2.1` returning the
9//!   [`NodeInfo`] documents the discovery references.
10//!
11//! The user supplies the per-version [`NodeInfo`] documents at
12//! construction time; the discovery document is generated
13//! automatically from the document set.
14
15use std::sync::Arc;
16
17use actpub_nodeinfo::{Discovery, NodeInfo, Version};
18use axum::Router;
19use axum::extract::State;
20use axum::http::{StatusCode, header};
21use axum::response::IntoResponse;
22use axum::routing::get;
23use url::Url;
24
25/// `Content-Type` Mastodon and most Fediverse peers send for
26/// `NodeInfo` documents (matches what nodeinfo.diaspora.software
27/// recommends).
28pub const NODEINFO_CONTENT_TYPE: &str = "application/json; charset=utf-8";
29
30/// Shared state for the `NodeInfo` router.
31///
32/// Holds the per-version documents and the public base URL the
33/// discovery document points at.
34#[derive(Debug, Clone)]
35pub struct NodeInfoState {
36    /// Public base URL of this server, used to build the discovery
37    /// links (e.g. `https://example.com`).
38    pub base_url: Url,
39    /// Optional `NodeInfo` 2.0 document.
40    pub v2_0: Option<NodeInfo>,
41    /// Optional `NodeInfo` 2.1 document.
42    pub v2_1: Option<NodeInfo>,
43}
44
45impl NodeInfoState {
46    /// Constructs a state holding both 2.0 and 2.1 documents
47    /// (the typical setup; both versions are spec-equivalent for the
48    /// fields most consumers care about).
49    #[must_use]
50    pub const fn dual(base_url: Url, v2_0: NodeInfo, v2_1: NodeInfo) -> Self {
51        Self {
52            base_url,
53            v2_0: Some(v2_0),
54            v2_1: Some(v2_1),
55        }
56    }
57
58    /// Constructs a state holding only a `NodeInfo` 2.1 document.
59    #[must_use]
60    pub const fn only_v2_1(base_url: Url, v2_1: NodeInfo) -> Self {
61        Self {
62            base_url,
63            v2_0: None,
64            v2_1: Some(v2_1),
65        }
66    }
67
68    fn discovery(&self) -> Discovery {
69        let mut disco = Discovery::default();
70        if self.v2_0.is_some()
71            && let Ok(href) = self.versioned_href(Version::V2_0)
72        {
73            disco = disco.with_version(Version::V2_0, href);
74        }
75        if self.v2_1.is_some()
76            && let Ok(href) = self.versioned_href(Version::V2_1)
77        {
78            disco = disco.with_version(Version::V2_1, href);
79        }
80        disco
81    }
82
83    fn versioned_href(&self, version: Version) -> Result<Url, url::ParseError> {
84        self.base_url
85            .join(&format!("/nodeinfo/{}", version.as_str()))
86    }
87}
88
89/// Builds the `NodeInfo` router.
90///
91/// Mount at the root of your service so the well-known path resolves
92/// at the conventional location:
93///
94/// ```ignore
95/// let app = axum::Router::new().merge(nodeinfo_router(state));
96/// ```
97pub fn nodeinfo_router(state: NodeInfoState) -> Router {
98    Router::new()
99        .route("/.well-known/nodeinfo", get(handle_discovery))
100        .route("/nodeinfo/2.0", get(handle_v2_0))
101        .route("/nodeinfo/2.1", get(handle_v2_1))
102        .with_state(Arc::new(state))
103}
104
105async fn handle_discovery(State(state): State<Arc<NodeInfoState>>) -> impl IntoResponse {
106    json_response(state.discovery())
107}
108
109async fn handle_v2_0(State(state): State<Arc<NodeInfoState>>) -> impl IntoResponse {
110    state.v2_0.as_ref().map_or_else(
111        || StatusCode::NOT_FOUND.into_response(),
112        |info| json_response_with_schema(info, Version::V2_0).into_response(),
113    )
114}
115
116async fn handle_v2_1(State(state): State<Arc<NodeInfoState>>) -> impl IntoResponse {
117    state.v2_1.as_ref().map_or_else(
118        || StatusCode::NOT_FOUND.into_response(),
119        |info| json_response_with_schema(info, Version::V2_1).into_response(),
120    )
121}
122
123fn json_response<T: serde::Serialize>(value: T) -> axum::response::Response {
124    match serde_json::to_vec(&value) {
125        Ok(bytes) => (
126            StatusCode::OK,
127            [(header::CONTENT_TYPE, NODEINFO_CONTENT_TYPE)],
128            bytes,
129        )
130            .into_response(),
131        Err(err) => {
132            tracing::error!(target: "actpub::axum::nodeinfo", %err, "JSON serialise failed");
133            StatusCode::INTERNAL_SERVER_ERROR.into_response()
134        }
135    }
136}
137
138/// Per the `NodeInfo` schema, the body Content-Type SHOULD include the
139/// schema URI as a `profile` parameter so clients can avoid
140/// guessing the version from the response shape.
141fn json_response_with_schema<T: serde::Serialize>(
142    value: T,
143    version: Version,
144) -> axum::response::Response {
145    let content_type = format!(
146        r#"application/json; charset=utf-8; profile="{}""#,
147        version.schema_uri(),
148    );
149    match serde_json::to_vec(&value) {
150        Ok(bytes) => (
151            StatusCode::OK,
152            [(header::CONTENT_TYPE, content_type.as_str())],
153            bytes,
154        )
155            .into_response(),
156        Err(err) => {
157            tracing::error!(target: "actpub::axum::nodeinfo", %err, "JSON serialise failed");
158            StatusCode::INTERNAL_SERVER_ERROR.into_response()
159        }
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use actpub_nodeinfo::{NodeInfo, Protocol, Software, Version};
166    use axum::body::Body;
167    use axum::http::{Request, StatusCode};
168    use http_body_util::BodyExt;
169    use serde_json::Value;
170    use tower::ServiceExt;
171
172    use super::*;
173
174    fn sample_node_info(version: Version) -> NodeInfo {
175        NodeInfo::builder(version, Software::new("test-server", "0.1.0"))
176            .protocol(Protocol::ActivityPub)
177            .build()
178    }
179
180    fn dual_state() -> NodeInfoState {
181        NodeInfoState::dual(
182            "https://example.com".parse().unwrap(),
183            sample_node_info(Version::V2_0),
184            sample_node_info(Version::V2_1),
185        )
186    }
187
188    #[tokio::test]
189    async fn discovery_lists_both_versions() {
190        let app = nodeinfo_router(dual_state());
191        let req = Request::builder()
192            .uri("/.well-known/nodeinfo")
193            .body(Body::empty())
194            .unwrap();
195        let resp = app.oneshot(req).await.unwrap();
196        assert_eq!(resp.status(), StatusCode::OK);
197        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
198        let v: Value = serde_json::from_slice(&bytes).unwrap();
199        let links = v["links"].as_array().unwrap();
200        assert_eq!(links.len(), 2);
201        assert!(
202            links
203                .iter()
204                .any(|l| l["href"].as_str().unwrap_or("").ends_with("/nodeinfo/2.0"))
205        );
206        assert!(
207            links
208                .iter()
209                .any(|l| l["href"].as_str().unwrap_or("").ends_with("/nodeinfo/2.1"))
210        );
211    }
212
213    #[tokio::test]
214    async fn schema_endpoint_returns_node_info_with_versioned_profile() {
215        let app = nodeinfo_router(dual_state());
216        let req = Request::builder()
217            .uri("/nodeinfo/2.0")
218            .body(Body::empty())
219            .unwrap();
220        let resp = app.oneshot(req).await.unwrap();
221        assert_eq!(resp.status(), StatusCode::OK);
222        let ct = resp
223            .headers()
224            .get(header::CONTENT_TYPE)
225            .and_then(|v| v.to_str().ok())
226            .unwrap_or("");
227        assert!(
228            ct.contains(r#"profile="http://nodeinfo.diaspora.software/ns/schema/2.0""#),
229            "missing schema profile parameter: {ct}",
230        );
231        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
232        let v: Value = serde_json::from_slice(&bytes).unwrap();
233        assert_eq!(v["version"], serde_json::json!("2.0"));
234        assert_eq!(v["software"]["name"], serde_json::json!("test-server"));
235    }
236
237    #[tokio::test]
238    async fn schema_endpoint_returns_404_for_a_disabled_version() {
239        let state = NodeInfoState::only_v2_1(
240            "https://example.com".parse().unwrap(),
241            sample_node_info(Version::V2_1),
242        );
243        let app = nodeinfo_router(state);
244        let req = Request::builder()
245            .uri("/nodeinfo/2.0")
246            .body(Body::empty())
247            .unwrap();
248        let resp = app.oneshot(req).await.unwrap();
249        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
250    }
251}