1use crate::signer::VectorSigner;
2use nostr_sdk::prelude::{Event, FinalizeEventAsync, Timestamp, Url};
3use bitcoin_hashes::sha256::Hash as Sha256Hash;
4use nostr_blossom::prelude::*;
5use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE};
6use reqwest::{Body, StatusCode};
7use std::str::FromStr;
8use std::sync::{Arc, Mutex};
9use std::sync::atomic::{AtomicBool, Ordering};
10use tokio::sync::mpsc;
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
18struct ProgressTrackingStream {
20 bytes_sent: Arc<Mutex<u64>>,
21 inner: mpsc::Receiver<Result<Vec<u8>, std::io::Error>>,
22}
23
24impl ProgressTrackingStream {
25 fn new(data: Arc<Vec<u8>>, bytes_sent: Arc<Mutex<u64>>) -> Self {
26 let (tx, rx) = mpsc::channel(8); tokio::spawn(async move {
32 let chunk_size = 64 * 1024; let mut position = 0;
34
35 while position < data.len() {
36 let end = std::cmp::min(position + chunk_size, data.len());
37 let chunk = data[position..end].to_vec();
38
39 if tx.send(Ok(chunk)).await.is_err() {
41 break; }
43
44 position = end;
45 }
46 });
47
48 Self {
49 bytes_sent,
50 inner: rx,
51 }
52 }
53}
54
55impl Stream for ProgressTrackingStream {
56 type Item = Result<Vec<u8>, std::io::Error>;
57
58 fn poll_next(
59 mut self: Pin<&mut Self>,
60 cx: &mut Context<'_>,
61 ) -> Poll<Option<Self::Item>> {
62 match self.inner.poll_recv(cx) {
63 Poll::Ready(Some(result)) => {
64 if let Ok(chunk) = &result {
66 let mut bytes_sent = self.bytes_sent.lock().unwrap();
67 *bytes_sent += chunk.len() as u64;
68 }
69 Poll::Ready(Some(result))
70 }
71 Poll::Ready(None) => Poll::Ready(None),
72 Poll::Pending => Poll::Pending,
73 }
74 }
75}
76
77async fn build_auth_header<T>(
79 signer: &T,
80 hash: Sha256Hash,
81) -> Result<HeaderValue, String>
82where
83 T: VectorSigner,
84{
85 let expiration = Timestamp::now() + std::time::Duration::from_secs(300);
87 let auth = BlossomAuthorization::new(
88 "Blossom upload authorization".to_string(),
89 expiration,
90 BlossomAuthorizationVerb::Upload,
91 BlossomAuthorizationScope::BlobSha256Hashes(vec![hash]),
92 );
93
94 let auth_event: Event = auth
96 .finalize_async(signer)
97 .await
98 .map_err(|e| format!("Failed to sign auth event: {}", e))?;
99
100 let encoded_auth = base64_simd::STANDARD.encode_to_string(auth_event.as_json());
102 let value = format!("Nostr {}", encoded_auth);
103
104 HeaderValue::try_from(value)
105 .map_err(|e| format!("Failed to create header value: {}", e))
106}
107
108pub async fn upload_blob_with_progress<T>(
111 signer: T,
112 server_url: &Url,
113 file_data: Arc<Vec<u8>>,
114 mime_type: Option<&str>,
115 progress_callback: ProgressCallback,
116 retry_count: Option<u32>,
117 retry_spacing: Option<std::time::Duration>,
118 cancel_flag: Option<Arc<AtomicBool>>,
119) -> Result<String, String>
120where
121 T: VectorSigner + Clone,
122{
123 let retry_count = retry_count.unwrap_or(0);
124 let retry_spacing = retry_spacing.unwrap_or(std::time::Duration::from_secs(1));
125
126 let mut last_error = None;
127
128 for attempt in 0..=retry_count {
129 if attempt > 0 {
130 tokio::time::sleep(retry_spacing).await;
131 }
132
133 if let Some(ref flag) = cancel_flag {
134 if flag.load(Ordering::Relaxed) {
135 return Err("Upload cancelled".to_string());
136 }
137 }
138
139 match upload_attempt(
140 signer.clone(),
141 server_url,
142 file_data.clone(),
143 mime_type,
144 &progress_callback,
145 cancel_flag.clone(),
146 ).await {
147 Ok(url) => return Ok(url),
148 Err(e) => {
149 if e == "Upload cancelled" {
150 return Err(e);
151 }
152 crate::log_warn!(
153 "[Blossom] Attempt {}/{} to {} failed: {}",
154 attempt + 1, retry_count + 1, server_url, e,
155 );
156 let status = parse_status_from_error(&e);
158 let permanent = crate::blossom_capabilities::is_mime_rejection(status, &e)
159 || crate::blossom_capabilities::is_size_rejection(status);
160 if permanent {
161 return Err(e);
162 }
163 if matches!(status, Some(504 | 520 | 521 | 522 | 523 | 524 | 525 | 526)) {
168 crate::log_warn!(
169 "[Blossom] {} origin unreachable (status {}) on {} bytes; routing to the next server",
170 server_url, status.unwrap_or(0), file_data.len(),
171 );
172 return Err(e);
173 }
174 let looks_like_mid_stream_drop = (
178 e.contains("Upload request failed")
179 || e.contains("error sending request")
180 || e.contains("connection reset")
181 || e.contains("connection closed")
182 || e.contains("connection refused")
183 || e.contains("body write")
184 || e.contains("IncompleteMessage")
185 || e.contains("broken pipe")
186 ) && file_data.len() > 8 * 1024 * 1024;
187 if looks_like_mid_stream_drop {
188 crate::log_warn!(
189 "[Blossom] {} dropped the connection mid-upload of {} bytes, treating as permanent",
190 server_url, file_data.len(),
191 );
192 return Err(e);
193 }
194 last_error = Some(e);
195 }
196 }
197 }
198
199 Err(last_error.unwrap_or_else(|| "No upload attempts were made".to_string()))
201}
202
203async fn upload_attempt<T>(
205 signer: T,
206 server_url: &Url,
207 file_data: Arc<Vec<u8>>,
208 mime_type: Option<&str>,
209 progress_callback: &ProgressCallback,
210 cancel_flag: Option<Arc<AtomicBool>>,
211) -> Result<String, String>
212where
213 T: VectorSigner,
214{
215 let upload_url = server_url.join("upload")
216 .map_err(|e| format!("Invalid server URL: {}", e))?;
217
218 let total_size = file_data.len() as u64;
219 let hash = Sha256Hash::hash(&*file_data);
220
221 progress_callback(Some(0), Some(0)).map_err(|e| e)?;
222
223 let auth_header = build_auth_header(&signer, hash).await?;
225
226 let client = crate::net::build_http_client_with_options(
228 std::time::Duration::from_secs(300),
229 None,
230 false,
231 )?;
232
233 {
235 let mut head_headers = HeaderMap::new();
236 head_headers.insert(AUTHORIZATION, auth_header.clone());
237 head_headers.insert(
238 "X-Content-Length",
239 HeaderValue::from_str(&total_size.to_string())
240 .map_err(|e| format!("Invalid X-Content-Length: {}", e))?,
241 );
242 head_headers.insert(
245 "X-SHA-256",
246 HeaderValue::from_str(&crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()))
247 .map_err(|e| format!("Invalid X-SHA-256: {}", e))?,
248 );
249 if let Some(ct) = mime_type {
250 head_headers.insert(
251 "X-Content-Type",
252 HeaderValue::from_str(ct).map_err(|e| format!("Invalid X-Content-Type: {}", e))?,
253 );
254 }
255 match tokio::time::timeout(
256 std::time::Duration::from_secs(5),
257 client.head(upload_url.clone()).headers(head_headers).send(),
258 ).await {
259 Ok(Ok(resp)) => {
260 let status = resp.status();
261 let x_reason = resp.headers().get("X-Reason")
264 .and_then(|v| v.to_str().ok())
265 .map(|s| s.to_string());
266 let body = resp.text().await.unwrap_or_default();
267 let diag = if !body.is_empty() {
268 if let Some(r) = &x_reason {
269 format!("{} (X-Reason: {})", body, r)
270 } else {
271 body
272 }
273 } else if let Some(r) = x_reason {
274 r
275 } else {
276 format!("rejected at preflight ({})", status)
277 };
278 let is_413 = status == StatusCode::PAYLOAD_TOO_LARGE;
279 let is_415 = status == StatusCode::UNSUPPORTED_MEDIA_TYPE;
280 let mime_hinted = status.is_client_error() && !is_413 && {
281 crate::blossom_capabilities::is_mime_rejection(Some(status.as_u16()), &diag)
282 };
283 if is_413 || is_415 || mime_hinted {
284 crate::log_warn!(
285 "[Blossom Preflight] {} REJECTED {} ({} bytes, {}): {}",
286 server_url, status, total_size,
287 mime_type.unwrap_or("(no mime)"), diag,
288 );
289 return Err(format!(
290 "Upload failed with status {}: {}",
291 status, diag,
292 ));
293 }
294 crate::log_debug!(
295 "[Blossom Preflight] {} → {} ({} bytes); proceeding to PUT",
296 server_url, status, total_size,
297 );
298 }
299 Ok(Err(e)) => {
300 crate::log_debug!("[Blossom Preflight] {} HEAD failed: {}, falling through to PUT", server_url, e);
301 }
302 Err(_) => {
303 crate::log_debug!("[Blossom Preflight] {} HEAD timed out (5s), falling through to PUT", server_url);
304 }
305 }
306 }
307
308 let bytes_sent = Arc::new(Mutex::new(0u64));
309 let tracking_stream = ProgressTrackingStream::new(file_data, Arc::clone(&bytes_sent));
310 let body = Body::wrap_stream(tracking_stream);
311
312 let mut headers = HeaderMap::new();
313 headers.insert(AUTHORIZATION, auth_header);
314 if let Some(ct) = mime_type {
315 headers.insert(
316 CONTENT_TYPE,
317 HeaderValue::from_str(ct).map_err(|e| format!("Invalid content type: {}", e))?
318 );
319 }
320 headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size));
324
325 let mut request_future = Box::pin(client
326 .put(upload_url.clone())
327 .headers(headers)
328 .body(body)
329 .send());
330
331 let mut last_percentage = 0;
332 let mut poll_interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
333
334 let response = loop {
335 tokio::select! {
336 response = &mut request_future => {
337 break response.map_err(|e| format!("Upload request failed: {}", e))?;
338 },
339 _ = poll_interval.tick() => {
340 if let Some(ref flag) = cancel_flag {
341 if flag.load(Ordering::Relaxed) {
342 return Err("Upload cancelled".to_string());
343 }
344 }
345
346 let current_bytes = *bytes_sent.lock().unwrap();
347 let percentage = if total_size > 0 {
348 ((current_bytes as f64 / total_size as f64) * 100.0) as u8
349 } else {
350 0
351 };
352
353 if percentage != last_percentage {
354 if let Err(e) = progress_callback(Some(percentage), Some(current_bytes)) {
355 return Err(e);
356 }
357 last_percentage = percentage;
358 }
359 }
360 }
361 };
362
363 let final_bytes = *bytes_sent.lock().unwrap();
364 if final_bytes == total_size && last_percentage < 100 {
365 progress_callback(Some(100), Some(total_size)).map_err(|e| e)?;
366 }
367
368 let status = response.status();
370 if status.is_success() {
371 let descriptor: BlobDescriptor = response.json().await
372 .map_err(|e| format!("Failed to parse response: {}", e))?;
373 if descriptor.sha256 != hash {
379 return Err(format!(
380 "[INTEGRITY] {} transformed the upload (returned {}, expected {})",
381 server_url, descriptor.sha256, hash,
382 ));
383 }
384 Ok(descriptor.url.to_string())
385 } else {
386 let x_reason = response.headers().get("X-Reason")
388 .and_then(|v| v.to_str().ok())
389 .map(|s| s.to_string());
390 let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
391 let display = match (error_text.is_empty(), x_reason) {
392 (false, Some(r)) => format!("{} (X-Reason: {})", error_text, r),
393 (false, None) => error_text,
394 (true, Some(r)) => r,
395 (true, None) => "Unknown error".to_string(),
396 };
397 crate::log_net_fail!("[Blossom] upload rejected: HTTP {} — {}", status, display);
398 Err(format!("Upload failed with status {}: {}", status, display))
399 }
400}
401
402pub async fn upload_blob<T>(
406 signer: T,
407 server_url: &Url,
408 file_data: Arc<Vec<u8>>,
409 mime_type: Option<&str>,
410 read_timeout: Option<std::time::Duration>,
411) -> Result<String, String>
412where
413 T: VectorSigner,
414{
415 let upload_url = server_url.join("upload")
416 .map_err(|e| format!("Invalid server URL: {}", e))?;
417
418 let hash = Sha256Hash::hash(&*file_data);
419 let total_size = file_data.len() as u64;
420
421 let auth_header = build_auth_header(&signer, hash).await?;
422
423 let mut headers = HeaderMap::new();
424 headers.insert(AUTHORIZATION, auth_header);
425 if let Some(ct) = mime_type {
426 headers.insert(
427 CONTENT_TYPE,
428 HeaderValue::from_str(ct).map_err(|e| format!("Invalid content type: {}", e))?
429 );
430 }
431 headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size));
432
433 let client = crate::net::build_http_client_with_options(
435 std::time::Duration::from_secs(300),
436 read_timeout,
437 false,
438 )?;
439
440 let body_data: Vec<u8> = Arc::try_unwrap(file_data)
441 .unwrap_or_else(|arc| (*arc).clone());
442 let response = client
443 .put(upload_url)
444 .headers(headers)
445 .body(body_data)
446 .send()
447 .await
448 .map_err(|e| format!("Upload request failed: {}", e))?;
449
450 let status = response.status();
452 if status.is_success() {
453 let descriptor: BlobDescriptor = response.json().await
454 .map_err(|e| format!("Failed to parse response: {}", e))?;
455 if descriptor.sha256 != hash {
458 return Err(format!(
459 "[INTEGRITY] {} transformed the upload (returned {}, expected {})",
460 server_url, descriptor.sha256, hash,
461 ));
462 }
463 Ok(descriptor.url.to_string())
464 } else {
465 let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
466 Err(format!("Upload failed with status {}: {}", status, error_text))
467 }
468}
469
470async fn uploaded_blob_serves(url: &str) -> bool {
476 let client = match crate::net::build_http_client(std::time::Duration::from_secs(10)) {
477 Ok(c) => c,
478 Err(_) => return true,
479 };
480 for attempt in 0..2 {
481 if attempt > 0 {
482 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
483 }
484 match client.head(url).send().await {
485 Ok(resp) => {
486 let status = resp.status();
487 if status == StatusCode::NOT_FOUND || status == StatusCode::GONE {
488 continue;
489 }
490 return true;
491 }
492 Err(_) => {
493 crate::log_net_info!("[Blossom] {} unreachable for post-upload verify — assuming stored", url);
494 return true;
495 }
496 }
497 }
498 crate::log_net_fail!("[Blossom] {} ACKed the upload but serves 404/410 — treating as dropped", url);
499 false
500}
501
502pub async fn upload_blob_with_failover<T>(
509 signer: T,
510 server_urls: Vec<String>,
511 file_data: Arc<Vec<u8>>,
512 mime_type: Option<&str>,
513 read_timeout: Option<std::time::Duration>,
514) -> Result<String, String>
515where
516 T: VectorSigner + Clone,
517{
518 let mut last_error = String::from("No servers available");
519
520 for (index, server_url_str) in server_urls.iter().enumerate() {
521 let server_url = match Url::parse(server_url_str) {
522 Ok(url) => url,
523 Err(e) => {
524 crate::log_net_fail!("[Blossom] invalid server URL '{}': {}", server_url_str, e);
525 last_error = format!("Invalid server URL: {}", e);
526 continue;
527 }
528 };
529
530 crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}",
531 index + 1, server_urls.len(), server_url_str);
532
533 match upload_blob(signer.clone(), &server_url, file_data.clone(), mime_type, read_timeout).await {
534 Ok(url) => {
535 if !uploaded_blob_serves(&url).await {
536 crate::log_warn!(
537 "[Blossom Error] {} ACKed the upload but does not serve {} — failing over",
538 server_url_str, url,
539 );
540 last_error = format!("{} accepted the upload but the blob is not retrievable", server_url_str);
541 continue;
542 }
543 crate::log_net_info!("[Blossom] upload OK via {}", server_url_str);
544 return Ok(url);
545 }
546 Err(e) => {
547 crate::log_net_fail!("[Blossom] upload failed to {}: {}", server_url_str, e);
548 last_error = e;
549 }
550 }
551 }
552
553 crate::log_net_fail!("[Blossom] ALL servers failed; last error: {}", last_error);
554 Err(format!("All Blossom servers failed. Last error: {}", last_error))
555}
556
557pub async fn upload_blob_with_progress_and_failover<T>(
559 signer: T,
560 server_urls: Vec<String>,
561 file_data: Arc<Vec<u8>>,
562 mime_type: Option<&str>,
563 is_encrypted: bool,
564 progress_callback: ProgressCallback,
565 retry_count: Option<u32>,
566 retry_spacing: Option<std::time::Duration>,
567 cancel_flag: Option<Arc<AtomicBool>>,
568) -> Result<String, String>
569where
570 T: VectorSigner + Clone,
571{
572 let mut last_error = String::from("No servers available");
573
574 let size_bytes = file_data.len() as u64;
577 let mime_for_routing = mime_type.unwrap_or("application/octet-stream");
578 let ranked = crate::blossom_capabilities::rank_servers(server_urls, mime_for_routing, is_encrypted, size_bytes);
579 for (index, server_url_str) in ranked.iter().enumerate() {
582 if let Some(ref flag) = cancel_flag {
583 if flag.load(Ordering::Relaxed) {
584 return Err("Upload cancelled".to_string());
585 }
586 }
587
588 let server_url = match Url::parse(server_url_str) {
589 Ok(url) => url,
590 Err(e) => {
591 crate::log_net_fail!("[Blossom] invalid server URL '{}': {}", server_url_str, e);
592 last_error = format!("Invalid server URL: {}", e);
593 continue;
594 }
595 };
596
597 crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}",
598 index + 1, ranked.len(), server_url_str);
599
600 match upload_blob_with_progress(
601 signer.clone(),
602 &server_url,
603 file_data.clone(),
604 mime_type,
605 progress_callback.clone(),
606 retry_count,
607 retry_spacing,
608 cancel_flag.clone(),
609 ).await {
610 Ok(url) => {
611 if !uploaded_blob_serves(&url).await {
612 crate::log_warn!(
613 "[Blossom Error] {} ACKed the upload but does not serve {} — failing over",
614 server_url_str, url,
615 );
616 last_error = format!("{} accepted the upload but the blob is not retrievable", server_url_str);
617 let _ = progress_callback(Some(0), Some(0));
618 continue;
619 }
620 crate::log_net_info!("[Blossom] upload OK via {}", server_url_str);
621 if let Err(err) = crate::blossom_capabilities::record_accepted(
622 server_url_str, mime_for_routing, is_encrypted, size_bytes,
623 ) {
624 crate::log_warn!("[Blossom Cap] record_accepted failed: {}", err);
625 }
626 return Ok(url);
627 }
628 Err(e) => {
629 if e == "Upload cancelled" {
630 return Err(e);
631 }
632 crate::log_net_fail!("[Blossom] upload failed to {}: {}", server_url_str, e);
633 let status = parse_status_from_error(&e);
634 if e.contains("[INTEGRITY]") || crate::blossom_capabilities::is_mime_rejection(status, &e) {
637 if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
638 server_url_str, mime_for_routing, is_encrypted,
639 ) {
640 crate::log_warn!("[Blossom Cap] record_rejected_mime failed: {}", err);
641 }
642 } else if crate::blossom_capabilities::is_size_rejection(status) {
643 if let Err(err) = crate::blossom_capabilities::record_rejected_size(
644 server_url_str, mime_for_routing, is_encrypted, size_bytes,
645 ) {
646 crate::log_warn!("[Blossom Cap] record_rejected_size failed: {}", err);
647 }
648 }
649 last_error = e;
652 let _ = progress_callback(Some(0), Some(0));
653 }
654 }
655 }
656
657 crate::log_net_fail!("[Blossom] ALL servers failed; last error: {}", last_error);
658 Err(format!("All Blossom servers failed. Last error: {}", last_error))
659}
660
661async fn build_delete_auth_header<T>(
667 signer: &T,
668 hash: Sha256Hash,
669) -> Result<HeaderValue, String>
670where
671 T: VectorSigner,
672{
673 let expiration = Timestamp::now() + std::time::Duration::from_secs(300);
674 let auth = BlossomAuthorization::new(
675 "Blossom delete authorization".to_string(),
676 expiration,
677 BlossomAuthorizationVerb::Delete,
678 BlossomAuthorizationScope::BlobSha256Hashes(vec![hash]),
679 );
680
681 let auth_event: Event = auth
682 .finalize_async(signer)
683 .await
684 .map_err(|e| format!("Failed to sign auth event: {}", e))?;
685
686 let encoded_auth = base64_simd::STANDARD.encode_to_string(auth_event.as_json());
687 let value = format!("Nostr {}", encoded_auth);
688
689 HeaderValue::try_from(value)
690 .map_err(|e| format!("Failed to create header value: {}", e))
691}
692
693pub async fn delete_blob<T>(
697 signer: T,
698 server_url: &Url,
699 hash: Sha256Hash,
700) -> Result<(), String>
701where
702 T: VectorSigner + Clone,
703{
704 let auth_header = build_delete_auth_header(&signer, hash).await?;
705
706 let mut url = server_url.clone();
707 url.set_path(&format!("/{}", hash));
709
710 let mut headers = HeaderMap::new();
711 headers.insert(AUTHORIZATION, auth_header);
712
713 let client = crate::net::build_http_client(std::time::Duration::from_secs(30))?;
714
715 let response = client
716 .delete(url)
717 .headers(headers)
718 .send()
719 .await
720 .map_err(|e| format!("Blossom DELETE request failed: {}", e))?;
721
722 let status = response.status();
723 if status.is_success() || status == StatusCode::NOT_FOUND {
724 Ok(())
725 } else {
726 let body = response.text().await.unwrap_or_else(|_| "<no body>".into());
727 Err(format!("Blossom DELETE failed with status {}: {}", status, body))
731 }
732}
733
734pub fn parse_blob_url(url_str: &str) -> Result<(Url, Sha256Hash), String> {
737 let parsed = Url::parse(url_str)
738 .map_err(|e| format!("Invalid Blossom URL: {}", e))?;
739 let last_segment = parsed
740 .path_segments()
741 .and_then(|segs| segs.rev().find(|s| !s.is_empty()))
742 .ok_or_else(|| "Blossom URL has no path segment".to_string())?;
743 let hash_str = last_segment.split('.').next().unwrap_or("");
744 let hash = Sha256Hash::from_str(hash_str)
745 .map_err(|e| format!("Path is not a SHA-256 hash: {}", e))?;
746 let mut origin = parsed.clone();
747 origin.set_path("/");
748 origin.set_query(None);
749 origin.set_fragment(None);
750 Ok((origin, hash))
751}
752
753pub fn verify_blob_content(url_str: &str, bytes: &[u8]) -> Option<bool> {
757 let (_, expected) = parse_blob_url(url_str).ok()?;
758 Some(Sha256Hash::hash(bytes) == expected)
759}
760
761pub async fn delete_blob_by_url<T>(signer: T, url_str: &str) -> Result<(), String>
765where
766 T: VectorSigner + Clone,
767{
768 let (origin, hash) = parse_blob_url(url_str)?;
769
770 crate::log_info!("[Blossom] DELETE {} from {}", hash, origin);
771 let timeout = std::time::Duration::from_secs(15);
776 match tokio::time::timeout(timeout, delete_blob(signer, &origin, hash)).await {
777 Ok(Ok(())) => {
778 crate::log_info!("[Blossom] DELETE successful: {} from {}", hash, origin);
779 Ok(())
780 }
781 Ok(Err(e)) => {
782 crate::log_warn!("[Blossom] DELETE failed: {} from {}: {}", hash, origin, e);
783 Err(e)
784 }
785 Err(_) => {
786 let msg = format!("DELETE timed out after {}s", timeout.as_secs());
787 crate::log_warn!("[Blossom] {} ({} from {})", msg, hash, origin);
788 Err(msg)
789 }
790 }
791}
792
793pub fn hash_swap_candidates(primary_url: &str, servers: &[String]) -> Vec<String> {
798 let Ok((primary_origin, hash)) = parse_blob_url(primary_url) else {
799 return Vec::new();
800 };
801 let ext = Url::parse(primary_url)
804 .ok()
805 .and_then(|u| {
806 u.path_segments()
807 .and_then(|segs| segs.rev().find(|s| !s.is_empty()).map(|s| s.to_string()))
808 })
809 .and_then(|leaf| leaf.split_once('.').map(|(_, e)| e.to_string()))
810 .filter(|e| !e.is_empty() && e.len() <= 8 && e.bytes().all(|b| b.is_ascii_alphanumeric()));
811 let leaf = match ext {
812 Some(e) => format!("{}.{}", hash, e),
813 None => hash.to_string(),
814 };
815 servers
816 .iter()
817 .filter_map(|s| {
818 let base = Url::parse(&format!("{}/", s.trim_end_matches('/'))).ok()?;
819 if base.origin() == primary_origin.origin() {
820 return None;
821 }
822 base.join(&leaf).ok().map(|u| u.to_string())
823 })
824 .collect()
825}
826
827pub async fn mirror_blob<T>(signer: T, target_server: &Url, source_url: &str) -> Result<String, String>
833where
834 T: VectorSigner + Clone,
835{
836 let (source_origin, hash) = parse_blob_url(source_url)?;
837 if target_server.origin() == source_origin.origin() {
838 return Err("Mirror target is the source server".to_string());
839 }
840 let auth_header = build_auth_header(&signer, hash).await?;
841 let mirror_url = target_server
842 .join("mirror")
843 .map_err(|e| format!("Invalid mirror URL: {}", e))?;
844
845 let client = crate::net::build_http_client(std::time::Duration::from_secs(30))?;
846 let response = client
847 .put(mirror_url)
848 .header(AUTHORIZATION, auth_header)
849 .header(CONTENT_TYPE, "application/json")
850 .body(format!("{{\"url\":{}}}", serde_json::to_string(source_url).map_err(|e| e.to_string())?))
851 .send()
852 .await
853 .map_err(|e| format!("Mirror request failed: {}", e))?;
854
855 let status = response.status();
856 if !status.is_success() {
857 let body = response.text().await.unwrap_or_else(|_| "<no body>".into());
858 return Err(format!("Mirror failed with status {}: {}", status, body));
859 }
860 let descriptor: BlobDescriptor = response
861 .json()
862 .await
863 .map_err(|e| format!("Failed to parse mirror response: {}", e))?;
864 if descriptor.sha256 != hash {
865 return Err(format!(
866 "[INTEGRITY] {} mirrored a different hash (returned {}, expected {})",
867 target_server, descriptor.sha256, hash,
868 ));
869 }
870 let url = descriptor.url.to_string();
871 let same_origin = Url::parse(&url)
875 .map(|u| u.origin() == target_server.origin() && u.query().is_none())
876 .unwrap_or(false);
877 if !same_origin {
878 return Err(format!("{} returned a foreign descriptor URL: {}", target_server, url));
879 }
880 if !uploaded_blob_serves(&url).await {
881 return Err(format!("{} ACKed the mirror but does not serve it", target_server));
882 }
883 Ok(url)
884}
885
886pub async fn mirror_blob_to_servers<T>(
892 signer: T,
893 source_url: &str,
894 server_urls: Vec<String>,
895 max_mirrors: usize,
896 budget: std::time::Duration,
897) -> Vec<String>
898where
899 T: VectorSigner + Clone,
900{
901 let source_origin = match parse_blob_url(source_url) {
902 Ok((origin, _)) => origin,
903 Err(e) => {
904 crate::log_debug!("[Blossom Mirror] unparseable source {}: {}", source_url, e);
905 return Vec::new();
906 }
907 };
908 let targets: Vec<Url> = server_urls
909 .iter()
910 .filter_map(|s| Url::parse(s).ok())
911 .filter(|u| u.origin() != source_origin.origin())
912 .take(max_mirrors)
913 .collect();
914 if targets.is_empty() {
915 return Vec::new();
916 }
917
918 let futures = targets.into_iter().map(|target| {
919 let signer = signer.clone();
920 let source = source_url.to_string();
921 async move {
922 match tokio::time::timeout(budget, mirror_blob(signer, &target, &source)).await {
926 Ok(Ok(url)) => {
927 crate::log_net_info!("[Blossom Mirror] {} now serves {}", target, url);
928 Some(url)
929 }
930 Ok(Err(e)) => {
931 crate::log_net_fail!("[Blossom Mirror] {} refused: {}", target, e);
932 None
933 }
934 Err(_) => {
935 crate::log_net_fail!("[Blossom Mirror] {} exceeded {:?} budget", target, budget);
936 None
937 }
938 }
939 }
940 });
941 futures_util::future::join_all(futures)
942 .await
943 .into_iter()
944 .flatten()
945 .collect()
946}
947
948pub fn delete_blobs_best_effort<T>(signer: T, urls: Vec<String>)
952where
953 T: VectorSigner + Clone + Send + Sync + 'static,
954{
955 for url_str in urls {
956 let url = match Url::parse(&url_str) {
957 Ok(u) => u,
958 Err(_) => continue,
959 };
960
961 let last_segment = match url.path_segments()
963 .and_then(|segs| segs.rev().find(|s| !s.is_empty()))
964 {
965 Some(s) => s,
966 None => continue,
967 };
968 let hash_str = last_segment.split('.').next().unwrap_or("");
970 let hash = match Sha256Hash::from_str(hash_str) {
971 Ok(h) => h,
972 Err(_) => continue,
973 };
974
975 let mut origin = url.clone();
976 origin.set_path("/");
977 origin.set_query(None);
978 origin.set_fragment(None);
979
980 let signer = signer.clone();
981 tokio::spawn(async move {
983 if let Err(e) = delete_blob(signer, &origin, hash).await {
984 crate::log_warn!("[Blossom delete] {} from {}: {}", hash, origin, e);
985 }
986 });
987 }
988}
989
990pub async fn probe_servers_for_octet_stream<T>(
995 signer: T,
996 server_urls: Vec<String>,
997) -> Result<usize, String>
998where
999 T: VectorSigner + Clone,
1000{
1001 use rand::RngCore;
1002 if server_urls.is_empty() { return Ok(0); }
1003
1004 const PROBE_MIME: &str = "application/octet-stream";
1005 let mut payload = vec![0u8; 32];
1006 rand::thread_rng().fill_bytes(&mut payload[..]);
1007 let payload = Arc::new(payload);
1008 let payload_size = payload.len() as u64;
1009
1010 let mut probed = 0usize;
1011 for server_url_str in &server_urls {
1012 if crate::blossom_capabilities::has_fresh_capability_for(server_url_str, PROBE_MIME, true) {
1013 continue;
1014 }
1015 let parsed = match Url::parse(server_url_str) {
1016 Ok(u) => u,
1017 Err(_) => continue,
1018 };
1019 let no_op_progress: ProgressCallback = Arc::new(|_, _| Ok(()));
1021 match tokio::time::timeout(
1022 std::time::Duration::from_secs(4),
1023 upload_blob_with_progress(
1024 signer.clone(),
1025 &parsed,
1026 payload.clone(),
1027 Some(PROBE_MIME),
1028 no_op_progress,
1029 Some(0),
1030 None,
1031 None,
1032 ),
1033 ).await {
1034 Ok(Ok(url)) => {
1035 if !crate::blossom_servers::is_enabled_server(server_url_str) {
1038 if let Some(hash) = extract_hash_from_blossom_url(&url) {
1039 let _ = delete_blob(signer.clone(), &parsed, hash).await;
1040 }
1041 continue;
1042 }
1043 let delete_result = match extract_hash_from_blossom_url(&url) {
1051 Some(hash) => tokio::time::timeout(
1052 std::time::Duration::from_secs(4),
1053 delete_blob(signer.clone(), &parsed, hash),
1054 ).await.ok(),
1055 None => None,
1056 };
1057 let refuses_deletion = matches!(
1058 &delete_result,
1059 Some(Err(e)) if matches!(parse_status_from_error(e), Some(403) | Some(405) | Some(501)),
1060 );
1061 if refuses_deletion {
1062 if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
1063 server_url_str, PROBE_MIME, true,
1064 ) {
1065 crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err);
1066 }
1067 probed += 1;
1068 crate::log_info!("[Blossom Probe] {} refuses deletion; routing around", server_url_str);
1069 } else {
1070 if let Err(e) = crate::blossom_capabilities::record_accepted(
1071 server_url_str, PROBE_MIME, true, payload_size,
1072 ) {
1073 crate::log_warn!("[Blossom Probe] record_accepted failed: {}", e);
1074 }
1075 probed += 1;
1076 crate::log_info!("[Blossom Probe] {} validated (accepts + verbatim + deletes)", server_url_str);
1077 }
1078 }
1079 Ok(Err(e)) => {
1080 let status = parse_status_from_error(&e);
1081 if e.contains("[INTEGRITY]") || crate::blossom_capabilities::is_mime_rejection(status, &e) {
1084 if !crate::blossom_servers::is_enabled_server(server_url_str) {
1085 continue;
1086 }
1087 if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
1088 server_url_str, PROBE_MIME, true,
1089 ) {
1090 crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err);
1091 }
1092 probed += 1;
1093 crate::log_info!("[Blossom Probe] {} unsuitable; routing around: {}", server_url_str, e);
1094 } else {
1095 crate::log_debug!("[Blossom Probe] {} transient error (not cached): {}", server_url_str, e);
1097 }
1098 }
1099 Err(_) => {
1100 crate::log_debug!("[Blossom Probe] {} timed out, not cached", server_url_str);
1101 }
1102 }
1103 }
1104 Ok(probed)
1105}
1106
1107fn extract_hash_from_blossom_url(url: &str) -> Option<Sha256Hash> {
1110 let parsed = Url::parse(url).ok()?;
1111 let last = parsed.path_segments()?.rev().find(|s| !s.is_empty())?;
1112 let stem = last.split('.').next()?;
1113 Sha256Hash::from_str(stem).ok()
1114}
1115
1116fn parse_status_from_error(msg: &str) -> Option<u16> {
1120 let key = "with status ";
1121 let i = msg.find(key)?;
1122 let tail = &msg[i + key.len()..];
1123 let digits: String = tail.chars().take_while(|c| c.is_ascii_digit()).collect();
1124 digits.parse::<u16>().ok()
1125}
1126
1127#[cfg(test)]
1128mod parse_status_tests {
1129 use super::parse_status_from_error;
1130
1131 #[test]
1132 fn extracts_status_code() {
1133 assert_eq!(parse_status_from_error("Upload failed with status 500 Internal Server Error: x"), Some(500));
1134 assert_eq!(parse_status_from_error("Upload failed with status 413 Payload Too Large"), Some(413));
1135 assert_eq!(parse_status_from_error("Upload failed with status 415"), Some(415));
1136 assert_eq!(parse_status_from_error("Upload failed with status 524 <unknown status code>: gateway"), Some(524));
1139 }
1140
1141 #[test]
1142 fn returns_none_when_absent() {
1143 assert_eq!(parse_status_from_error("network error: timeout"), None);
1144 }
1145}
1146
1147#[cfg(test)]
1148mod hash_swap_tests {
1149 use super::hash_swap_candidates;
1150
1151 const HASH: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
1152
1153 #[test]
1154 fn derives_clean_leaves_and_skips_the_source_origin() {
1155 let primary = format!("https://a.example/{}.png?utm=track#frag", HASH);
1158 let servers = vec![
1159 "https://a.example".to_string(),
1160 "https://b.example".to_string(),
1161 "not a url".to_string(),
1162 ];
1163 assert_eq!(
1164 hash_swap_candidates(&primary, &servers),
1165 vec![format!("https://b.example/{}.png", HASH)],
1166 );
1167 }
1168
1169 #[test]
1170 fn extensionless_primary_yields_the_bare_hash() {
1171 let primary = format!("https://a.example/{}", HASH);
1172 assert_eq!(
1173 hash_swap_candidates(&primary, &["https://b.example/".to_string()]),
1174 vec![format!("https://b.example/{}", HASH)],
1175 );
1176 }
1177
1178 #[test]
1179 fn unparseable_primary_yields_nothing() {
1180 assert!(hash_swap_candidates("https://a.example/not-a-hash.png", &["https://b.example".to_string()]).is_empty());
1181 }
1182}
1183
1184#[cfg(test)]
1185mod hash_extract_tests {
1186 use super::extract_hash_from_blossom_url;
1187
1188 const HASH_HEX: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
1189
1190 #[test]
1191 fn plain_url() {
1192 let url = format!("https://srv.example/{}", HASH_HEX);
1193 assert!(extract_hash_from_blossom_url(&url).is_some());
1194 }
1195
1196 #[test]
1197 fn with_extension() {
1198 let url = format!("https://srv.example/{}.jpg", HASH_HEX);
1199 assert!(extract_hash_from_blossom_url(&url).is_some());
1200 }
1201
1202 #[test]
1203 fn trailing_slash_still_resolves() {
1204 let url = format!("https://srv.example/{}/", HASH_HEX);
1206 assert!(extract_hash_from_blossom_url(&url).is_some());
1207 }
1208
1209 #[test]
1210 fn malformed_returns_none() {
1211 assert!(extract_hash_from_blossom_url("https://srv.example/").is_none());
1212 assert!(extract_hash_from_blossom_url("not a url").is_none());
1213 assert!(extract_hash_from_blossom_url("https://srv.example/notahash").is_none());
1214 }
1215
1216 #[test]
1217 fn x_sha256_simd_hex_matches_lowerhex() {
1218 use bitcoin_hashes::sha256::Hash as Sha256Hash;
1219 let hash = Sha256Hash::hash(b"vector blossom x-sha-256 parity check");
1223 assert_eq!(
1224 crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()),
1225 format!("{:x}", hash),
1226 );
1227 }
1228}