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(
61 pod_base: &str,
62 oidc_issuer: &str,
63) -> SolidWellKnown {
64 let base = pod_base.trim_end_matches('/');
65 SolidWellKnown {
66 context: serde_json::json!("https://www.w3.org/ns/solid/terms"),
67 solid_oidc_issuer: oidc_issuer.trim_end_matches('/').to_string(),
68 notification_gateway: format!("{base}/.notifications"),
69 storage: format!("{base}/"),
70 webfinger: Some(format!("{base}/.well-known/webfinger")),
71 api: Some(SolidWellKnownApi {
72 accounts: SolidWellKnownAccounts {
73 new: format!("{base}/api/accounts/new"),
74 recover: format!("{base}/api/accounts/recover"),
75 signin: format!("{base}/login"),
76 signout: format!("{base}/logout"),
77 },
78 }),
79 }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct WebFingerJrd {
89 pub subject: String,
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub aliases: Vec<String>,
92 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub links: Vec<WebFingerLink>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct WebFingerLink {
98 pub rel: String,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub href: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none", rename = "type")]
102 pub content_type: Option<String>,
103}
104
105pub fn webfinger_response(
108 resource: &str,
109 pod_base: &str,
110 webid: &str,
111) -> Option<WebFingerJrd> {
112 if !resource.starts_with("acct:") && !resource.starts_with("https://") {
113 return None;
114 }
115 let base = pod_base.trim_end_matches('/');
116 Some(WebFingerJrd {
117 subject: resource.to_string(),
118 aliases: vec![webid.to_string()],
119 links: vec![
120 WebFingerLink {
121 rel: "http://openid.net/specs/connect/1.0/issuer".to_string(),
122 href: Some(format!("{base}/")),
123 content_type: None,
124 },
125 WebFingerLink {
126 rel: "http://www.w3.org/ns/solid#webid".to_string(),
127 href: Some(webid.to_string()),
128 content_type: None,
129 },
130 WebFingerLink {
131 rel: "http://www.w3.org/ns/pim/space#storage".to_string(),
132 href: Some(format!("{base}/")),
133 content_type: None,
134 },
135 ],
136 })
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct Nip05Document {
147 pub names: std::collections::HashMap<String, String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub relays: Option<std::collections::HashMap<String, Vec<String>>>,
150}
151
152pub fn verify_nip05(
155 identifier: &str,
156 document: &Nip05Document,
157) -> Result<String, PodError> {
158 let (local, _domain) = identifier
159 .split_once('@')
160 .ok_or_else(|| PodError::Nip98(format!("invalid NIP-05 identifier: {identifier}")))?;
161 let lookup = if local.is_empty() { "_" } else { local };
162 let pubkey = document
163 .names
164 .get(lookup)
165 .ok_or_else(|| PodError::NotFound(format!("NIP-05 name not found: {lookup}")))?;
166 if pubkey.len() != 64 || hex::decode(pubkey).is_err() {
167 return Err(PodError::Nip98(format!(
168 "NIP-05 pubkey malformed for {identifier}"
169 )));
170 }
171 Ok(pubkey.clone())
172}
173
174pub fn nip05_document(
176 names: impl IntoIterator<Item = (String, String)>,
177) -> Nip05Document {
178 Nip05Document {
179 names: names.into_iter().collect(),
180 relays: None,
181 }
182}
183
184#[derive(Debug, Clone)]
193pub struct DevSession {
194 pub webid: String,
195 pub pubkey: Option<String>,
196 pub is_admin: bool,
197}
198
199pub fn dev_session(webid: impl Into<String>, is_admin: bool) -> DevSession {
203 DevSession {
204 webid: webid.into(),
205 pubkey: None,
206 is_admin,
207 }
208}
209
210#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn well_known_solid_advertises_oidc_and_storage() {
220 let d = well_known_solid("https://pod.example/", "https://op.example/");
221 assert_eq!(d.solid_oidc_issuer, "https://op.example");
222 assert!(d.notification_gateway.ends_with(".notifications"));
223 assert!(d.storage.ends_with('/'));
224 }
225
226 #[test]
227 fn webfinger_returns_links_for_acct() {
228 let j = webfinger_response(
229 "acct:alice@pod.example",
230 "https://pod.example",
231 "https://pod.example/profile/card#me",
232 )
233 .unwrap();
234 assert_eq!(j.subject, "acct:alice@pod.example");
235 assert!(j.links.iter().any(|l| l.rel == "http://www.w3.org/ns/solid#webid"));
236 }
237
238 #[test]
239 fn webfinger_rejects_unknown_scheme() {
240 assert!(webfinger_response("mailto:a@b", "https://p", "https://w").is_none());
241 }
242
243 #[test]
244 fn nip05_verify_returns_pubkey() {
245 let mut names = std::collections::HashMap::new();
246 names.insert("alice".to_string(), "a".repeat(64));
247 let doc = nip05_document(names);
248 let pk = verify_nip05("alice@pod.example", &doc).unwrap();
249 assert_eq!(pk, "a".repeat(64));
250 }
251
252 #[test]
253 fn nip05_verify_rejects_malformed_pubkey() {
254 let mut names = std::collections::HashMap::new();
255 names.insert("alice".to_string(), "shortkey".to_string());
256 let doc = nip05_document(names);
257 assert!(verify_nip05("alice@p", &doc).is_err());
258 }
259
260 #[test]
261 fn nip05_root_name_resolves_via_underscore() {
262 let mut names = std::collections::HashMap::new();
263 names.insert("_".to_string(), "b".repeat(64));
264 let doc = nip05_document(names);
265 assert!(verify_nip05("@pod.example", &doc).is_ok());
266 }
267
268 #[test]
269 fn dev_session_stores_admin_flag() {
270 let s = dev_session("https://me/profile#me", true);
271 assert!(s.is_admin);
272 assert_eq!(s.webid, "https://me/profile#me");
273 }
274}
275
276#[cfg(feature = "did-nostr")]
295pub mod did_nostr {
296 use std::collections::HashMap;
297 use std::sync::{Arc, RwLock};
298 use std::time::{Duration, Instant};
299
300 use reqwest::Client;
301 use serde::{Deserialize, Serialize};
302 use url::Url;
303
304 use crate::security::ssrf::SsrfPolicy;
305
306 pub fn did_nostr_well_known_url(origin: &str, pubkey: &str) -> String {
310 format!(
311 "{}/.well-known/did/nostr/{}.json",
312 origin.trim_end_matches('/'),
313 pubkey
314 )
315 }
316
317 pub fn did_nostr_document(pubkey: &str, also_known_as: &[String]) -> serde_json::Value {
328 serde_json::json!({
329 "@context": [
330 "https://www.w3.org/ns/did/v1",
331 "https://w3id.org/security/suites/secp256k1-2019/v1"
332 ],
333 "id": format!("did:nostr:{}", pubkey),
334 "alsoKnownAs": also_known_as,
335 "verificationMethod": [{
336 "id": format!("did:nostr:{}#nostr-schnorr", pubkey),
337 "type": "SchnorrSecp256k1VerificationKey2019",
338 "controller": format!("did:nostr:{}", pubkey),
339 "publicKeyHex": pubkey,
340 }]
341 })
342 }
343
344 #[derive(Debug, Clone, Deserialize, Serialize)]
347 pub struct DidNostrDoc {
348 pub id: String,
349 #[serde(default, rename = "alsoKnownAs")]
350 pub also_known_as: Vec<String>,
351 }
352
353 pub struct DidNostrResolver {
356 ssrf: Arc<SsrfPolicy>,
357 client: Client,
358 cache: Arc<RwLock<HashMap<String, CachedEntry>>>,
359 success_ttl: Duration,
360 failure_ttl: Duration,
361 }
362
363 struct CachedEntry {
364 fetched: Instant,
365 web_id: Option<String>,
366 }
367
368 impl DidNostrResolver {
369 pub fn new(ssrf: Arc<SsrfPolicy>) -> Self {
373 let client = Client::builder()
374 .timeout(Duration::from_secs(10))
375 .build()
376 .unwrap_or_else(|_| Client::new());
377 Self {
378 ssrf,
379 client,
380 cache: Arc::new(RwLock::new(HashMap::new())),
381 success_ttl: Duration::from_secs(300),
382 failure_ttl: Duration::from_secs(60),
383 }
384 }
385
386 pub fn with_ttls(mut self, success: Duration, failure: Duration) -> Self {
388 self.success_ttl = success;
389 self.failure_ttl = failure;
390 self
391 }
392
393 pub async fn resolve(&self, origin: &str, pubkey: &str) -> Option<String> {
407 let cache_key = format!("{origin}|{pubkey}");
408
409 if let Ok(guard) = self.cache.read() {
411 if let Some(entry) = guard.get(&cache_key) {
412 let ttl = if entry.web_id.is_some() {
413 self.success_ttl
414 } else {
415 self.failure_ttl
416 };
417 if entry.fetched.elapsed() < ttl {
418 return entry.web_id.clone();
419 }
420 }
421 }
422
423 let result = self.resolve_uncached(origin, pubkey).await;
424
425 if let Ok(mut guard) = self.cache.write() {
426 guard.insert(
427 cache_key,
428 CachedEntry {
429 fetched: Instant::now(),
430 web_id: result.clone(),
431 },
432 );
433 }
434
435 result
436 }
437
438 async fn resolve_uncached(&self, origin: &str, pubkey: &str) -> Option<String> {
439 let origin_url = Url::parse(origin).ok()?;
441 self.ssrf.resolve_and_check(&origin_url).await.ok()?;
442
443 let url = did_nostr_well_known_url(origin, pubkey);
445 let resp = self
446 .client
447 .get(&url)
448 .header("accept", "application/did+json, application/json")
449 .send()
450 .await
451 .ok()?
452 .error_for_status()
453 .ok()?;
454 let doc: DidNostrDoc = resp.json().await.ok()?;
455
456 if doc.id != format!("did:nostr:{pubkey}") {
457 return None;
458 }
459
460 let did_iri = format!("did:nostr:{pubkey}");
462 for candidate in &doc.also_known_as {
463 if let Some(web_id) = self.try_candidate(candidate, &did_iri).await {
464 return Some(web_id);
465 }
466 }
467 None
468 }
469
470 async fn try_candidate(&self, candidate: &str, did_iri: &str) -> Option<String> {
471 let url = Url::parse(candidate).ok()?;
472 self.ssrf.resolve_and_check(&url).await.ok()?;
473 let resp = self
474 .client
475 .get(url.as_str())
476 .header("accept", "text/turtle, application/ld+json")
477 .send()
478 .await
479 .ok()?
480 .error_for_status()
481 .ok()?;
482 let body = resp.text().await.ok()?;
483
484 let has_predicate = body.contains("owl:sameAs")
488 || body.contains("schema:sameAs")
489 || body.contains("http://www.w3.org/2002/07/owl#sameAs")
490 || body.contains("https://schema.org/sameAs");
491 if has_predicate && body.contains(did_iri) {
492 Some(candidate.to_string())
493 } else {
494 None
495 }
496 }
497 }
498}
499
500pub fn nodeinfo_discovery(base_url: &str) -> serde_json::Value {
508 serde_json::json!({
509 "links": [
510 {
511 "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
512 "href": format!(
513 "{}/.well-known/nodeinfo/2.1",
514 base_url.trim_end_matches('/')
515 )
516 }
517 ]
518 })
519}
520
521pub fn nodeinfo_2_1(
524 software_name: &str,
525 software_version: &str,
526 open_registrations: bool,
527 total_users: u64,
528) -> serde_json::Value {
529 serde_json::json!({
530 "version": "2.1",
531 "software": {
532 "name": software_name,
533 "version": software_version,
534 "repository": "https://github.com/dreamlab-ai/solid-pod-rs",
535 "homepage": "https://github.com/dreamlab-ai/solid-pod-rs"
536 },
537 "protocols": ["solid", "activitypub"],
538 "services": {
539 "inbound": [],
540 "outbound": []
541 },
542 "openRegistrations": open_registrations,
543 "usage": {
544 "users": {
545 "total": total_users
546 }
547 },
548 "metadata": {}
549 })
550}