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