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 #[serde(skip_serializing_if = "Option::is_none")]
41 pub api: Option<SolidWellKnownApi>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct SolidWellKnownApi {
47 pub accounts: SolidWellKnownAccounts,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SolidWellKnownAccounts {
53 pub new: String,
54 pub recover: String,
55 pub signin: String,
56 pub signout: String,
57}
58
59pub fn well_known_solid(pod_base: &str, oidc_issuer: &str) -> SolidWellKnown {
61 let base = pod_base.trim_end_matches('/');
62 SolidWellKnown {
63 context: serde_json::json!("https://www.w3.org/ns/solid/terms"),
64 solid_oidc_issuer: oidc_issuer.trim_end_matches('/').to_string(),
65 notification_gateway: format!("{base}/.notifications"),
66 storage: format!("{base}/"),
67 webfinger: Some(format!("{base}/.well-known/webfinger")),
68 api: Some(SolidWellKnownApi {
69 accounts: SolidWellKnownAccounts {
70 new: format!("{base}/api/accounts/new"),
71 recover: format!("{base}/api/accounts/recover"),
72 signin: format!("{base}/login"),
73 signout: format!("{base}/logout"),
74 },
75 }),
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct WebFingerJrd {
86 pub subject: String,
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub aliases: Vec<String>,
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
90 pub links: Vec<WebFingerLink>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct WebFingerLink {
95 pub rel: String,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub href: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none", rename = "type")]
99 pub content_type: Option<String>,
100}
101
102pub fn webfinger_response(resource: &str, pod_base: &str, webid: &str) -> Option<WebFingerJrd> {
105 if !resource.starts_with("acct:") && !resource.starts_with("https://") {
106 return None;
107 }
108 let base = pod_base.trim_end_matches('/');
109 Some(WebFingerJrd {
110 subject: resource.to_string(),
111 aliases: vec![webid.to_string()],
112 links: vec![
113 WebFingerLink {
114 rel: "http://openid.net/specs/connect/1.0/issuer".to_string(),
115 href: Some(format!("{base}/")),
116 content_type: None,
117 },
118 WebFingerLink {
119 rel: "http://www.w3.org/ns/solid#webid".to_string(),
120 href: Some(webid.to_string()),
121 content_type: None,
122 },
123 WebFingerLink {
124 rel: "http://www.w3.org/ns/pim/space#storage".to_string(),
125 href: Some(format!("{base}/")),
126 content_type: None,
127 },
128 ],
129 })
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Nip05Document {
140 pub names: std::collections::HashMap<String, String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub relays: Option<std::collections::HashMap<String, Vec<String>>>,
143}
144
145pub fn verify_nip05(identifier: &str, document: &Nip05Document) -> Result<String, PodError> {
148 let (local, _domain) = identifier
149 .split_once('@')
150 .ok_or_else(|| PodError::Nip98(format!("invalid NIP-05 identifier: {identifier}")))?;
151 let lookup = if local.is_empty() { "_" } else { local };
152 let pubkey = document
153 .names
154 .get(lookup)
155 .ok_or_else(|| PodError::NotFound(format!("NIP-05 name not found: {lookup}")))?;
156 if pubkey.len() != 64 || hex::decode(pubkey).is_err() {
157 return Err(PodError::Nip98(format!(
158 "NIP-05 pubkey malformed for {identifier}"
159 )));
160 }
161 Ok(pubkey.clone())
162}
163
164pub fn nip05_document(names: impl IntoIterator<Item = (String, String)>) -> Nip05Document {
166 Nip05Document {
167 names: names.into_iter().collect(),
168 relays: None,
169 }
170}
171
172#[derive(Debug, Clone)]
181pub struct DevSession {
182 pub webid: String,
183 pub pubkey: Option<String>,
184 pub is_admin: bool,
185}
186
187pub fn dev_session(webid: impl Into<String>, is_admin: bool) -> DevSession {
191 DevSession {
192 webid: webid.into(),
193 pubkey: None,
194 is_admin,
195 }
196}
197
198#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn well_known_solid_advertises_oidc_and_storage() {
208 let d = well_known_solid("https://pod.example/", "https://op.example/");
209 assert_eq!(d.solid_oidc_issuer, "https://op.example");
210 assert!(d.notification_gateway.ends_with(".notifications"));
211 assert!(d.storage.ends_with('/'));
212 }
213
214 #[test]
215 fn webfinger_returns_links_for_acct() {
216 let j = webfinger_response(
217 "acct:alice@pod.example",
218 "https://pod.example",
219 "https://pod.example/profile/card#me",
220 )
221 .unwrap();
222 assert_eq!(j.subject, "acct:alice@pod.example");
223 assert!(j
224 .links
225 .iter()
226 .any(|l| l.rel == "http://www.w3.org/ns/solid#webid"));
227 }
228
229 #[test]
230 fn webfinger_rejects_unknown_scheme() {
231 assert!(webfinger_response("mailto:a@b", "https://p", "https://w").is_none());
232 }
233
234 #[test]
235 fn nip05_verify_returns_pubkey() {
236 let mut names = std::collections::HashMap::new();
237 names.insert("alice".to_string(), "a".repeat(64));
238 let doc = nip05_document(names);
239 let pk = verify_nip05("alice@pod.example", &doc).unwrap();
240 assert_eq!(pk, "a".repeat(64));
241 }
242
243 #[test]
244 fn nip05_verify_rejects_malformed_pubkey() {
245 let mut names = std::collections::HashMap::new();
246 names.insert("alice".to_string(), "shortkey".to_string());
247 let doc = nip05_document(names);
248 assert!(verify_nip05("alice@p", &doc).is_err());
249 }
250
251 #[test]
252 fn nip05_root_name_resolves_via_underscore() {
253 let mut names = std::collections::HashMap::new();
254 names.insert("_".to_string(), "b".repeat(64));
255 let doc = nip05_document(names);
256 assert!(verify_nip05("@pod.example", &doc).is_ok());
257 }
258
259 #[test]
260 fn dev_session_stores_admin_flag() {
261 let s = dev_session("https://me/profile#me", true);
262 assert!(s.is_admin);
263 assert_eq!(s.webid, "https://me/profile#me");
264 }
265}
266
267#[cfg(feature = "did-nostr")]
286pub mod did_nostr {
287 use std::collections::HashMap;
288 use std::sync::{Arc, RwLock};
289 use std::time::{Duration, Instant};
290
291 use reqwest::Client;
292 use serde::{Deserialize, Serialize};
293 use url::Url;
294
295 use crate::security::ssrf::SsrfPolicy;
296
297 pub fn did_nostr_well_known_url(origin: &str, pubkey: &str) -> String {
301 format!(
302 "{}/.well-known/did/nostr/{}.json",
303 origin.trim_end_matches('/'),
304 pubkey
305 )
306 }
307
308 pub fn did_nostr_document(pubkey: &str, also_known_as: &[String]) -> serde_json::Value {
326 #[cfg(feature = "did-nostr-types")]
330 if let Ok(pk) = crate::did_nostr_types::NostrPubkey::from_hex(pubkey) {
331 let mut doc = crate::did_nostr_types::render_did_document_tier1(&pk);
332 if !also_known_as.is_empty() {
333 doc["alsoKnownAs"] = serde_json::json!(also_known_as);
334 }
335 return doc;
336 }
337
338 serde_json::json!({
341 "@context": [
342 "https://www.w3.org/ns/did/v1",
343 "https://w3id.org/security/suites/secp256k1-2019/v1"
344 ],
345 "id": format!("did:nostr:{}", pubkey),
346 "alsoKnownAs": also_known_as,
347 "verificationMethod": [{
348 "id": format!("did:nostr:{}#nostr-schnorr", pubkey),
349 "type": "SchnorrSecp256k1VerificationKey2019",
350 "controller": format!("did:nostr:{}", pubkey),
351 "publicKeyHex": pubkey,
352 }]
353 })
354 }
355
356 #[derive(Debug, Clone, Deserialize, Serialize)]
359 pub struct DidNostrDoc {
360 pub id: String,
361 #[serde(default, rename = "alsoKnownAs")]
362 pub also_known_as: Vec<String>,
363 }
364
365 pub struct DidNostrResolver {
368 ssrf: Arc<SsrfPolicy>,
369 client: Client,
370 cache: Arc<RwLock<HashMap<String, CachedEntry>>>,
371 success_ttl: Duration,
372 failure_ttl: Duration,
373 }
374
375 struct CachedEntry {
376 fetched: Instant,
377 web_id: Option<String>,
378 }
379
380 impl DidNostrResolver {
381 pub fn new(ssrf: Arc<SsrfPolicy>) -> Self {
385 let client = Client::builder()
386 .timeout(Duration::from_secs(10))
387 .build()
388 .unwrap_or_else(|_| Client::new());
389 Self {
390 ssrf,
391 client,
392 cache: Arc::new(RwLock::new(HashMap::new())),
393 success_ttl: Duration::from_secs(300),
394 failure_ttl: Duration::from_secs(60),
395 }
396 }
397
398 pub fn with_ttls(mut self, success: Duration, failure: Duration) -> Self {
400 self.success_ttl = success;
401 self.failure_ttl = failure;
402 self
403 }
404
405 pub async fn resolve(&self, origin: &str, pubkey: &str) -> Option<String> {
419 let cache_key = format!("{origin}|{pubkey}");
420
421 if let Ok(guard) = self.cache.read() {
423 if let Some(entry) = guard.get(&cache_key) {
424 let ttl = if entry.web_id.is_some() {
425 self.success_ttl
426 } else {
427 self.failure_ttl
428 };
429 if entry.fetched.elapsed() < ttl {
430 return entry.web_id.clone();
431 }
432 }
433 }
434
435 let result = self.resolve_uncached(origin, pubkey).await;
436
437 if let Ok(mut guard) = self.cache.write() {
438 guard.insert(
439 cache_key,
440 CachedEntry {
441 fetched: Instant::now(),
442 web_id: result.clone(),
443 },
444 );
445 }
446
447 result
448 }
449
450 async fn resolve_uncached(&self, origin: &str, pubkey: &str) -> Option<String> {
451 let origin_url = Url::parse(origin).ok()?;
453 self.ssrf.resolve_and_check(&origin_url).await.ok()?;
454
455 let url = did_nostr_well_known_url(origin, pubkey);
457 let resp = self
458 .client
459 .get(&url)
460 .header("accept", "application/did+json, application/json")
461 .send()
462 .await
463 .ok()?
464 .error_for_status()
465 .ok()?;
466 let doc: DidNostrDoc = resp.json().await.ok()?;
467
468 if doc.id != format!("did:nostr:{pubkey}") {
469 return None;
470 }
471
472 let did_iri = format!("did:nostr:{pubkey}");
474 for candidate in &doc.also_known_as {
475 if let Some(web_id) = self.try_candidate(candidate, &did_iri).await {
476 return Some(web_id);
477 }
478 }
479 None
480 }
481
482 async fn try_candidate(&self, candidate: &str, did_iri: &str) -> Option<String> {
483 let url = Url::parse(candidate).ok()?;
484 self.ssrf.resolve_and_check(&url).await.ok()?;
485 let resp = self
486 .client
487 .get(url.as_str())
488 .header("accept", "text/turtle, application/ld+json")
489 .send()
490 .await
491 .ok()?
492 .error_for_status()
493 .ok()?;
494 let body = resp.text().await.ok()?;
495
496 let has_predicate = body.contains("owl:sameAs")
500 || body.contains("schema:sameAs")
501 || body.contains("http://www.w3.org/2002/07/owl#sameAs")
502 || body.contains("https://schema.org/sameAs");
503 if has_predicate && body.contains(did_iri) {
504 Some(candidate.to_string())
505 } else {
506 None
507 }
508 }
509 }
510}
511
512pub fn nodeinfo_discovery(base_url: &str) -> serde_json::Value {
520 serde_json::json!({
521 "links": [
522 {
523 "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
524 "href": format!(
525 "{}/.well-known/nodeinfo/2.1",
526 base_url.trim_end_matches('/')
527 )
528 }
529 ]
530 })
531}
532
533pub fn nodeinfo_2_1(
536 software_name: &str,
537 software_version: &str,
538 open_registrations: bool,
539 total_users: u64,
540) -> serde_json::Value {
541 serde_json::json!({
542 "version": "2.1",
543 "software": {
544 "name": software_name,
545 "version": software_version,
546 "repository": "https://github.com/dreamlab-ai/solid-pod-rs",
547 "homepage": "https://github.com/dreamlab-ai/solid-pod-rs"
548 },
549 "protocols": ["solid", "activitypub"],
550 "services": {
551 "inbound": [],
552 "outbound": []
553 },
554 "openRegistrations": open_registrations,
555 "usage": {
556 "users": {
557 "total": total_users
558 }
559 },
560 "metadata": {}
561 })
562}