1use std::sync::atomic::{AtomicU64, Ordering};
10use std::collections::HashSet;
11
12use nostr_sdk::prelude::*;
13use serde::{Deserialize, Serialize};
14
15use crate::state::nostr_client;
16
17pub const DEFAULT_BLOSSOM_SERVERS: &[&str] = &[
20 "https://blossom.ditto.pub",
21 "https://blossom.primal.net",
22 "https://blossom.data.haus",
23];
24
25#[derive(Serialize, Deserialize, Clone, Debug)]
26pub struct CustomBlossomServer {
27 pub url: String,
28 pub enabled: bool,
29}
30
31pub fn validate_url(url: &str) -> Result<String, String> {
34 let trimmed = url.trim();
35 if trimmed.is_empty() {
36 return Err("URL cannot be empty".to_string());
37 }
38 let with_scheme = if trimmed.contains("://") {
39 trimmed.to_string()
40 } else {
41 format!("https://{}", trimmed)
42 };
43 let parsed = ::url::Url::parse(&with_scheme)
44 .map_err(|e| format!("Invalid URL: {}", e))?;
45 let scheme = parsed.scheme();
46 if scheme != "https" && scheme != "http" {
47 return Err("URL must use https:// or http://".to_string());
48 }
49 if parsed.host_str().map_or(true, str::is_empty) {
50 return Err("URL must include a host".to_string());
51 }
52 Ok(with_scheme.trim_end_matches('/').to_string())
53}
54
55#[derive(Serialize, Clone, Debug)]
57pub struct BlossomServerInfo {
58 pub url: String,
59 pub is_default: bool,
60 pub is_custom: bool,
61 pub enabled: bool,
62}
63
64pub fn load_custom_blossom_servers() -> Result<Vec<CustomBlossomServer>, String> {
69 match crate::db::get_sql_setting("custom_blossom_servers".to_string())
70 .ok().flatten()
71 {
72 Some(json) => serde_json::from_str(&json)
73 .map_err(|e| format!("Failed to parse custom_blossom_servers: {}", e)),
74 None => Ok(Vec::new()),
75 }
76}
77
78pub fn save_custom_blossom_servers(servers: &[CustomBlossomServer]) -> Result<(), String> {
79 let json = serde_json::to_string(servers)
80 .map_err(|e| format!("Failed to serialize custom blossom servers: {}", e))?;
81 crate::db::set_sql_setting("custom_blossom_servers".to_string(), json)
82}
83
84pub fn load_disabled_default_blossom_servers() -> Result<Vec<String>, String> {
85 match crate::db::get_sql_setting("disabled_default_blossom_servers".to_string())
86 .ok().flatten()
87 {
88 Some(json) => serde_json::from_str(&json)
89 .map_err(|e| format!("Failed to parse disabled_default_blossom_servers: {}", e)),
90 None => Ok(Vec::new()),
91 }
92}
93
94pub fn save_disabled_default_blossom_servers(urls: &[String]) -> Result<(), String> {
95 let json = serde_json::to_string(urls)
96 .map_err(|e| format!("Failed to serialize disabled default blossom servers: {}", e))?;
97 crate::db::set_sql_setting("disabled_default_blossom_servers".to_string(), json)
98}
99
100pub fn is_default_server(url: &str) -> bool {
105 let norm = url.trim().trim_end_matches('/').to_lowercase();
106 DEFAULT_BLOSSOM_SERVERS.iter()
107 .any(|d| d.trim_end_matches('/').to_lowercase() == norm)
108}
109
110pub fn is_enabled_server(url: &str) -> bool {
113 let target = url.trim().trim_end_matches('/').to_lowercase();
114 compute_enabled_servers().iter()
115 .any(|s| s.trim_end_matches('/').to_lowercase() == target)
116}
117
118pub fn compute_enabled_servers() -> Vec<String> {
119 let disabled = load_disabled_default_blossom_servers().unwrap_or_default();
120 let disabled_lower: HashSet<String> = disabled.iter()
121 .map(|s| s.trim().trim_end_matches('/').to_lowercase())
122 .collect();
123
124 let mut out: Vec<String> = Vec::new();
130 let customs = load_custom_blossom_servers().unwrap_or_default();
131 for c in customs {
132 if c.enabled {
133 out.push(c.url);
134 }
135 }
136 for d in DEFAULT_BLOSSOM_SERVERS {
137 let key = d.trim_end_matches('/').to_lowercase();
138 if !disabled_lower.contains(&key) {
139 out.push((*d).to_string());
140 }
141 }
142 out
143}
144
145pub fn list_all_servers() -> Vec<BlossomServerInfo> {
147 let disabled = load_disabled_default_blossom_servers().unwrap_or_default();
148 let disabled_lower: HashSet<String> = disabled.iter()
149 .map(|s| s.trim().trim_end_matches('/').to_lowercase())
150 .collect();
151
152 let mut out: Vec<BlossomServerInfo> = Vec::new();
153 for d in DEFAULT_BLOSSOM_SERVERS {
154 let key = d.trim_end_matches('/').to_lowercase();
155 out.push(BlossomServerInfo {
156 url: (*d).to_string(),
157 is_default: true,
158 is_custom: false,
159 enabled: !disabled_lower.contains(&key),
160 });
161 }
162 for c in load_custom_blossom_servers().unwrap_or_default() {
163 out.push(BlossomServerInfo {
164 url: c.url,
165 is_default: false,
166 is_custom: true,
167 enabled: c.enabled,
168 });
169 }
170 out
171}
172
173pub fn refresh_cache() {
175 let merged = compute_enabled_servers();
176 let mutex = crate::state::BLOSSOM_SERVERS
177 .get_or_init(|| std::sync::Mutex::new(merged.clone()));
178 if let Ok(mut guard) = mutex.lock() {
179 *guard = merged;
180 }
181}
182
183pub async fn publish_blossom_servers(client: &Client) -> Result<(), String> {
191 let servers = compute_enabled_servers();
192 let mut builder = EventBuilder::new(Kind::Custom(10063), "");
193 for url in &servers {
194 builder = builder.tag(Tag::custom("server", vec![url.clone()]));
195 }
196 crate::sign_and_send(client, builder).await
197 .map_err(|e| format!("Failed to publish blossom servers: {}", e))?;
198 crate::log_info!("[BlossomServers] Published kind 10063 with {} server(s)", servers.len());
199 Ok(())
200}
201
202static REPUBLISH_GEN: AtomicU64 = AtomicU64::new(0);
203
204pub fn republish_blossom_servers_debounced() {
209 let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
210 let session = crate::state::SessionGuard::capture();
211 tokio::spawn(async move {
212 tokio::time::sleep(std::time::Duration::from_millis(800)).await;
213 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
214 if !session.is_valid() { return; }
215 let client = match nostr_client() {
216 Some(c) => c,
217 None => return,
218 };
219 if let Err(e) = publish_blossom_servers(&client).await {
220 crate::log_warn!("[BlossomServers] Republish failed: {} (retrying in 5s)", e);
221 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
222 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
223 if !session.is_valid() { return; }
224 if let Err(e2) = publish_blossom_servers(&client).await {
225 crate::log_warn!("[BlossomServers] Republish retry failed: {}", e2);
226 }
227 }
228 });
229}
230
231pub fn merge_urls_into_customs(
238 incoming: &[String],
239 mut customs: Vec<CustomBlossomServer>,
240) -> (Vec<CustomBlossomServer>, usize) {
241 let mut known_lower: HashSet<String> = customs.iter()
242 .map(|c| c.url.trim_end_matches('/').to_lowercase())
243 .collect();
244 for d in DEFAULT_BLOSSOM_SERVERS {
245 known_lower.insert(d.trim_end_matches('/').to_lowercase());
246 }
247
248 let mut added = 0usize;
249 for raw in incoming {
250 let normalized = match validate_url(raw) {
251 Ok(u) => u,
252 Err(_) => continue,
253 };
254 let key = normalized.to_lowercase();
255 if known_lower.contains(&key) { continue; }
256 customs.push(CustomBlossomServer { url: normalized, enabled: true });
257 known_lower.insert(key);
258 added += 1;
259 }
260 (customs, added)
261}
262
263pub async fn fetch_and_merge_own_list(
267 client: &Client,
268 my_pubkey: PublicKey,
269 session: crate::state::SessionGuard,
270) -> Result<usize, String> {
271 let filter = Filter::new()
272 .author(my_pubkey)
273 .kind(Kind::Custom(10063))
274 .limit(1);
275 let events = client
276 .fetch_events(filter).timeout(std::time::Duration::from_secs(8))
277 .await
278 .map_err(|e| format!("Failed to fetch kind 10063: {}", e))?;
279
280 if !session.is_valid() { return Ok(0); }
281
282 let event = match events.into_iter().max_by_key(|e| e.created_at) {
283 Some(e) => e,
284 None => {
285 crate::log_debug!("[BlossomServers] No kind 10063 found for own pubkey");
286 return Ok(0);
287 }
288 };
289
290 let urls_from_event: Vec<String> = event.tags.iter()
291 .filter_map(|t| {
292 if t.kind() == "server" {
293 t.content().map(|s| s.to_string())
294 } else {
295 None
296 }
297 })
298 .collect();
299
300 let urls_lower: HashSet<String> = urls_from_event.iter()
304 .map(|u| u.trim().trim_end_matches('/').to_lowercase())
305 .collect();
306 let mut disabled = load_disabled_default_blossom_servers().unwrap_or_default();
307 let mut defaults_changed = false;
308 for d in DEFAULT_BLOSSOM_SERVERS {
309 let key = d.trim_end_matches('/').to_lowercase();
310 let in_event = urls_lower.contains(&key);
311 let currently_disabled = disabled.iter()
312 .any(|s| s.trim_end_matches('/').to_lowercase() == key);
313 if in_event && currently_disabled {
314 disabled.retain(|s| s.trim_end_matches('/').to_lowercase() != key);
315 defaults_changed = true;
316 } else if !in_event && !currently_disabled {
317 disabled.push(d.to_string());
318 defaults_changed = true;
319 }
320 }
321
322 let customs = load_custom_blossom_servers().unwrap_or_default();
323 let (new_customs, customs_added) = merge_urls_into_customs(&urls_from_event, customs);
324
325 let any_changes = customs_added > 0 || defaults_changed;
326 if any_changes {
327 if !session.is_valid() { return Ok(0); }
328 if defaults_changed {
329 save_disabled_default_blossom_servers(&disabled)?;
330 }
331 if customs_added > 0 {
332 save_custom_blossom_servers(&new_customs)?;
333 }
334 if !session.is_valid() { return Ok(customs_added); }
335 refresh_cache();
336 crate::traits::emit_event("blossom_servers_updated", &());
337 crate::log_info!(
338 "[BlossomServers] Merged kind 10063: {} custom server(s) added, defaults reconciled (disabled now {})",
339 customs_added, disabled.len(),
340 );
341 }
342 Ok(customs_added)
343}
344
345static USER_SERVER_LIST_CACHE: std::sync::LazyLock<
353 std::sync::Mutex<std::collections::HashMap<String, (Vec<String>, std::time::Instant)>>,
354> = std::sync::LazyLock::new(Default::default);
355const USER_SERVER_LIST_TTL: std::time::Duration = std::time::Duration::from_secs(15 * 60);
356const USER_SERVER_LIST_MAX: usize = 8;
357
358pub async fn fetch_user_server_list(client: &Client, author: PublicKey) -> Vec<String> {
363 let key = author.to_hex();
364 if let Ok(cache) = USER_SERVER_LIST_CACHE.lock() {
365 if let Some((servers, at)) = cache.get(&key) {
366 if at.elapsed() < USER_SERVER_LIST_TTL {
367 return servers.clone();
368 }
369 }
370 }
371
372 let filter = Filter::new()
373 .author(author)
374 .kind(Kind::Custom(10063))
375 .limit(1);
376 let events = match client
377 .fetch_events(filter)
378 .timeout(std::time::Duration::from_secs(8))
379 .await
380 {
381 Ok(e) => e,
382 Err(e) => {
383 crate::log_debug!("[BlossomServers] foreign 10063 fetch failed for {}: {}", key, e);
384 return Vec::new();
385 }
386 };
387
388 let servers: Vec<String> = events
389 .into_iter()
390 .max_by_key(|e| e.created_at)
391 .map(|event| {
392 event
393 .tags
394 .iter()
395 .filter_map(|t| {
396 if t.kind() == "server" {
397 t.content()
398 .and_then(|s| validate_url(s).ok())
399 .filter(|u| u.starts_with("https://"))
402 } else {
403 None
404 }
405 })
406 .take(USER_SERVER_LIST_MAX)
407 .collect()
408 })
409 .unwrap_or_default();
410
411 if servers.is_empty() {
412 let any_connected = client
415 .relays()
416 .await
417 .values()
418 .any(|r| r.status() == RelayStatus::Connected);
419 if !any_connected {
420 return servers;
421 }
422 }
423
424 if let Ok(mut cache) = USER_SERVER_LIST_CACHE.lock() {
425 if cache.len() > 512 {
428 cache.clear();
429 }
430 cache.insert(key, (servers.clone(), std::time::Instant::now()));
431 }
432 servers
433}
434
435pub async fn author_swap_servers(author_npub: Option<&str>, is_own_blob: bool) -> Vec<String> {
439 let me = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok());
440 if is_own_blob || (author_npub.is_some() && author_npub == me.as_deref()) {
441 return compute_enabled_servers();
442 }
443 let Some(author) = author_npub else {
444 return Vec::new();
445 };
446 match (crate::state::nostr_client(), PublicKey::parse(author)) {
447 (Some(client), Ok(pk)) => fetch_user_server_list(&client, pk).await,
448 _ => Vec::new(),
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 fn custom(url: &str, enabled: bool) -> CustomBlossomServer {
457 CustomBlossomServer { url: url.to_string(), enabled }
458 }
459
460 #[test]
461 fn validate_url_strips_trailing_slash_and_whitespace() {
462 assert_eq!(validate_url(" https://example.com/ ").unwrap(), "https://example.com");
463 assert_eq!(validate_url("https://example.com").unwrap(), "https://example.com");
464 }
465
466 #[test]
467 fn validate_url_auto_prefixes_bare_domain_with_https() {
468 assert_eq!(validate_url("blossom.band").unwrap(), "https://blossom.band");
469 assert_eq!(validate_url(" blossom.primal.net/ ").unwrap(), "https://blossom.primal.net");
470 }
471
472 #[test]
473 fn validate_url_keeps_explicit_http_scheme() {
474 assert_eq!(validate_url("http://localhost:8080").unwrap(), "http://localhost:8080");
475 }
476
477 #[test]
478 fn validate_url_rejects_non_http_schemes() {
479 assert!(validate_url("ftp://example.com").is_err());
480 assert!(validate_url("wss://example.com").is_err());
481 assert!(validate_url("javascript:alert(1)").is_err());
482 }
483
484 #[test]
485 fn validate_url_rejects_empty_or_hostless() {
486 assert!(validate_url("").is_err());
487 assert!(validate_url("https://").is_err());
488 assert!(validate_url("not a url").is_err());
489 }
490
491 #[test]
492 fn is_default_server_normalizes() {
493 assert!(is_default_server("https://blossom.primal.net"));
494 assert!(is_default_server("https://blossom.primal.net/"));
495 assert!(is_default_server("HTTPS://BLOSSOM.PRIMAL.NET"));
496 assert!(!is_default_server("https://other.example.com"));
497 }
498
499 #[test]
500 fn merge_appends_new_urls_normalized() {
501 let incoming = vec![
502 "https://new.example.com/".to_string(),
503 "https://another.example.com".to_string(),
504 ];
505 let (out, added) = merge_urls_into_customs(&incoming, vec![]);
506 assert_eq!(added, 2);
507 assert_eq!(out.len(), 2);
508 assert_eq!(out[0].url, "https://new.example.com");
509 assert!(out[0].enabled);
510 }
511
512 #[test]
513 fn merge_skips_urls_already_in_customs_regardless_of_slash_or_case() {
514 let existing = vec![custom("https://Existing.example.com", true)];
515 let incoming = vec![
516 "https://existing.example.com/".to_string(),
517 "HTTPS://EXISTING.EXAMPLE.COM".to_string(),
518 ];
519 let (out, added) = merge_urls_into_customs(&incoming, existing);
520 assert_eq!(added, 0);
521 assert_eq!(out.len(), 1);
522 }
523
524 #[test]
525 fn merge_skips_default_servers() {
526 let incoming = vec!["https://blossom.primal.net/".to_string()];
527 let (out, added) = merge_urls_into_customs(&incoming, vec![]);
528 assert_eq!(added, 0);
529 assert!(out.is_empty());
530 }
531
532 #[test]
533 fn merge_drops_malformed_urls() {
534 let incoming = vec![
535 "".to_string(),
536 "not a url".to_string(),
537 "ftp://example.com".to_string(),
538 "https://".to_string(),
539 "https://valid.example.com".to_string(),
540 ];
541 let (out, added) = merge_urls_into_customs(&incoming, vec![]);
542 assert_eq!(added, 1);
543 assert_eq!(out[0].url, "https://valid.example.com");
544 }
545
546 #[test]
547 fn merge_preserves_existing_order_and_appends() {
548 let existing = vec![
549 custom("https://a.example.com", true),
550 custom("https://b.example.com", false),
551 ];
552 let incoming = vec!["https://c.example.com".to_string()];
553 let (out, _) = merge_urls_into_customs(&incoming, existing);
554 assert_eq!(out.len(), 3);
555 assert_eq!(out[0].url, "https://a.example.com");
556 assert_eq!(out[1].url, "https://b.example.com");
557 assert!(!out[1].enabled, "merge must not flip enabled state of existing rows");
558 assert_eq!(out[2].url, "https://c.example.com");
559 }
560}