1use crate::signer::VectorSigner;
2use crate::blossom_error::{host_of, summarise_failures, Refusal, UploadFailure};
3use nostr_sdk::prelude::{Event, FinalizeEventAsync, Timestamp, Url};
4use bitcoin_hashes::sha256::Hash as Sha256Hash;
5use nostr_blossom::prelude::*;
6use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE};
7use reqwest::{Body, StatusCode};
8use std::str::FromStr;
9use std::sync::{Arc, Mutex};
10use std::sync::atomic::{AtomicBool, Ordering};
11use futures_util::Stream;
12use std::pin::Pin;
13use std::task::{Context, Poll};
14
15pub type ProgressCallback = std::sync::Arc<dyn Fn(Option<u8>, Option<u64>) -> Result<(), String> + Send + Sync>;
17
18#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
21struct ProgressTrackingStream {
22 data: bytes::Bytes,
23 position: usize,
24 bytes_sent: Arc<Mutex<u64>>,
25}
26
27#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
29struct SharedBuffer(Arc<Vec<u8>>);
30
31impl AsRef<[u8]> for SharedBuffer {
32 fn as_ref(&self) -> &[u8] {
33 &self.0
34 }
35}
36
37#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
38impl ProgressTrackingStream {
39 const SLICE: usize = 16 * 1024;
42
43 fn new(data: Arc<Vec<u8>>, bytes_sent: Arc<Mutex<u64>>) -> Self {
44 Self { data: bytes::Bytes::from_owner(SharedBuffer(data)), position: 0, bytes_sent }
45 }
46}
47
48impl Stream for ProgressTrackingStream {
49 type Item = Result<bytes::Bytes, std::io::Error>;
50
51 fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
52 if self.position >= self.data.len() {
53 return Poll::Ready(None);
54 }
55 let end = (self.position + Self::SLICE).min(self.data.len());
56 let slice = self.data.slice(self.position..end);
57 self.position = end;
58 *self.bytes_sent.lock().unwrap() += slice.len() as u64;
59 Poll::Ready(Some(Ok(slice)))
60 }
61}
62
63#[derive(Clone)]
66pub enum UploadBody {
67 Memory(Arc<Vec<u8>>),
68 File { path: std::path::PathBuf, len: u64, sha256: Sha256Hash },
71}
72
73impl UploadBody {
74 pub fn file(path: std::path::PathBuf, len: u64, sha256_hex: &str) -> Result<Self, String> {
76 let sha256 = Sha256Hash::from_str(sha256_hex).map_err(|e| format!("bad blob hash: {e}"))?;
77 Ok(Self::File { path, len, sha256 })
78 }
79
80 pub fn len(&self) -> u64 {
81 match self {
82 Self::Memory(data) => data.len() as u64,
83 Self::File { len, .. } => *len,
84 }
85 }
86
87 pub fn is_empty(&self) -> bool {
88 self.len() == 0
89 }
90
91 fn sha256(&self) -> Sha256Hash {
92 match self {
93 Self::Memory(data) => Sha256Hash::hash(data),
94 Self::File { sha256, .. } => *sha256,
95 }
96 }
97
98 #[cfg(not(target_arch = "wasm32"))]
100 fn stream(&self, bytes_sent: Arc<Mutex<u64>>) -> Pin<Box<dyn Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>> {
101 match self {
102 Self::Memory(data) => Box::pin(ProgressTrackingStream::new(Arc::clone(data), bytes_sent)),
103 Self::File { path, len, .. } => {
104 const FILE_SLICE: u64 = 1024 * 1024;
107 let start = (None::<tokio::fs::File>, path.clone(), *len);
108 Box::pin(futures_util::stream::unfold(start, move |(file, path, remaining)| {
109 let bytes_sent = Arc::clone(&bytes_sent);
110 async move {
111 use tokio::io::AsyncReadExt;
112 if remaining == 0 {
113 return None;
114 }
115 let mut file = match file {
116 Some(f) => f,
117 None => match tokio::fs::File::open(&path).await {
118 Ok(f) => f,
119 Err(e) => return Some((Err(e), (None, path, 0))),
120 },
121 };
122 let mut slice = vec![0u8; remaining.min(FILE_SLICE) as usize];
123 match file.read_exact(&mut slice).await {
124 Ok(_) => {
125 *bytes_sent.lock().unwrap() += slice.len() as u64;
126 let left = remaining - slice.len() as u64;
127 Some((Ok(bytes::Bytes::from(slice)), (Some(file), path, left)))
128 }
129 Err(e) => Some((Err(e), (None, path, 0))),
130 }
131 }
132 }))
133 }
134 }
135 }
136}
137
138const RESPONSE_WAIT: std::time::Duration = std::time::Duration::from_secs(300);
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151enum Stall {
152 NoProgress { idle: std::time::Duration },
154 NoResponse { waited: std::time::Duration },
156}
157
158struct StallWatch {
161 total: u64,
162 last_bytes: u64,
163 last_progress: crate::rt::time::Instant,
164 stall_limit: std::time::Duration,
165 response_limit: std::time::Duration,
166}
167
168impl StallWatch {
169 fn new(total: u64, stall_limit: std::time::Duration, response_limit: std::time::Duration) -> Self {
170 Self {
171 total,
172 last_bytes: 0,
173 last_progress: crate::rt::time::Instant::now(),
174 stall_limit,
175 response_limit,
176 }
177 }
178
179 fn observe(&mut self, bytes_sent: u64) -> Option<Stall> {
181 let now = crate::rt::time::Instant::now();
182 if bytes_sent > self.last_bytes {
183 self.last_bytes = bytes_sent;
184 self.last_progress = now;
185 return None;
186 }
187 let idle = now.duration_since(self.last_progress);
188 if bytes_sent >= self.total {
189 (idle > self.response_limit).then_some(Stall::NoResponse { waited: idle })
190 } else {
191 (idle > self.stall_limit).then_some(Stall::NoProgress { idle })
192 }
193 }
194}
195
196fn callback_failure(e: String) -> UploadFailure {
200 if e == "Upload cancelled" {
201 UploadFailure::Cancelled
202 } else {
203 UploadFailure::Other(e)
204 }
205}
206
207async fn send_upload(
214 request: reqwest::RequestBuilder,
215 server_url: &Url,
216 body: &UploadBody,
217 stall_limit: std::time::Duration,
218 cancel_flag: Option<&Arc<AtomicBool>>,
219 progress_callback: Option<&ProgressCallback>,
220) -> Result<reqwest::Response, UploadFailure> {
221 let total_size = body.len();
222 let bytes_sent = Arc::new(Mutex::new(0u64));
223 #[cfg(not(target_arch = "wasm32"))]
224 let request_body = Body::wrap_stream(body.stream(Arc::clone(&bytes_sent)));
225 #[cfg(target_arch = "wasm32")]
227 let request_body = match body {
228 UploadBody::Memory(data) => {
229 *bytes_sent.lock().unwrap() = data.len() as u64;
230 Body::from(data.as_ref().clone())
231 }
232 UploadBody::File { .. } => return Err(UploadFailure::Transport("no filesystem on the web".into())),
233 };
234 let mut request_future = Box::pin(request.body(request_body).send());
235
236 #[cfg(target_arch = "wasm32")]
239 let response_wait = RESPONSE_WAIT + std::time::Duration::from_secs(total_size / (64 * 1024));
240 #[cfg(not(target_arch = "wasm32"))]
241 let response_wait = RESPONSE_WAIT;
242 let mut watch = StallWatch::new(total_size, stall_limit, response_wait);
243 let mut last_percentage = 0;
244 let mut poll_interval = crate::rt::time::interval(crate::rt::time::Duration::from_millis(100));
245
246 let response = loop {
247 tokio::select! {
248 response = &mut request_future => {
249 break response.map_err(|e| UploadFailure::Transport(format!("Upload request failed: {}", e)))?;
250 },
251 _ = poll_interval.tick() => {
252 if let Some(flag) = cancel_flag {
253 if flag.load(Ordering::Relaxed) {
254 return Err(UploadFailure::Cancelled);
255 }
256 }
257
258 let current_bytes = *bytes_sent.lock().unwrap();
259 match watch.observe(current_bytes) {
260 Some(Stall::NoProgress { idle }) => {
261 return Err(UploadFailure::Transport(format!(
262 "Upload stalled: {} accepted nothing for {}s ({} of {} bytes sent)",
263 server_url, idle.as_secs(), current_bytes, total_size,
264 )));
265 }
266 Some(Stall::NoResponse { waited }) => {
267 return Err(UploadFailure::Transport(format!(
268 "Upload stalled: {} received all {} bytes but gave no answer in {}s",
269 server_url, total_size, waited.as_secs(),
270 )));
271 }
272 None => {}
273 }
274
275 if let Some(cb) = progress_callback {
276 let percentage = if total_size > 0 {
277 ((current_bytes as f64 / total_size as f64) * 100.0) as u8
278 } else {
279 0
280 };
281 if percentage != last_percentage {
282 cb(Some(percentage), Some(current_bytes)).map_err(callback_failure)?;
283 last_percentage = percentage;
284 }
285 }
286 }
287 }
288 };
289
290 if let Some(cb) = progress_callback {
291 let final_bytes = *bytes_sent.lock().unwrap();
292 if final_bytes == total_size && last_percentage < 100 {
293 cb(Some(100), Some(total_size)).map_err(callback_failure)?;
294 }
295 }
296 Ok(response)
297}
298
299async fn build_auth_header<T>(
301 signer: &T,
302 hash: Sha256Hash,
303) -> Result<HeaderValue, String>
304where
305 T: VectorSigner,
306{
307 let expiration = Timestamp::now() + std::time::Duration::from_secs(300);
309 let auth = BlossomAuthorization::new(
310 "Blossom upload authorization".to_string(),
311 expiration,
312 BlossomAuthorizationVerb::Upload,
313 BlossomAuthorizationScope::BlobSha256Hashes(vec![hash]),
314 );
315
316 let auth_event: Event = auth
318 .finalize_async(signer)
319 .await
320 .map_err(|e| format!("Failed to sign auth event: {}", e))?;
321
322 let encoded_auth = base64_simd::STANDARD.encode_to_string(auth_event.as_json());
324 let value = format!("Nostr {}", encoded_auth);
325
326 HeaderValue::try_from(value)
327 .map_err(|e| format!("Failed to create header value: {}", e))
328}
329
330#[allow(clippy::too_many_arguments)]
333pub async fn upload_blob_with_progress<T>(
334 signer: T,
335 server_url: &Url,
336 file_data: Arc<Vec<u8>>,
337 mime_type: Option<&str>,
338 progress_callback: ProgressCallback,
339 retry_count: Option<u32>,
340 retry_spacing: Option<std::time::Duration>,
341 cancel_flag: Option<Arc<AtomicBool>>,
342) -> Result<String, UploadFailure>
343where
344 T: VectorSigner + Clone,
345{
346 upload_with_retries(signer, server_url, UploadBody::Memory(file_data), mime_type, progress_callback, retry_count, retry_spacing, cancel_flag, true).await
347}
348
349#[allow(clippy::too_many_arguments)]
352async fn upload_with_retries<T>(
353 signer: T,
354 server_url: &Url,
355 body: UploadBody,
356 mime_type: Option<&str>,
357 progress_callback: ProgressCallback,
358 retry_count: Option<u32>,
359 retry_spacing: Option<std::time::Duration>,
360 cancel_flag: Option<Arc<AtomicBool>>,
361 preflight_first: bool,
362) -> Result<String, UploadFailure>
363where
364 T: VectorSigner + Clone,
365{
366 let retry_count = retry_count.unwrap_or(0);
367 let retry_spacing = retry_spacing.unwrap_or(std::time::Duration::from_secs(1));
368
369 let mut last_error = None;
370 let mut next_delay = retry_spacing;
371
372 for attempt in 0..=retry_count {
373 if attempt > 0 {
374 crate::rt::time::sleep(next_delay).await;
375 next_delay = retry_spacing;
376 }
377
378 if let Some(ref flag) = cancel_flag {
379 if flag.load(Ordering::Relaxed) {
380 return Err(UploadFailure::Cancelled);
381 }
382 }
383
384 match upload_attempt(
385 signer.clone(),
386 server_url,
387 body.clone(),
388 mime_type,
389 &progress_callback,
390 cancel_flag.clone(),
391 preflight_first,
392 ).await {
393 Ok(url) => return Ok(url),
394 Err(UploadFailure::Cancelled) => return Err(UploadFailure::Cancelled),
395 Err(e) => {
396 crate::log_warn!(
397 "[Blossom] Attempt {}/{} to {} failed: {}",
398 attempt + 1, retry_count + 1, server_url, e,
399 );
400 let again = match &e {
401 UploadFailure::Refused(r) => r.worth_retrying_here(),
405 UploadFailure::Transport(_) => {
409 !(e.is_mid_stream_drop() && body.len() > 8 * 1024 * 1024)
410 }
411 UploadFailure::Integrity(_) | UploadFailure::Other(_) | UploadFailure::Cancelled => false,
412 };
413 if !again {
414 if let Some(r) = e.refusal() {
415 if crate::blossom_error::is_gateway_status(r.status) && r.code().is_none() {
416 crate::log_warn!(
417 "[Blossom] {} origin unreachable (status {}) on {} bytes; routing to the next server",
418 server_url, r.status, body.len(),
419 );
420 }
421 } else if e.is_mid_stream_drop() {
422 crate::log_warn!(
423 "[Blossom] {} dropped the connection mid-upload of {} bytes, treating as permanent",
424 server_url, body.len(),
425 );
426 }
427 return Err(e);
428 }
429 if let Some(wait) = e.refusal().and_then(|r| r.retry_after()) {
430 next_delay = next_delay.max(wait);
431 }
432 last_error = Some(e);
433 }
434 }
435 }
436
437 Err(last_error.unwrap_or_else(|| UploadFailure::Other("No upload attempts were made".to_string())))
439}
440
441enum Preflight {
443 Proceed,
445 AlreadyStored(String),
448}
449
450async fn preflight(
464 client: &reqwest::Client,
465 upload_url: &Url,
466 server_url: &Url,
467 auth_header: &HeaderValue,
468 hash: Sha256Hash,
469 total_size: u64,
470 mime_type: Option<&str>,
471) -> Result<Preflight, UploadFailure> {
472 let mut head_headers = HeaderMap::new();
473 head_headers.insert(AUTHORIZATION, auth_header.clone());
474 head_headers.insert(
475 "X-Content-Length",
476 HeaderValue::from_str(&total_size.to_string())
477 .map_err(|e| UploadFailure::Other(format!("Invalid X-Content-Length: {}", e)))?,
478 );
479 head_headers.insert(
482 "X-SHA-256",
483 HeaderValue::from_str(&crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()))
484 .map_err(|e| UploadFailure::Other(format!("Invalid X-SHA-256: {}", e)))?,
485 );
486 if let Some(ct) = mime_type {
487 head_headers.insert(
488 "X-Content-Type",
489 HeaderValue::from_str(ct).map_err(|e| UploadFailure::Other(format!("Invalid X-Content-Type: {}", e)))?,
490 );
491 }
492 let asked_at = web_time::Instant::now();
493 let resp = match crate::rt::time::timeout(
494 std::time::Duration::from_secs(5),
495 client.head(upload_url.clone()).headers(head_headers).send(),
496 ).await {
497 Ok(Ok(resp)) => {
498 crate::blossom_stats::record_latency(server_url.as_str(), asked_at.elapsed().as_secs_f64() * 1000.0);
499 resp
500 }
501 Ok(Err(e)) => {
502 crate::log_debug!("[Blossom Preflight] {} HEAD failed: {}, falling through to PUT", server_url, e);
503 return Ok(Preflight::Proceed);
504 }
505 Err(_) => {
506 crate::log_debug!("[Blossom Preflight] {} HEAD timed out (5s), falling through to PUT", server_url);
507 return Ok(Preflight::Proceed);
508 }
509 };
510
511 let status = resp.status();
512 if status.is_success() {
513 let already = resp.headers().get("x-already-stored")
514 .and_then(|v| v.to_str().ok())
515 .is_some_and(|v| v.eq_ignore_ascii_case("true"));
516 let url = resp.headers().get("x-blob-url")
517 .and_then(|v| v.to_str().ok())
518 .map(|s| s.to_string());
519 if let (true, Some(url)) = (already, url) {
520 match parse_blob_url(&url) {
523 Ok((_, stored)) if stored == hash => {
524 crate::log_info!(
525 "[Blossom Preflight] {} already holds {} ({} bytes); nothing to send",
526 server_url, hash, total_size,
527 );
528 return Ok(Preflight::AlreadyStored(url));
529 }
530 _ => crate::log_warn!(
531 "[Blossom Preflight] {} claims to hold the blob at {} which is not {}; uploading anyway",
532 server_url, url, hash,
533 ),
534 }
535 }
536 crate::log_debug!(
537 "[Blossom Preflight] {} → {} ({} bytes); proceeding to PUT",
538 server_url, status, total_size,
539 );
540 return Ok(Preflight::Proceed);
541 }
542
543 let headers = resp.headers().clone();
547 let body = resp.text().await.unwrap_or_default();
548 let refusal = Refusal::from_response(status.as_u16(), &headers, &body);
549 let is_413 = status == StatusCode::PAYLOAD_TOO_LARGE;
550 let is_415 = status == StatusCode::UNSUPPORTED_MEDIA_TYPE;
551 let final_here = refusal.code().is_some()
552 || is_413
553 || is_415
554 || (status.is_client_error() && refusal.is_mime());
555 if final_here {
556 crate::log_warn!(
557 "[Blossom Preflight] {} REJECTED {} ({} bytes, {}): {}",
558 server_url, status, total_size,
559 mime_type.unwrap_or("(no mime)"), refusal.message,
560 );
561 return Err(UploadFailure::Refused(refusal));
562 }
563 crate::log_debug!(
564 "[Blossom Preflight] {} → {} ({} bytes); proceeding to PUT",
565 server_url, status, total_size,
566 );
567 Ok(Preflight::Proceed)
568}
569
570async fn refusal_from(response: reqwest::Response) -> Refusal {
573 let status = response.status().as_u16();
574 let headers = response.headers().clone();
575 let body = response.text().await.unwrap_or_default();
576 Refusal::from_response(status, &headers, &body)
577}
578
579async fn upload_attempt<T>(
581 signer: T,
582 server_url: &Url,
583 body: UploadBody,
584 mime_type: Option<&str>,
585 progress_callback: &ProgressCallback,
586 cancel_flag: Option<Arc<AtomicBool>>,
587 preflight_first: bool,
588) -> Result<String, UploadFailure>
589where
590 T: VectorSigner,
591{
592 let upload_url = server_url.join("upload")
593 .map_err(|e| UploadFailure::Other(format!("Invalid server URL: {}", e)))?;
594
595 let total_size = body.len();
596 let hash = body.sha256();
597
598 progress_callback(Some(0), Some(0)).map_err(callback_failure)?;
599
600 let auth_header = build_auth_header(&signer, hash).await.map_err(UploadFailure::Other)?;
602
603 let client = crate::net::build_http_client_with_options(None, None, false)
606 .map_err(UploadFailure::Other)?;
607
608 if preflight_first {
609 match preflight(&client, &upload_url, server_url, &auth_header, hash, total_size, mime_type).await? {
610 Preflight::Proceed => {}
611 Preflight::AlreadyStored(url) => {
612 progress_callback(Some(100), Some(total_size)).map_err(callback_failure)?;
613 return Ok(url);
614 }
615 }
616 }
617
618 let mut headers = HeaderMap::new();
619 headers.insert(AUTHORIZATION, auth_header);
620 if let Some(ct) = mime_type {
621 headers.insert(
622 CONTENT_TYPE,
623 HeaderValue::from_str(ct).map_err(|e| UploadFailure::Other(format!("Invalid content type: {}", e)))?
624 );
625 }
626 headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size));
630
631 let started = web_time::Instant::now();
632 let response = send_upload(
633 client.put(upload_url.clone()).headers(headers),
634 server_url,
635 &body,
636 crate::net::TRANSFER_STALL,
637 cancel_flag.as_ref(),
638 Some(progress_callback),
639 ).await?;
640
641 let status = response.status();
643 if status.is_success() {
644 crate::blossom_stats::record_upload(server_url.as_str(), total_size, started.elapsed().as_secs_f64() * 1000.0);
645 let descriptor: BlobDescriptor = response.json().await
646 .map_err(|e| UploadFailure::Other(format!("Failed to parse response: {}", e)))?;
647 if descriptor.sha256 != hash {
653 return Err(UploadFailure::Integrity(format!(
654 "[INTEGRITY] {} transformed the upload (returned {}, expected {})",
655 server_url, descriptor.sha256, hash,
656 )));
657 }
658 Ok(descriptor.url.to_string())
659 } else {
660 let refusal = refusal_from(response).await;
661 crate::log_net_fail!(
662 "[Blossom] upload rejected: HTTP {}{} — {}",
663 status,
664 refusal.code().map(|c| format!(" [{}]", c)).unwrap_or_default(),
665 refusal.message,
666 );
667 Err(UploadFailure::Refused(refusal))
668 }
669}
670
671pub async fn upload_blob<T>(
678 signer: T,
679 server_url: &Url,
680 file_data: Arc<Vec<u8>>,
681 mime_type: Option<&str>,
682 stall_timeout: Option<std::time::Duration>,
683) -> Result<String, UploadFailure>
684where
685 T: VectorSigner,
686{
687 let upload_url = server_url.join("upload")
688 .map_err(|e| UploadFailure::Other(format!("Invalid server URL: {}", e)))?;
689
690 let hash = Sha256Hash::hash(&file_data);
691 let total_size = file_data.len() as u64;
692
693 let auth_header = build_auth_header(&signer, hash).await.map_err(UploadFailure::Other)?;
694
695 let client = crate::net::build_http_client_with_options(None, None, false)
697 .map_err(UploadFailure::Other)?;
698
699 if let Preflight::AlreadyStored(url) =
700 preflight(&client, &upload_url, server_url, &auth_header, hash, total_size, mime_type).await?
701 {
702 return Ok(url);
703 }
704
705 let mut headers = HeaderMap::new();
706 headers.insert(AUTHORIZATION, auth_header);
707 if let Some(ct) = mime_type {
708 headers.insert(
709 CONTENT_TYPE,
710 HeaderValue::from_str(ct).map_err(|e| UploadFailure::Other(format!("Invalid content type: {}", e)))?
711 );
712 }
713 headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size));
714
715 let started = web_time::Instant::now();
716 let response = send_upload(
717 client.put(upload_url).headers(headers),
718 server_url,
719 &UploadBody::Memory(file_data),
720 stall_timeout.unwrap_or(crate::net::TRANSFER_STALL),
721 None,
722 None,
723 ).await?;
724
725 let status = response.status();
727 if status.is_success() {
728 crate::blossom_stats::record_upload(server_url.as_str(), total_size, started.elapsed().as_secs_f64() * 1000.0);
729 let descriptor: BlobDescriptor = response.json().await
730 .map_err(|e| UploadFailure::Other(format!("Failed to parse response: {}", e)))?;
731 if descriptor.sha256 != hash {
734 return Err(UploadFailure::Integrity(format!(
735 "[INTEGRITY] {} transformed the upload (returned {}, expected {})",
736 server_url, descriptor.sha256, hash,
737 )));
738 }
739 Ok(descriptor.url.to_string())
740 } else {
741 Err(UploadFailure::Refused(refusal_from(response).await))
742 }
743}
744
745pub async fn blob_is_served(url: &str, timeout: std::time::Duration) -> bool {
750 matches!(crate::net::remote_status(url, timeout).await, Some(200..=299))
751}
752
753async fn uploaded_blob_serves(url: &str) -> bool {
758 for attempt in 0..2 {
759 if attempt > 0 {
760 crate::rt::time::sleep(std::time::Duration::from_secs(1)).await;
761 }
762 match uploaded_blob_status(url).await {
763 Some(404) | Some(410) => continue,
764 Some(_) => return true,
765 None => {
766 crate::log_net_info!("[Blossom] {} unreachable for post-upload verify — assuming stored", url);
767 return true;
768 }
769 }
770 }
771 crate::log_net_fail!("[Blossom] {} ACKed the upload but serves 404/410 — treating as dropped", url);
772 false
773}
774
775async fn uploaded_blob_status(url: &str) -> Option<u16> {
778 let timeout = std::time::Duration::from_secs(10);
779 if crate::net::egress(url).await.proxied() {
780 return crate::net::remote_status(url, timeout).await;
781 }
782 let client = crate::net::build_http_client_with_options(None, None, false).ok()?;
783 let status = crate::rt::time::timeout(timeout, client.head(url).send()).await.ok()?.ok()?.status();
784 if status == StatusCode::METHOD_NOT_ALLOWED || status == StatusCode::NOT_IMPLEMENTED {
785 return crate::net::remote_status(url, timeout).await;
786 }
787 Some(status.as_u16())
788}
789
790fn note_failure(server_url: &str, e: &UploadFailure) {
794 match e {
795 UploadFailure::Transport(_) => {
796 crate::blossom_stats::record_failure(server_url, crate::blossom_stats::FAIL_OFFLINE)
797 }
798 UploadFailure::Refused(r) => {
799 if matches!(r.code(), Some("quota_storage_exceeded" | "quota_daily_exceeded" | "server_full")) {
800 crate::blossom_stats::record_failure(server_url, crate::blossom_stats::FAIL_MAXED);
801 } else {
802 crate::blossom_stats::record_ok(server_url);
803 }
804 }
805 _ => {}
806 }
807}
808
809pub async fn upload_blob_with_failover<T>(
816 signer: T,
817 server_urls: Vec<String>,
818 file_data: Arc<Vec<u8>>,
819 mime_type: Option<&str>,
820 stall_timeout: Option<std::time::Duration>,
821) -> Result<String, String>
822where
823 T: VectorSigner + Clone,
824{
825 let mut failures: Vec<(String, UploadFailure)> = Vec::new();
826
827 for (index, server_url_str) in server_urls.iter().enumerate() {
828 let host = host_of(server_url_str);
829 let server_url = match Url::parse(server_url_str) {
830 Ok(url) => url,
831 Err(e) => {
832 crate::log_net_fail!("[Blossom] invalid server URL '{}': {}", server_url_str, e);
833 failures.push((host, UploadFailure::Other(format!("Invalid server URL: {}", e))));
834 continue;
835 }
836 };
837
838 crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}",
839 index + 1, server_urls.len(), server_url_str);
840
841 match upload_blob(signer.clone(), &server_url, file_data.clone(), mime_type, stall_timeout).await {
842 Ok(url) => {
843 if !uploaded_blob_serves(&url).await {
844 crate::log_warn!(
845 "[Blossom Error] {} ACKed the upload but does not serve {} — failing over",
846 server_url_str, url,
847 );
848 failures.push((host, UploadFailure::Other(format!(
849 "{} accepted the upload but the blob is not retrievable", server_url_str,
850 ))));
851 continue;
852 }
853 crate::log_net_info!("[Blossom] upload OK via {}", server_url_str);
854 crate::blossom_stats::record_ok(server_url_str);
855 return Ok(url);
856 }
857 Err(e) => {
858 crate::log_net_fail!("[Blossom] upload failed to {}: {}", server_url_str, e);
859 note_failure(server_url_str, &e);
860 failures.push((host, e));
861 }
862 }
863 }
864
865 let summary = summarise_failures(&failures);
866 crate::log_net_fail!("[Blossom] ALL servers failed: {}", summary);
867 Err(summary)
868}
869
870#[derive(Debug, Clone, PartialEq, Eq)]
879pub struct AcceptedUpload {
880 pub url: String,
881 pub server: String,
882}
883
884pub async fn warm_upload_connection(server_urls: Vec<String>, mime_type: &str, is_encrypted: bool, size_bytes: u64) {
888 let ranked = crate::blossom_capabilities::rank_servers(server_urls, mime_type, is_encrypted, size_bytes);
889 let Some(url) = ranked.first().and_then(|u| Url::parse(u).ok()) else { return };
890 let Ok(client) = crate::net::build_http_client_with_options(None, None, false) else { return };
892 let _ = crate::rt::time::timeout(std::time::Duration::from_secs(10), client.head(url).send()).await;
893}
894
895#[allow(clippy::too_many_arguments)]
897pub async fn upload_blob_with_progress_and_failover<T>(
898 signer: T,
899 server_urls: Vec<String>,
900 file_data: Arc<Vec<u8>>,
901 mime_type: Option<&str>,
902 is_encrypted: bool,
903 progress_callback: ProgressCallback,
904 retry_count: Option<u32>,
905 retry_spacing: Option<std::time::Duration>,
906 cancel_flag: Option<Arc<AtomicBool>>,
907) -> Result<AcceptedUpload, String>
908where
909 T: VectorSigner + Clone,
910{
911 upload_body_with_progress_and_failover(
912 signer, server_urls, UploadBody::Memory(file_data), mime_type, is_encrypted,
913 progress_callback, retry_count, retry_spacing, cancel_flag,
914 ).await
915}
916
917#[allow(clippy::too_many_arguments)]
920pub async fn upload_body_with_progress_and_failover<T>(
921 signer: T,
922 server_urls: Vec<String>,
923 body: UploadBody,
924 mime_type: Option<&str>,
925 is_encrypted: bool,
926 progress_callback: ProgressCallback,
927 retry_count: Option<u32>,
928 retry_spacing: Option<std::time::Duration>,
929 cancel_flag: Option<Arc<AtomicBool>>,
930) -> Result<AcceptedUpload, String>
931where
932 T: VectorSigner + Clone,
933{
934 let mut failures: Vec<(String, UploadFailure)> = Vec::new();
935
936 let size_bytes = body.len();
939 let mime_for_routing = mime_type.unwrap_or("application/octet-stream");
940 let ranked = crate::blossom_capabilities::rank_servers(server_urls, mime_for_routing, is_encrypted, size_bytes);
941
942 for (index, server_url_str) in ranked.iter().enumerate() {
943 if let Some(ref flag) = cancel_flag {
944 if flag.load(Ordering::Relaxed) {
945 return Err("Upload cancelled".to_string());
946 }
947 }
948
949 let host = host_of(server_url_str);
950 let server_url = match Url::parse(server_url_str) {
951 Ok(url) => url,
952 Err(e) => {
953 crate::log_net_fail!("[Blossom] invalid server URL '{}': {}", server_url_str, e);
954 failures.push((host, UploadFailure::Other(format!("Invalid server URL: {}", e))));
955 continue;
956 }
957 };
958
959 crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}",
960 index + 1, ranked.len(), server_url_str);
961
962 let preflight_first = !crate::blossom_capabilities::preflight_redundant(
965 server_url_str, mime_for_routing, is_encrypted, size_bytes,
966 );
967 match upload_with_retries(
968 signer.clone(),
969 &server_url,
970 body.clone(),
971 mime_type,
972 progress_callback.clone(),
973 retry_count,
974 retry_spacing,
975 cancel_flag.clone(),
976 preflight_first,
977 ).await {
978 Ok(url) => {
979 if !uploaded_blob_serves(&url).await {
980 crate::log_warn!(
981 "[Blossom Error] {} ACKed the upload but does not serve {} — failing over",
982 server_url_str, url,
983 );
984 failures.push((host, UploadFailure::Other(format!(
985 "{} accepted the upload but the blob is not retrievable", server_url_str,
986 ))));
987 let _ = progress_callback(Some(0), Some(0));
988 continue;
989 }
990 crate::log_net_info!("[Blossom] upload OK via {}", server_url_str);
991 if let Err(err) = crate::blossom_capabilities::record_accepted(
992 server_url_str, mime_for_routing, is_encrypted, size_bytes,
993 ) {
994 crate::log_warn!("[Blossom Cap] record_accepted failed: {}", err);
995 }
996 crate::blossom_info::note_upload(server_url_str, size_bytes);
997 crate::blossom_stats::record_ok(server_url_str);
998 return Ok(AcceptedUpload {
999 url,
1000 server: server_url_str.clone(),
1001 });
1002 }
1003 Err(UploadFailure::Cancelled) => {
1004 return Err("Upload cancelled".to_string());
1005 }
1006 Err(e) => {
1007 crate::log_net_fail!("[Blossom] upload failed to {}: {}", server_url_str, e);
1008 note_failure(server_url_str, &e);
1009 match &e {
1014 UploadFailure::Integrity(_) => {
1015 if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
1016 server_url_str, mime_for_routing, is_encrypted,
1017 ) {
1018 crate::log_warn!("[Blossom Cap] record_rejected_mime failed: {}", err);
1019 }
1020 }
1021 UploadFailure::Refused(r) if r.is_mime() => {
1022 if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
1023 server_url_str, mime_for_routing, is_encrypted,
1024 ) {
1025 crate::log_warn!("[Blossom Cap] record_rejected_mime failed: {}", err);
1026 }
1027 }
1028 UploadFailure::Refused(r) if r.is_size_limit() => {
1029 let rejects_from = r.limit().map(|l| l.saturating_add(1)).unwrap_or(size_bytes).min(size_bytes);
1032 if let Err(err) = crate::blossom_capabilities::record_rejected_size(
1033 server_url_str, mime_for_routing, is_encrypted, rejects_from,
1034 ) {
1035 crate::log_warn!("[Blossom Cap] record_rejected_size failed: {}", err);
1036 }
1037 }
1038 _ => {}
1041 }
1042 failures.push((host, e));
1043 let _ = progress_callback(Some(0), Some(0));
1044 }
1045 }
1046 }
1047
1048 let summary = summarise_failures(&failures);
1049 crate::log_net_fail!("[Blossom] ALL servers failed: {}", summary);
1050 Err(summary)
1051}
1052
1053async fn build_delete_auth_header<T>(
1059 signer: &T,
1060 hash: Sha256Hash,
1061) -> Result<HeaderValue, String>
1062where
1063 T: VectorSigner,
1064{
1065 let expiration = Timestamp::now() + std::time::Duration::from_secs(300);
1066 let auth = BlossomAuthorization::new(
1067 "Blossom delete authorization".to_string(),
1068 expiration,
1069 BlossomAuthorizationVerb::Delete,
1070 BlossomAuthorizationScope::BlobSha256Hashes(vec![hash]),
1071 );
1072
1073 let auth_event: Event = auth
1074 .finalize_async(signer)
1075 .await
1076 .map_err(|e| format!("Failed to sign auth event: {}", e))?;
1077
1078 let encoded_auth = base64_simd::STANDARD.encode_to_string(auth_event.as_json());
1079 let value = format!("Nostr {}", encoded_auth);
1080
1081 HeaderValue::try_from(value)
1082 .map_err(|e| format!("Failed to create header value: {}", e))
1083}
1084
1085pub async fn delete_blob<T>(
1089 signer: T,
1090 server_url: &Url,
1091 hash: Sha256Hash,
1092) -> Result<(), String>
1093where
1094 T: VectorSigner + Clone,
1095{
1096 let auth_header = build_delete_auth_header(&signer, hash).await?;
1097
1098 let mut url = server_url.clone();
1099 url.set_path(&format!("/{}", hash));
1101
1102 let mut headers = HeaderMap::new();
1103 headers.insert(AUTHORIZATION, auth_header);
1104
1105 let client = crate::net::build_http_client(std::time::Duration::from_secs(30))?;
1106
1107 let response = client
1108 .delete(url)
1109 .headers(headers)
1110 .send()
1111 .await
1112 .map_err(|e| format!("Blossom DELETE request failed: {}", e))?;
1113
1114 let status = response.status();
1115 if status.is_success() || status == StatusCode::NOT_FOUND {
1116 Ok(())
1117 } else {
1118 let body = response.text().await.unwrap_or_else(|_| "<no body>".into());
1119 Err(format!("Blossom DELETE failed with status {}: {}", status, body))
1123 }
1124}
1125
1126pub fn parse_blob_url(url_str: &str) -> Result<(Url, Sha256Hash), String> {
1129 let parsed = Url::parse(url_str)
1130 .map_err(|e| format!("Invalid Blossom URL: {}", e))?;
1131 let last_segment = parsed
1132 .path_segments()
1133 .and_then(|segs| segs.rev().find(|s| !s.is_empty()))
1134 .ok_or_else(|| "Blossom URL has no path segment".to_string())?;
1135 let hash_str = last_segment.split('.').next().unwrap_or("");
1136 let hash = Sha256Hash::from_str(hash_str)
1137 .map_err(|e| format!("Path is not a SHA-256 hash: {}", e))?;
1138 let mut origin = parsed.clone();
1139 origin.set_path("/");
1140 origin.set_query(None);
1141 origin.set_fragment(None);
1142 Ok((origin, hash))
1143}
1144
1145pub fn blob_hash(url_str: &str) -> Option<String> {
1148 parse_blob_url(url_str).ok().map(|(_, hash)| hash.to_string())
1149}
1150
1151pub fn verify_blob_content(url_str: &str, bytes: &[u8]) -> Option<bool> {
1155 let (_, expected) = parse_blob_url(url_str).ok()?;
1156 Some(Sha256Hash::hash(bytes) == expected)
1157}
1158
1159pub async fn delete_blob_by_url<T>(signer: T, url_str: &str) -> Result<(), String>
1163where
1164 T: VectorSigner + Clone,
1165{
1166 let (origin, hash) = parse_blob_url(url_str)?;
1167
1168 crate::log_info!("[Blossom] DELETE {} from {}", hash, origin);
1169 let timeout = std::time::Duration::from_secs(15);
1174 match crate::rt::time::timeout(timeout, delete_blob(signer, &origin, hash)).await {
1175 Ok(Ok(())) => {
1176 crate::log_info!("[Blossom] DELETE successful: {} from {}", hash, origin);
1177 Ok(())
1178 }
1179 Ok(Err(e)) => {
1180 crate::log_warn!("[Blossom] DELETE failed: {} from {}: {}", hash, origin, e);
1181 Err(e)
1182 }
1183 Err(_) => {
1184 let msg = format!("DELETE timed out after {}s", timeout.as_secs());
1185 crate::log_warn!("[Blossom] {} ({} from {})", msg, hash, origin);
1186 Err(msg)
1187 }
1188 }
1189}
1190
1191pub fn hash_swap_candidates(primary_url: &str, servers: &[String]) -> Vec<String> {
1196 let Ok((primary_origin, hash)) = parse_blob_url(primary_url) else {
1197 return Vec::new();
1198 };
1199 let ext = Url::parse(primary_url)
1202 .ok()
1203 .and_then(|u| {
1204 u.path_segments()
1205 .and_then(|segs| segs.rev().find(|s| !s.is_empty()).map(|s| s.to_string()))
1206 })
1207 .and_then(|leaf| leaf.split_once('.').map(|(_, e)| e.to_string()))
1208 .filter(|e| !e.is_empty() && e.len() <= 8 && e.bytes().all(|b| b.is_ascii_alphanumeric()));
1209 let leaf = match ext {
1210 Some(e) => format!("{}.{}", hash, e),
1211 None => hash.to_string(),
1212 };
1213 servers
1214 .iter()
1215 .filter_map(|s| {
1216 let base = Url::parse(&format!("{}/", s.trim_end_matches('/'))).ok()?;
1217 if base.origin() == primary_origin.origin() {
1218 return None;
1219 }
1220 base.join(&leaf).ok().map(|u| u.to_string())
1221 })
1222 .collect()
1223}
1224
1225pub async fn mirror_blob<T>(signer: T, target_server: &Url, source_url: &str) -> Result<String, String>
1231where
1232 T: VectorSigner + Clone,
1233{
1234 let (source_origin, hash) = parse_blob_url(source_url)?;
1235 if target_server.origin() == source_origin.origin() {
1236 return Err("Mirror target is the source server".to_string());
1237 }
1238 let auth_header = build_auth_header(&signer, hash).await?;
1239 let mirror_url = target_server
1240 .join("mirror")
1241 .map_err(|e| format!("Invalid mirror URL: {}", e))?;
1242
1243 let client = crate::net::build_http_client(std::time::Duration::from_secs(30))?;
1244 let response = client
1245 .put(mirror_url)
1246 .header(AUTHORIZATION, auth_header)
1247 .header(CONTENT_TYPE, "application/json")
1248 .body(format!("{{\"url\":{}}}", serde_json::to_string(source_url).map_err(|e| e.to_string())?))
1249 .send()
1250 .await
1251 .map_err(|e| format!("Mirror request failed: {}", e))?;
1252
1253 let status = response.status();
1254 if !status.is_success() {
1255 let body = response.text().await.unwrap_or_else(|_| "<no body>".into());
1256 return Err(format!("Mirror failed with status {}: {}", status, body));
1257 }
1258 let descriptor: BlobDescriptor = response
1259 .json()
1260 .await
1261 .map_err(|e| format!("Failed to parse mirror response: {}", e))?;
1262 if descriptor.sha256 != hash {
1263 return Err(format!(
1264 "[INTEGRITY] {} mirrored a different hash (returned {}, expected {})",
1265 target_server, descriptor.sha256, hash,
1266 ));
1267 }
1268 let url = descriptor.url.to_string();
1269 let same_origin = Url::parse(&url)
1273 .map(|u| u.origin() == target_server.origin() && u.query().is_none())
1274 .unwrap_or(false);
1275 if !same_origin {
1276 return Err(format!("{} returned a foreign descriptor URL: {}", target_server, url));
1277 }
1278 if !uploaded_blob_serves(&url).await {
1279 return Err(format!("{} ACKed the mirror but does not serve it", target_server));
1280 }
1281 Ok(url)
1282}
1283
1284fn mirror_targets(
1297 source_url: &str,
1298 server_urls: &[String],
1299 max_mirrors: usize,
1300 already_held_by: &[String],
1301) -> Vec<Url> {
1302 let source_origin = match parse_blob_url(source_url) {
1303 Ok((origin, _)) => origin,
1304 Err(e) => {
1305 crate::log_debug!("[Blossom Mirror] unparseable source {}: {}", source_url, e);
1306 return Vec::new();
1307 }
1308 };
1309 let excluded: Vec<url::Origin> = std::iter::once(source_origin.origin())
1310 .chain(
1311 already_held_by
1312 .iter()
1313 .filter_map(|s| Url::parse(s).ok())
1314 .map(|u| u.origin()),
1315 )
1316 .collect();
1317 server_urls
1318 .iter()
1319 .filter_map(|s| Url::parse(s).ok())
1320 .filter(|u| !excluded.contains(&u.origin()))
1321 .take(max_mirrors)
1322 .collect()
1323}
1324
1325pub async fn mirror_blob_to_servers<T>(
1331 signer: T,
1332 source_url: &str,
1333 server_urls: Vec<String>,
1334 max_mirrors: usize,
1335 budget: std::time::Duration,
1336 already_held_by: &[String],
1337) -> Vec<String>
1338where
1339 T: VectorSigner + Clone,
1340{
1341 let targets = mirror_targets(source_url, &server_urls, max_mirrors, already_held_by);
1342 if targets.is_empty() {
1343 return Vec::new();
1344 }
1345
1346 let futures = targets.into_iter().map(|target| {
1347 let signer = signer.clone();
1348 let source = source_url.to_string();
1349 async move {
1350 match crate::rt::time::timeout(budget, mirror_blob(signer, &target, &source)).await {
1354 Ok(Ok(url)) => {
1355 crate::log_net_info!("[Blossom Mirror] {} now serves {}", target, url);
1356 Some(url)
1357 }
1358 Ok(Err(e)) => {
1359 crate::log_net_fail!("[Blossom Mirror] {} refused: {}", target, e);
1360 None
1361 }
1362 Err(_) => {
1363 crate::log_net_fail!("[Blossom Mirror] {} exceeded {:?} budget", target, budget);
1364 None
1365 }
1366 }
1367 }
1368 });
1369 futures_util::future::join_all(futures)
1370 .await
1371 .into_iter()
1372 .flatten()
1373 .collect()
1374}
1375
1376pub fn delete_blobs_best_effort<T>(signer: T, urls: Vec<String>)
1380where
1381 T: VectorSigner + Clone + Send + Sync + 'static,
1382{
1383 for url_str in urls {
1384 let url = match Url::parse(&url_str) {
1385 Ok(u) => u,
1386 Err(_) => continue,
1387 };
1388
1389 let last_segment = match url.path_segments()
1391 .and_then(|segs| segs.rev().find(|s| !s.is_empty()))
1392 {
1393 Some(s) => s,
1394 None => continue,
1395 };
1396 let hash_str = last_segment.split('.').next().unwrap_or("");
1398 let hash = match Sha256Hash::from_str(hash_str) {
1399 Ok(h) => h,
1400 Err(_) => continue,
1401 };
1402
1403 let mut origin = url.clone();
1404 origin.set_path("/");
1405 origin.set_query(None);
1406 origin.set_fragment(None);
1407
1408 let signer = signer.clone();
1409 crate::rt::spawn(async move {
1411 if let Err(e) = delete_blob(signer, &origin, hash).await {
1412 crate::log_warn!("[Blossom delete] {} from {}: {}", hash, origin, e);
1413 }
1414 });
1415 }
1416}
1417
1418pub async fn probe_servers_for_octet_stream<T>(
1423 signer: T,
1424 server_urls: Vec<String>,
1425) -> Result<usize, String>
1426where
1427 T: VectorSigner + Clone,
1428{
1429 use rand::RngCore;
1430 if server_urls.is_empty() { return Ok(0); }
1431
1432 const PROBE_MIME: &str = "application/octet-stream";
1433 let mut payload = vec![0u8; 32];
1434 rand::thread_rng().fill_bytes(&mut payload[..]);
1435 let payload = Arc::new(payload);
1436 let payload_size = payload.len() as u64;
1437
1438 let mut probed = 0usize;
1439 for server_url_str in &server_urls {
1440 if crate::blossom_capabilities::has_fresh_capability_for(server_url_str, PROBE_MIME, true) {
1441 continue;
1442 }
1443 let parsed = match Url::parse(server_url_str) {
1444 Ok(u) => u,
1445 Err(_) => continue,
1446 };
1447 let no_op_progress: ProgressCallback = Arc::new(|_, _| Ok(()));
1449 match crate::rt::time::timeout(
1450 std::time::Duration::from_secs(4),
1451 upload_blob_with_progress(
1452 signer.clone(),
1453 &parsed,
1454 payload.clone(),
1455 Some(PROBE_MIME),
1456 no_op_progress,
1457 Some(0),
1458 None,
1459 None,
1460 ),
1461 ).await {
1462 Ok(Ok(url)) => {
1463 if !crate::blossom_servers::is_enabled_server(server_url_str) {
1466 if let Some(hash) = extract_hash_from_blossom_url(&url) {
1467 let _ = delete_blob(signer.clone(), &parsed, hash).await;
1468 }
1469 continue;
1470 }
1471 let delete_result = match extract_hash_from_blossom_url(&url) {
1479 Some(hash) => crate::rt::time::timeout(
1480 std::time::Duration::from_secs(4),
1481 delete_blob(signer.clone(), &parsed, hash),
1482 ).await.ok(),
1483 None => None,
1484 };
1485 let refuses_deletion = matches!(
1486 &delete_result,
1487 Some(Err(e)) if matches!(parse_status_from_error(e), Some(403) | Some(405) | Some(501)),
1488 );
1489 if refuses_deletion {
1490 if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
1491 server_url_str, PROBE_MIME, true,
1492 ) {
1493 crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err);
1494 }
1495 probed += 1;
1496 crate::log_info!("[Blossom Probe] {} refuses deletion; routing around", server_url_str);
1497 } else {
1498 if let Err(e) = crate::blossom_capabilities::record_accepted(
1499 server_url_str, PROBE_MIME, true, payload_size,
1500 ) {
1501 crate::log_warn!("[Blossom Probe] record_accepted failed: {}", e);
1502 }
1503 probed += 1;
1504 crate::log_info!("[Blossom Probe] {} validated (accepts + verbatim + deletes)", server_url_str);
1505 }
1506 }
1507 Ok(Err(e)) => {
1508 let unsuitable = matches!(e, UploadFailure::Integrity(_))
1513 || e.refusal().is_some_and(|r| r.is_mime());
1514 if unsuitable {
1515 if !crate::blossom_servers::is_enabled_server(server_url_str) {
1516 continue;
1517 }
1518 if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
1519 server_url_str, PROBE_MIME, true,
1520 ) {
1521 crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err);
1522 }
1523 probed += 1;
1524 crate::log_info!("[Blossom Probe] {} unsuitable; routing around: {}", server_url_str, e);
1525 } else {
1526 crate::log_debug!("[Blossom Probe] {} transient error (not cached): {}", server_url_str, e);
1528 }
1529 }
1530 Err(_) => {
1531 crate::log_debug!("[Blossom Probe] {} timed out, not cached", server_url_str);
1532 }
1533 }
1534 }
1535 Ok(probed)
1536}
1537
1538fn extract_hash_from_blossom_url(url: &str) -> Option<Sha256Hash> {
1541 let parsed = Url::parse(url).ok()?;
1542 let last = parsed.path_segments()?.rev().find(|s| !s.is_empty())?;
1543 let stem = last.split('.').next()?;
1544 Sha256Hash::from_str(stem).ok()
1545}
1546
1547fn parse_status_from_error(msg: &str) -> Option<u16> {
1551 let key = "with status ";
1552 let i = msg.find(key)?;
1553 let tail = &msg[i + key.len()..];
1554 let digits: String = tail.chars().take_while(|c| c.is_ascii_digit()).collect();
1555 digits.parse::<u16>().ok()
1556}
1557
1558#[cfg(test)]
1559mod parse_status_tests {
1560 use super::parse_status_from_error;
1561
1562 #[test]
1563 fn extracts_status_code() {
1564 assert_eq!(parse_status_from_error("Upload failed with status 500 Internal Server Error: x"), Some(500));
1565 assert_eq!(parse_status_from_error("Upload failed with status 413 Payload Too Large"), Some(413));
1566 assert_eq!(parse_status_from_error("Upload failed with status 415"), Some(415));
1567 assert_eq!(parse_status_from_error("Upload failed with status 524 <unknown status code>: gateway"), Some(524));
1570 }
1571
1572 #[test]
1573 fn returns_none_when_absent() {
1574 assert_eq!(parse_status_from_error("network error: timeout"), None);
1575 }
1576}
1577
1578#[cfg(test)]
1579mod hash_swap_tests {
1580 use super::hash_swap_candidates;
1581
1582 const HASH: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
1583
1584 #[test]
1585 fn derives_clean_leaves_and_skips_the_source_origin() {
1586 let primary = format!("https://a.example/{}.png?utm=track#frag", HASH);
1589 let servers = vec![
1590 "https://a.example".to_string(),
1591 "https://b.example".to_string(),
1592 "not a url".to_string(),
1593 ];
1594 assert_eq!(
1595 hash_swap_candidates(&primary, &servers),
1596 vec![format!("https://b.example/{}.png", HASH)],
1597 );
1598 }
1599
1600 #[test]
1601 fn extensionless_primary_yields_the_bare_hash() {
1602 let primary = format!("https://a.example/{}", HASH);
1603 assert_eq!(
1604 hash_swap_candidates(&primary, &["https://b.example/".to_string()]),
1605 vec![format!("https://b.example/{}", HASH)],
1606 );
1607 }
1608
1609 #[test]
1610 fn unparseable_primary_yields_nothing() {
1611 assert!(hash_swap_candidates("https://a.example/not-a-hash.png", &["https://b.example".to_string()]).is_empty());
1612 }
1613}
1614
1615#[cfg(test)]
1616mod hash_extract_tests {
1617 use super::extract_hash_from_blossom_url;
1618
1619 const HASH_HEX: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
1620
1621 #[test]
1622 fn plain_url() {
1623 let url = format!("https://srv.example/{}", HASH_HEX);
1624 assert!(extract_hash_from_blossom_url(&url).is_some());
1625 }
1626
1627 #[test]
1628 fn with_extension() {
1629 let url = format!("https://srv.example/{}.jpg", HASH_HEX);
1630 assert!(extract_hash_from_blossom_url(&url).is_some());
1631 }
1632
1633 #[test]
1634 fn trailing_slash_still_resolves() {
1635 let url = format!("https://srv.example/{}/", HASH_HEX);
1637 assert!(extract_hash_from_blossom_url(&url).is_some());
1638 }
1639
1640 #[test]
1641 fn malformed_returns_none() {
1642 assert!(extract_hash_from_blossom_url("https://srv.example/").is_none());
1643 assert!(extract_hash_from_blossom_url("not a url").is_none());
1644 assert!(extract_hash_from_blossom_url("https://srv.example/notahash").is_none());
1645 }
1646
1647 #[test]
1648 fn x_sha256_simd_hex_matches_lowerhex() {
1649 use bitcoin_hashes::sha256::Hash as Sha256Hash;
1650 let hash = Sha256Hash::hash(b"vector blossom x-sha-256 parity check");
1654 assert_eq!(
1655 crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()),
1656 format!("{:x}", hash),
1657 );
1658 }
1659}
1660
1661#[cfg(test)]
1662mod mirror_target_tests {
1663 use super::mirror_targets;
1664
1665 const BLOB: &str = "3ad3648700bc0000000000000000000000000000000000000000000000000000.bin";
1668
1669 fn servers(v: &[&str]) -> Vec<String> {
1670 v.iter().map(|s| s.to_string()).collect()
1671 }
1672
1673 fn names(t: Vec<url::Url>) -> Vec<String> {
1674 t.into_iter().map(|u| u.host_str().unwrap_or("").to_string()).collect()
1675 }
1676
1677 #[test]
1678 fn the_server_that_accepted_the_upload_is_not_a_mirror_target() {
1679 let t = mirror_targets(
1685 &format!("https://magnitude.jskitty.com/{BLOB}"),
1686 &servers(&["https://asia.magnitude.jskitty.com", "https://blossom.band"]),
1687 2,
1688 &["https://asia.magnitude.jskitty.com".to_string()],
1689 );
1690 assert_eq!(names(t), vec!["blossom.band"]);
1691 }
1692
1693 #[test]
1694 fn genuinely_different_servers_are_still_mirrored_to() {
1695 let t = mirror_targets(
1696 &format!("https://magnitude.jskitty.com/{BLOB}"),
1697 &servers(&["https://blossom.band", "https://nostr.download"]),
1698 2,
1699 &["https://asia.magnitude.jskitty.com".to_string()],
1700 );
1701 assert_eq!(names(t), vec!["blossom.band", "nostr.download"]);
1702 }
1703
1704 #[test]
1705 fn the_source_origin_is_excluded_even_with_nothing_passed_in() {
1706 let t = mirror_targets(
1708 &format!("https://magnitude.jskitty.com/{BLOB}"),
1709 &servers(&["https://magnitude.jskitty.com", "https://blossom.band"]),
1710 2,
1711 &[],
1712 );
1713 assert_eq!(names(t), vec!["blossom.band"]);
1714 }
1715
1716 #[test]
1717 fn max_mirrors_is_applied_after_exclusion_not_before() {
1718 let t = mirror_targets(
1721 &format!("https://magnitude.jskitty.com/{BLOB}"),
1722 &servers(&[
1723 "https://asia.magnitude.jskitty.com",
1724 "https://magnitude.jskitty.com",
1725 "https://blossom.band",
1726 ]),
1727 1,
1728 &["https://asia.magnitude.jskitty.com".to_string()],
1729 );
1730 assert_eq!(names(t), vec!["blossom.band"]);
1731 }
1732
1733 #[test]
1734 fn a_port_or_scheme_difference_is_a_different_server() {
1735 let t = mirror_targets(
1738 &format!("https://example.com/{BLOB}"),
1739 &servers(&["https://example.com:8443"]),
1740 2,
1741 &[],
1742 );
1743 assert_eq!(names(t), vec!["example.com"]);
1744 }
1745
1746 #[test]
1747 fn an_unparseable_source_mirrors_nowhere() {
1748 let t = mirror_targets("not a url", &servers(&["https://blossom.band"]), 2, &[]);
1749 assert!(t.is_empty());
1750 }
1751}
1752
1753#[cfg(test)]
1754mod stall_watch_tests {
1755 use super::{Stall, StallWatch};
1756 use std::time::Duration;
1757
1758 const STALL: Duration = Duration::from_secs(60);
1759 const RESPONSE: Duration = Duration::from_secs(300);
1760
1761 #[tokio::test(start_paused = true)]
1762 async fn a_transfer_moving_at_any_rate_is_never_abandoned() {
1763 let mut w = StallWatch::new(1 << 20, STALL, RESPONSE);
1766 let mut sent = 0u64;
1767 for _ in 0..8 {
1768 crate::rt::time::advance(Duration::from_secs(50)).await;
1769 sent += 16 * 1024;
1770 assert_eq!(w.observe(sent), None);
1771 }
1772 }
1773
1774 #[tokio::test(start_paused = true)]
1775 async fn a_transfer_that_stops_is_abandoned_after_the_window() {
1776 let mut w = StallWatch::new(1 << 20, STALL, RESPONSE);
1777 assert_eq!(w.observe(4096), None);
1778 crate::rt::time::advance(Duration::from_secs(59)).await;
1779 assert_eq!(w.observe(4096), None, "inside the window it is only slow");
1780 crate::rt::time::advance(Duration::from_secs(2)).await;
1781 assert!(matches!(w.observe(4096), Some(Stall::NoProgress { .. })));
1782 }
1783
1784 #[tokio::test(start_paused = true)]
1785 async fn progress_resets_the_window() {
1786 let mut w = StallWatch::new(1 << 20, STALL, RESPONSE);
1787 w.observe(100);
1788 crate::rt::time::advance(Duration::from_secs(55)).await;
1789 w.observe(200);
1790 crate::rt::time::advance(Duration::from_secs(55)).await;
1791 assert_eq!(w.observe(200), None, "the clock restarted at the second byte");
1792 }
1793
1794 #[tokio::test(start_paused = true)]
1795 async fn after_the_last_byte_the_server_gets_longer_but_not_forever() {
1796 let total = 1 << 20;
1797 let mut w = StallWatch::new(total, STALL, RESPONSE);
1798 w.observe(total);
1799 crate::rt::time::advance(Duration::from_secs(240)).await;
1801 assert_eq!(w.observe(total), None, "buffers drain and the blob is stored");
1802 crate::rt::time::advance(Duration::from_secs(61)).await;
1803 assert!(matches!(w.observe(total), Some(Stall::NoResponse { .. })));
1804 }
1805
1806 #[tokio::test(start_paused = true)]
1807 async fn an_empty_upload_is_waiting_on_the_server_from_the_start() {
1808 let mut w = StallWatch::new(0, STALL, RESPONSE);
1809 crate::rt::time::advance(STALL + Duration::from_secs(1)).await;
1810 assert_eq!(w.observe(0), None, "nothing to send is not a stall");
1811 crate::rt::time::advance(RESPONSE).await;
1812 assert!(matches!(w.observe(0), Some(Stall::NoResponse { .. })));
1813 }
1814}