1use 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
26pub const INFO_EXTENSION: &str = "magnitude-info";
29
30pub const PLAN_TITLE_MAX: usize = 24;
33pub const PLAN_DESCRIPTION_MAX: usize = 100;
34
35pub 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#[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 pub allowed: bool,
59 pub reasons: Vec<String>,
60 pub plan_title: Option<String>,
62 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 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 pub fetched_at: i64,
82 #[serde(skip)]
84 pub raw: Value,
85}
86
87impl ServerInfo {
88 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 pub fn is_personalised(&self) -> bool {
153 self.extensions.iter().any(|e| e == INFO_EXTENSION)
154 }
155
156 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 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
197struct Cached {
202 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
212pub 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
236pub 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 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
263pub 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
279async 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
292pub 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
315pub 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 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
350pub 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
375pub 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 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 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}