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://blossom.data.haus",
23];
24
25#[derive(Serialize, Deserialize, Clone, Debug)]
26pub struct CustomBlossomServer {
27    pub url: String,
28    pub enabled: bool,
29}
30
31/// Validate + canonicalize a server URL: trim, strip trailing slash,
32/// auto-prefix `https://` on bare domains, enforce http(s) + non-empty host.
33pub 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/// One row in the frontend's Media Servers list.
56#[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
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        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
178/// The resolved list, or None while no database is open to resolve it from.
179pub 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
184/// Refresh this account's resolved server list. Call after edits + on login.
185pub fn refresh_cache() {
186    crate::state::set_blossom_servers(compute_enabled_servers());
187    // The proxy picks were made against the old list.
188    crate::proxy::forget_picks();
189}
190
191// ============================================================================
192// BUD-03 publish (kind 10063)
193// ============================================================================
194
195/// Publish the resolved enabled list (defaults + customs, in trust order)
196/// as a BUD-03 kind 10063 replaceable event. Peers using our list as a
197/// fallback need to see every server we use, not just the customs.
198pub 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
212/// Debounced republish: rapid edits coalesce; mid-window session swap aborts.
213/// Retries once on failure (5s backoff): a stale event on the network would
214/// otherwise let `fetch_and_merge_own_list` overwrite the local prefs on
215/// the next boot.
216pub 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
236// ============================================================================
237// BUD-03 fetch — pull our own kind 10063, merge into customs
238// ============================================================================
239
240/// Pure merge: append novel URLs from `incoming` (validated, normalized)
241/// into `customs` as enabled. Existing rows are never removed or reordered.
242pub 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
268/// Fetch our latest kind 10063 and reconcile: append unknown customs,
269/// follow the originating device's default enable/disable choices.
270/// `session` gates writes against a mid-fetch account swap.
271pub 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    // Event presence means the user has published preferences somewhere.
304    // A default's absence from the list = explicit disable on that
305    // device. (No event at all takes the early return above.)
306    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
346// ============================================================================
347// BUD-03 foreign lists — hash-swap fallback source
348// ============================================================================
349
350/// author hex → (servers, fetched_at). Public network data keyed by FOREIGN
351/// pubkey, so it's account-agnostic and survives swaps safely. Empty lists
352/// cache too — a sender with no 10063 mustn't be re-queried per broken blob.
353type 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
360/// Fetch another user's BUD-03 kind 10063 server list (cached, TTL 15 min).
361/// Feeds the download hash-swap: any of these servers may hold a blob whose
362/// embedded URLs have all died. Network failure returns empty WITHOUT
363/// caching, so a relay blip doesn't blind us for the TTL.
364pub 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                            // Foreign lists must be https — never fetch a
402                            // stranger's ciphertext over cleartext.
403                            .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        // Ok-but-empty is ambiguous while disconnected (Ok ≠ EOSE): a relay
415        // blip must not cache "author has no servers" for the whole TTL.
416        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        // Unbounded growth guard: an old entry per author is tiny, but a
428        // hostile sync could touch thousands — cap and clear wholesale.
429        if cache.len() > 512 {
430            cache.clear();
431        }
432        cache.insert(key, (servers.clone(), web_time::Instant::now()));
433    }
434    servers
435}
436
437/// Server list whose content-addresses may plausibly hold an author's blob:
438/// the local enabled list for own blobs (the local list IS the author's list,
439/// no network needed), the author's kind 10063 otherwise.
440pub 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}