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().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/// 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}
63
64// ============================================================================
65// Storage
66// ============================================================================
67
68pub 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
100// ============================================================================
101// Resolver — enabled customs (first) + defaults (minus disabled)
102// ============================================================================
103
104pub 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
110/// Race-guard for in-flight probes: true if `url` is in the currently
111/// enabled list at this moment.
112pub 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    // Enabled custom servers are tried FIRST — a user who adds their own
125    // server wants it used (self-hosted / trusted). The capability ranker
126    // (`rank_servers`) then sinks any that fail validation (won't accept our
127    // encrypted type, transform the bytes, or refuse deletion) below the
128    // defaults, so a bad custom quietly falls through to ditto/etc.
129    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
145/// All rows for the frontend (defaults then customs, including disabled).
146pub 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
173/// Refresh the in-memory `BLOSSOM_SERVERS` cache. Call after edits + on login.
174pub 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
183// ============================================================================
184// BUD-03 publish (kind 10063)
185// ============================================================================
186
187/// Publish the resolved enabled list (defaults + customs, in trust order)
188/// as a BUD-03 kind 10063 replaceable event. Peers using our list as a
189/// fallback need to see every server we use, not just the customs.
190pub 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
204/// Debounced republish: rapid edits coalesce; mid-window session swap aborts.
205/// Retries once on failure (5s backoff): a stale event on the network would
206/// otherwise let `fetch_and_merge_own_list` overwrite the local prefs on
207/// the next boot.
208pub 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
231// ============================================================================
232// BUD-03 fetch — pull our own kind 10063, merge into customs
233// ============================================================================
234
235/// Pure merge: append novel URLs from `incoming` (validated, normalized)
236/// into `customs` as enabled. Existing rows are never removed or reordered.
237pub 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
263/// Fetch our latest kind 10063 and reconcile: append unknown customs,
264/// follow the originating device's default enable/disable choices.
265/// `session` gates writes against a mid-fetch account swap.
266pub 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    // Event presence means the user has published preferences somewhere.
301    // A default's absence from the list = explicit disable on that
302    // device. (No event at all takes the early return above.)
303    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
345// ============================================================================
346// BUD-03 foreign lists — hash-swap fallback source
347// ============================================================================
348
349/// author hex → (servers, fetched_at). Public network data keyed by FOREIGN
350/// pubkey, so it's account-agnostic and survives swaps safely. Empty lists
351/// cache too — a sender with no 10063 mustn't be re-queried per broken blob.
352static 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
358/// Fetch another user's BUD-03 kind 10063 server list (cached, TTL 15 min).
359/// Feeds the download hash-swap: any of these servers may hold a blob whose
360/// embedded URLs have all died. Network failure returns empty WITHOUT
361/// caching, so a relay blip doesn't blind us for the TTL.
362pub 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                            // Foreign lists must be https — never fetch a
400                            // stranger's ciphertext over cleartext.
401                            .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        // Ok-but-empty is ambiguous while disconnected (Ok ≠ EOSE): a relay
413        // blip must not cache "author has no servers" for the whole TTL.
414        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        // Unbounded growth guard: an old entry per author is tiny, but a
426        // hostile sync could touch thousands — cap and clear wholesale.
427        if cache.len() > 512 {
428            cache.clear();
429        }
430        cache.insert(key, (servers.clone(), std::time::Instant::now()));
431    }
432    servers
433}
434
435/// Server list whose content-addresses may plausibly hold an author's blob:
436/// the local enabled list for own blobs (the local list IS the author's list,
437/// no network needed), the author's kind 10063 otherwise.
438pub 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}