1use serde::{Deserialize, Serialize};
16
17use crate::error::PodError;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SolidWellKnown {
28 #[serde(rename = "@context")]
29 pub context: serde_json::Value,
30
31 pub solid_oidc_issuer: String,
32
33 pub notification_gateway: String,
34
35 pub storage: String,
36
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub webfinger: Option<String>,
39}
40
41pub fn well_known_solid(
43 pod_base: &str,
44 oidc_issuer: &str,
45) -> SolidWellKnown {
46 let base = pod_base.trim_end_matches('/');
47 SolidWellKnown {
48 context: serde_json::json!("https://www.w3.org/ns/solid/terms"),
49 solid_oidc_issuer: oidc_issuer.trim_end_matches('/').to_string(),
50 notification_gateway: format!("{base}/.notifications"),
51 storage: format!("{base}/"),
52 webfinger: Some(format!("{base}/.well-known/webfinger")),
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct WebFingerJrd {
63 pub subject: String,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub aliases: Vec<String>,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 pub links: Vec<WebFingerLink>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct WebFingerLink {
72 pub rel: String,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub href: Option<String>,
75 #[serde(skip_serializing_if = "Option::is_none", rename = "type")]
76 pub content_type: Option<String>,
77}
78
79pub fn webfinger_response(
82 resource: &str,
83 pod_base: &str,
84 webid: &str,
85) -> Option<WebFingerJrd> {
86 if !resource.starts_with("acct:") && !resource.starts_with("https://") {
87 return None;
88 }
89 let base = pod_base.trim_end_matches('/');
90 Some(WebFingerJrd {
91 subject: resource.to_string(),
92 aliases: vec![webid.to_string()],
93 links: vec![
94 WebFingerLink {
95 rel: "http://openid.net/specs/connect/1.0/issuer".to_string(),
96 href: Some(format!("{base}/")),
97 content_type: None,
98 },
99 WebFingerLink {
100 rel: "http://www.w3.org/ns/solid#webid".to_string(),
101 href: Some(webid.to_string()),
102 content_type: None,
103 },
104 WebFingerLink {
105 rel: "http://www.w3.org/ns/pim/space#storage".to_string(),
106 href: Some(format!("{base}/")),
107 content_type: None,
108 },
109 ],
110 })
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct Nip05Document {
121 pub names: std::collections::HashMap<String, String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub relays: Option<std::collections::HashMap<String, Vec<String>>>,
124}
125
126pub fn verify_nip05(
129 identifier: &str,
130 document: &Nip05Document,
131) -> Result<String, PodError> {
132 let (local, _domain) = identifier
133 .split_once('@')
134 .ok_or_else(|| PodError::Nip98(format!("invalid NIP-05 identifier: {identifier}")))?;
135 let lookup = if local.is_empty() { "_" } else { local };
136 let pubkey = document
137 .names
138 .get(lookup)
139 .ok_or_else(|| PodError::NotFound(format!("NIP-05 name not found: {lookup}")))?;
140 if pubkey.len() != 64 || hex::decode(pubkey).is_err() {
141 return Err(PodError::Nip98(format!(
142 "NIP-05 pubkey malformed for {identifier}"
143 )));
144 }
145 Ok(pubkey.clone())
146}
147
148pub fn nip05_document(
150 names: impl IntoIterator<Item = (String, String)>,
151) -> Nip05Document {
152 Nip05Document {
153 names: names.into_iter().collect(),
154 relays: None,
155 }
156}
157
158#[derive(Debug, Clone)]
167pub struct DevSession {
168 pub webid: String,
169 pub pubkey: Option<String>,
170 pub is_admin: bool,
171}
172
173pub fn dev_session(webid: impl Into<String>, is_admin: bool) -> DevSession {
177 DevSession {
178 webid: webid.into(),
179 pubkey: None,
180 is_admin,
181 }
182}
183
184#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn well_known_solid_advertises_oidc_and_storage() {
194 let d = well_known_solid("https://pod.example/", "https://op.example/");
195 assert_eq!(d.solid_oidc_issuer, "https://op.example");
196 assert!(d.notification_gateway.ends_with(".notifications"));
197 assert!(d.storage.ends_with('/'));
198 }
199
200 #[test]
201 fn webfinger_returns_links_for_acct() {
202 let j = webfinger_response(
203 "acct:alice@pod.example",
204 "https://pod.example",
205 "https://pod.example/profile/card#me",
206 )
207 .unwrap();
208 assert_eq!(j.subject, "acct:alice@pod.example");
209 assert!(j.links.iter().any(|l| l.rel == "http://www.w3.org/ns/solid#webid"));
210 }
211
212 #[test]
213 fn webfinger_rejects_unknown_scheme() {
214 assert!(webfinger_response("mailto:a@b", "https://p", "https://w").is_none());
215 }
216
217 #[test]
218 fn nip05_verify_returns_pubkey() {
219 let mut names = std::collections::HashMap::new();
220 names.insert("alice".to_string(), "a".repeat(64));
221 let doc = nip05_document(names);
222 let pk = verify_nip05("alice@pod.example", &doc).unwrap();
223 assert_eq!(pk, "a".repeat(64));
224 }
225
226 #[test]
227 fn nip05_verify_rejects_malformed_pubkey() {
228 let mut names = std::collections::HashMap::new();
229 names.insert("alice".to_string(), "shortkey".to_string());
230 let doc = nip05_document(names);
231 assert!(verify_nip05("alice@p", &doc).is_err());
232 }
233
234 #[test]
235 fn nip05_root_name_resolves_via_underscore() {
236 let mut names = std::collections::HashMap::new();
237 names.insert("_".to_string(), "b".repeat(64));
238 let doc = nip05_document(names);
239 assert!(verify_nip05("@pod.example", &doc).is_ok());
240 }
241
242 #[test]
243 fn dev_session_stores_admin_flag() {
244 let s = dev_session("https://me/profile#me", true);
245 assert!(s.is_admin);
246 assert_eq!(s.webid, "https://me/profile#me");
247 }
248}
249
250#[cfg(feature = "did-nostr")]
269pub mod did_nostr {
270 use std::collections::HashMap;
271 use std::sync::{Arc, RwLock};
272 use std::time::{Duration, Instant};
273
274 use reqwest::Client;
275 use serde::{Deserialize, Serialize};
276 use url::Url;
277
278 use crate::security::ssrf::SsrfPolicy;
279
280 pub fn did_nostr_well_known_url(origin: &str, pubkey: &str) -> String {
284 format!(
285 "{}/.well-known/did/nostr/{}.json",
286 origin.trim_end_matches('/'),
287 pubkey
288 )
289 }
290
291 pub fn did_nostr_document(pubkey: &str, also_known_as: &[String]) -> serde_json::Value {
296 serde_json::json!({
297 "@context": ["https://www.w3.org/ns/did/v1"],
298 "id": format!("did:nostr:{}", pubkey),
299 "alsoKnownAs": also_known_as,
300 "verificationMethod": [{
301 "id": format!("did:nostr:{}#nostr-schnorr", pubkey),
302 "type": "NostrSchnorrKey2024",
303 "controller": format!("did:nostr:{}", pubkey),
304 "publicKeyHex": pubkey,
305 }]
306 })
307 }
308
309 #[derive(Debug, Clone, Deserialize, Serialize)]
312 pub struct DidNostrDoc {
313 pub id: String,
314 #[serde(default, rename = "alsoKnownAs")]
315 pub also_known_as: Vec<String>,
316 }
317
318 pub struct DidNostrResolver {
321 ssrf: Arc<SsrfPolicy>,
322 client: Client,
323 cache: Arc<RwLock<HashMap<String, CachedEntry>>>,
324 success_ttl: Duration,
325 failure_ttl: Duration,
326 }
327
328 struct CachedEntry {
329 fetched: Instant,
330 web_id: Option<String>,
331 }
332
333 impl DidNostrResolver {
334 pub fn new(ssrf: Arc<SsrfPolicy>) -> Self {
338 let client = Client::builder()
339 .timeout(Duration::from_secs(10))
340 .build()
341 .unwrap_or_else(|_| Client::new());
342 Self {
343 ssrf,
344 client,
345 cache: Arc::new(RwLock::new(HashMap::new())),
346 success_ttl: Duration::from_secs(300),
347 failure_ttl: Duration::from_secs(60),
348 }
349 }
350
351 pub fn with_ttls(mut self, success: Duration, failure: Duration) -> Self {
353 self.success_ttl = success;
354 self.failure_ttl = failure;
355 self
356 }
357
358 pub async fn resolve(&self, origin: &str, pubkey: &str) -> Option<String> {
372 let cache_key = format!("{origin}|{pubkey}");
373
374 if let Ok(guard) = self.cache.read() {
376 if let Some(entry) = guard.get(&cache_key) {
377 let ttl = if entry.web_id.is_some() {
378 self.success_ttl
379 } else {
380 self.failure_ttl
381 };
382 if entry.fetched.elapsed() < ttl {
383 return entry.web_id.clone();
384 }
385 }
386 }
387
388 let result = self.resolve_uncached(origin, pubkey).await;
389
390 if let Ok(mut guard) = self.cache.write() {
391 guard.insert(
392 cache_key,
393 CachedEntry {
394 fetched: Instant::now(),
395 web_id: result.clone(),
396 },
397 );
398 }
399
400 result
401 }
402
403 async fn resolve_uncached(&self, origin: &str, pubkey: &str) -> Option<String> {
404 let origin_url = Url::parse(origin).ok()?;
406 self.ssrf.resolve_and_check(&origin_url).await.ok()?;
407
408 let url = did_nostr_well_known_url(origin, pubkey);
410 let resp = self
411 .client
412 .get(&url)
413 .header("accept", "application/did+json, application/json")
414 .send()
415 .await
416 .ok()?
417 .error_for_status()
418 .ok()?;
419 let doc: DidNostrDoc = resp.json().await.ok()?;
420
421 if doc.id != format!("did:nostr:{pubkey}") {
422 return None;
423 }
424
425 let did_iri = format!("did:nostr:{pubkey}");
427 for candidate in &doc.also_known_as {
428 if let Some(web_id) = self.try_candidate(candidate, &did_iri).await {
429 return Some(web_id);
430 }
431 }
432 None
433 }
434
435 async fn try_candidate(&self, candidate: &str, did_iri: &str) -> Option<String> {
436 let url = Url::parse(candidate).ok()?;
437 self.ssrf.resolve_and_check(&url).await.ok()?;
438 let resp = self
439 .client
440 .get(url.as_str())
441 .header("accept", "text/turtle, application/ld+json")
442 .send()
443 .await
444 .ok()?
445 .error_for_status()
446 .ok()?;
447 let body = resp.text().await.ok()?;
448
449 let has_predicate = body.contains("owl:sameAs")
453 || body.contains("schema:sameAs")
454 || body.contains("http://www.w3.org/2002/07/owl#sameAs")
455 || body.contains("https://schema.org/sameAs");
456 if has_predicate && body.contains(did_iri) {
457 Some(candidate.to_string())
458 } else {
459 None
460 }
461 }
462 }
463}
464
465pub fn nodeinfo_discovery(base_url: &str) -> serde_json::Value {
473 serde_json::json!({
474 "links": [
475 {
476 "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
477 "href": format!(
478 "{}/.well-known/nodeinfo/2.1",
479 base_url.trim_end_matches('/')
480 )
481 }
482 ]
483 })
484}
485
486pub fn nodeinfo_2_1(
489 software_name: &str,
490 software_version: &str,
491 open_registrations: bool,
492 total_users: u64,
493) -> serde_json::Value {
494 serde_json::json!({
495 "version": "2.1",
496 "software": {
497 "name": software_name,
498 "version": software_version,
499 "repository": "https://github.com/dreamlab-ai/solid-pod-rs",
500 "homepage": "https://github.com/dreamlab-ai/solid-pod-rs"
501 },
502 "protocols": ["solid", "activitypub"],
503 "services": {
504 "inbound": [],
505 "outbound": []
506 },
507 "openRegistrations": open_registrations,
508 "usage": {
509 "users": {
510 "total": total_users
511 }
512 },
513 "metadata": {}
514 })
515}