Skip to main content

vector_core/
blossom_info.rs

1//! The server information document (`GET /.well-known/blossom`).
2//!
3//! No BUD defines one. Magnitude publishes it (extension `magnitude-info`)
4//! and personalises it for a signed caller: the caller's tier, per-file
5//! limit, storage and daily allowances with what is used of each, and
6//! whether the server would take an upload at all. A server that has it is
7//! told what it can do before sending a byte; one that hasn't is learned the
8//! old way, by bouncing uploads (`blossom_capabilities`).
9//!
10//! Documents are cached per account. Ranking reads the cache and never waits
11//! on the network; login and the server dialog refresh it.
12
13use std::collections::HashMap;
14use std::sync::{Arc, Mutex};
15use std::time::Duration;
16use web_time::Instant;
17
18use nostr_sdk::prelude::{Event, FinalizeEventAsync, Timestamp, Url};
19use nostr_blossom::prelude::*;
20use reqwest::header::{ACCEPT, AUTHORIZATION};
21use serde::Serialize;
22use serde_json::Value;
23
24use crate::signer::VectorSigner;
25
26/// The extension name a server advertises when its document carries limits
27/// and a `caller` block.
28pub const INFO_EXTENSION: &str = "magnitude-info";
29
30/// A plan label is brief vanity at most. Anything past these is dropped
31/// whole rather than trimmed, so a server cannot half-render into the card.
32pub const PLAN_TITLE_MAX: usize = 24;
33pub const PLAN_DESCRIPTION_MAX: usize = 100;
34
35/// One line of plain text within `max` characters, or nothing.
36pub fn plan_text(raw: Option<&str>, max: usize) -> Option<String> {
37    let cleaned: String = raw?
38        .split_whitespace()
39        .collect::<Vec<_>>()
40        .join(" ");
41    let ok = !cleaned.is_empty()
42        && cleaned.chars().count() <= max
43        && !cleaned.chars().any(|c| c.is_control());
44    ok.then_some(cleaned)
45}
46
47/// What this server will do for the account that asked.
48#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
49pub struct CallerInfo {
50    pub tier: String,
51    pub max_blob: u64,
52    pub storage_used: u64,
53    pub storage_limit: u64,
54    pub blobs: u64,
55    pub daily_used: u64,
56    pub daily_limit: u64,
57    /// Whether an upload would be admitted at all; `reasons` says why not.
58    pub allowed: bool,
59    pub reasons: Vec<String>,
60    /// The operator's name for this tier, bounded by [`PLAN_TITLE_MAX`].
61    pub plan_title: Option<String>,
62    /// One line under it, bounded by [`PLAN_DESCRIPTION_MAX`].
63    pub plan_description: Option<String>,
64}
65
66#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
67pub struct ServerInfo {
68    pub name: Option<String>,
69    pub software: Option<String>,
70    pub version: Option<String>,
71    pub description: Option<String>,
72    pub buds: Vec<u8>,
73    pub extensions: Vec<String>,
74    /// The largest blob any account may upload.
75    pub max_blob: Option<u64>,
76    pub mime: Vec<String>,
77    pub capacity_used: Option<u64>,
78    pub capacity_limit: Option<u64>,
79    pub caller: Option<CallerInfo>,
80    /// Unix seconds.
81    pub fetched_at: i64,
82    /// The document as received, for persisting; not sent to the UI.
83    #[serde(skip)]
84    pub raw: Value,
85}
86
87impl ServerInfo {
88    /// Read a document. `None` when the JSON is not an information document
89    /// at all (a server that answers the path with something else).
90    pub fn parse(v: &Value) -> Option<Self> {
91        let obj = v.as_object()?;
92        let looks_like_one = obj.contains_key("buds")
93            || obj.contains_key("software")
94            || obj.contains_key("accepts");
95        if !looks_like_one {
96            return None;
97        }
98        let str_of = |key: &str| obj.get(key).and_then(|v| v.as_str()).map(|s| s.to_string());
99        let u64_at = |path: &[&str]| {
100            let mut cur = v;
101            for p in path {
102                cur = cur.get(p)?;
103            }
104            cur.as_u64()
105        };
106        let strings = |val: Option<&Value>| -> Vec<String> {
107            val.and_then(|v| v.as_array())
108                .map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect())
109                .unwrap_or_default()
110        };
111        let caller = obj.get("caller").and_then(|c| {
112            let allowed = c.get("allowed")?.as_bool()?;
113            let n = |key: &str| c.get(key).and_then(|v| v.as_u64()).unwrap_or(0);
114            let plan = c.get("plan");
115            let plan_str = |key: &str| plan.and_then(|p| p.get(key)).and_then(|v| v.as_str());
116            Some(CallerInfo {
117                tier: c.get("tier").and_then(|v| v.as_str()).unwrap_or("").to_string(),
118                max_blob: n("max_blob"),
119                storage_used: n("storage_used"),
120                storage_limit: n("storage_limit"),
121                blobs: n("blobs"),
122                daily_used: n("daily_used"),
123                daily_limit: n("daily_limit"),
124                allowed,
125                reasons: strings(c.get("reasons")),
126                plan_title: plan_text(plan_str("title"), PLAN_TITLE_MAX),
127                plan_description: plan_text(plan_str("description"), PLAN_DESCRIPTION_MAX),
128            })
129        });
130        Some(ServerInfo {
131            name: str_of("name"),
132            software: str_of("software"),
133            version: str_of("version"),
134            description: str_of("description"),
135            buds: obj
136                .get("buds")
137                .and_then(|b| b.as_array())
138                .map(|a| a.iter().filter_map(|x| x.as_u64().map(|n| n as u8)).collect())
139                .unwrap_or_default(),
140            extensions: strings(obj.get("extensions")),
141            max_blob: u64_at(&["accepts", "max_blob"]),
142            mime: strings(obj.get("accepts").and_then(|a| a.get("mime"))),
143            capacity_used: u64_at(&["capacity", "stored_bytes"]),
144            capacity_limit: u64_at(&["capacity", "limit_bytes"]),
145            caller,
146            fetched_at: now_secs(),
147            raw: v.clone(),
148        })
149    }
150
151    /// The document carries limits and a caller block, not just a BUD list.
152    pub fn is_personalised(&self) -> bool {
153        self.extensions.iter().any(|e| e == INFO_EXTENSION)
154    }
155
156    /// Whether this server would take a blob of `size` from us, as far as the
157    /// document says. `None` when it says nothing about size.
158    pub fn accepts_size(&self, size: u64) -> Option<bool> {
159        if let Some(c) = &self.caller {
160            return Some(c.allowed && size <= c.max_blob);
161        }
162        self.max_blob.map(|max| size <= max)
163    }
164
165    /// Why an upload of `size` would be refused, for a person. `None` when
166    /// the document does not say it would be.
167    pub fn refusal_reason(&self, host: &str, size: u64) -> Option<String> {
168        let bytes = crate::crypto::format_bytes;
169        match &self.caller {
170            Some(c) if !c.allowed => Some(match c.reasons.first() {
171                Some(why) => format!("{}: {}", host, why),
172                None => format!("{} won't take uploads from this account.", host),
173            }),
174            Some(c) if size > c.max_blob => Some(format!(
175                "{} allows files up to {} for your account.", host, bytes(c.max_blob),
176            )),
177            Some(_) => None,
178            None => match self.max_blob {
179                Some(max) if size > max => Some(format!("{} allows files up to {}.", host, bytes(max))),
180                _ => None,
181            },
182        }
183    }
184}
185
186fn now_secs() -> i64 {
187    web_time::SystemTime::now()
188        .duration_since(web_time::UNIX_EPOCH)
189        .map(|d| d.as_secs() as i64)
190        .unwrap_or(0)
191}
192
193fn norm_url(url: &str) -> String {
194    url.trim().trim_end_matches('/').to_lowercase()
195}
196
197// ============================================================================
198// Cache — one document per server, per account
199// ============================================================================
200
201struct Cached {
202    /// `None`: the server was asked and has no document.
203    info: Option<ServerInfo>,
204    at: Instant,
205}
206
207struct InfoCache;
208fn cache() -> Arc<Mutex<HashMap<String, Cached>>> {
209    crate::db::current_session().scoped::<InfoCache, _>()
210}
211
212/// The last document fetched for `server_url`, at any age. `None` when the
213/// server has none or was never asked.
214///
215/// Memory first; failing that, the copy persisted by the last fetch, so a
216/// fresh process paints the dialog's final shape before it has asked anyone.
217pub fn cached(server_url: &str) -> Option<ServerInfo> {
218    let key = norm_url(server_url);
219    if let Ok(c) = cache().lock() {
220        if let Some(entry) = c.get(&key) {
221            return entry.info.clone();
222        }
223    }
224    load_persisted(&key)
225}
226
227fn persist_key(key: &str) -> String {
228    format!("blossom_info:{}", key)
229}
230
231fn load_persisted(key: &str) -> Option<ServerInfo> {
232    let json = crate::db::get_sql_setting(persist_key(key)).ok().flatten()?;
233    serde_json::from_str::<Value>(&json).ok().and_then(|v| ServerInfo::parse(&v))
234}
235
236/// Whether `server_url` has been asked at all this session.
237pub fn is_known(server_url: &str) -> bool {
238    cache().lock().map(|c| c.contains_key(&norm_url(server_url))).unwrap_or(false)
239}
240
241fn age_of(server_url: &str) -> Option<Duration> {
242    cache().lock().ok()?.get(&norm_url(server_url)).map(|c| c.at.elapsed())
243}
244
245fn store(server_url: &str, info: Option<ServerInfo>) {
246    let key = norm_url(server_url);
247    if let Ok(mut c) = cache().lock() {
248        c.insert(key.clone(), Cached { info: info.clone(), at: Instant::now() });
249    }
250    // The raw document is what parse() reads back, so it is what is kept.
251    let persisted = info.as_ref().and_then(|i| serde_json::to_string(&i.raw).ok()).unwrap_or_default();
252    let _ = crate::db::set_sql_setting(persist_key(&key), persisted);
253}
254
255pub fn invalidate(server_url: &str) {
256    let key = norm_url(server_url);
257    if let Ok(mut c) = cache().lock() {
258        c.remove(&key);
259    }
260    let _ = crate::db::set_sql_setting(persist_key(&key), String::new());
261}
262
263/// An upload of `bytes` just landed on `server_url`: move the caller's usage
264/// so the dialog is right before the next fetch.
265pub fn note_upload(server_url: &str, bytes: u64) {
266    if let Ok(mut c) = cache().lock() {
267        if let Some(caller) = c
268            .get_mut(&norm_url(server_url))
269            .and_then(|e| e.info.as_mut())
270            .and_then(|i| i.caller.as_mut())
271        {
272            caller.storage_used = caller.storage_used.saturating_add(bytes);
273            caller.daily_used = caller.daily_used.saturating_add(bytes);
274            caller.blobs = caller.blobs.saturating_add(1);
275        }
276    }
277}
278
279// ============================================================================
280// Fetch
281// ============================================================================
282
283/// A signed `get` authorization scoped to the server, so the document comes
284/// back personalised.
285async fn build_info_auth_header<T>(signer: &T, server: &Url) -> Result<reqwest::header::HeaderValue, String>
286where
287    T: VectorSigner,
288{
289    build_get_auth_header(signer, server, "Blossom server information").await
290}
291
292/// A signed `Authorization` for a `GET` scoped to `server`, good for two
293/// minutes: what the information document is fetched with, and what a
294/// proxied fetch carries so the proxy charges the account, not the address.
295pub async fn build_get_auth_header<T>(signer: &T, server: &Url, reason: &str) -> Result<reqwest::header::HeaderValue, String>
296where
297    T: VectorSigner,
298{
299    let expiration = Timestamp::now() + Duration::from_secs(120);
300    let auth = BlossomAuthorization::new(
301        reason.to_string(),
302        expiration,
303        BlossomAuthorizationVerb::Get,
304        BlossomAuthorizationScope::ServerUrl(server.clone()),
305    );
306    let auth_event: Event = auth
307        .finalize_async(signer)
308        .await
309        .map_err(|e| format!("Failed to sign auth event: {}", e))?;
310    let encoded = base64_simd::STANDARD.encode_to_string(auth_event.as_json());
311    reqwest::header::HeaderValue::try_from(format!("Nostr {}", encoded))
312        .map_err(|e| format!("Failed to create header value: {}", e))
313}
314
315/// Ask `server_url` for its document. `Ok(None)` is a server without one.
316pub async fn fetch<T>(signer: &T, server_url: &str) -> Result<Option<ServerInfo>, String>
317where
318    T: VectorSigner,
319{
320    let base = Url::parse(&format!("{}/", server_url.trim_end_matches('/')))
321        .map_err(|e| format!("Invalid server URL: {}", e))?;
322    let doc_url = base
323        .join(".well-known/blossom")
324        .map_err(|e| format!("Invalid server URL: {}", e))?;
325    let auth = build_info_auth_header(signer, &base).await?;
326    let client = crate::net::build_http_client(Duration::from_secs(8))?;
327    let asked_at = Instant::now();
328    let resp = match client
329        .get(doc_url)
330        .header(ACCEPT, "application/json")
331        .header(AUTHORIZATION, auth)
332        .send()
333        .await
334    {
335        Ok(r) => r,
336        Err(e) => {
337            crate::blossom_stats::record_failure(server_url, crate::blossom_stats::FAIL_OFFLINE);
338            return Err(format!("Info request failed: {}", e));
339        }
340    };
341    // Any answer, document or not, is the server being there.
342    crate::blossom_stats::record_latency(server_url, asked_at.elapsed().as_secs_f64() * 1000.0);
343    if !resp.status().is_success() {
344        return Ok(None);
345    }
346    let body = resp.text().await.map_err(|e| format!("Info body unreadable: {}", e))?;
347    Ok(serde_json::from_str::<Value>(&body).ok().and_then(|v| ServerInfo::parse(&v)))
348}
349
350/// The document for `server_url`, fetched unless a copy younger than
351/// `max_age` is held.
352pub async fn refresh<T>(signer: &T, server_url: &str, max_age: Duration) -> Result<Option<ServerInfo>, String>
353where
354    T: VectorSigner,
355{
356    if age_of(server_url).is_some_and(|age| age < max_age) {
357        return Ok(cached(server_url));
358    }
359    let info = fetch(signer, server_url).await?;
360    match &info {
361        Some(i) => crate::log_info!(
362            "[Blossom Info] {} → {} {} (personalised: {}, tier: {})",
363            server_url,
364            i.software.as_deref().unwrap_or("?"),
365            i.version.as_deref().unwrap_or(""),
366            i.is_personalised(),
367            i.caller.as_ref().map(|c| c.tier.as_str()).unwrap_or("-"),
368        ),
369        None => crate::log_debug!("[Blossom Info] {} has no information document", server_url),
370    }
371    store(server_url, info.clone());
372    Ok(info)
373}
374
375/// Refresh every server, concurrently. Returns how many have a document.
376pub async fn refresh_all<T>(signer: T, server_urls: Vec<String>, max_age: Duration) -> usize
377where
378    T: VectorSigner + Clone,
379{
380    let futures = server_urls.into_iter().map(|server| {
381        let signer = signer.clone();
382        async move {
383            match refresh(&signer, &server, max_age).await {
384                Ok(Some(_)) => true,
385                Ok(None) => false,
386                Err(e) => {
387                    crate::log_debug!("[Blossom Info] {} not fetched: {}", server, e);
388                    false
389                }
390            }
391        }
392    });
393    futures_util::future::join_all(futures)
394        .await
395        .into_iter()
396        .filter(|has| *has)
397        .count()
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use serde_json::json;
404
405    fn magnitude_doc() -> Value {
406        json!({
407            "name": "Magnitude", "software": "magnitude", "version": "0.1.0",
408            "buds": [1, 2, 4, 6, 8, 11, 12],
409            "extensions": ["magnitude-info", "magnitude-errors"],
410            "accepts": { "max_blob": 1073741824, "mime": ["*/*"] },
411            "capacity": { "stored_bytes": 552631601, "limit_bytes": 107374182400u64 },
412            "description": "Media server for Vector",
413            "caller": {
414                "pubkey": "ab", "tier": "badged", "max_blob": 1073741824,
415                "storage_used": 1000, "storage_limit": 26843545600u64, "blobs": 3,
416                "daily_used": 500, "daily_limit": 10737418240u64, "rps": 10,
417                "allowed": true, "reasons": []
418            }
419        })
420    }
421
422    #[test]
423    fn a_magnitude_document_is_read_in_full() {
424        let info = ServerInfo::parse(&magnitude_doc()).unwrap();
425        assert_eq!(info.name.as_deref(), Some("Magnitude"));
426        assert_eq!(info.buds, vec![1, 2, 4, 6, 8, 11, 12]);
427        assert!(info.is_personalised());
428        assert_eq!(info.max_blob, Some(1073741824));
429        assert_eq!(info.capacity_limit, Some(107374182400));
430        let c = info.caller.unwrap();
431        assert_eq!(c.tier, "badged");
432        assert_eq!(c.storage_limit, 26843545600);
433        assert!(c.allowed);
434    }
435
436    #[test]
437    fn the_caller_block_decides_what_is_accepted() {
438        let info = ServerInfo::parse(&magnitude_doc()).unwrap();
439        assert_eq!(info.accepts_size(1 << 20), Some(true));
440        assert_eq!(info.accepts_size(2 << 30), Some(false));
441        assert_eq!(info.refusal_reason("m.example", 1 << 20), None);
442        assert_eq!(
443            info.refusal_reason("m.example", 2 << 30),
444            Some("m.example allows files up to 1.0 GB for your account.".to_string()),
445        );
446    }
447
448    #[test]
449    fn a_closed_gate_refuses_every_size_and_says_why() {
450        let mut doc = magnitude_doc();
451        doc["caller"] = json!({
452            "tier": "default", "max_blob": 0, "allowed": false,
453            "reasons": ["Uploads require a profile published from Vector"]
454        });
455        let info = ServerInfo::parse(&doc).unwrap();
456        assert_eq!(info.accepts_size(1), Some(false));
457        assert_eq!(
458            info.refusal_reason("m.example", 1),
459            Some("m.example: Uploads require a profile published from Vector".to_string()),
460        );
461    }
462
463    #[test]
464    fn a_plan_label_is_taken_within_bounds_and_dropped_whole_beyond_them() {
465        let mut doc = magnitude_doc();
466        doc["caller"]["plan"] = json!({ "title": "  Premium ", "description": "Earned by\nearly  supporters." });
467        let c = ServerInfo::parse(&doc).unwrap().caller.unwrap();
468        assert_eq!(c.plan_title.as_deref(), Some("Premium"));
469        assert_eq!(c.plan_description.as_deref(), Some("Earned by early supporters."));
470
471        doc["caller"]["plan"] = json!({ "title": "x".repeat(25), "description": "y".repeat(101) });
472        let c = ServerInfo::parse(&doc).unwrap().caller.unwrap();
473        assert_eq!(c.plan_title, None, "not truncated: dropped");
474        assert_eq!(c.plan_description, None);
475
476        doc["caller"]["plan"] = json!({ "title": "Bad\u{7}", "description": "" });
477        let c = ServerInfo::parse(&doc).unwrap().caller.unwrap();
478        assert_eq!(c.plan_title, None);
479        assert_eq!(c.plan_description, None);
480
481        doc["caller"].as_object_mut().unwrap().remove("plan");
482        let c = ServerInfo::parse(&doc).unwrap().caller.unwrap();
483        assert_eq!(c.plan_title, None);
484    }
485
486    #[test]
487    fn a_bad_token_leaves_a_caller_that_is_not_allowed() {
488        // The shape Magnitude sends when the Authorization header did not verify.
489        let mut doc = magnitude_doc();
490        doc["caller"] = json!({ "allowed": false, "reasons": ["authorization expired"] });
491        let info = ServerInfo::parse(&doc).unwrap();
492        let c = info.caller.unwrap();
493        assert!(!c.allowed);
494        assert_eq!(c.tier, "");
495        assert_eq!(c.reasons, vec!["authorization expired"]);
496    }
497
498    #[test]
499    fn a_document_without_a_caller_falls_back_to_the_server_wide_limit() {
500        let mut doc = magnitude_doc();
501        doc.as_object_mut().unwrap().remove("caller");
502        let info = ServerInfo::parse(&doc).unwrap();
503        assert_eq!(info.accepts_size(1 << 20), Some(true));
504        assert_eq!(info.accepts_size(2 << 30), Some(false));
505        assert_eq!(info.refusal_reason("m.example", 2 << 30), Some("m.example allows files up to 1.0 GB.".to_string()));
506    }
507
508    #[test]
509    fn a_bare_bud_list_says_nothing_about_size() {
510        let info = ServerInfo::parse(&json!({ "buds": [1, 2] })).unwrap();
511        assert!(!info.is_personalised());
512        assert_eq!(info.accepts_size(1), None);
513        assert_eq!(info.refusal_reason("x", 1), None);
514    }
515
516    #[test]
517    fn something_that_is_not_a_document_is_not_one() {
518        assert!(ServerInfo::parse(&json!({ "status": "ok" })).is_none());
519        assert!(ServerInfo::parse(&json!("<html>")).is_none());
520        assert!(ServerInfo::parse(&json!([1, 2, 3])).is_none());
521    }
522
523    #[test]
524    fn a_noted_upload_moves_the_usage_counters() {
525        // The cache lives on the session, which a concurrent database test can replace.
526        let _serialized = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
527        let info = ServerInfo::parse(&magnitude_doc()).unwrap();
528        store("https://m.example/", Some(info));
529        note_upload("https://M.example", 250);
530        let c = cached("https://m.example").unwrap().caller.unwrap();
531        assert_eq!(c.storage_used, 1250);
532        assert_eq!(c.daily_used, 750);
533        assert_eq!(c.blobs, 4);
534        invalidate("https://m.example");
535        assert!(cached("https://m.example").is_none());
536    }
537}