Skip to main content

vector_core/
blossom.rs

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
14/// Progress callback function type
15pub type ProgressCallback = std::sync::Arc<dyn Fn(Option<u8>, Option<u64>) -> Result<(), String> + Send + Sync>;
16
17/// Custom upload stream that tracks progress
18struct 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); // Buffer size of 8 chunks
26
27        // Spawn a background task to feed the stream
28        tokio::spawn(async move {
29            let chunk_size = 64 * 1024; // 64 KB chunks - only unavoidable copy
30            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                // Send chunk through channel
37                if tx.send(Ok(chunk)).await.is_err() {
38                    break; // Receiver was dropped
39                }
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                // Update the bytes sent counter
62                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
74/// Builds the Blossom authorization header
75async fn build_auth_header<T>(
76    signer: &T,
77    hash: Sha256Hash,
78) -> Result<HeaderValue, String>
79where
80    T: NostrSigner,
81{
82    // Create Blossom authorization
83    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    // Sign the authorization event
92    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    // Encode as base64
98    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
105/// Upload to a single Blossom server with progress callbacks.
106/// `retry_count` defaults to 0; `retry_spacing` defaults to 1s.
107pub 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                // Deterministic rejections (413/415 etc.) — outer failover handles them.
154                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                // On large uploads, mid-stream drops are almost always a
161                // size policy; don't burn retries. Below 8MB, treat as a
162                // genuine transient blip and retry.
163                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    // All attempts failed, return the last error
186    Err(last_error.unwrap_or_else(|| "No upload attempts were made".to_string()))
187}
188
189/// Internal function that performs a single upload attempt with progress tracking
190async 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    // One auth event covers both HEAD preflight and PUT.
210    let auth_header = build_auth_header(&signer, hash).await?;
211
212    // Redirects disabled: a 3xx mid-PUT would re-issue as GET and drop the body.
213    let client = crate::net::build_http_client_with_options(
214        std::time::Duration::from_secs(300),
215        false,
216    )?;
217
218    // BUD-06 preflight (best-effort; non-supporting servers 404/405).
219    {
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        // BUD-06 requires lowercase hex. SIMD encode of the 32-byte digest (sha256::Hash displays
228        // in forward byte order, matching to_byte_array — see the parity test).
229        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                // BUD-02: X-Reason is display-only. Body IS fed to the classifier
247                // to catch non-compliant servers that 400 instead of 415.
248                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    // `Body::wrap_stream` is unknown-length so reqwest would default to
306    // chunked encoding and omit Content-Length — some servers (e.g.
307    // blossom.data.haus) then 411.
308    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    // BUD-02: accept any 2xx (200 OK or 201 Created).
354    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        // BUD-02: X-Reason is display-only; body feeds the classifier.
361        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
376/// Simple upload without progress tracking
377pub 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    // Redirects disabled so a 3xx mid-PUT doesn't re-issue as GET.
405    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    // BUD-02: accept any 2xx (200 OK or 201 Created).
421    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
432/// Upload to multiple Blossom servers with failover, in input order.
433///
434/// **Does NOT participate in the capability cache.** Used by the
435/// marketplace (plaintext mini-app uploads); for high-volume callers
436/// prefer `upload_blob_with_progress_and_failover` so they benefit
437/// from cache-aware routing.
438pub 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
477/// Upload with progress + failover, cache-aware routing, and capability learning.
478pub 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    // Known-good first, unknown second, MIME-rejected last. Stable within
495    // tier so the user's BUD-03 trust order wins ties.
496    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    // Pin capability writes to the account that started the upload.
500    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                // Mid-stream drops aren't cached (too ambiguous); only
560                // an explicit 413 sets min_rejected_size.
561                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
570// ============================================================================
571// Blossom DELETE — paired with NIP-17 message deletion
572// ============================================================================
573
574/// Build a BUD-01 DELETE authorization header (kind-24242, verb=delete).
575async 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
602/// Delete a blob from a Blossom server. 2xx and 404 both count as
603/// success (idempotent: "blob is gone" is the goal). 401/403/5xx and
604/// network errors propagate.
605pub 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    // BUD-01 DELETE endpoint: `<origin>/<hash>`.
617    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
640/// Parse a Blossom blob URL into (origin, hash) and DELETE that blob.
641/// Awaitable single-URL variant of `delete_blobs_best_effort` — caller
642/// drives sequencing + per-URL UI feedback.
643pub 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    // Hard ceiling — a black-holed server must not hang the caller's
664    // UI (e.g. the pack creator's "Deleting…" overlay) indefinitely.
665    // 15s is generous for a healthy server and short enough that a
666    // misbehaving one fails over to the next blob in a batch quickly.
667    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
685/// Fire-and-forget DELETE for each parseable blob URL. Pairs with
686/// `delete_own_dm` so removing a NIP-17 file message also removes
687/// the ciphertext from the server it was uploaded to.
688pub 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        // Last non-empty path segment (trailing-slash URLs leave an empty tail).
699        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        // Strip an optional `.ext` suffix some servers append.
706        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
726/// Probe `(server, application/octet-stream, encrypted=true)` with a
727/// 32-byte random blob to learn whether the server accepts the binary
728/// uploads Vector produces for chat attachments. Single-shot per
729/// (server,mime,encrypted). Successful probes are cleaned up via DELETE.
730pub 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        // 4s per-server budget bounds worst-case probe pass.
759        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                // Race-guard: server may have been disabled/removed
775                // between spawn and now (purge_server already cleared).
776                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                    // Transient — leave reputation unchanged so we re-probe later.
808                    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
819/// Parse the sha256 out of `<origin>/<sha256>[.<ext>][/]`. Skips an
820/// empty trailing segment when the URL came back with a trailing slash.
821fn 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
828/// Extract the HTTP status from an error string. Anchored to the
829/// `"with status NNN"` shape produced by `upload_blob_with_progress`
830/// so unrelated `status` substrings don't false-match.
831fn 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        // Some servers append a trailing slash to the descriptor URL.
877        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        // The X-SHA-256 header swapped format!("{:x}") for the SIMD encoder; they MUST agree
892        // byte-for-byte (sha256::Hash displays in forward order — a reversed-display hash type would
893        // silently corrupt the upload header).
894        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}