Skip to main content

ojak/federation/
nodeinfo.rs

1//! NodeInfo, 2.0 and 2.1, from one dispatcher.
2
3use super::{Context, empty, response};
4use http::{HeaderValue, StatusCode, header};
5use serde_json::{Value, json};
6
7pub(super) const LINKS_PATH: &str = "/.well-known/nodeinfo";
8pub(super) const PATH_2_0: &str = "/nodeinfo/2.0";
9pub(super) const PATH_2_1: &str = "/nodeinfo/2.1";
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub(super) enum Version {
13    V2_0,
14    V2_1,
15}
16
17impl Version {
18    fn number(self) -> &'static str {
19        match self {
20            Self::V2_0 => "2.0",
21            Self::V2_1 => "2.1",
22        }
23    }
24
25    fn schema(self) -> String {
26        format!(
27            "http://nodeinfo.diaspora.software/ns/schema/{}",
28            self.number()
29        )
30    }
31
32    fn path(self) -> &'static str {
33        match self {
34            Self::V2_0 => PATH_2_0,
35            Self::V2_1 => PATH_2_1,
36        }
37    }
38}
39
40/// The software a server runs.
41#[derive(Clone, Debug, Default, Eq, PartialEq)]
42pub struct Software {
43    /// Lower-case letters, digits and hyphens, as the schema requires.
44    pub name: String,
45    pub version: String,
46    /// Only in 2.1.
47    pub repository: Option<String>,
48    /// Only in 2.1.
49    pub homepage: Option<String>,
50}
51
52/// How much a server is used.
53#[derive(Clone, Debug, Default, Eq, PartialEq)]
54pub struct Usage {
55    pub users_total: Option<u64>,
56    pub users_active_month: Option<u64>,
57    pub users_active_halfyear: Option<u64>,
58    pub local_posts: Option<u64>,
59    pub local_comments: Option<u64>,
60}
61
62/// A server's NodeInfo, in the 2.1 schema; 2.0 is served from it without what
63/// 2.0 lacks.
64#[derive(Clone, Debug, PartialEq)]
65pub struct NodeInfo {
66    pub software: Software,
67    /// `activitypub` unless the server speaks more.
68    pub protocols: Vec<String>,
69    pub inbound_services: Vec<String>,
70    pub outbound_services: Vec<String>,
71    pub open_registrations: bool,
72    pub usage: Usage,
73    /// Free-form, such as `nodeName` and `nodeDescription`.
74    pub metadata: Value,
75}
76
77impl NodeInfo {
78    /// A NodeInfo for `software`, speaking ActivityPub, closed to
79    /// registrations, with nothing else said.
80    #[must_use]
81    pub fn new(software: Software) -> Self {
82        Self {
83            software,
84            protocols: vec!["activitypub".to_owned()],
85            inbound_services: Vec::new(),
86            outbound_services: Vec::new(),
87            open_registrations: false,
88            usage: Usage::default(),
89            metadata: json!({}),
90        }
91    }
92
93    /// Read another server's NodeInfo document, in any 2.x schema, or 1.x
94    /// as far as it says the same: leniently, as servers write it, keeping
95    /// what is there and leaving out what is not. `None` when it names no
96    /// software.
97    #[must_use]
98    pub fn from_document(document: &Value) -> Option<Self> {
99        let text = |value: Option<&Value>| value.and_then(Value::as_str).map(str::to_owned);
100        let strings = |value: Option<&Value>| -> Vec<String> {
101            match value {
102                Some(Value::Array(items)) => items
103                    .iter()
104                    .filter_map(Value::as_str)
105                    .map(str::to_owned)
106                    .collect(),
107                _ => Vec::new(),
108            }
109        };
110        let count = |value: Option<&Value>| value.and_then(Value::as_u64);
111        let software = document.get("software")?;
112        let name = text(software.get("name"))?.trim().to_ascii_lowercase();
113        if name.is_empty() {
114            return None;
115        }
116        let usage = document.get("usage");
117        let users = usage.and_then(|usage| usage.get("users"));
118        let services = document.get("services");
119        Some(Self {
120            software: Software {
121                name,
122                version: text(software.get("version")).unwrap_or_default(),
123                repository: text(software.get("repository")),
124                homepage: text(software.get("homepage")),
125            },
126            protocols: strings(document.get("protocols")),
127            inbound_services: strings(services.and_then(|services| services.get("inbound"))),
128            outbound_services: strings(services.and_then(|services| services.get("outbound"))),
129            open_registrations: document
130                .get("openRegistrations")
131                .and_then(Value::as_bool)
132                .unwrap_or(false),
133            usage: Usage {
134                users_total: count(users.and_then(|users| users.get("total"))),
135                users_active_month: count(users.and_then(|users| users.get("activeMonth"))),
136                users_active_halfyear: count(users.and_then(|users| users.get("activeHalfyear"))),
137                local_posts: count(usage.and_then(|usage| usage.get("localPosts"))),
138                local_comments: count(usage.and_then(|usage| usage.get("localComments"))),
139            },
140            metadata: document
141                .get("metadata")
142                .cloned()
143                .unwrap_or_else(|| json!({})),
144        })
145    }
146
147    fn document(&self, version: Version) -> Value {
148        let mut software = json!({
149            "name": self.software.name,
150            "version": self.software.version,
151        });
152        if version == Version::V2_1 {
153            if let Some(repository) = &self.software.repository {
154                software["repository"] = repository.as_str().into();
155            }
156            if let Some(homepage) = &self.software.homepage {
157                software["homepage"] = homepage.as_str().into();
158            }
159        }
160        let mut users = serde_json::Map::new();
161        for (name, value) in [
162            ("total", self.usage.users_total),
163            ("activeMonth", self.usage.users_active_month),
164            ("activeHalfyear", self.usage.users_active_halfyear),
165        ] {
166            if let Some(value) = value {
167                users.insert(name.into(), value.into());
168            }
169        }
170        let mut usage = json!({"users": users});
171        if let Some(posts) = self.usage.local_posts {
172            usage["localPosts"] = posts.into();
173        }
174        if let Some(comments) = self.usage.local_comments {
175            usage["localComments"] = comments.into();
176        }
177        json!({
178            "version": version.number(),
179            "software": software,
180            "protocols": self.protocols,
181            "services": {"inbound": self.inbound_services, "outbound": self.outbound_services},
182            "openRegistrations": self.open_registrations,
183            "usage": usage,
184            "metadata": self.metadata,
185        })
186    }
187}
188
189fn cors(mut response: http::Response<Vec<u8>>) -> http::Response<Vec<u8>> {
190    response.headers_mut().insert(
191        header::ACCESS_CONTROL_ALLOW_ORIGIN,
192        HeaderValue::from_static("*"),
193    );
194    response
195}
196
197pub(super) fn links<D: Clone + Send + Sync + 'static>(
198    context: &Context<D>,
199) -> http::Response<Vec<u8>> {
200    let links: Vec<Value> = [Version::V2_0, Version::V2_1]
201        .into_iter()
202        .map(|version| {
203            let mut href = context.origin().clone();
204            href.set_path(version.path());
205            href.set_query(None);
206            json!({"rel": version.schema(), "href": href.as_str()})
207        })
208        .collect();
209    cors(response(
210        StatusCode::OK,
211        "application/json",
212        serde_json::to_vec(&json!({"links": links})).unwrap_or_default(),
213    ))
214}
215
216pub(super) async fn document<D: Clone + Send + Sync + 'static>(
217    context: &Context<D>,
218    version: Version,
219) -> http::Response<Vec<u8>> {
220    let Some(nodeinfo) = &context.inner.federation.nodeinfo else {
221        return empty(StatusCode::NOT_FOUND);
222    };
223    match nodeinfo(context.clone()).await {
224        Ok(nodeinfo) => cors(response(
225            StatusCode::OK,
226            &format!("application/json; profile=\"{}#\"", version.schema()),
227            serde_json::to_vec(&nodeinfo.document(version)).unwrap_or_default(),
228        )),
229        Err(error) => {
230            context.report(&error);
231            empty(StatusCode::INTERNAL_SERVER_ERROR)
232        }
233    }
234}