1use nostr_sdk::{NostrSigner, Url, Event, EventBuilder, Timestamp, JsonUtil};
2use nostr_sdk::hashes::{sha256::Hash as Sha256Hash, Hash};
3use nostr_blossom::prelude::*;
4use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE};
5use reqwest::{Body, StatusCode};
6use std::str::FromStr;
7use std::sync::{Arc, Mutex};
8use std::sync::atomic::{AtomicBool, Ordering};
9use tokio::sync::mpsc;
10use futures_util::Stream;
11use std::pin::Pin;
12use std::task::{Context, Poll};
13
14pub type ProgressCallback = std::sync::Arc<dyn Fn(Option<u8>, Option<u64>) -> Result<(), String> + Send + Sync>;
16
17struct ProgressTrackingStream {
19 bytes_sent: Arc<Mutex<u64>>,
20 inner: mpsc::Receiver<Result<Vec<u8>, std::io::Error>>,
21}
22
23impl ProgressTrackingStream {
24 fn new(data: Arc<Vec<u8>>, bytes_sent: Arc<Mutex<u64>>) -> Self {
25 let (tx, rx) = mpsc::channel(8); tokio::spawn(async move {
29 let chunk_size = 64 * 1024; let mut position = 0;
31
32 while position < data.len() {
33 let end = std::cmp::min(position + chunk_size, data.len());
34 let chunk = data[position..end].to_vec();
35
36 if tx.send(Ok(chunk)).await.is_err() {
38 break; }
40
41 position = end;
42 }
43 });
44
45 Self {
46 bytes_sent,
47 inner: rx,
48 }
49 }
50}
51
52impl Stream for ProgressTrackingStream {
53 type Item = Result<Vec<u8>, std::io::Error>;
54
55 fn poll_next(
56 mut self: Pin<&mut Self>,
57 cx: &mut Context<'_>,
58 ) -> Poll<Option<Self::Item>> {
59 match self.inner.poll_recv(cx) {
60 Poll::Ready(Some(result)) => {
61 if let Ok(chunk) = &result {
63 let mut bytes_sent = self.bytes_sent.lock().unwrap();
64 *bytes_sent += chunk.len() as u64;
65 }
66 Poll::Ready(Some(result))
67 }
68 Poll::Ready(None) => Poll::Ready(None),
69 Poll::Pending => Poll::Pending,
70 }
71 }
72}
73
74async fn build_auth_header<T>(
76 signer: &T,
77 hash: Sha256Hash,
78) -> Result<HeaderValue, String>
79where
80 T: NostrSigner,
81{
82 let expiration = Timestamp::now() + std::time::Duration::from_secs(300);
84 let auth = BlossomAuthorization::new(
85 "Blossom upload authorization".to_string(),
86 expiration,
87 BlossomAuthorizationVerb::Upload,
88 BlossomAuthorizationScope::BlobSha256Hashes(vec![hash]),
89 );
90
91 let auth_event: Event = EventBuilder::blossom_auth(auth)
93 .sign(signer)
94 .await
95 .map_err(|e| format!("Failed to sign auth event: {}", e))?;
96
97 let encoded_auth = base64_simd::STANDARD.encode_to_string(auth_event.as_json());
99 let value = format!("Nostr {}", encoded_auth);
100
101 HeaderValue::try_from(value)
102 .map_err(|e| format!("Failed to create header value: {}", e))
103}
104
105pub async fn upload_blob_with_progress<T>(
108 signer: T,
109 server_url: &Url,
110 file_data: Arc<Vec<u8>>,
111 mime_type: Option<&str>,
112 progress_callback: ProgressCallback,
113 retry_count: Option<u32>,
114 retry_spacing: Option<std::time::Duration>,
115 cancel_flag: Option<Arc<AtomicBool>>,
116) -> Result<String, String>
117where
118 T: NostrSigner + Clone,
119{
120 let retry_count = retry_count.unwrap_or(0);
121 let retry_spacing = retry_spacing.unwrap_or(std::time::Duration::from_secs(1));
122
123 let mut last_error = None;
124
125 for attempt in 0..=retry_count {
126 if attempt > 0 {
127 tokio::time::sleep(retry_spacing).await;
128 }
129
130 if let Some(ref flag) = cancel_flag {
131 if flag.load(Ordering::Relaxed) {
132 return Err("Upload cancelled".to_string());
133 }
134 }
135
136 match upload_attempt(
137 signer.clone(),
138 server_url,
139 file_data.clone(),
140 mime_type,
141 &progress_callback,
142 cancel_flag.clone(),
143 ).await {
144 Ok(url) => return Ok(url),
145 Err(e) => {
146 if e == "Upload cancelled" {
147 return Err(e);
148 }
149 crate::log_warn!(
150 "[Blossom] Attempt {}/{} to {} failed: {}",
151 attempt + 1, retry_count + 1, server_url, e,
152 );
153 let status = parse_status_from_error(&e);
155 let permanent = crate::blossom_capabilities::is_mime_rejection(status, &e)
156 || crate::blossom_capabilities::is_size_rejection(status);
157 if permanent {
158 return Err(e);
159 }
160 if matches!(status, Some(504 | 520 | 522 | 524)) {
164 crate::log_warn!(
165 "[Blossom] {} gateway-timed-out (status {}) on {} bytes; routing to the next server",
166 server_url, status.unwrap_or(0), file_data.len(),
167 );
168 return Err(e);
169 }
170 let looks_like_mid_stream_drop = (
174 e.contains("Upload request failed")
175 || e.contains("error sending request")
176 || e.contains("connection reset")
177 || e.contains("connection closed")
178 || e.contains("connection refused")
179 || e.contains("body write")
180 || e.contains("IncompleteMessage")
181 || e.contains("broken pipe")
182 ) && file_data.len() > 8 * 1024 * 1024;
183 if looks_like_mid_stream_drop {
184 crate::log_warn!(
185 "[Blossom] {} dropped the connection mid-upload of {} bytes, treating as permanent",
186 server_url, file_data.len(),
187 );
188 return Err(e);
189 }
190 last_error = Some(e);
191 }
192 }
193 }
194
195 Err(last_error.unwrap_or_else(|| "No upload attempts were made".to_string()))
197}
198
199async fn upload_attempt<T>(
201 signer: T,
202 server_url: &Url,
203 file_data: Arc<Vec<u8>>,
204 mime_type: Option<&str>,
205 progress_callback: &ProgressCallback,
206 cancel_flag: Option<Arc<AtomicBool>>,
207) -> Result<String, String>
208where
209 T: NostrSigner,
210{
211 let upload_url = server_url.join("upload")
212 .map_err(|e| format!("Invalid server URL: {}", e))?;
213
214 let total_size = file_data.len() as u64;
215 let hash = Sha256Hash::hash(&*file_data);
216
217 progress_callback(Some(0), Some(0)).map_err(|e| e)?;
218
219 let auth_header = build_auth_header(&signer, hash).await?;
221
222 let client = crate::net::build_http_client_with_options(
224 std::time::Duration::from_secs(300),
225 false,
226 )?;
227
228 {
230 let mut head_headers = HeaderMap::new();
231 head_headers.insert(AUTHORIZATION, auth_header.clone());
232 head_headers.insert(
233 "X-Content-Length",
234 HeaderValue::from_str(&total_size.to_string())
235 .map_err(|e| format!("Invalid X-Content-Length: {}", e))?,
236 );
237 head_headers.insert(
240 "X-SHA-256",
241 HeaderValue::from_str(&crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()))
242 .map_err(|e| format!("Invalid X-SHA-256: {}", e))?,
243 );
244 if let Some(ct) = mime_type {
245 head_headers.insert(
246 "X-Content-Type",
247 HeaderValue::from_str(ct).map_err(|e| format!("Invalid X-Content-Type: {}", e))?,
248 );
249 }
250 match tokio::time::timeout(
251 std::time::Duration::from_secs(5),
252 client.head(upload_url.clone()).headers(head_headers).send(),
253 ).await {
254 Ok(Ok(resp)) => {
255 let status = resp.status();
256 let x_reason = resp.headers().get("X-Reason")
259 .and_then(|v| v.to_str().ok())
260 .map(|s| s.to_string());
261 let body = resp.text().await.unwrap_or_default();
262 let diag = if !body.is_empty() {
263 if let Some(r) = &x_reason {
264 format!("{} (X-Reason: {})", body, r)
265 } else {
266 body
267 }
268 } else if let Some(r) = x_reason {
269 r
270 } else {
271 format!("rejected at preflight ({})", status)
272 };
273 let is_413 = status == StatusCode::PAYLOAD_TOO_LARGE;
274 let is_415 = status == StatusCode::UNSUPPORTED_MEDIA_TYPE;
275 let mime_hinted = status.is_client_error() && !is_413 && {
276 crate::blossom_capabilities::is_mime_rejection(Some(status.as_u16()), &diag)
277 };
278 if is_413 || is_415 || mime_hinted {
279 crate::log_warn!(
280 "[Blossom Preflight] {} REJECTED {} ({} bytes, {}): {}",
281 server_url, status, total_size,
282 mime_type.unwrap_or("(no mime)"), diag,
283 );
284 return Err(format!(
285 "Upload failed with status {}: {}",
286 status, diag,
287 ));
288 }
289 crate::log_debug!(
290 "[Blossom Preflight] {} → {} ({} bytes); proceeding to PUT",
291 server_url, status, total_size,
292 );
293 }
294 Ok(Err(e)) => {
295 crate::log_debug!("[Blossom Preflight] {} HEAD failed: {}, falling through to PUT", server_url, e);
296 }
297 Err(_) => {
298 crate::log_debug!("[Blossom Preflight] {} HEAD timed out (5s), falling through to PUT", server_url);
299 }
300 }
301 }
302
303 let bytes_sent = Arc::new(Mutex::new(0u64));
304 let tracking_stream = ProgressTrackingStream::new(file_data, Arc::clone(&bytes_sent));
305 let body = Body::wrap_stream(tracking_stream);
306
307 let mut headers = HeaderMap::new();
308 headers.insert(AUTHORIZATION, auth_header);
309 if let Some(ct) = mime_type {
310 headers.insert(
311 CONTENT_TYPE,
312 HeaderValue::from_str(ct).map_err(|e| format!("Invalid content type: {}", e))?
313 );
314 }
315 headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size));
319
320 let mut request_future = Box::pin(client
321 .put(upload_url.clone())
322 .headers(headers)
323 .body(body)
324 .send());
325
326 let mut last_percentage = 0;
327 let mut poll_interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
328
329 let response = loop {
330 tokio::select! {
331 response = &mut request_future => {
332 break response.map_err(|e| format!("Upload request failed: {}", e))?;
333 },
334 _ = poll_interval.tick() => {
335 if let Some(ref flag) = cancel_flag {
336 if flag.load(Ordering::Relaxed) {
337 return Err("Upload cancelled".to_string());
338 }
339 }
340
341 let current_bytes = *bytes_sent.lock().unwrap();
342 let percentage = if total_size > 0 {
343 ((current_bytes as f64 / total_size as f64) * 100.0) as u8
344 } else {
345 0
346 };
347
348 if percentage != last_percentage {
349 if let Err(e) = progress_callback(Some(percentage), Some(current_bytes)) {
350 return Err(e);
351 }
352 last_percentage = percentage;
353 }
354 }
355 }
356 };
357
358 let final_bytes = *bytes_sent.lock().unwrap();
359 if final_bytes == total_size && last_percentage < 100 {
360 progress_callback(Some(100), Some(total_size)).map_err(|e| e)?;
361 }
362
363 let status = response.status();
365 if status.is_success() {
366 let descriptor: BlobDescriptor = response.json().await
367 .map_err(|e| format!("Failed to parse response: {}", e))?;
368 if descriptor.sha256 != hash {
374 return Err(format!(
375 "[INTEGRITY] {} transformed the upload (returned {}, expected {})",
376 server_url, descriptor.sha256, hash,
377 ));
378 }
379 Ok(descriptor.url.to_string())
380 } else {
381 let x_reason = response.headers().get("X-Reason")
383 .and_then(|v| v.to_str().ok())
384 .map(|s| s.to_string());
385 let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
386 let display = match (error_text.is_empty(), x_reason) {
387 (false, Some(r)) => format!("{} (X-Reason: {})", error_text, r),
388 (false, None) => error_text,
389 (true, Some(r)) => r,
390 (true, None) => "Unknown error".to_string(),
391 };
392 crate::log_warn!("[Blossom Error] Upload failed with status {}: {}", status, display);
393 Err(format!("Upload failed with status {}: {}", status, display))
394 }
395}
396
397pub async fn upload_blob<T>(
399 signer: T,
400 server_url: &Url,
401 file_data: Arc<Vec<u8>>,
402 mime_type: Option<&str>,
403) -> Result<String, String>
404where
405 T: NostrSigner,
406{
407 let upload_url = server_url.join("upload")
408 .map_err(|e| format!("Invalid server URL: {}", e))?;
409
410 let hash = Sha256Hash::hash(&*file_data);
411 let total_size = file_data.len() as u64;
412
413 let auth_header = build_auth_header(&signer, hash).await?;
414
415 let mut headers = HeaderMap::new();
416 headers.insert(AUTHORIZATION, auth_header);
417 if let Some(ct) = mime_type {
418 headers.insert(
419 CONTENT_TYPE,
420 HeaderValue::from_str(ct).map_err(|e| format!("Invalid content type: {}", e))?
421 );
422 }
423 headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size));
424
425 let client = crate::net::build_http_client_with_options(
427 std::time::Duration::from_secs(300),
428 false,
429 )?;
430
431 let body_data: Vec<u8> = Arc::try_unwrap(file_data)
432 .unwrap_or_else(|arc| (*arc).clone());
433 let response = client
434 .put(upload_url)
435 .headers(headers)
436 .body(body_data)
437 .send()
438 .await
439 .map_err(|e| format!("Upload request failed: {}", e))?;
440
441 let status = response.status();
443 if status.is_success() {
444 let descriptor: BlobDescriptor = response.json().await
445 .map_err(|e| format!("Failed to parse response: {}", e))?;
446 if descriptor.sha256 != hash {
449 return Err(format!(
450 "[INTEGRITY] {} transformed the upload (returned {}, expected {})",
451 server_url, descriptor.sha256, hash,
452 ));
453 }
454 Ok(descriptor.url.to_string())
455 } else {
456 let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
457 Err(format!("Upload failed with status {}: {}", status, error_text))
458 }
459}
460
461pub async fn upload_blob_with_failover<T>(
468 signer: T,
469 server_urls: Vec<String>,
470 file_data: Arc<Vec<u8>>,
471 mime_type: Option<&str>,
472) -> Result<String, String>
473where
474 T: NostrSigner + Clone,
475{
476 let mut last_error = String::from("No servers available");
477
478 for (index, server_url_str) in server_urls.iter().enumerate() {
479 let server_url = match Url::parse(server_url_str) {
480 Ok(url) => url,
481 Err(e) => {
482 crate::log_warn!("[Blossom Error] Invalid server URL '{}': {}", server_url_str, e);
483 last_error = format!("Invalid server URL: {}", e);
484 continue;
485 }
486 };
487
488 crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}",
489 index + 1, server_urls.len(), server_url_str);
490
491 match upload_blob(signer.clone(), &server_url, file_data.clone(), mime_type).await {
492 Ok(url) => {
493 crate::log_info!("[Blossom] Upload successful to: {}", server_url_str);
494 return Ok(url);
495 }
496 Err(e) => {
497 crate::log_warn!("[Blossom Error] Upload failed to {}: {}", server_url_str, e);
498 last_error = e;
499 }
500 }
501 }
502
503 Err(format!("All Blossom servers failed. Last error: {}", last_error))
504}
505
506pub async fn upload_blob_with_progress_and_failover<T>(
508 signer: T,
509 server_urls: Vec<String>,
510 file_data: Arc<Vec<u8>>,
511 mime_type: Option<&str>,
512 is_encrypted: bool,
513 progress_callback: ProgressCallback,
514 retry_count: Option<u32>,
515 retry_spacing: Option<std::time::Duration>,
516 cancel_flag: Option<Arc<AtomicBool>>,
517) -> Result<String, String>
518where
519 T: NostrSigner + Clone,
520{
521 let mut last_error = String::from("No servers available");
522
523 let size_bytes = file_data.len() as u64;
526 let mime_for_routing = mime_type.unwrap_or("application/octet-stream");
527 let ranked = crate::blossom_capabilities::rank_servers(server_urls, mime_for_routing, is_encrypted, size_bytes);
528 let upload_session = crate::state::SessionGuard::capture();
530
531 for (index, server_url_str) in ranked.iter().enumerate() {
532 if let Some(ref flag) = cancel_flag {
533 if flag.load(Ordering::Relaxed) {
534 return Err("Upload cancelled".to_string());
535 }
536 }
537
538 let server_url = match Url::parse(server_url_str) {
539 Ok(url) => url,
540 Err(e) => {
541 crate::log_warn!("[Blossom Error] Invalid server URL '{}': {}", server_url_str, e);
542 last_error = format!("Invalid server URL: {}", e);
543 continue;
544 }
545 };
546
547 crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}",
548 index + 1, ranked.len(), server_url_str);
549
550 match upload_blob_with_progress(
551 signer.clone(),
552 &server_url,
553 file_data.clone(),
554 mime_type,
555 progress_callback.clone(),
556 retry_count,
557 retry_spacing,
558 cancel_flag.clone(),
559 ).await {
560 Ok(url) => {
561 crate::log_info!("[Blossom] Upload successful to: {}", server_url_str);
562 if let Err(err) = crate::blossom_capabilities::record_accepted(
563 server_url_str, mime_for_routing, is_encrypted, size_bytes, upload_session,
564 ) {
565 crate::log_warn!("[Blossom Cap] record_accepted failed: {}", err);
566 }
567 return Ok(url);
568 }
569 Err(e) => {
570 if e == "Upload cancelled" {
571 return Err(e);
572 }
573 crate::log_warn!("[Blossom Error] Upload failed to {}: {}", server_url_str, e);
574 let status = parse_status_from_error(&e);
575 if e.contains("[INTEGRITY]") || crate::blossom_capabilities::is_mime_rejection(status, &e) {
578 if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
579 server_url_str, mime_for_routing, is_encrypted, upload_session,
580 ) {
581 crate::log_warn!("[Blossom Cap] record_rejected_mime failed: {}", err);
582 }
583 } else if crate::blossom_capabilities::is_size_rejection(status) {
584 if let Err(err) = crate::blossom_capabilities::record_rejected_size(
585 server_url_str, mime_for_routing, is_encrypted, size_bytes, upload_session,
586 ) {
587 crate::log_warn!("[Blossom Cap] record_rejected_size failed: {}", err);
588 }
589 }
590 last_error = e;
593 let _ = progress_callback(Some(0), Some(0));
594 }
595 }
596 }
597
598 Err(format!("All Blossom servers failed. Last error: {}", last_error))
599}
600
601async fn build_delete_auth_header<T>(
607 signer: &T,
608 hash: Sha256Hash,
609) -> Result<HeaderValue, String>
610where
611 T: NostrSigner,
612{
613 let expiration = Timestamp::now() + std::time::Duration::from_secs(300);
614 let auth = BlossomAuthorization::new(
615 "Blossom delete authorization".to_string(),
616 expiration,
617 BlossomAuthorizationVerb::Delete,
618 BlossomAuthorizationScope::BlobSha256Hashes(vec![hash]),
619 );
620
621 let auth_event: Event = EventBuilder::blossom_auth(auth)
622 .sign(signer)
623 .await
624 .map_err(|e| format!("Failed to sign auth event: {}", e))?;
625
626 let encoded_auth = base64_simd::STANDARD.encode_to_string(auth_event.as_json());
627 let value = format!("Nostr {}", encoded_auth);
628
629 HeaderValue::try_from(value)
630 .map_err(|e| format!("Failed to create header value: {}", e))
631}
632
633pub async fn delete_blob<T>(
637 signer: T,
638 server_url: &Url,
639 hash: Sha256Hash,
640) -> Result<(), String>
641where
642 T: NostrSigner + Clone,
643{
644 let auth_header = build_delete_auth_header(&signer, hash).await?;
645
646 let mut url = server_url.clone();
647 url.set_path(&format!("/{}", hash));
649
650 let mut headers = HeaderMap::new();
651 headers.insert(AUTHORIZATION, auth_header);
652
653 let client = crate::net::build_http_client(std::time::Duration::from_secs(30))?;
654
655 let response = client
656 .delete(url)
657 .headers(headers)
658 .send()
659 .await
660 .map_err(|e| format!("Blossom DELETE request failed: {}", e))?;
661
662 let status = response.status();
663 if status.is_success() || status == StatusCode::NOT_FOUND {
664 Ok(())
665 } else {
666 let body = response.text().await.unwrap_or_else(|_| "<no body>".into());
667 Err(format!("Blossom DELETE failed with status {}: {}", status, body))
671 }
672}
673
674pub async fn delete_blob_by_url<T>(signer: T, url_str: &str) -> Result<(), String>
678where
679 T: NostrSigner + Clone,
680{
681 let parsed = Url::parse(url_str)
682 .map_err(|e| format!("Invalid Blossom URL: {}", e))?;
683 let last_segment = parsed
684 .path_segments()
685 .and_then(|segs| segs.rev().find(|s| !s.is_empty()))
686 .ok_or_else(|| "Blossom URL has no path segment".to_string())?;
687 let hash_str = last_segment.split('.').next().unwrap_or("");
688 let hash = Sha256Hash::from_str(hash_str)
689 .map_err(|e| format!("Path is not a SHA-256 hash: {}", e))?;
690
691 let mut origin = parsed.clone();
692 origin.set_path("/");
693 origin.set_query(None);
694 origin.set_fragment(None);
695
696 crate::log_info!("[Blossom] DELETE {} from {}", hash, origin);
697 let timeout = std::time::Duration::from_secs(15);
702 match tokio::time::timeout(timeout, delete_blob(signer, &origin, hash)).await {
703 Ok(Ok(())) => {
704 crate::log_info!("[Blossom] DELETE successful: {} from {}", hash, origin);
705 Ok(())
706 }
707 Ok(Err(e)) => {
708 crate::log_warn!("[Blossom] DELETE failed: {} from {}: {}", hash, origin, e);
709 Err(e)
710 }
711 Err(_) => {
712 let msg = format!("DELETE timed out after {}s", timeout.as_secs());
713 crate::log_warn!("[Blossom] {} ({} from {})", msg, hash, origin);
714 Err(msg)
715 }
716 }
717}
718
719pub fn delete_blobs_best_effort<T>(signer: T, urls: Vec<String>)
723where
724 T: NostrSigner + Clone + Send + Sync + 'static,
725{
726 for url_str in urls {
727 let url = match Url::parse(&url_str) {
728 Ok(u) => u,
729 Err(_) => continue,
730 };
731
732 let last_segment = match url.path_segments()
734 .and_then(|segs| segs.rev().find(|s| !s.is_empty()))
735 {
736 Some(s) => s,
737 None => continue,
738 };
739 let hash_str = last_segment.split('.').next().unwrap_or("");
741 let hash = match Sha256Hash::from_str(hash_str) {
742 Ok(h) => h,
743 Err(_) => continue,
744 };
745
746 let mut origin = url.clone();
747 origin.set_path("/");
748 origin.set_query(None);
749 origin.set_fragment(None);
750
751 let signer = signer.clone();
752 tokio::spawn(async move {
753 if let Err(e) = delete_blob(signer, &origin, hash).await {
754 crate::log_warn!("[Blossom delete] {} from {}: {}", hash, origin, e);
755 }
756 });
757 }
758}
759
760pub async fn probe_servers_for_octet_stream<T>(
765 signer: T,
766 server_urls: Vec<String>,
767 session: crate::state::SessionGuard,
768) -> Result<usize, String>
769where
770 T: NostrSigner + Clone,
771{
772 use rand::RngCore;
773 if !session.is_valid() { return Ok(0); }
774 if server_urls.is_empty() { return Ok(0); }
775
776 const PROBE_MIME: &str = "application/octet-stream";
777 let mut payload = vec![0u8; 32];
778 rand::thread_rng().fill_bytes(&mut payload[..]);
779 let payload = Arc::new(payload);
780 let payload_size = payload.len() as u64;
781
782 let mut probed = 0usize;
783 for server_url_str in &server_urls {
784 if !session.is_valid() { return Ok(probed); }
785 if crate::blossom_capabilities::has_fresh_capability_for(server_url_str, PROBE_MIME, true) {
786 continue;
787 }
788 let parsed = match Url::parse(server_url_str) {
789 Ok(u) => u,
790 Err(_) => continue,
791 };
792 let no_op_progress: ProgressCallback = Arc::new(|_, _| Ok(()));
794 match tokio::time::timeout(
795 std::time::Duration::from_secs(4),
796 upload_blob_with_progress(
797 signer.clone(),
798 &parsed,
799 payload.clone(),
800 Some(PROBE_MIME),
801 no_op_progress,
802 Some(0),
803 None,
804 None,
805 ),
806 ).await {
807 Ok(Ok(url)) => {
808 if !crate::blossom_servers::is_enabled_server(server_url_str) {
811 if let Some(hash) = extract_hash_from_blossom_url(&url) {
812 let _ = delete_blob(signer.clone(), &parsed, hash).await;
813 }
814 continue;
815 }
816 let delete_result = match extract_hash_from_blossom_url(&url) {
824 Some(hash) => tokio::time::timeout(
825 std::time::Duration::from_secs(4),
826 delete_blob(signer.clone(), &parsed, hash),
827 ).await.ok(),
828 None => None,
829 };
830 let refuses_deletion = matches!(
831 &delete_result,
832 Some(Err(e)) if matches!(parse_status_from_error(e), Some(403) | Some(405) | Some(501)),
833 );
834 if refuses_deletion {
835 if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
836 server_url_str, PROBE_MIME, true, session,
837 ) {
838 crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err);
839 }
840 probed += 1;
841 crate::log_info!("[Blossom Probe] {} refuses deletion; routing around", server_url_str);
842 } else {
843 if let Err(e) = crate::blossom_capabilities::record_accepted(
844 server_url_str, PROBE_MIME, true, payload_size, session,
845 ) {
846 crate::log_warn!("[Blossom Probe] record_accepted failed: {}", e);
847 }
848 probed += 1;
849 crate::log_info!("[Blossom Probe] {} validated (accepts + verbatim + deletes)", server_url_str);
850 }
851 }
852 Ok(Err(e)) => {
853 let status = parse_status_from_error(&e);
854 if e.contains("[INTEGRITY]") || crate::blossom_capabilities::is_mime_rejection(status, &e) {
857 if !crate::blossom_servers::is_enabled_server(server_url_str) {
858 continue;
859 }
860 if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
861 server_url_str, PROBE_MIME, true, session,
862 ) {
863 crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err);
864 }
865 probed += 1;
866 crate::log_info!("[Blossom Probe] {} unsuitable; routing around: {}", server_url_str, e);
867 } else {
868 crate::log_debug!("[Blossom Probe] {} transient error (not cached): {}", server_url_str, e);
870 }
871 }
872 Err(_) => {
873 crate::log_debug!("[Blossom Probe] {} timed out, not cached", server_url_str);
874 }
875 }
876 }
877 Ok(probed)
878}
879
880fn extract_hash_from_blossom_url(url: &str) -> Option<Sha256Hash> {
883 let parsed = Url::parse(url).ok()?;
884 let last = parsed.path_segments()?.rev().find(|s| !s.is_empty())?;
885 let stem = last.split('.').next()?;
886 Sha256Hash::from_str(stem).ok()
887}
888
889fn parse_status_from_error(msg: &str) -> Option<u16> {
893 let key = "with status ";
894 let i = msg.find(key)?;
895 let tail = &msg[i + key.len()..];
896 let digits: String = tail.chars().take_while(|c| c.is_ascii_digit()).collect();
897 digits.parse::<u16>().ok()
898}
899
900#[cfg(test)]
901mod parse_status_tests {
902 use super::parse_status_from_error;
903
904 #[test]
905 fn extracts_status_code() {
906 assert_eq!(parse_status_from_error("Upload failed with status 500 Internal Server Error: x"), Some(500));
907 assert_eq!(parse_status_from_error("Upload failed with status 413 Payload Too Large"), Some(413));
908 assert_eq!(parse_status_from_error("Upload failed with status 415"), Some(415));
909 assert_eq!(parse_status_from_error("Upload failed with status 524 <unknown status code>: gateway"), Some(524));
912 }
913
914 #[test]
915 fn returns_none_when_absent() {
916 assert_eq!(parse_status_from_error("network error: timeout"), None);
917 }
918}
919
920#[cfg(test)]
921mod hash_extract_tests {
922 use super::extract_hash_from_blossom_url;
923
924 const HASH_HEX: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
925
926 #[test]
927 fn plain_url() {
928 let url = format!("https://srv.example/{}", HASH_HEX);
929 assert!(extract_hash_from_blossom_url(&url).is_some());
930 }
931
932 #[test]
933 fn with_extension() {
934 let url = format!("https://srv.example/{}.jpg", HASH_HEX);
935 assert!(extract_hash_from_blossom_url(&url).is_some());
936 }
937
938 #[test]
939 fn trailing_slash_still_resolves() {
940 let url = format!("https://srv.example/{}/", HASH_HEX);
942 assert!(extract_hash_from_blossom_url(&url).is_some());
943 }
944
945 #[test]
946 fn malformed_returns_none() {
947 assert!(extract_hash_from_blossom_url("https://srv.example/").is_none());
948 assert!(extract_hash_from_blossom_url("not a url").is_none());
949 assert!(extract_hash_from_blossom_url("https://srv.example/notahash").is_none());
950 }
951
952 #[test]
953 fn x_sha256_simd_hex_matches_lowerhex() {
954 use nostr_sdk::hashes::{sha256::Hash as Sha256Hash, Hash};
955 let hash = Sha256Hash::hash(b"vector blossom x-sha-256 parity check");
959 assert_eq!(
960 crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()),
961 format!("{:x}", hash),
962 );
963 }
964}