1use serde_json::{json, Value};
10
11pub fn pod_root_url(pod_base: &str, pubkey: &str) -> String {
24 format!("{}/pods/{}/", pod_base.trim_end_matches('/'), pubkey)
25}
26
27pub fn webid_document_url(pod_base: &str, pubkey: &str) -> String {
30 format!("{}/pods/{}/profile/card", pod_base.trim_end_matches('/'), pubkey)
31}
32
33pub fn webid_url(pod_base: &str, pubkey: &str) -> String {
35 format!("{}#me", webid_document_url(pod_base, pubkey))
36}
37
38pub fn pod_git_clone_url(pod_base: &str, pubkey: &str) -> String {
43 pod_root_url(pod_base, pubkey)
44}
45
46pub fn generate_webid_html(pubkey: &str, name: Option<&str>, pod_base: &str) -> String {
51 generate_webid_html_with_issuer(pubkey, name, pod_base, None)
52}
53
54pub fn generate_webid_html_with_issuer(
58 pubkey: &str,
59 name: Option<&str>,
60 pod_base: &str,
61 oidc_issuer: Option<&str>,
62) -> String {
63 let display_name = name.unwrap_or("Solid Pod User");
64 let pod_url = format!("{pod_base}/pods/{pubkey}/");
65 let webid = format!("{pod_base}/pods/{pubkey}/profile/card#me");
66 let doc_url = webid.split('#').next().unwrap_or(&webid).to_string();
69
70 let key_id = format!("{doc_url}#nostr-key");
71
72 let mut context = json!({
73 "foaf": "http://xmlns.com/foaf/0.1/",
74 "solid": "http://www.w3.org/ns/solid/terms#",
75 "schema": "http://schema.org/",
76 "cid": "https://www.w3.org/ns/cid/v1#",
77 "lws": "https://www.w3.org/ns/lws#",
78 "isPrimaryTopicOf": { "@id": "foaf:isPrimaryTopicOf", "@type": "@id" },
79 "mainEntityOfPage": { "@id": "schema:mainEntityOfPage", "@type": "@id" },
80 "service": { "@id": "cid:service", "@container": "@set" },
81 "serviceEndpoint": { "@id": "cid:serviceEndpoint", "@type": "@id" },
82 "controller": { "@id": "cid:controller", "@type": "@id" },
83 "verificationMethod": { "@id": "cid:verificationMethod", "@container": "@set" },
84 "authentication": { "@id": "cid:authentication", "@container": "@set" },
85 "assertionMethod": { "@id": "cid:assertionMethod", "@container": "@set" },
86 "publicKeyJwk": "cid:publicKeyJwk",
87 "publicKeyMultibase": "cid:publicKeyMultibase",
88 "Multikey": "cid:Multikey",
89 "JsonWebKey": "cid:JsonWebKey"
90 });
91 let _ = context.as_object_mut();
93
94 let pubkey_multibase = format!("feb{pubkey}");
115
116 let mut body = json!({
117 "@context": context,
118 "@id": webid,
119 "@type": "foaf:Person",
120 "foaf:name": display_name,
121 "foaf:isPrimaryTopicOf": "",
122 "schema:mainEntityOfPage": "",
123 "solid:account": pod_url,
124 "solid:privateTypeIndex": format!("{pod_url}settings/privateTypeIndex"),
125 "solid:publicTypeIndex": format!("{pod_url}settings/publicTypeIndex"),
126 "schema:identifier": format!("did:nostr:{pubkey}"),
127 "controller": &webid,
128 "verificationMethod": [{
129 "@id": &key_id,
130 "@type": "Multikey",
131 "controller": &webid,
132 "publicKeyMultibase": &pubkey_multibase
133 }],
134 "authentication": [&key_id],
135 "assertionMethod": [&key_id]
136 });
137
138 if let Some(iss) = oidc_issuer {
139 body["solid:oidcIssuer"] = json!({ "@id": iss });
141 body["service"] = json!([{
143 "@id": format!("{doc_url}#oidc"),
144 "@type": "lws:OpenIdProvider",
145 "serviceEndpoint": iss
146 }]);
147 }
148
149 let body_json =
150 serde_json::to_string_pretty(&body).expect("serde_json::Value always serialises");
151
152 format!(
153 r#"<!DOCTYPE html>
154<html>
155<head>
156 <meta charset="utf-8">
157 <title>{display_name}</title>
158 <script type="application/ld+json">
159{body_json}
160 </script>
161</head>
162<body>
163 <h1>{display_name}</h1>
164 <p>WebID: <a href="{webid}">{webid}</a></p>
165 <p>Pod: <a href="{pod_url}">{pod_url}</a></p>
166</body>
167</html>"#
168 )
169}
170
171fn parse_json_ld(data: &[u8]) -> Result<Option<Value>, String> {
173 let text =
174 std::str::from_utf8(data).map_err(|_| "WebID profile must be valid UTF-8".to_string())?;
175 let start = match text.find("application/ld+json") {
176 Some(s) => s,
177 None => return Ok(None),
178 };
179 let tag_end = match text[start..].find('>') {
180 Some(e) => e,
181 None => return Ok(None),
182 };
183 let json_start = start + tag_end + 1;
184 let script_end = match text[json_start..].find("</script>") {
185 Some(e) => e,
186 None => return Ok(None),
187 };
188 let json_str = text[json_start..json_start + script_end].trim();
189 let value: Value =
190 serde_json::from_str(json_str).map_err(|e| format!("WebID JSON-LD parse error: {e}"))?;
191 Ok(Some(value))
192}
193
194pub fn extract_oidc_issuer(data: &[u8]) -> Result<Option<String>, String> {
198 let value = match parse_json_ld(data)? {
199 Some(v) => v,
200 None => return Ok(None),
201 };
202 let issuer = value
203 .get("solid:oidcIssuer")
204 .or_else(|| value.get("http://www.w3.org/ns/solid/terms#oidcIssuer"));
205 match issuer {
206 Some(Value::String(s)) => Ok(Some(s.clone())),
207 Some(Value::Object(m)) => {
208 if let Some(Value::String(s)) = m.get("@id") {
209 Ok(Some(s.clone()))
210 } else {
211 Ok(None)
212 }
213 }
214 _ => Ok(None),
215 }
216}
217
218pub fn extract_nostr_pubkey(data: &[u8]) -> Result<Option<String>, String> {
228 let value = match parse_json_ld(data)? {
229 Some(v) => v,
230 None => return Ok(None),
231 };
232 let raw = value
233 .get("nostr:pubkey")
234 .or_else(|| value.get("https://nostr.org/ns#pubkey"));
235 match raw {
236 Some(Value::String(s)) => Ok(Some(s.clone())),
237 Some(Value::Object(m)) => {
238 if let Some(Value::String(s)) = m.get("@value").or_else(|| m.get("@id")) {
239 Ok(Some(s.clone()))
240 } else {
241 Ok(None)
242 }
243 }
244 _ => Ok(None),
245 }
246}
247
248pub fn extract_cid_openid_provider(data: &[u8]) -> Result<Option<String>, String> {
253 let value = match parse_json_ld(data)? {
254 Some(v) => v,
255 None => return Ok(None),
256 };
257 let service = value
258 .get("service")
259 .or_else(|| value.get("cid:service"))
260 .or_else(|| value.get("https://www.w3.org/ns/cid/v1#service"));
261 let arr = match service {
262 Some(Value::Array(a)) => a,
263 _ => return Ok(None),
264 };
265 for entry in arr {
266 let Some(obj) = entry.as_object() else {
267 continue;
268 };
269 let ty = obj.get("@type");
270 let matches = match ty {
271 Some(Value::String(s)) => {
272 s == "lws:OpenIdProvider" || s == "https://www.w3.org/ns/lws#OpenIdProvider"
273 }
274 Some(Value::Array(ts)) => ts.iter().any(|t| {
275 matches!(
276 t.as_str(),
277 Some("lws:OpenIdProvider") | Some("https://www.w3.org/ns/lws#OpenIdProvider")
278 )
279 }),
280 _ => false,
281 };
282 if !matches {
283 continue;
284 }
285 let endpoint = obj
286 .get("serviceEndpoint")
287 .or_else(|| obj.get("cid:serviceEndpoint"))
288 .or_else(|| obj.get("https://www.w3.org/ns/cid/v1#serviceEndpoint"));
289 match endpoint {
290 Some(Value::String(s)) => return Ok(Some(s.clone())),
291 Some(Value::Object(m)) => {
292 if let Some(Value::String(s)) = m.get("@id") {
293 return Ok(Some(s.clone()));
294 }
295 }
296 _ => {}
297 }
298 }
299 Ok(None)
300}
301
302pub fn validate_webid_html(data: &[u8]) -> Result<(), String> {
304 let text =
305 std::str::from_utf8(data).map_err(|_| "WebID profile must be valid UTF-8".to_string())?;
306 if !text.contains("application/ld+json") {
307 return Err(
308 "WebID profile must contain a <script type=\"application/ld+json\"> block".to_string(),
309 );
310 }
311 parse_json_ld(data)?;
313 Ok(())
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 fn json_ld_body(html: &str) -> serde_json::Value {
321 let start = html.find("application/ld+json").expect("ld+json tag");
322 let tag_end = html[start..].find('>').expect("script >");
323 let body_start = start + tag_end + 1;
324 let body_end = html[body_start..].find("</script>").expect("/script");
325 let body = html[body_start..body_start + body_end].trim();
326 serde_json::from_str(body).expect("body parses")
327 }
328
329 #[test]
330 fn contains_pubkey() {
331 let html = generate_webid_html("abc123", None, "https://pods.example.com");
332 assert!(html.contains("abc123"));
333 assert!(html.contains("did:nostr:abc123"));
334 }
335
336 #[test]
337 fn validate_accepts_valid() {
338 let html = generate_webid_html("abc", Some("Alice"), "https://pods.example.com");
339 assert!(validate_webid_html(html.as_bytes()).is_ok());
340 }
341
342 #[test]
343 fn validate_rejects_missing_jsonld() {
344 let html = "<!DOCTYPE html><html><body>no ld+json</body></html>";
345 assert!(validate_webid_html(html.as_bytes()).is_err());
346 }
347
348 #[test]
349 fn generate_with_issuer_embeds_oidc_triple() {
350 let html = generate_webid_html_with_issuer(
351 "abc",
352 Some("Alice"),
353 "https://pods.example.com",
354 Some("https://op.example"),
355 );
356 assert!(html.contains("solid:oidcIssuer"));
357 assert!(html.contains("https://op.example"));
358 }
359
360 #[test]
361 fn extract_oidc_issuer_returns_issuer_id() {
362 let html = generate_webid_html_with_issuer(
363 "abc",
364 Some("Alice"),
365 "https://pods.example.com",
366 Some("https://op.example"),
367 );
368 let iss = extract_oidc_issuer(html.as_bytes()).unwrap();
369 assert_eq!(iss.as_deref(), Some("https://op.example"));
370 }
371
372 #[test]
373 fn extract_oidc_issuer_absent_returns_none() {
374 let html = generate_webid_html_with_issuer("abc", Some("Alice"), "https://p", None);
375 let iss = extract_oidc_issuer(html.as_bytes()).unwrap();
376 assert!(iss.is_none());
377 }
378
379 #[test]
382 fn emits_cid_service_when_issuer_present() {
383 let html = generate_webid_html_with_issuer(
384 "abc",
385 Some("Alice"),
386 "https://pods.example.com",
387 Some("https://op.example"),
388 );
389 assert!(
391 html.contains("https://www.w3.org/ns/cid/v1#"),
392 "cid namespace missing"
393 );
394 assert!(
395 html.contains("https://www.w3.org/ns/lws#"),
396 "lws namespace missing"
397 );
398 assert!(
400 html.contains("lws:OpenIdProvider"),
401 "lws:OpenIdProvider type missing"
402 );
403 assert!(
405 html.contains("https://pods.example.com/pods/abc/profile/card#oidc"),
406 "service @id fragment missing"
407 );
408 }
409
410 #[test]
411 fn omits_cid_service_when_no_issuer() {
412 let html = generate_webid_html_with_issuer("abc", Some("Alice"), "https://p", None);
413 let body = json_ld_body(&html);
414 assert!(
415 body.get("service").is_none(),
416 "service array must be absent without issuer"
417 );
418 assert!(
419 !html.contains("lws:OpenIdProvider"),
420 "OpenIdProvider must not leak when issuer absent"
421 );
422 }
423
424 #[test]
425 fn emits_primary_topic_of_and_main_entity_of_page() {
426 let html =
427 generate_webid_html_with_issuer("abc", Some("Alice"), "https://pods.example.com", None);
428 let body = json_ld_body(&html);
429 assert_eq!(
430 body.get("foaf:isPrimaryTopicOf").and_then(|v| v.as_str()),
431 Some(""),
432 "foaf:isPrimaryTopicOf must be empty string (relative self-ref)"
433 );
434 assert_eq!(
435 body.get("schema:mainEntityOfPage").and_then(|v| v.as_str()),
436 Some(""),
437 "schema:mainEntityOfPage must be empty string (relative self-ref)"
438 );
439 let ctx = body.get("@context").expect("@context");
441 assert!(ctx.get("isPrimaryTopicOf").is_some());
442 assert!(ctx.get("mainEntityOfPage").is_some());
443 }
444
445 #[test]
446 fn extract_cid_openid_provider_returns_endpoint() {
447 let html = generate_webid_html_with_issuer(
448 "abc",
449 Some("Alice"),
450 "https://pods.example.com",
451 Some("https://op.example"),
452 );
453 let endpoint = extract_cid_openid_provider(html.as_bytes()).unwrap();
454 assert_eq!(endpoint.as_deref(), Some("https://op.example"));
455 }
456
457 #[test]
458 fn extract_cid_openid_provider_absent_returns_none() {
459 let html = generate_webid_html_with_issuer("abc", Some("Alice"), "https://p", None);
460 let endpoint = extract_cid_openid_provider(html.as_bytes()).unwrap();
461 assert!(endpoint.is_none());
462 }
463
464 #[test]
465 fn json_ld_body_is_valid_json() {
466 for issuer in [None, Some("https://op.example/path?q=1&x=2")] {
469 let html = generate_webid_html_with_issuer(
470 "abc",
471 Some(r#"Alice "Quoted" O'Neil"#),
472 "https://pods.example.com",
473 issuer,
474 );
475 let start = html
476 .find("application/ld+json")
477 .expect("ld+json tag present");
478 let tag_end = html[start..].find('>').expect("script open >");
479 let body_start = start + tag_end + 1;
480 let body_end = html[body_start..].find("</script>").expect("script close");
481 let body = html[body_start..body_start + body_end].trim();
482 serde_json::from_str::<serde_json::Value>(body).unwrap_or_else(|e| {
483 panic!("embedded JSON-LD failed to parse: {e}\n----\n{body}\n----")
484 });
485 }
486 }
487}