solid_pod_rs_server/
cross_identity.rs1use std::sync::Arc;
25
26use async_trait::async_trait;
27use solid_pod_rs::storage::Storage;
28
29#[async_trait]
31pub trait CrossIdentityResolver: Send + Sync {
32 async fn linked_web_id(&self, pubkey: &str) -> Option<String>;
35}
36
37pub fn is_hex_pubkey(s: &str) -> bool {
39 s.len() == 64
40 && s.bytes()
41 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
42}
43
44pub struct PodCrossIdentity {
46 storage: Arc<dyn Storage>,
47 base_url: String,
48 #[cfg(feature = "did-nostr")]
49 remote: Option<(
50 solid_pod_rs::interop::did_nostr::DidNostrResolver,
51 Vec<String>,
52 )>,
53}
54
55impl PodCrossIdentity {
56 pub fn new(storage: Arc<dyn Storage>, base_url: impl Into<String>) -> Self {
58 Self {
59 storage,
60 base_url: base_url.into().trim_end_matches('/').to_string(),
61 #[cfg(feature = "did-nostr")]
62 remote: None,
63 }
64 }
65
66 #[cfg(feature = "did-nostr")]
68 pub fn with_remote(
69 mut self,
70 resolver: solid_pod_rs::interop::did_nostr::DidNostrResolver,
71 origins: Vec<String>,
72 ) -> Self {
73 self.remote = Some((resolver, origins));
74 self
75 }
76
77 async fn local(&self, pubkey: &str) -> Option<String> {
78 let (body, _) = self.storage.get("/profile/card").await.ok()?;
79 let owner = solid_pod_rs::webid::extract_nostr_pubkey(&body)
80 .ok()
81 .flatten()?;
82 if owner.eq_ignore_ascii_case(pubkey) {
83 Some(format!("{}/profile/card#me", self.base_url))
84 } else {
85 None
86 }
87 }
88}
89
90#[async_trait]
91impl CrossIdentityResolver for PodCrossIdentity {
92 async fn linked_web_id(&self, pubkey: &str) -> Option<String> {
93 if !is_hex_pubkey(pubkey) {
94 return None;
95 }
96 if let Some(w) = self.local(pubkey).await {
97 return Some(w);
98 }
99 #[cfg(feature = "did-nostr")]
100 if let Some((resolver, origins)) = &self.remote {
101 for origin in origins {
102 if let Some(w) = resolver.resolve(origin, pubkey).await {
103 return Some(w);
104 }
105 }
106 }
107 None
108 }
109}
110
111pub async fn linked_identities(
115 resolver: Option<&Arc<dyn CrossIdentityResolver>>,
116 agent_uri: Option<&str>,
117) -> Vec<String> {
118 let (Some(resolver), Some(agent)) = (resolver, agent_uri) else {
119 return Vec::new();
120 };
121 let Some(pk) = agent.strip_prefix("did:nostr:") else {
122 return Vec::new();
123 };
124 match resolver.linked_web_id(pk).await {
125 Some(w) if (w.starts_with("https://") || w.starts_with("http://")) && w != agent => {
126 vec![w]
127 }
128 _ => Vec::new(),
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use bytes::Bytes;
136 use solid_pod_rs::storage::memory::MemoryBackend;
137
138 const PK: &str = "abcd000000000000000000000000000000000000000000000000000000000001";
139
140 #[tokio::test]
141 async fn local_profile_link_resolves() {
142 let st = Arc::new(MemoryBackend::new());
143 let card = format!(
144 r##"<html><script type="application/ld+json">{{"@id":"#me","nostr:pubkey":"{PK}"}}</script></html>"##
145 );
146 st.put("/profile/card", Bytes::from(card), "text/html")
147 .await
148 .unwrap();
149 let r = PodCrossIdentity::new(st, "https://pod.example/");
150 assert_eq!(
151 r.linked_web_id(PK).await.as_deref(),
152 Some("https://pod.example/profile/card#me")
153 );
154 let other = "ef".repeat(32);
155 assert_eq!(r.linked_web_id(&other).await, None);
156 }
157
158 #[tokio::test]
159 async fn malformed_pubkey_never_resolves() {
160 let st = Arc::new(MemoryBackend::new());
161 let r = PodCrossIdentity::new(st, "https://pod.example");
162 assert_eq!(r.linked_web_id("../etc").await, None);
163 assert_eq!(r.linked_web_id(&PK.to_uppercase()).await, None);
164 }
165
166 struct Fake(Option<&'static str>);
167 #[async_trait]
168 impl CrossIdentityResolver for Fake {
169 async fn linked_web_id(&self, _pk: &str) -> Option<String> {
170 self.0.map(String::from)
171 }
172 }
173
174 #[tokio::test]
175 async fn linked_identities_filters() {
176 let did = format!("did:nostr:{PK}");
177 let ok: Arc<dyn CrossIdentityResolver> = Arc::new(Fake(Some("https://a.example/me#i")));
178 assert_eq!(
179 linked_identities(Some(&ok), Some(&did)).await,
180 vec!["https://a.example/me#i".to_string()]
181 );
182 assert!(linked_identities(Some(&ok), Some("https://x.example/#me"))
184 .await
185 .is_empty());
186 assert!(linked_identities(Some(&ok), None).await.is_empty());
188 assert!(linked_identities(None, Some(&did)).await.is_empty());
189 let bad: Arc<dyn CrossIdentityResolver> = Arc::new(Fake(Some("did:web:evil")));
191 assert!(linked_identities(Some(&bad), Some(&did)).await.is_empty());
192 let none: Arc<dyn CrossIdentityResolver> = Arc::new(Fake(None));
193 assert!(linked_identities(Some(&none), Some(&did)).await.is_empty());
194 }
195}