Skip to main content

vector_core/
blossom_servers.rs

1//! BUD-03 user blossom server list (kind 10063) — store, merge, publish, fetch.
2//!
3//! Two SQL settings rows back the user's preferences:
4//!   * `custom_blossom_servers`             — `Vec<CustomBlossomServer>` JSON
5//!   * `disabled_default_blossom_servers`   — `Vec<String>` JSON
6//!
7//! Resolution order (BUD-03 trust order): enabled defaults, then enabled customs.
8
9use 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
17/// Default servers in trust order (first = first try). All verified to
18/// accept Vector's encrypted octet-stream uploads at common sizes.
19pub 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
32/// Validate + canonicalize a server URL: trim, strip trailing slash,
33/// auto-prefix `https://` on bare domains, enforce http(s) + non-empty host.
34pub 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/// One row in the frontend's Media Servers list.
57#[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
65// ============================================================================
66// Storage
67// ============================================================================
68
69pub 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
101// ============================================================================
102// Resolver — enabled customs (first) + defaults (minus disabled)
103// ============================================================================
104
105pub 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
111/// Race-guard for in-flight probes: true if `url` is in the currently
112/// enabled list at this moment.
113pub 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    // Enabled custom servers are tried FIRST — a user who adds their own
126    // server wants it used (self-hosted / trusted). The capability ranker
127    // (`rank_servers`) then sinks any that fail validation (won't accept our
128    // encrypted type, transform the bytes, or refuse deletion) below the
129    // defaults, so a bad custom quietly falls through to ditto/etc.
130    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
146/// All rows for the frontend (defaults then customs, including disabled).
147pub 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
174/// Refresh the in-memory `BLOSSOM_SERVERS` cache. Call after edits + on login.
175pub 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
184// ============================================================================
185// BUD-03 publish (kind 10063)
186// ============================================================================
187
188/// Publish the resolved enabled list (defaults + customs, in trust order)
189/// as a BUD-03 kind 10063 replaceable event. Peers using our list as a
190/// fallback need to see every server we use, not just the customs.
191pub 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
205/// Debounced republish: rapid edits coalesce; mid-window session swap aborts.
206/// Retries once on failure (5s backoff): a stale event on the network would
207/// otherwise let `fetch_and_merge_own_list` overwrite the local prefs on
208/// the next boot.
209pub 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
232// ============================================================================
233// BUD-03 fetch — pull our own kind 10063, merge into customs
234// ============================================================================
235
236/// Pure merge: append novel URLs from `incoming` (validated, normalized)
237/// into `customs` as enabled. Existing rows are never removed or reordered.
238pub 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
264/// Fetch our latest kind 10063 and reconcile: append unknown customs,
265/// follow the originating device's default enable/disable choices.
266/// `session` gates writes against a mid-fetch account swap.
267pub 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    // Event presence means the user has published preferences somewhere.
302    // A default's absence from the list = explicit disable on that
303    // device. (No event at all takes the early return above.)
304    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}