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 let looks_like_mid_stream_drop = (
164 e.contains("Upload request failed")
165 || e.contains("error sending request")
166 || e.contains("connection reset")
167 || e.contains("connection closed")
168 || e.contains("connection refused")
169 || e.contains("body write")
170 || e.contains("IncompleteMessage")
171 || e.contains("broken pipe")
172 ) && file_data.len() > 8 * 1024 * 1024;
173 if looks_like_mid_stream_drop {
174 crate::log_warn!(
175 "[Blossom] {} dropped the connection mid-upload of {} bytes, treating as permanent",
176 server_url, file_data.len(),
177 );
178 return Err(e);
179 }
180 last_error = Some(e);
181 }
182 }
183 }
184
185 Err(last_error.unwrap_or_else(|| "No upload attempts were made".to_string()))
187}
188
189async fn upload_attempt<T>(
191 signer: T,
192 server_url: &Url,
193 file_data: Arc<Vec<u8>>,
194 mime_type: Option<&str>,
195 progress_callback: &ProgressCallback,
196 cancel_flag: Option<Arc<AtomicBool>>,
197) -> Result<String, String>
198where
199 T: NostrSigner,
200{
201 let upload_url = server_url.join("upload")
202 .map_err(|e| format!("Invalid server URL: {}", e))?;
203
204 let total_size = file_data.len() as u64;
205 let hash = Sha256Hash::hash(&*file_data);
206
207 progress_callback(Some(0), Some(0)).map_err(|e| e)?;
208
209 let auth_header = build_auth_header(&signer, hash).await?;
211
212 let client = crate::net::build_http_client_with_options(
214 std::time::Duration::from_secs(300),
215 false,
216 )?;
217
218 {
220 let mut head_headers = HeaderMap::new();
221 head_headers.insert(AUTHORIZATION, auth_header.clone());
222 head_headers.insert(
223 "X-Content-Length",
224 HeaderValue::from_str(&total_size.to_string())
225 .map_err(|e| format!("Invalid X-Content-Length: {}", e))?,
226 );
227 head_headers.insert(
230 "X-SHA-256",
231 HeaderValue::from_str(&crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()))
232 .map_err(|e| format!("Invalid X-SHA-256: {}", e))?,
233 );
234 if let Some(ct) = mime_type {
235 head_headers.insert(
236 "X-Content-Type",
237 HeaderValue::from_str(ct).map_err(|e| format!("Invalid X-Content-Type: {}", e))?,
238 );
239 }
240 match tokio::time::timeout(
241 std::time::Duration::from_secs(5),
242 client.head(upload_url.clone()).headers(head_headers).send(),
243 ).await {
244 Ok(Ok(resp)) => {
245 let status = resp.status();
246 let x_reason = resp.headers().get("X-Reason")
249 .and_then(|v| v.to_str().ok())
250 .map(|s| s.to_string());
251 let body = resp.text().await.unwrap_or_default();
252 let diag = if !body.is_empty() {
253 if let Some(r) = &x_reason {
254 format!("{} (X-Reason: {})", body, r)
255 } else {
256 body
257 }
258 } else if let Some(r) = x_reason {
259 r
260 } else {
261 format!("rejected at preflight ({})", status)
262 };
263 let is_413 = status == StatusCode::PAYLOAD_TOO_LARGE;
264 let is_415 = status == StatusCode::UNSUPPORTED_MEDIA_TYPE;
265 let mime_hinted = status.is_client_error() && !is_413 && {
266 crate::blossom_capabilities::is_mime_rejection(Some(status.as_u16()), &diag)
267 };
268 if is_413 || is_415 || mime_hinted {
269 crate::log_warn!(
270 "[Blossom Preflight] {} REJECTED {} ({} bytes, {}): {}",
271 server_url, status, total_size,
272 mime_type.unwrap_or("(no mime)"), diag,
273 );
274 return Err(format!(
275 "Upload failed with status {}: {}",
276 status, diag,
277 ));
278 }
279 crate::log_debug!(
280 "[Blossom Preflight] {} → {} ({} bytes); proceeding to PUT",
281 server_url, status, total_size,
282 );
283 }
284 Ok(Err(e)) => {
285 crate::log_debug!("[Blossom Preflight] {} HEAD failed: {}, falling through to PUT", server_url, e);
286 }
287 Err(_) => {
288 crate::log_debug!("[Blossom Preflight] {} HEAD timed out (5s), falling through to PUT", server_url);
289 }
290 }
291 }
292
293 let bytes_sent = Arc::new(Mutex::new(0u64));
294 let tracking_stream = ProgressTrackingStream::new(file_data, Arc::clone(&bytes_sent));
295 let body = Body::wrap_stream(tracking_stream);
296
297 let mut headers = HeaderMap::new();
298 headers.insert(AUTHORIZATION, auth_header);
299 if let Some(ct) = mime_type {
300 headers.insert(
301 CONTENT_TYPE,
302 HeaderValue::from_str(ct).map_err(|e| format!("Invalid content type: {}", e))?
303 );
304 }
305 headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size));
309
310 let mut request_future = Box::pin(client
311 .put(upload_url.clone())
312 .headers(headers)
313 .body(body)
314 .send());
315
316 let mut last_percentage = 0;
317 let mut poll_interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
318
319 let response = loop {
320 tokio::select! {
321 response = &mut request_future => {
322 break response.map_err(|e| format!("Upload request failed: {}", e))?;
323 },
324 _ = poll_interval.tick() => {
325 if let Some(ref flag) = cancel_flag {
326 if flag.load(Ordering::Relaxed) {
327 return Err("Upload cancelled".to_string());
328 }
329 }
330
331 let current_bytes = *bytes_sent.lock().unwrap();
332 let percentage = if total_size > 0 {
333 ((current_bytes as f64 / total_size as f64) * 100.0) as u8
334 } else {
335 0
336 };
337
338 if percentage != last_percentage {
339 if let Err(e) = progress_callback(Some(percentage), Some(current_bytes)) {
340 return Err(e);
341 }
342 last_percentage = percentage;
343 }
344 }
345 }
346 };
347
348 let final_bytes = *bytes_sent.lock().unwrap();
349 if final_bytes == total_size && last_percentage < 100 {
350 progress_callback(Some(100), Some(total_size)).map_err(|e| e)?;
351 }
352
353 let status = response.status();
355 if status.is_success() {
356 let descriptor: BlobDescriptor = response.json().await
357 .map_err(|e| format!("Failed to parse response: {}", e))?;
358 Ok(descriptor.url.to_string())
359 } else {
360 let x_reason = response.headers().get("X-Reason")
362 .and_then(|v| v.to_str().ok())
363 .map(|s| s.to_string());
364 let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
365 let display = match (error_text.is_empty(), x_reason) {
366 (false, Some(r)) => format!("{} (X-Reason: {})", error_text, r),
367 (false, None) => error_text,
368 (true, Some(r)) => r,
369 (true, None) => "Unknown error".to_string(),
370 };
371 crate::log_warn!("[Blossom Error] Upload failed with status {}: {}", status, display);
372 Err(format!("Upload failed with status {}: {}", status, display))
373 }
374}
375
376pub async fn upload_blob<T>(
378 signer: T,
379 server_url: &Url,
380 file_data: Arc<Vec<u8>>,
381 mime_type: Option<&str>,
382) -> Result<String, String>
383where
384 T: NostrSigner,
385{
386 let upload_url = server_url.join("upload")
387 .map_err(|e| format!("Invalid server URL: {}", e))?;
388
389 let hash = Sha256Hash::hash(&*file_data);
390 let total_size = file_data.len() as u64;
391
392 let auth_header = build_auth_header(&signer, hash).await?;
393
394 let mut headers = HeaderMap::new();
395 headers.insert(AUTHORIZATION, auth_header);
396 if let Some(ct) = mime_type {
397 headers.insert(
398 CONTENT_TYPE,
399 HeaderValue::from_str(ct).map_err(|e| format!("Invalid content type: {}", e))?
400 );
401 }
402 headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size));
403
404 let client = crate::net::build_http_client_with_options(
406 std::time::Duration::from_secs(300),
407 false,
408 )?;
409
410 let body_data: Vec<u8> = Arc::try_unwrap(file_data)
411 .unwrap_or_else(|arc| (*arc).clone());
412 let response = client
413 .put(upload_url)
414 .headers(headers)
415 .body(body_data)
416 .send()
417 .await
418 .map_err(|e| format!("Upload request failed: {}", e))?;
419
420 let status = response.status();
422 if status.is_success() {
423 let descriptor: BlobDescriptor = response.json().await
424 .map_err(|e| format!("Failed to parse response: {}", e))?;
425 Ok(descriptor.url.to_string())
426 } else {
427 let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
428 Err(format!("Upload failed with status {}: {}", status, error_text))
429 }
430}
431
432pub async fn upload_blob_with_failover<T>(
439 signer: T,
440 server_urls: Vec<String>,
441 file_data: Arc<Vec<u8>>,
442 mime_type: Option<&str>,
443) -> Result<String, String>
444where
445 T: NostrSigner + Clone,
446{
447 let mut last_error = String::from("No servers available");
448
449 for (index, server_url_str) in server_urls.iter().enumerate() {
450 let server_url = match Url::parse(server_url_str) {
451 Ok(url) => url,
452 Err(e) => {
453 crate::log_warn!("[Blossom Error] Invalid server URL '{}': {}", server_url_str, e);
454 last_error = format!("Invalid server URL: {}", e);
455 continue;
456 }
457 };
458
459 crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}",
460 index + 1, server_urls.len(), server_url_str);
461
462 match upload_blob(signer.clone(), &server_url, file_data.clone(), mime_type).await {
463 Ok(url) => {
464 crate::log_info!("[Blossom] Upload successful to: {}", server_url_str);
465 return Ok(url);
466 }
467 Err(e) => {
468 crate::log_warn!("[Blossom Error] Upload failed to {}: {}", server_url_str, e);
469 last_error = e;
470 }
471 }
472 }
473
474 Err(format!("All Blossom servers failed. Last error: {}", last_error))
475}
476
477pub async fn upload_blob_with_progress_and_failover<T>(
479 signer: T,
480 server_urls: Vec<String>,
481 file_data: Arc<Vec<u8>>,
482 mime_type: Option<&str>,
483 is_encrypted: bool,
484 progress_callback: ProgressCallback,
485 retry_count: Option<u32>,
486 retry_spacing: Option<std::time::Duration>,
487 cancel_flag: Option<Arc<AtomicBool>>,
488) -> Result<String, String>
489where
490 T: NostrSigner + Clone,
491{
492 let mut last_error = String::from("No servers available");
493
494 let size_bytes = file_data.len() as u64;
497 let mime_for_routing = mime_type.unwrap_or("application/octet-stream");
498 let ranked = crate::blossom_capabilities::rank_servers(server_urls, mime_for_routing, is_encrypted, size_bytes);
499 let upload_session = crate::state::SessionGuard::capture();
501
502 for (index, server_url_str) in ranked.iter().enumerate() {
503 if let Some(ref flag) = cancel_flag {
504 if flag.load(Ordering::Relaxed) {
505 return Err("Upload cancelled".to_string());
506 }
507 }
508
509 let server_url = match Url::parse(server_url_str) {
510 Ok(url) => url,
511 Err(e) => {
512 crate::log_warn!("[Blossom Error] Invalid server URL '{}': {}", server_url_str, e);
513 last_error = format!("Invalid server URL: {}", e);
514 continue;
515 }
516 };
517
518 crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}",
519 index + 1, ranked.len(), server_url_str);
520
521 match upload_blob_with_progress(
522 signer.clone(),
523 &server_url,
524 file_data.clone(),
525 mime_type,
526 progress_callback.clone(),
527 retry_count,
528 retry_spacing,
529 cancel_flag.clone(),
530 ).await {
531 Ok(url) => {
532 crate::log_info!("[Blossom] Upload successful to: {}", server_url_str);
533 if let Err(err) = crate::blossom_capabilities::record_accepted(
534 server_url_str, mime_for_routing, is_encrypted, size_bytes, upload_session,
535 ) {
536 crate::log_warn!("[Blossom Cap] record_accepted failed: {}", err);
537 }
538 return Ok(url);
539 }
540 Err(e) => {
541 if e == "Upload cancelled" {
542 return Err(e);
543 }
544 crate::log_warn!("[Blossom Error] Upload failed to {}: {}", server_url_str, e);
545 let status = parse_status_from_error(&e);
546 if crate::blossom_capabilities::is_mime_rejection(status, &e) {
547 if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
548 server_url_str, mime_for_routing, is_encrypted, upload_session,
549 ) {
550 crate::log_warn!("[Blossom Cap] record_rejected_mime failed: {}", err);
551 }
552 } else if crate::blossom_capabilities::is_size_rejection(status) {
553 if let Err(err) = crate::blossom_capabilities::record_rejected_size(
554 server_url_str, mime_for_routing, is_encrypted, size_bytes, upload_session,
555 ) {
556 crate::log_warn!("[Blossom Cap] record_rejected_size failed: {}", err);
557 }
558 }
559 last_error = e;
562 let _ = progress_callback(Some(0), Some(0));
563 }
564 }
565 }
566
567 Err(format!("All Blossom servers failed. Last error: {}", last_error))
568}
569
570async fn build_delete_auth_header<T>(
576 signer: &T,
577 hash: Sha256Hash,
578) -> Result<HeaderValue, String>
579where
580 T: NostrSigner,
581{
582 let expiration = Timestamp::now() + std::time::Duration::from_secs(300);
583 let auth = BlossomAuthorization::new(
584 "Blossom delete authorization".to_string(),
585 expiration,
586 BlossomAuthorizationVerb::Delete,
587 BlossomAuthorizationScope::BlobSha256Hashes(vec![hash]),
588 );
589
590 let auth_event: Event = EventBuilder::blossom_auth(auth)
591 .sign(signer)
592 .await
593 .map_err(|e| format!("Failed to sign auth event: {}", e))?;
594
595 let encoded_auth = base64_simd::STANDARD.encode_to_string(auth_event.as_json());
596 let value = format!("Nostr {}", encoded_auth);
597
598 HeaderValue::try_from(value)
599 .map_err(|e| format!("Failed to create header value: {}", e))
600}
601
602pub async fn delete_blob<T>(
606 signer: T,
607 server_url: &Url,
608 hash: Sha256Hash,
609) -> Result<(), String>
610where
611 T: NostrSigner + Clone,
612{
613 let auth_header = build_delete_auth_header(&signer, hash).await?;
614
615 let mut url = server_url.clone();
616 url.set_path(&format!("/{}", hash));
618
619 let mut headers = HeaderMap::new();
620 headers.insert(AUTHORIZATION, auth_header);
621
622 let client = crate::net::build_http_client(std::time::Duration::from_secs(30))?;
623
624 let response = client
625 .delete(url)
626 .headers(headers)
627 .send()
628 .await
629 .map_err(|e| format!("Blossom DELETE request failed: {}", e))?;
630
631 let status = response.status();
632 if status.is_success() || status == StatusCode::NOT_FOUND {
633 Ok(())
634 } else {
635 let body = response.text().await.unwrap_or_else(|_| "<no body>".into());
636 Err(format!("Blossom DELETE returned {}: {}", status, body))
637 }
638}
639
640pub async fn delete_blob_by_url<T>(signer: T, url_str: &str) -> Result<(), String>
644where
645 T: NostrSigner + Clone,
646{
647 let parsed = Url::parse(url_str)
648 .map_err(|e| format!("Invalid Blossom URL: {}", e))?;
649 let last_segment = parsed
650 .path_segments()
651 .and_then(|segs| segs.rev().find(|s| !s.is_empty()))
652 .ok_or_else(|| "Blossom URL has no path segment".to_string())?;
653 let hash_str = last_segment.split('.').next().unwrap_or("");
654 let hash = Sha256Hash::from_str(hash_str)
655 .map_err(|e| format!("Path is not a SHA-256 hash: {}", e))?;
656
657 let mut origin = parsed.clone();
658 origin.set_path("/");
659 origin.set_query(None);
660 origin.set_fragment(None);
661
662 crate::log_info!("[Blossom] DELETE {} from {}", hash, origin);
663 let timeout = std::time::Duration::from_secs(15);
668 match tokio::time::timeout(timeout, delete_blob(signer, &origin, hash)).await {
669 Ok(Ok(())) => {
670 crate::log_info!("[Blossom] DELETE successful: {} from {}", hash, origin);
671 Ok(())
672 }
673 Ok(Err(e)) => {
674 crate::log_warn!("[Blossom] DELETE failed: {} from {}: {}", hash, origin, e);
675 Err(e)
676 }
677 Err(_) => {
678 let msg = format!("DELETE timed out after {}s", timeout.as_secs());
679 crate::log_warn!("[Blossom] {} ({} from {})", msg, hash, origin);
680 Err(msg)
681 }
682 }
683}
684
685pub fn delete_blobs_best_effort<T>(signer: T, urls: Vec<String>)
689where
690 T: NostrSigner + Clone + Send + Sync + 'static,
691{
692 for url_str in urls {
693 let url = match Url::parse(&url_str) {
694 Ok(u) => u,
695 Err(_) => continue,
696 };
697
698 let last_segment = match url.path_segments()
700 .and_then(|segs| segs.rev().find(|s| !s.is_empty()))
701 {
702 Some(s) => s,
703 None => continue,
704 };
705 let hash_str = last_segment.split('.').next().unwrap_or("");
707 let hash = match Sha256Hash::from_str(hash_str) {
708 Ok(h) => h,
709 Err(_) => continue,
710 };
711
712 let mut origin = url.clone();
713 origin.set_path("/");
714 origin.set_query(None);
715 origin.set_fragment(None);
716
717 let signer = signer.clone();
718 tokio::spawn(async move {
719 if let Err(e) = delete_blob(signer, &origin, hash).await {
720 crate::log_warn!("[Blossom delete] {} from {}: {}", hash, origin, e);
721 }
722 });
723 }
724}
725
726pub async fn probe_servers_for_octet_stream<T>(
731 signer: T,
732 server_urls: Vec<String>,
733 session: crate::state::SessionGuard,
734) -> Result<usize, String>
735where
736 T: NostrSigner + Clone,
737{
738 use rand::RngCore;
739 if !session.is_valid() { return Ok(0); }
740 if server_urls.is_empty() { return Ok(0); }
741
742 const PROBE_MIME: &str = "application/octet-stream";
743 let mut payload = vec![0u8; 32];
744 rand::thread_rng().fill_bytes(&mut payload[..]);
745 let payload = Arc::new(payload);
746 let payload_size = payload.len() as u64;
747
748 let mut probed = 0usize;
749 for server_url_str in &server_urls {
750 if !session.is_valid() { return Ok(probed); }
751 if crate::blossom_capabilities::has_fresh_capability_for(server_url_str, PROBE_MIME, true) {
752 continue;
753 }
754 let parsed = match Url::parse(server_url_str) {
755 Ok(u) => u,
756 Err(_) => continue,
757 };
758 let no_op_progress: ProgressCallback = Arc::new(|_, _| Ok(()));
760 match tokio::time::timeout(
761 std::time::Duration::from_secs(4),
762 upload_blob_with_progress(
763 signer.clone(),
764 &parsed,
765 payload.clone(),
766 Some(PROBE_MIME),
767 no_op_progress,
768 Some(0),
769 None,
770 None,
771 ),
772 ).await {
773 Ok(Ok(url)) => {
774 if !crate::blossom_servers::is_enabled_server(server_url_str) {
777 if let Some(hash) = extract_hash_from_blossom_url(&url) {
778 let _ = delete_blob(signer.clone(), &parsed, hash).await;
779 }
780 continue;
781 }
782 if let Err(e) = crate::blossom_capabilities::record_accepted(
783 server_url_str, PROBE_MIME, true, payload_size, session,
784 ) {
785 crate::log_warn!("[Blossom Probe] record_accepted failed: {}", e);
786 }
787 if let Some(hash) = extract_hash_from_blossom_url(&url) {
788 let _ = delete_blob(signer.clone(), &parsed, hash).await;
789 }
790 probed += 1;
791 crate::log_info!("[Blossom Probe] {} accepts octet-stream", server_url_str);
792 }
793 Ok(Err(e)) => {
794 let status = parse_status_from_error(&e);
795 if crate::blossom_capabilities::is_mime_rejection(status, &e) {
796 if !crate::blossom_servers::is_enabled_server(server_url_str) {
797 continue;
798 }
799 if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
800 server_url_str, PROBE_MIME, true, session,
801 ) {
802 crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err);
803 }
804 probed += 1;
805 crate::log_info!("[Blossom Probe] {} rejects octet-stream: {}", server_url_str, e);
806 } else {
807 crate::log_debug!("[Blossom Probe] {} transient error (not cached): {}", server_url_str, e);
809 }
810 }
811 Err(_) => {
812 crate::log_debug!("[Blossom Probe] {} timed out, not cached", server_url_str);
813 }
814 }
815 }
816 Ok(probed)
817}
818
819fn extract_hash_from_blossom_url(url: &str) -> Option<Sha256Hash> {
822 let parsed = Url::parse(url).ok()?;
823 let last = parsed.path_segments()?.rev().find(|s| !s.is_empty())?;
824 let stem = last.split('.').next()?;
825 Sha256Hash::from_str(stem).ok()
826}
827
828fn parse_status_from_error(msg: &str) -> Option<u16> {
832 let key = "with status ";
833 let i = msg.find(key)?;
834 let tail = &msg[i + key.len()..];
835 let digits: String = tail.chars().take_while(|c| c.is_ascii_digit()).collect();
836 digits.parse::<u16>().ok()
837}
838
839#[cfg(test)]
840mod parse_status_tests {
841 use super::parse_status_from_error;
842
843 #[test]
844 fn extracts_status_code() {
845 assert_eq!(parse_status_from_error("Upload failed with status 500 Internal Server Error: x"), Some(500));
846 assert_eq!(parse_status_from_error("Upload failed with status 413 Payload Too Large"), Some(413));
847 assert_eq!(parse_status_from_error("Upload failed with status 415"), Some(415));
848 }
849
850 #[test]
851 fn returns_none_when_absent() {
852 assert_eq!(parse_status_from_error("network error: timeout"), None);
853 }
854}
855
856#[cfg(test)]
857mod hash_extract_tests {
858 use super::extract_hash_from_blossom_url;
859
860 const HASH_HEX: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
861
862 #[test]
863 fn plain_url() {
864 let url = format!("https://srv.example/{}", HASH_HEX);
865 assert!(extract_hash_from_blossom_url(&url).is_some());
866 }
867
868 #[test]
869 fn with_extension() {
870 let url = format!("https://srv.example/{}.jpg", HASH_HEX);
871 assert!(extract_hash_from_blossom_url(&url).is_some());
872 }
873
874 #[test]
875 fn trailing_slash_still_resolves() {
876 let url = format!("https://srv.example/{}/", HASH_HEX);
878 assert!(extract_hash_from_blossom_url(&url).is_some());
879 }
880
881 #[test]
882 fn malformed_returns_none() {
883 assert!(extract_hash_from_blossom_url("https://srv.example/").is_none());
884 assert!(extract_hash_from_blossom_url("not a url").is_none());
885 assert!(extract_hash_from_blossom_url("https://srv.example/notahash").is_none());
886 }
887
888 #[test]
889 fn x_sha256_simd_hex_matches_lowerhex() {
890 use nostr_sdk::hashes::{sha256::Hash as Sha256Hash, Hash};
891 let hash = Sha256Hash::hash(b"vector blossom x-sha-256 parity check");
895 assert_eq!(
896 crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()),
897 format!("{:x}", hash),
898 );
899 }
900}