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().is_none_or(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 pub status: crate::blossom_stats::ServerStatus,
63}
64
65pub fn load_custom_blossom_servers() -> Result<Vec<CustomBlossomServer>, String> {
70 match crate::db::get_sql_setting("custom_blossom_servers".to_string())
71 .ok().flatten()
72 {
73 Some(json) => serde_json::from_str(&json)
74 .map_err(|e| format!("Failed to parse custom_blossom_servers: {}", e)),
75 None => Ok(Vec::new()),
76 }
77}
78
79pub fn save_custom_blossom_servers(servers: &[CustomBlossomServer]) -> Result<(), String> {
80 let json = serde_json::to_string(servers)
81 .map_err(|e| format!("Failed to serialize custom blossom servers: {}", e))?;
82 crate::db::set_sql_setting("custom_blossom_servers".to_string(), json)
83}
84
85pub fn load_disabled_default_blossom_servers() -> Result<Vec<String>, String> {
86 match crate::db::get_sql_setting("disabled_default_blossom_servers".to_string())
87 .ok().flatten()
88 {
89 Some(json) => serde_json::from_str(&json)
90 .map_err(|e| format!("Failed to parse disabled_default_blossom_servers: {}", e)),
91 None => Ok(Vec::new()),
92 }
93}
94
95pub fn save_disabled_default_blossom_servers(urls: &[String]) -> Result<(), String> {
96 let json = serde_json::to_string(urls)
97 .map_err(|e| format!("Failed to serialize disabled default blossom servers: {}", e))?;
98 crate::db::set_sql_setting("disabled_default_blossom_servers".to_string(), json)
99}
100
101pub fn is_default_server(url: &str) -> bool {
106 let norm = url.trim().trim_end_matches('/').to_lowercase();
107 DEFAULT_BLOSSOM_SERVERS.iter()
108 .any(|d| d.trim_end_matches('/').to_lowercase() == norm)
109}
110
111pub fn is_enabled_server(url: &str) -> bool {
114 let target = url.trim().trim_end_matches('/').to_lowercase();
115 compute_enabled_servers().iter()
116 .any(|s| s.trim_end_matches('/').to_lowercase() == target)
117}
118
119pub fn compute_enabled_servers() -> Vec<String> {
120 let disabled = load_disabled_default_blossom_servers().unwrap_or_default();
121 let disabled_lower: HashSet<String> = disabled.iter()
122 .map(|s| s.trim().trim_end_matches('/').to_lowercase())
123 .collect();
124
125 let mut out: Vec<String> = Vec::new();
131 let customs = load_custom_blossom_servers().unwrap_or_default();
132 for c in customs {
133 if c.enabled {
134 out.push(c.url);
135 }
136 }
137 for d in DEFAULT_BLOSSOM_SERVERS {
138 let key = d.trim_end_matches('/').to_lowercase();
139 if !disabled_lower.contains(&key) {
140 out.push((*d).to_string());
141 }
142 }
143 out
144}
145
146pub fn list_all_servers() -> Vec<BlossomServerInfo> {
148 let disabled = load_disabled_default_blossom_servers().unwrap_or_default();
149 let disabled_lower: HashSet<String> = disabled.iter()
150 .map(|s| s.trim().trim_end_matches('/').to_lowercase())
151 .collect();
152
153 let mut out: Vec<BlossomServerInfo> = Vec::new();
154 for d in DEFAULT_BLOSSOM_SERVERS {
155 let key = d.trim_end_matches('/').to_lowercase();
156 let enabled = !disabled_lower.contains(&key);
157 out.push(BlossomServerInfo {
158 url: (*d).to_string(),
159 is_default: true,
160 is_custom: false,
161 enabled,
162 status: crate::blossom_stats::status_for(d, enabled),
163 });
164 }
165 for c in load_custom_blossom_servers().unwrap_or_default() {
166 let status = crate::blossom_stats::status_for(&c.url, c.enabled);
167 out.push(BlossomServerInfo {
168 url: c.url,
169 is_default: false,
170 is_custom: true,
171 enabled: c.enabled,
172 status,
173 });
174 }
175 out
176}
177
178pub fn resolve_if_open() -> Option<Vec<String>> {
180 crate::db::get_sql_setting("custom_blossom_servers".to_string()).ok()?;
181 Some(compute_enabled_servers())
182}
183
184pub fn refresh_cache() {
186 crate::state::set_blossom_servers(compute_enabled_servers());
187 crate::proxy::forget_picks();
189}
190
191pub async fn publish_blossom_servers(client: &Client) -> Result<(), String> {
199 let servers = compute_enabled_servers();
200 let mut builder = EventBuilder::new(Kind::Custom(10063), "");
201 for url in &servers {
202 builder = builder.tag(Tag::custom("server", vec![url.clone()]));
203 }
204 crate::sign_and_send(client, builder).await
205 .map_err(|e| format!("Failed to publish blossom servers: {}", e))?;
206 crate::log_info!("[BlossomServers] Published kind 10063 with {} server(s)", servers.len());
207 Ok(())
208}
209
210static REPUBLISH_GEN: AtomicU64 = AtomicU64::new(0);
211
212pub fn republish_blossom_servers_debounced() {
217 let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
218 crate::db::spawn_bound(async move {
219 crate::rt::time::sleep(std::time::Duration::from_millis(800)).await;
220 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
221 let client = match nostr_client() {
222 Some(c) => c,
223 None => return,
224 };
225 if let Err(e) = publish_blossom_servers(&client).await {
226 crate::log_warn!("[BlossomServers] Republish failed: {} (retrying in 5s)", e);
227 crate::rt::time::sleep(std::time::Duration::from_secs(5)).await;
228 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
229 if let Err(e2) = publish_blossom_servers(&client).await {
230 crate::log_warn!("[BlossomServers] Republish retry failed: {}", e2);
231 }
232 }
233 });
234}
235
236pub fn merge_urls_into_customs(
243 incoming: &[String],
244 mut customs: Vec<CustomBlossomServer>,
245) -> (Vec<CustomBlossomServer>, usize) {
246 let mut known_lower: HashSet<String> = customs.iter()
247 .map(|c| c.url.trim_end_matches('/').to_lowercase())
248 .collect();
249 for d in DEFAULT_BLOSSOM_SERVERS {
250 known_lower.insert(d.trim_end_matches('/').to_lowercase());
251 }
252
253 let mut added = 0usize;
254 for raw in incoming {
255 let normalized = match validate_url(raw) {
256 Ok(u) => u,
257 Err(_) => continue,
258 };
259 let key = normalized.to_lowercase();
260 if known_lower.contains(&key) { continue; }
261 customs.push(CustomBlossomServer { url: normalized, enabled: true });
262 known_lower.insert(key);
263 added += 1;
264 }
265 (customs, added)
266}
267
268pub async fn fetch_and_merge_own_list(
272 client: &Client,
273 my_pubkey: PublicKey,
274) -> Result<usize, String> {
275 let filter = Filter::new()
276 .author(my_pubkey)
277 .kind(Kind::Custom(10063))
278 .limit(1);
279 let events = client
280 .fetch_events(filter).timeout(std::time::Duration::from_secs(8))
281 .await
282 .map_err(|e| format!("Failed to fetch kind 10063: {}", e))?;
283
284
285 let event = match events.into_iter().max_by_key(|e| e.created_at) {
286 Some(e) => e,
287 None => {
288 crate::log_debug!("[BlossomServers] No kind 10063 found for own pubkey");
289 return Ok(0);
290 }
291 };
292
293 let urls_from_event: Vec<String> = event.tags.iter()
294 .filter_map(|t| {
295 if t.kind() == "server" {
296 t.content().map(|s| s.to_string())
297 } else {
298 None
299 }
300 })
301 .collect();
302
303 let urls_lower: HashSet<String> = urls_from_event.iter()
307 .map(|u| u.trim().trim_end_matches('/').to_lowercase())
308 .collect();
309 let mut disabled = load_disabled_default_blossom_servers().unwrap_or_default();
310 let mut defaults_changed = false;
311 for d in DEFAULT_BLOSSOM_SERVERS {
312 let key = d.trim_end_matches('/').to_lowercase();
313 let in_event = urls_lower.contains(&key);
314 let currently_disabled = disabled.iter()
315 .any(|s| s.trim_end_matches('/').to_lowercase() == key);
316 if in_event && currently_disabled {
317 disabled.retain(|s| s.trim_end_matches('/').to_lowercase() != key);
318 defaults_changed = true;
319 } else if !in_event && !currently_disabled {
320 disabled.push(d.to_string());
321 defaults_changed = true;
322 }
323 }
324
325 let customs = load_custom_blossom_servers().unwrap_or_default();
326 let (new_customs, customs_added) = merge_urls_into_customs(&urls_from_event, customs);
327
328 let any_changes = customs_added > 0 || defaults_changed;
329 if any_changes {
330 if defaults_changed {
331 save_disabled_default_blossom_servers(&disabled)?;
332 }
333 if customs_added > 0 {
334 save_custom_blossom_servers(&new_customs)?;
335 }
336 refresh_cache();
337 crate::traits::emit_event("blossom_servers_updated", &());
338 crate::log_info!(
339 "[BlossomServers] Merged kind 10063: {} custom server(s) added, defaults reconciled (disabled now {})",
340 customs_added, disabled.len(),
341 );
342 }
343 Ok(customs_added)
344}
345
346type UserServerLists = std::collections::HashMap<String, (Vec<String>, web_time::Instant)>;
354
355static USER_SERVER_LIST_CACHE: std::sync::LazyLock<std::sync::Mutex<UserServerLists>> =
356 std::sync::LazyLock::new(Default::default);
357const USER_SERVER_LIST_TTL: std::time::Duration = std::time::Duration::from_secs(15 * 60);
358const USER_SERVER_LIST_MAX: usize = 8;
359
360pub async fn fetch_user_server_list(client: &Client, author: PublicKey) -> Vec<String> {
365 let key = author.to_hex();
366 if let Ok(cache) = USER_SERVER_LIST_CACHE.lock() {
367 if let Some((servers, at)) = cache.get(&key) {
368 if at.elapsed() < USER_SERVER_LIST_TTL {
369 return servers.clone();
370 }
371 }
372 }
373
374 let filter = Filter::new()
375 .author(author)
376 .kind(Kind::Custom(10063))
377 .limit(1);
378 let events = match client
379 .fetch_events(filter)
380 .timeout(std::time::Duration::from_secs(8))
381 .await
382 {
383 Ok(e) => e,
384 Err(e) => {
385 crate::log_debug!("[BlossomServers] foreign 10063 fetch failed for {}: {}", key, e);
386 return Vec::new();
387 }
388 };
389
390 let servers: Vec<String> = events
391 .into_iter()
392 .max_by_key(|e| e.created_at)
393 .map(|event| {
394 event
395 .tags
396 .iter()
397 .filter_map(|t| {
398 if t.kind() == "server" {
399 t.content()
400 .and_then(|s| validate_url(s).ok())
401 .filter(|u| u.starts_with("https://"))
404 } else {
405 None
406 }
407 })
408 .take(USER_SERVER_LIST_MAX)
409 .collect()
410 })
411 .unwrap_or_default();
412
413 if servers.is_empty() {
414 let any_connected = client
417 .relays()
418 .await
419 .values()
420 .any(|r| r.status() == RelayStatus::Connected);
421 if !any_connected {
422 return servers;
423 }
424 }
425
426 if let Ok(mut cache) = USER_SERVER_LIST_CACHE.lock() {
427 if cache.len() > 512 {
430 cache.clear();
431 }
432 cache.insert(key, (servers.clone(), web_time::Instant::now()));
433 }
434 servers
435}
436
437pub async fn author_swap_servers(author_npub: Option<&str>, is_own_blob: bool) -> Vec<String> {
441 let me = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok());
442 if is_own_blob || (author_npub.is_some() && author_npub == me.as_deref()) {
443 return compute_enabled_servers();
444 }
445 let Some(author) = author_npub else {
446 return Vec::new();
447 };
448 match (crate::state::nostr_client(), PublicKey::parse(author)) {
449 (Some(client), Ok(pk)) => fetch_user_server_list(&client, pk).await,
450 _ => Vec::new(),
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 #[test]
459 fn a_fresh_session_resolves_its_account_list_without_a_refresh() {
460 let _guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
461 crate::db::close_database();
462 crate::db::clear_id_caches();
463 use nostr_sdk::prelude::ToBech32;
464 let account = nostr_sdk::prelude::Keys::generate().public_key().to_bech32().unwrap();
465 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
466 crate::db::set_current_account(account.clone()).unwrap();
467 crate::db::init_database(&account).unwrap();
468
469 save_custom_blossom_servers(&[custom("https://self.hosted", true)]).unwrap();
470 save_disabled_default_blossom_servers(&[DEFAULT_BLOSSOM_SERVERS[0].to_string()]).unwrap();
471
472 let servers = crate::state::get_blossom_servers();
473 assert_eq!(servers.first().map(String::as_str), Some("https://self.hosted"));
474 assert!(!servers.iter().any(|s| s == DEFAULT_BLOSSOM_SERVERS[0]), "{servers:?}");
475 }
476
477 fn custom(url: &str, enabled: bool) -> CustomBlossomServer {
478 CustomBlossomServer { url: url.to_string(), enabled }
479 }
480
481 #[test]
482 fn validate_url_strips_trailing_slash_and_whitespace() {
483 assert_eq!(validate_url(" https://example.com/ ").unwrap(), "https://example.com");
484 assert_eq!(validate_url("https://example.com").unwrap(), "https://example.com");
485 }
486
487 #[test]
488 fn validate_url_auto_prefixes_bare_domain_with_https() {
489 assert_eq!(validate_url("blossom.band").unwrap(), "https://blossom.band");
490 assert_eq!(validate_url(" blossom.primal.net/ ").unwrap(), "https://blossom.primal.net");
491 }
492
493 #[test]
494 fn validate_url_keeps_explicit_http_scheme() {
495 assert_eq!(validate_url("http://localhost:8080").unwrap(), "http://localhost:8080");
496 }
497
498 #[test]
499 fn validate_url_rejects_non_http_schemes() {
500 assert!(validate_url("ftp://example.com").is_err());
501 assert!(validate_url("wss://example.com").is_err());
502 assert!(validate_url("javascript:alert(1)").is_err());
503 }
504
505 #[test]
506 fn validate_url_rejects_empty_or_hostless() {
507 assert!(validate_url("").is_err());
508 assert!(validate_url("https://").is_err());
509 assert!(validate_url("not a url").is_err());
510 }
511
512 #[test]
513 fn is_default_server_normalizes() {
514 assert!(is_default_server("https://blossom.primal.net"));
515 assert!(is_default_server("https://blossom.primal.net/"));
516 assert!(is_default_server("HTTPS://BLOSSOM.PRIMAL.NET"));
517 assert!(!is_default_server("https://other.example.com"));
518 }
519
520 #[test]
521 fn merge_appends_new_urls_normalized() {
522 let incoming = vec![
523 "https://new.example.com/".to_string(),
524 "https://another.example.com".to_string(),
525 ];
526 let (out, added) = merge_urls_into_customs(&incoming, vec![]);
527 assert_eq!(added, 2);
528 assert_eq!(out.len(), 2);
529 assert_eq!(out[0].url, "https://new.example.com");
530 assert!(out[0].enabled);
531 }
532
533 #[test]
534 fn merge_skips_urls_already_in_customs_regardless_of_slash_or_case() {
535 let existing = vec![custom("https://Existing.example.com", true)];
536 let incoming = vec![
537 "https://existing.example.com/".to_string(),
538 "HTTPS://EXISTING.EXAMPLE.COM".to_string(),
539 ];
540 let (out, added) = merge_urls_into_customs(&incoming, existing);
541 assert_eq!(added, 0);
542 assert_eq!(out.len(), 1);
543 }
544
545 #[test]
546 fn merge_skips_default_servers() {
547 let incoming = vec!["https://blossom.primal.net/".to_string()];
548 let (out, added) = merge_urls_into_customs(&incoming, vec![]);
549 assert_eq!(added, 0);
550 assert!(out.is_empty());
551 }
552
553 #[test]
554 fn merge_drops_malformed_urls() {
555 let incoming = vec![
556 "".to_string(),
557 "not a url".to_string(),
558 "ftp://example.com".to_string(),
559 "https://".to_string(),
560 "https://valid.example.com".to_string(),
561 ];
562 let (out, added) = merge_urls_into_customs(&incoming, vec![]);
563 assert_eq!(added, 1);
564 assert_eq!(out[0].url, "https://valid.example.com");
565 }
566
567 #[test]
568 fn merge_preserves_existing_order_and_appends() {
569 let existing = vec![
570 custom("https://a.example.com", true),
571 custom("https://b.example.com", false),
572 ];
573 let incoming = vec!["https://c.example.com".to_string()];
574 let (out, _) = merge_urls_into_customs(&incoming, existing);
575 assert_eq!(out.len(), 3);
576 assert_eq!(out[0].url, "https://a.example.com");
577 assert_eq!(out[1].url, "https://b.example.com");
578 assert!(!out[1].enabled, "merge must not flip enabled state of existing rows");
579 assert_eq!(out[2].url, "https://c.example.com");
580 }
581}