1pub fn mint_topic_id(file_hash: &str, sender_hex: &str) -> String {
21 use sha2::{Digest, Sha256};
22 use std::sync::atomic::{AtomicU64, Ordering};
23 static MINTED: AtomicU64 = AtomicU64::new(0);
24 let nanos = web_time::SystemTime::now()
25 .duration_since(web_time::UNIX_EPOCH)
26 .map(|d| d.as_nanos())
27 .unwrap_or(0);
28 let seq = MINTED.fetch_add(1, Ordering::Relaxed);
29 let mut hasher = Sha256::new();
30 hasher.update(b"webxdc-realtime-v1:");
31 hasher.update(file_hash.as_bytes());
32 hasher.update(b":");
33 hasher.update(sender_hex.as_bytes());
34 hasher.update(b":");
35 hasher.update(nanos.to_le_bytes());
36 hasher.update(b":");
37 hasher.update(seq.to_le_bytes());
38 base32_nopad_encode(&hasher.finalize())
39}
40
41pub fn derive_url_topic_id(url: &str, msg_id: &str) -> String {
51 use sha2::{Digest, Sha256};
52 let mut hasher = Sha256::new();
53 hasher.update(b"webxdc-url-realtime-v1:");
54 hasher.update(url.as_bytes());
55 hasher.update(b":");
56 hasher.update(msg_id.as_bytes());
57 base32_nopad_encode(&hasher.finalize())
58}
59
60pub fn base32_nopad_decode(encoded: &[u8]) -> Result<Vec<u8>, String> {
62 let mut out = Vec::with_capacity(encoded.len() * 5 / 8);
63 let (mut buf, mut bits) = (0u64, 0u32);
64 for &c in encoded {
65 let v = match c {
66 b'A'..=b'Z' => c - b'A',
67 b'a'..=b'z' => c - b'a',
68 b'2'..=b'7' => c - b'2' + 26,
69 _ => return Err(format!("invalid base32 character {:?}", c as char)),
70 };
71 buf = (buf << 5) | v as u64;
72 bits += 5;
73 if bits >= 8 {
74 bits -= 8;
75 out.push((buf >> bits) as u8);
76 }
77 }
78 Ok(out)
79}
80
81pub fn base32_nopad_encode(bytes: &[u8]) -> String {
84 const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
85 let mut out = String::with_capacity((bytes.len() * 8).div_ceil(5));
86 let mut buf: u64 = 0;
87 let mut bits: u32 = 0;
88 for &b in bytes {
89 buf = (buf << 8) | b as u64;
90 bits += 8;
91 while bits >= 5 {
92 bits -= 5;
93 out.push(ALPHABET[((buf >> bits) & 0x1F) as usize] as char);
94 }
95 }
96 if bits > 0 {
97 out.push(ALPHABET[((buf << (5 - bits)) & 0x1F) as usize] as char);
98 }
99 out
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn minted_topic_is_32_bytes_base32() {
108 let t = mint_topic_id("abc123", "deadbeef");
109 assert_eq!(t.len(), 52);
111 assert!(t.chars().all(|c| c.is_ascii_uppercase() || ('2'..='7').contains(&c)));
112 }
113
114 #[test]
115 fn resends_mint_distinct_topics() {
116 let a = mint_topic_id("abc123", "deadbeef");
117 let b = mint_topic_id("abc123", "deadbeef");
118 assert_ne!(a, b, "same file re-sent must start a fresh session topic");
119 }
120
121 #[test]
122 fn base32_matches_rfc4648_vectors() {
123 assert_eq!(base32_nopad_encode(b""), "");
125 assert_eq!(base32_nopad_encode(b"f"), "MY");
126 assert_eq!(base32_nopad_encode(b"fo"), "MZXQ");
127 assert_eq!(base32_nopad_encode(b"foo"), "MZXW6");
128 assert_eq!(base32_nopad_encode(b"foob"), "MZXW6YQ");
129 assert_eq!(base32_nopad_encode(b"fooba"), "MZXW6YTB");
130 assert_eq!(base32_nopad_encode(b"foobar"), "MZXW6YTBOI");
131 }
132}
133
134pub fn peer_signal_content(topic_id: &str, node_addr: Option<&str>) -> String {
139 match node_addr {
140 Some(addr) => serde_json::json!({ "op": "ad", "topic": topic_id, "addr": addr }).to_string(),
141 None => serde_json::json!({ "op": "left", "topic": topic_id }).to_string(),
142 }
143}
144
145pub fn parse_peer_signal(content: &str) -> Option<(String, Option<String>)> {
150 let v: serde_json::Value = serde_json::from_str(content).ok()?;
151 let topic_id = v
152 .get("topic")
153 .and_then(|t| t.as_str())
154 .filter(|t| t.len() == 52 && t.bytes().all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)))?
155 .to_string();
156 let node_addr = match v.get("op").and_then(|o| o.as_str())? {
157 "ad" => Some(v.get("addr").and_then(|a| a.as_str()).filter(|a| !a.is_empty() && a.len() <= 2048)?.to_string()),
158 "left" => None,
159 _ => return None,
160 };
161 Some((topic_id, node_addr))
162}
163
164#[cfg(test)]
165mod url_topic_tests {
166 use super::*;
167
168 #[test]
169 fn a_url_topic_is_deterministic_per_message_and_survives_server_rebuilds() {
170 let a = derive_url_topic_id("https://x.org/app.xdc", "msg1");
171 assert_eq!(a, derive_url_topic_id("https://x.org/app.xdc", "msg1"));
172 assert_eq!(a.len(), 52, "must be a valid 52-char base32 TopicId");
173 assert!(parse_peer_signal(&peer_signal_content(&a, Some("iroh:x"))).is_some());
174 assert_ne!(a, derive_url_topic_id("https://x.org/other.xdc", "msg1"), "different URL = disjoint topics");
175 assert_ne!(a, derive_url_topic_id("https://x.org/app.xdc", "msg2"), "re-share = fresh session");
176 }
177}
178
179#[cfg(test)]
180mod peer_signal_tests {
181 use super::*;
182
183 #[test]
184 fn peer_signal_round_trips_and_bounds() {
185 let topic = "A".repeat(52);
186 let ad = peer_signal_content(&topic, Some("iroh:node/abc"));
187 assert_eq!(parse_peer_signal(&ad), Some((topic.clone(), Some("iroh:node/abc".into()))));
188 let left = peer_signal_content(&topic, None);
189 assert_eq!(parse_peer_signal(&left), Some((topic.clone(), None)));
190
191 assert_eq!(parse_peer_signal(&peer_signal_content("short", Some("a"))), None);
193 let oversized = "a".repeat(2049);
194 assert_eq!(parse_peer_signal(&peer_signal_content(&topic, Some(&oversized))), None);
195 assert_eq!(parse_peer_signal(&format!("{{\"op\":\"warp\",\"topic\":\"{topic}\"}}")), None);
196 assert_eq!(parse_peer_signal("not json"), None);
197 }
198}
199
200#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
202#[serde(default)]
203pub struct MarketplaceApp {
204 pub id: String,
206 pub name: String,
208 pub description: String,
210 pub version: String,
212 pub blossom_hash: String,
214 pub download_url: String,
216 pub size: u64,
218 pub icon_url: Option<String>,
220 pub icon_mime: Option<String>,
222 pub icon_cached: Option<String>,
224 pub categories: Vec<String>,
226 pub changelog: Option<String>,
228 pub developer: Option<String>,
230 pub source_url: Option<String>,
232 pub publisher: String,
234 pub published_at: u64,
236 pub installed: bool,
238 pub local_path: Option<String>,
240 pub installed_version: Option<String>,
242 pub update_available: bool,
244 pub requested_permissions: String,
247}
248
249use nostr_sdk::prelude::*;
252
253pub const MINIAPP_MARKETPLACE_KIND: u16 = 30078;
256
257pub const MINIAPP_APP_ID: &str = "vector/miniapp";
259
260pub const TRUSTED_PUBLISHER: &str = "npub16ye7evyevwnl0fc9hujsxf9zym72e063awn0pvde0huvpyec5nyq4dg4wn";
263
264pub fn is_safe_app_id(id: &str) -> bool {
269 !id.is_empty()
270 && id.len() <= 128
271 && !id.contains("..")
272 && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
273}
274
275pub fn parse_marketplace_event(event: &Event) -> Option<MarketplaceApp> {
276 if event.kind.as_u16() != MINIAPP_MARKETPLACE_KIND {
278 return None;
279 }
280
281 let mut id = None;
283 let mut name = None;
284 let mut description = None;
285 let mut version = None;
286 let mut blossom_hash = None;
287 let mut download_url = None;
288 let mut size: Option<u64> = None;
289 let mut icon_url = None;
290 let mut icon_mime = None;
291 let mut categories = Vec::new();
292 let mut developer = None;
293 let mut source_url = None;
294 let mut requested_permissions = String::new();
295
296 for tag in event.tags.iter() {
297 let tag_vec: Vec<String> = tag.clone().to_vec();
298 if tag_vec.len() < 2 {
299 continue;
300 }
301
302 match tag_vec[0].as_str() {
303 "d" => id = Some(tag_vec[1].clone()),
304 "name" => name = Some(tag_vec[1].clone()),
305 "description" => description = Some(tag_vec[1].clone()),
306 "version" => version = Some(tag_vec[1].clone()),
307 "x" => blossom_hash = Some(tag_vec[1].clone()),
308 "url" => download_url = Some(tag_vec[1].clone()),
309 "size" => size = tag_vec[1].parse().ok(),
310 "icon" => {
311 icon_url = Some(tag_vec[1].clone());
313 if tag_vec.len() >= 3 {
314 icon_mime = Some(tag_vec[2].clone());
315 }
316 }
317 "dev" => developer = Some(tag_vec[1].clone()),
318 "src" => source_url = Some(tag_vec[1].clone()),
319 "permissions" => {
320 requested_permissions = tag_vec[1].clone();
322 }
323 "t" => {
324 let tag_value = &tag_vec[1];
326 if tag_value != "miniapp" && tag_value != "webxdc" {
327 categories.push(tag_value.clone());
328 }
329 }
330 _ => {}
331 }
332 }
333
334 let id = id?;
336 if !is_safe_app_id(&id) {
337 log_warn!("[Marketplace] Rejected app with unsafe d-tag id: {:?}", id);
338 return None;
339 }
340 let name = name?;
341 let blossom_hash = blossom_hash?;
342 let download_url = download_url?;
343
344 let publisher = event.pubkey.to_bech32().unwrap_or_else(|_| event.pubkey.to_hex());
345
346 Some(MarketplaceApp {
347 id,
348 name,
349 description: description.unwrap_or_default(),
350 version: version.unwrap_or_else(|| "1.0.0".to_string()),
351 blossom_hash,
352 download_url,
353 size: size.unwrap_or(0),
354 icon_url,
355 icon_mime,
356 icon_cached: None,
357 categories,
358 changelog: if event.content.is_empty() { None } else { Some(event.content.clone()) },
359 developer,
360 source_url,
361 publisher,
362 published_at: event.created_at.as_secs(),
363 installed: false,
364 local_path: None,
365 installed_version: None,
366 update_available: false,
367 requested_permissions,
368 })
369}
370
371pub struct MarketplaceListing<'a> {
373 pub app_id: &'a str,
374 pub name: &'a str,
375 pub description: &'a str,
376 pub version: &'a str,
377 pub categories: Vec<&'a str>,
378 pub changelog: Option<&'a str>,
379 pub developer: Option<&'a str>,
380 pub source_url: Option<&'a str>,
381 pub permissions: Option<&'a str>,
383}
384
385pub async fn build_marketplace_event<T: crate::signer::VectorSigner>(
387 signer: &T,
388 listing: &MarketplaceListing<'_>,
389 blossom_hash: &str,
390 download_url: &str,
391 size: u64,
392 icon_info: Option<(&str, &str)>, ) -> Result<Event, String> {
394 let MarketplaceListing {
395 app_id,
396 name,
397 description,
398 version,
399 ref categories,
400 changelog,
401 developer,
402 source_url,
403 permissions,
404 } = *listing;
405 let mut tags = vec![
406 Tag::custom("d", vec![app_id.to_string()]),
407 Tag::custom("name", vec![name.to_string()]),
408 Tag::custom("description", vec![description.to_string()]),
409 Tag::custom("version", vec![version.to_string()]),
410 Tag::custom("x", vec![blossom_hash.to_string()]),
411 Tag::custom("url", vec![download_url.to_string()]),
412 Tag::custom("size", vec![size.to_string()]),
413 Tag::custom("t", vec!["miniapp".to_string()]),
414 Tag::custom("t", vec!["webxdc".to_string()]),
415 ];
416
417 if let Some((icon_url, mime_type)) = icon_info {
419 tags.push(Tag::custom(
420 "icon",
421 vec![icon_url.to_string(), mime_type.to_string()]
422 ));
423 }
424
425 if let Some(dev) = developer {
427 if !dev.is_empty() {
428 tags.push(Tag::custom(
429 "dev",
430 vec![dev.to_string()]
431 ));
432 }
433 }
434
435 if let Some(src) = source_url {
437 if !src.is_empty() {
438 tags.push(Tag::custom(
439 "src",
440 vec![src.to_string()]
441 ));
442 }
443 }
444
445 for category in categories {
447 tags.push(Tag::custom("t", vec![category.to_string()]));
448 }
449
450 if let Some(perms) = permissions {
452 if !perms.is_empty() {
453 tags.push(Tag::custom(
454 "permissions",
455 vec![perms.to_string()]
456 ));
457 }
458 }
459
460 let content = changelog.unwrap_or("");
461
462 let event_builder = EventBuilder::new(Kind::from(MINIAPP_MARKETPLACE_KIND), content)
463 .tags(tags);
464
465 event_builder
466 .finalize_async(signer)
467 .await
468 .map_err(|e| format!("Failed to sign marketplace event: {}", e))
469}
470
471#[cfg(test)]
472mod app_id_tests {
473 use super::is_safe_app_id;
474
475 #[test]
476 fn accepts_normal_ids() {
477 for id in ["com.example.app", "my-cool-app", "abc123", "app.v2", "a"] {
478 assert!(is_safe_app_id(id), "should accept {:?}", id);
479 }
480 assert!(is_safe_app_id(&"x".repeat(128)), "128 chars should be accepted");
481 }
482
483 #[test]
484 fn rejects_traversal_and_unsafe() {
485 for id in ["", "..", "....", "a..b", "../../etc/passwd", "..\\win",
486 "foo/bar", "foo\\bar", "/abs", "C:\\x", "app id", "café"] {
487 assert!(!is_safe_app_id(id), "should reject {:?}", id);
488 }
489 assert!(!is_safe_app_id(&"x".repeat(129)), "129 chars should be rejected");
490 }
491}