1use 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
25pub const NODEINFO_CONTENT_TYPE: &str = "application/json; charset=utf-8";
29
30#[derive(Debug, Clone)]
35pub struct NodeInfoState {
36 pub base_url: Url,
39 pub v2_0: Option<NodeInfo>,
41 pub v2_1: Option<NodeInfo>,
43}
44
45impl NodeInfoState {
46 #[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 #[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
89pub 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
138fn 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}