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://nostr.download",
23 "https://blossom.data.haus",
24];
25
26#[derive(Serialize, Deserialize, Clone, Debug)]
27pub struct CustomBlossomServer {
28 pub url: String,
29 pub enabled: bool,
30}
31
32pub fn validate_url(url: &str) -> Result<String, String> {
35 let trimmed = url.trim();
36 if trimmed.is_empty() {
37 return Err("URL cannot be empty".to_string());
38 }
39 let with_scheme = if trimmed.contains("://") {
40 trimmed.to_string()
41 } else {
42 format!("https://{}", trimmed)
43 };
44 let parsed = ::url::Url::parse(&with_scheme)
45 .map_err(|e| format!("Invalid URL: {}", e))?;
46 let scheme = parsed.scheme();
47 if scheme != "https" && scheme != "http" {
48 return Err("URL must use https:// or http://".to_string());
49 }
50 if parsed.host_str().map_or(true, str::is_empty) {
51 return Err("URL must include a host".to_string());
52 }
53 Ok(with_scheme.trim_end_matches('/').to_string())
54}
55
56#[derive(Serialize, Clone, Debug)]
58pub struct BlossomServerInfo {
59 pub url: String,
60 pub is_default: bool,
61 pub is_custom: bool,
62 pub enabled: bool,
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 out.push(BlossomServerInfo {
157 url: (*d).to_string(),
158 is_default: true,
159 is_custom: false,
160 enabled: !disabled_lower.contains(&key),
161 });
162 }
163 for c in load_custom_blossom_servers().unwrap_or_default() {
164 out.push(BlossomServerInfo {
165 url: c.url,
166 is_default: false,
167 is_custom: true,
168 enabled: c.enabled,
169 });
170 }
171 out
172}
173
174pub fn refresh_cache() {
176 let merged = compute_enabled_servers();
177 let mutex = crate::state::BLOSSOM_SERVERS
178 .get_or_init(|| std::sync::Mutex::new(merged.clone()));
179 if let Ok(mut guard) = mutex.lock() {
180 *guard = merged;
181 }
182}
183
184pub async fn publish_blossom_servers(client: &Client) -> Result<(), String> {
192 let servers = compute_enabled_servers();
193 let mut builder = EventBuilder::new(Kind::Custom(10063), "");
194 for url in &servers {
195 builder = builder.tag(Tag::custom(TagKind::custom("server"), vec![url.clone()]));
196 }
197 client.send_event_builder(builder).await
198 .map_err(|e| format!("Failed to publish blossom servers: {}", e))?;
199 crate::log_info!("[BlossomServers] Published kind 10063 with {} server(s)", servers.len());
200 Ok(())
201}
202
203static REPUBLISH_GEN: AtomicU64 = AtomicU64::new(0);
204
205pub fn republish_blossom_servers_debounced() {
210 let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
211 let session = crate::state::SessionGuard::capture();
212 tokio::spawn(async move {
213 tokio::time::sleep(std::time::Duration::from_millis(800)).await;
214 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
215 if !session.is_valid() { return; }
216 let client = match nostr_client() {
217 Some(c) => c,
218 None => return,
219 };
220 if let Err(e) = publish_blossom_servers(&client).await {
221 crate::log_warn!("[BlossomServers] Republish failed: {} (retrying in 5s)", e);
222 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
223 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
224 if !session.is_valid() { return; }
225 if let Err(e2) = publish_blossom_servers(&client).await {
226 crate::log_warn!("[BlossomServers] Republish retry failed: {}", e2);
227 }
228 }
229 });
230}
231
232pub fn merge_urls_into_customs(
239 incoming: &[String],
240 mut customs: Vec<CustomBlossomServer>,
241) -> (Vec<CustomBlossomServer>, usize) {
242 let mut known_lower: HashSet<String> = customs.iter()
243 .map(|c| c.url.trim_end_matches('/').to_lowercase())
244 .collect();
245 for d in DEFAULT_BLOSSOM_SERVERS {
246 known_lower.insert(d.trim_end_matches('/').to_lowercase());
247 }
248
249 let mut added = 0usize;
250 for raw in incoming {
251 let normalized = match validate_url(raw) {
252 Ok(u) => u,
253 Err(_) => continue,
254 };
255 let key = normalized.to_lowercase();
256 if known_lower.contains(&key) { continue; }
257 customs.push(CustomBlossomServer { url: normalized, enabled: true });
258 known_lower.insert(key);
259 added += 1;
260 }
261 (customs, added)
262}
263
264pub async fn fetch_and_merge_own_list(
268 client: &Client,
269 my_pubkey: PublicKey,
270 session: crate::state::SessionGuard,
271) -> Result<usize, String> {
272 let filter = Filter::new()
273 .author(my_pubkey)
274 .kind(Kind::Custom(10063))
275 .limit(1);
276 let events = client
277 .fetch_events(filter, std::time::Duration::from_secs(8))
278 .await
279 .map_err(|e| format!("Failed to fetch kind 10063: {}", e))?;
280
281 if !session.is_valid() { return Ok(0); }
282
283 let event = match events.into_iter().max_by_key(|e| e.created_at) {
284 Some(e) => e,
285 None => {
286 crate::log_debug!("[BlossomServers] No kind 10063 found for own pubkey");
287 return Ok(0);
288 }
289 };
290
291 let urls_from_event: Vec<String> = event.tags.iter()
292 .filter_map(|t| {
293 if t.kind() == TagKind::custom("server") {
294 t.content().map(|s| s.to_string())
295 } else {
296 None
297 }
298 })
299 .collect();
300
301 let urls_lower: HashSet<String> = urls_from_event.iter()
305 .map(|u| u.trim().trim_end_matches('/').to_lowercase())
306 .collect();
307 let mut disabled = load_disabled_default_blossom_servers().unwrap_or_default();
308 let mut defaults_changed = false;
309 for d in DEFAULT_BLOSSOM_SERVERS {
310 let key = d.trim_end_matches('/').to_lowercase();
311 let in_event = urls_lower.contains(&key);
312 let currently_disabled = disabled.iter()
313 .any(|s| s.trim_end_matches('/').to_lowercase() == key);
314 if in_event && currently_disabled {
315 disabled.retain(|s| s.trim_end_matches('/').to_lowercase() != key);
316 defaults_changed = true;
317 } else if !in_event && !currently_disabled {
318 disabled.push(d.to_string());
319 defaults_changed = true;
320 }
321 }
322
323 let customs = load_custom_blossom_servers().unwrap_or_default();
324 let (new_customs, customs_added) = merge_urls_into_customs(&urls_from_event, customs);
325
326 let any_changes = customs_added > 0 || defaults_changed;
327 if any_changes {
328 if !session.is_valid() { return Ok(0); }
329 if defaults_changed {
330 save_disabled_default_blossom_servers(&disabled)?;
331 }
332 if customs_added > 0 {
333 save_custom_blossom_servers(&new_customs)?;
334 }
335 if !session.is_valid() { return Ok(customs_added); }
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
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 fn custom(url: &str, enabled: bool) -> CustomBlossomServer {
351 CustomBlossomServer { url: url.to_string(), enabled }
352 }
353
354 #[test]
355 fn validate_url_strips_trailing_slash_and_whitespace() {
356 assert_eq!(validate_url(" https://example.com/ ").unwrap(), "https://example.com");
357 assert_eq!(validate_url("https://example.com").unwrap(), "https://example.com");
358 }
359
360 #[test]
361 fn validate_url_auto_prefixes_bare_domain_with_https() {
362 assert_eq!(validate_url("blossom.band").unwrap(), "https://blossom.band");
363 assert_eq!(validate_url(" blossom.primal.net/ ").unwrap(), "https://blossom.primal.net");
364 }
365
366 #[test]
367 fn validate_url_keeps_explicit_http_scheme() {
368 assert_eq!(validate_url("http://localhost:8080").unwrap(), "http://localhost:8080");
369 }
370
371 #[test]
372 fn validate_url_rejects_non_http_schemes() {
373 assert!(validate_url("ftp://example.com").is_err());
374 assert!(validate_url("wss://example.com").is_err());
375 assert!(validate_url("javascript:alert(1)").is_err());
376 }
377
378 #[test]
379 fn validate_url_rejects_empty_or_hostless() {
380 assert!(validate_url("").is_err());
381 assert!(validate_url("https://").is_err());
382 assert!(validate_url("not a url").is_err());
383 }
384
385 #[test]
386 fn is_default_server_normalizes() {
387 assert!(is_default_server("https://blossom.primal.net"));
388 assert!(is_default_server("https://blossom.primal.net/"));
389 assert!(is_default_server("HTTPS://BLOSSOM.PRIMAL.NET"));
390 assert!(!is_default_server("https://other.example.com"));
391 }
392
393 #[test]
394 fn merge_appends_new_urls_normalized() {
395 let incoming = vec![
396 "https://new.example.com/".to_string(),
397 "https://another.example.com".to_string(),
398 ];
399 let (out, added) = merge_urls_into_customs(&incoming, vec![]);
400 assert_eq!(added, 2);
401 assert_eq!(out.len(), 2);
402 assert_eq!(out[0].url, "https://new.example.com");
403 assert!(out[0].enabled);
404 }
405
406 #[test]
407 fn merge_skips_urls_already_in_customs_regardless_of_slash_or_case() {
408 let existing = vec![custom("https://Existing.example.com", true)];
409 let incoming = vec![
410 "https://existing.example.com/".to_string(),
411 "HTTPS://EXISTING.EXAMPLE.COM".to_string(),
412 ];
413 let (out, added) = merge_urls_into_customs(&incoming, existing);
414 assert_eq!(added, 0);
415 assert_eq!(out.len(), 1);
416 }
417
418 #[test]
419 fn merge_skips_default_servers() {
420 let incoming = vec!["https://blossom.primal.net/".to_string()];
421 let (out, added) = merge_urls_into_customs(&incoming, vec![]);
422 assert_eq!(added, 0);
423 assert!(out.is_empty());
424 }
425
426 #[test]
427 fn merge_drops_malformed_urls() {
428 let incoming = vec![
429 "".to_string(),
430 "not a url".to_string(),
431 "ftp://example.com".to_string(),
432 "https://".to_string(),
433 "https://valid.example.com".to_string(),
434 ];
435 let (out, added) = merge_urls_into_customs(&incoming, vec![]);
436 assert_eq!(added, 1);
437 assert_eq!(out[0].url, "https://valid.example.com");
438 }
439
440 #[test]
441 fn merge_preserves_existing_order_and_appends() {
442 let existing = vec![
443 custom("https://a.example.com", true),
444 custom("https://b.example.com", false),
445 ];
446 let incoming = vec!["https://c.example.com".to_string()];
447 let (out, _) = merge_urls_into_customs(&incoming, existing);
448 assert_eq!(out.len(), 3);
449 assert_eq!(out[0].url, "https://a.example.com");
450 assert_eq!(out[1].url, "https://b.example.com");
451 assert!(!out[1].enabled, "merge must not flip enabled state of existing rows");
452 assert_eq!(out[2].url, "https://c.example.com");
453 }
454}