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                // Gateway timeouts (Cloudflare 524/522/520, 504): the origin couldn't ingest the upload
161                // within the edge window (a too-slow/too-large upload). Retrying the same server just
162                // repeats the multi-minute timeout, so route around to the next server immediately.
163                if matches!(status, Some(504 | 520 | 522 | 524)) {
164                    crate::log_warn!(
165                        "[Blossom] {} gateway-timed-out (status {}) on {} bytes; routing to the next server",
166                        server_url, status.unwrap_or(0), file_data.len(),
167                    );
168                    return Err(e);
169                }
170                // On large uploads, mid-stream drops are almost always a
171                // size policy; don't burn retries. Below 8MB, treat as a
172                // genuine transient blip and retry.
173                let looks_like_mid_stream_drop = (
174                    e.contains("Upload request failed")
175                    || e.contains("error sending request")
176                    || e.contains("connection reset")
177                    || e.contains("connection closed")
178                    || e.contains("connection refused")
179                    || e.contains("body write")
180                    || e.contains("IncompleteMessage")
181                    || e.contains("broken pipe")
182                ) && file_data.len() > 8 * 1024 * 1024;
183                if looks_like_mid_stream_drop {
184                    crate::log_warn!(
185                        "[Blossom] {} dropped the connection mid-upload of {} bytes, treating as permanent",
186                        server_url, file_data.len(),
187                    );
188                    return Err(e);
189                }
190                last_error = Some(e);
191            }
192        }
193    }
194
195    // All attempts failed, return the last error
196    Err(last_error.unwrap_or_else(|| "No upload attempts were made".to_string()))
197}
198
199/// Internal function that performs a single upload attempt with progress tracking
200async fn upload_attempt<T>(
201    signer: T,
202    server_url: &Url,
203    file_data: Arc<Vec<u8>>,
204    mime_type: Option<&str>,
205    progress_callback: &ProgressCallback,
206    cancel_flag: Option<Arc<AtomicBool>>,
207) -> Result<String, String>
208where
209    T: NostrSigner,
210{
211    let upload_url = server_url.join("upload")
212        .map_err(|e| format!("Invalid server URL: {}", e))?;
213
214    let total_size = file_data.len() as u64;
215    let hash = Sha256Hash::hash(&*file_data);
216
217    progress_callback(Some(0), Some(0)).map_err(|e| e)?;
218
219    // One auth event covers both HEAD preflight and PUT.
220    let auth_header = build_auth_header(&signer, hash).await?;
221
222    // Redirects disabled: a 3xx mid-PUT would re-issue as GET and drop the body.
223    let client = crate::net::build_http_client_with_options(
224        std::time::Duration::from_secs(300),
225        false,
226    )?;
227
228    // BUD-06 preflight (best-effort; non-supporting servers 404/405).
229    {
230        let mut head_headers = HeaderMap::new();
231        head_headers.insert(AUTHORIZATION, auth_header.clone());
232        head_headers.insert(
233            "X-Content-Length",
234            HeaderValue::from_str(&total_size.to_string())
235                .map_err(|e| format!("Invalid X-Content-Length: {}", e))?,
236        );
237        // BUD-06 requires lowercase hex. SIMD encode of the 32-byte digest (sha256::Hash displays
238        // in forward byte order, matching to_byte_array — see the parity test).
239        head_headers.insert(
240            "X-SHA-256",
241            HeaderValue::from_str(&crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()))
242                .map_err(|e| format!("Invalid X-SHA-256: {}", e))?,
243        );
244        if let Some(ct) = mime_type {
245            head_headers.insert(
246                "X-Content-Type",
247                HeaderValue::from_str(ct).map_err(|e| format!("Invalid X-Content-Type: {}", e))?,
248            );
249        }
250        match tokio::time::timeout(
251            std::time::Duration::from_secs(5),
252            client.head(upload_url.clone()).headers(head_headers).send(),
253        ).await {
254            Ok(Ok(resp)) => {
255                let status = resp.status();
256                // BUD-02: X-Reason is display-only. Body IS fed to the classifier
257                // to catch non-compliant servers that 400 instead of 415.
258                let x_reason = resp.headers().get("X-Reason")
259                    .and_then(|v| v.to_str().ok())
260                    .map(|s| s.to_string());
261                let body = resp.text().await.unwrap_or_default();
262                let diag = if !body.is_empty() {
263                    if let Some(r) = &x_reason {
264                        format!("{} (X-Reason: {})", body, r)
265                    } else {
266                        body
267                    }
268                } else if let Some(r) = x_reason {
269                    r
270                } else {
271                    format!("rejected at preflight ({})", status)
272                };
273                let is_413 = status == StatusCode::PAYLOAD_TOO_LARGE;
274                let is_415 = status == StatusCode::UNSUPPORTED_MEDIA_TYPE;
275                let mime_hinted = status.is_client_error() && !is_413 && {
276                    crate::blossom_capabilities::is_mime_rejection(Some(status.as_u16()), &diag)
277                };
278                if is_413 || is_415 || mime_hinted {
279                    crate::log_warn!(
280                        "[Blossom Preflight] {} REJECTED {} ({} bytes, {}): {}",
281                        server_url, status, total_size,
282                        mime_type.unwrap_or("(no mime)"), diag,
283                    );
284                    return Err(format!(
285                        "Upload failed with status {}: {}",
286                        status, diag,
287                    ));
288                }
289                crate::log_debug!(
290                    "[Blossom Preflight] {} → {} ({} bytes); proceeding to PUT",
291                    server_url, status, total_size,
292                );
293            }
294            Ok(Err(e)) => {
295                crate::log_debug!("[Blossom Preflight] {} HEAD failed: {}, falling through to PUT", server_url, e);
296            }
297            Err(_) => {
298                crate::log_debug!("[Blossom Preflight] {} HEAD timed out (5s), falling through to PUT", server_url);
299            }
300        }
301    }
302
303    let bytes_sent = Arc::new(Mutex::new(0u64));
304    let tracking_stream = ProgressTrackingStream::new(file_data, Arc::clone(&bytes_sent));
305    let body = Body::wrap_stream(tracking_stream);
306
307    let mut headers = HeaderMap::new();
308    headers.insert(AUTHORIZATION, auth_header);
309    if let Some(ct) = mime_type {
310        headers.insert(
311            CONTENT_TYPE,
312            HeaderValue::from_str(ct).map_err(|e| format!("Invalid content type: {}", e))?
313        );
314    }
315    // `Body::wrap_stream` is unknown-length so reqwest would default to
316    // chunked encoding and omit Content-Length — some servers (e.g.
317    // blossom.data.haus) then 411.
318    headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size));
319
320    let mut request_future = Box::pin(client
321        .put(upload_url.clone())
322        .headers(headers)
323        .body(body)
324        .send());
325
326    let mut last_percentage = 0;
327    let mut poll_interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
328
329    let response = loop {
330        tokio::select! {
331            response = &mut request_future => {
332                break response.map_err(|e| format!("Upload request failed: {}", e))?;
333            },
334            _ = poll_interval.tick() => {
335                if let Some(ref flag) = cancel_flag {
336                    if flag.load(Ordering::Relaxed) {
337                        return Err("Upload cancelled".to_string());
338                    }
339                }
340
341                let current_bytes = *bytes_sent.lock().unwrap();
342                let percentage = if total_size > 0 {
343                    ((current_bytes as f64 / total_size as f64) * 100.0) as u8
344                } else {
345                    0
346                };
347
348                if percentage != last_percentage {
349                    if let Err(e) = progress_callback(Some(percentage), Some(current_bytes)) {
350                        return Err(e);
351                    }
352                    last_percentage = percentage;
353                }
354            }
355        }
356    };
357
358    let final_bytes = *bytes_sent.lock().unwrap();
359    if final_bytes == total_size && last_percentage < 100 {
360        progress_callback(Some(100), Some(total_size)).map_err(|e| e)?;
361    }
362
363    // BUD-02: accept any 2xx (200 OK or 201 Created).
364    let status = response.status();
365    if status.is_success() {
366        let descriptor: BlobDescriptor = response.json().await
367            .map_err(|e| format!("Failed to parse response: {}", e))?;
368        // Integrity gate: a compliant server stores our bytes verbatim, so the
369        // returned descriptor hash MUST equal what we uploaded. A mismatch means
370        // the server transformed/re-encoded the blob, which is fatal for an
371        // encrypted upload (corrupts the ciphertext). `[INTEGRITY]` marks it so
372        // the failover loop routes around the server like a hard rejection.
373        if descriptor.sha256 != hash {
374            return Err(format!(
375                "[INTEGRITY] {} transformed the upload (returned {}, expected {})",
376                server_url, descriptor.sha256, hash,
377            ));
378        }
379        Ok(descriptor.url.to_string())
380    } else {
381        // BUD-02: X-Reason is display-only; body feeds the classifier.
382        let x_reason = response.headers().get("X-Reason")
383            .and_then(|v| v.to_str().ok())
384            .map(|s| s.to_string());
385        let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
386        let display = match (error_text.is_empty(), x_reason) {
387            (false, Some(r)) => format!("{} (X-Reason: {})", error_text, r),
388            (false, None)    => error_text,
389            (true, Some(r))  => r,
390            (true, None)     => "Unknown error".to_string(),
391        };
392        crate::log_warn!("[Blossom Error] Upload failed with status {}: {}", status, display);
393        Err(format!("Upload failed with status {}: {}", status, display))
394    }
395}
396
397/// Simple upload without progress tracking
398pub async fn upload_blob<T>(
399    signer: T,
400    server_url: &Url,
401    file_data: Arc<Vec<u8>>,
402    mime_type: Option<&str>,
403) -> Result<String, String>
404where
405    T: NostrSigner,
406{
407    let upload_url = server_url.join("upload")
408        .map_err(|e| format!("Invalid server URL: {}", e))?;
409
410    let hash = Sha256Hash::hash(&*file_data);
411    let total_size = file_data.len() as u64;
412
413    let auth_header = build_auth_header(&signer, hash).await?;
414
415    let mut headers = HeaderMap::new();
416    headers.insert(AUTHORIZATION, auth_header);
417    if let Some(ct) = mime_type {
418        headers.insert(
419            CONTENT_TYPE,
420            HeaderValue::from_str(ct).map_err(|e| format!("Invalid content type: {}", e))?
421        );
422    }
423    headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size));
424
425    // Redirects disabled so a 3xx mid-PUT doesn't re-issue as GET.
426    let client = crate::net::build_http_client_with_options(
427        std::time::Duration::from_secs(300),
428        false,
429    )?;
430
431    let body_data: Vec<u8> = Arc::try_unwrap(file_data)
432        .unwrap_or_else(|arc| (*arc).clone());
433    let response = client
434        .put(upload_url)
435        .headers(headers)
436        .body(body_data)
437        .send()
438        .await
439        .map_err(|e| format!("Upload request failed: {}", e))?;
440
441    // BUD-02: accept any 2xx (200 OK or 201 Created).
442    let status = response.status();
443    if status.is_success() {
444        let descriptor: BlobDescriptor = response.json().await
445            .map_err(|e| format!("Failed to parse response: {}", e))?;
446        // Integrity gate (see upload_attempt): reject a server that returns a
447        // different hash than we uploaded — it re-encoded the blob.
448        if descriptor.sha256 != hash {
449            return Err(format!(
450                "[INTEGRITY] {} transformed the upload (returned {}, expected {})",
451                server_url, descriptor.sha256, hash,
452            ));
453        }
454        Ok(descriptor.url.to_string())
455    } else {
456        let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
457        Err(format!("Upload failed with status {}: {}", status, error_text))
458    }
459}
460
461/// Upload to multiple Blossom servers with failover, in input order.
462///
463/// **Does NOT participate in the capability cache.** Used by the
464/// marketplace (plaintext mini-app uploads); for high-volume callers
465/// prefer `upload_blob_with_progress_and_failover` so they benefit
466/// from cache-aware routing.
467pub async fn upload_blob_with_failover<T>(
468    signer: T,
469    server_urls: Vec<String>,
470    file_data: Arc<Vec<u8>>,
471    mime_type: Option<&str>,
472) -> Result<String, String>
473where
474    T: NostrSigner + Clone,
475{
476    let mut last_error = String::from("No servers available");
477
478    for (index, server_url_str) in server_urls.iter().enumerate() {
479        let server_url = match Url::parse(server_url_str) {
480            Ok(url) => url,
481            Err(e) => {
482                crate::log_warn!("[Blossom Error] Invalid server URL '{}': {}", server_url_str, e);
483                last_error = format!("Invalid server URL: {}", e);
484                continue;
485            }
486        };
487
488        crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}",
489            index + 1, server_urls.len(), server_url_str);
490
491        match upload_blob(signer.clone(), &server_url, file_data.clone(), mime_type).await {
492            Ok(url) => {
493                crate::log_info!("[Blossom] Upload successful to: {}", server_url_str);
494                return Ok(url);
495            }
496            Err(e) => {
497                crate::log_warn!("[Blossom Error] Upload failed to {}: {}", server_url_str, e);
498                last_error = e;
499            }
500        }
501    }
502
503    Err(format!("All Blossom servers failed. Last error: {}", last_error))
504}
505
506/// Upload with progress + failover, cache-aware routing, and capability learning.
507pub async fn upload_blob_with_progress_and_failover<T>(
508    signer: T,
509    server_urls: Vec<String>,
510    file_data: Arc<Vec<u8>>,
511    mime_type: Option<&str>,
512    is_encrypted: bool,
513    progress_callback: ProgressCallback,
514    retry_count: Option<u32>,
515    retry_spacing: Option<std::time::Duration>,
516    cancel_flag: Option<Arc<AtomicBool>>,
517) -> Result<String, String>
518where
519    T: NostrSigner + Clone,
520{
521    let mut last_error = String::from("No servers available");
522
523    // Known-good first, unknown second, MIME-rejected last. Stable within
524    // tier so the user's BUD-03 trust order wins ties.
525    let size_bytes = file_data.len() as u64;
526    let mime_for_routing = mime_type.unwrap_or("application/octet-stream");
527    let ranked = crate::blossom_capabilities::rank_servers(server_urls, mime_for_routing, is_encrypted, size_bytes);
528    // Pin capability writes to the account that started the upload.
529    let upload_session = crate::state::SessionGuard::capture();
530
531    for (index, server_url_str) in ranked.iter().enumerate() {
532        if let Some(ref flag) = cancel_flag {
533            if flag.load(Ordering::Relaxed) {
534                return Err("Upload cancelled".to_string());
535            }
536        }
537
538        let server_url = match Url::parse(server_url_str) {
539            Ok(url) => url,
540            Err(e) => {
541                crate::log_warn!("[Blossom Error] Invalid server URL '{}': {}", server_url_str, e);
542                last_error = format!("Invalid server URL: {}", e);
543                continue;
544            }
545        };
546
547        crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}",
548            index + 1, ranked.len(), server_url_str);
549
550        match upload_blob_with_progress(
551            signer.clone(),
552            &server_url,
553            file_data.clone(),
554            mime_type,
555            progress_callback.clone(),
556            retry_count,
557            retry_spacing,
558            cancel_flag.clone(),
559        ).await {
560            Ok(url) => {
561                crate::log_info!("[Blossom] Upload successful to: {}", server_url_str);
562                if let Err(err) = crate::blossom_capabilities::record_accepted(
563                    server_url_str, mime_for_routing, is_encrypted, size_bytes, upload_session,
564                ) {
565                    crate::log_warn!("[Blossom Cap] record_accepted failed: {}", err);
566                }
567                return Ok(url);
568            }
569            Err(e) => {
570                if e == "Upload cancelled" {
571                    return Err(e);
572                }
573                crate::log_warn!("[Blossom Error] Upload failed to {}: {}", server_url_str, e);
574                let status = parse_status_from_error(&e);
575                // `[INTEGRITY]` = server stored a different hash (transformed the
576                // blob); route around it exactly like a hard MIME rejection.
577                if e.contains("[INTEGRITY]") || crate::blossom_capabilities::is_mime_rejection(status, &e) {
578                    if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
579                        server_url_str, mime_for_routing, is_encrypted, upload_session,
580                    ) {
581                        crate::log_warn!("[Blossom Cap] record_rejected_mime failed: {}", err);
582                    }
583                } else if crate::blossom_capabilities::is_size_rejection(status) {
584                    if let Err(err) = crate::blossom_capabilities::record_rejected_size(
585                        server_url_str, mime_for_routing, is_encrypted, size_bytes, upload_session,
586                    ) {
587                        crate::log_warn!("[Blossom Cap] record_rejected_size failed: {}", err);
588                    }
589                }
590                // Mid-stream drops aren't cached (too ambiguous); only
591                // an explicit 413 sets min_rejected_size.
592                last_error = e;
593                let _ = progress_callback(Some(0), Some(0));
594            }
595        }
596    }
597
598    Err(format!("All Blossom servers failed. Last error: {}", last_error))
599}
600
601// ============================================================================
602// Blossom DELETE — paired with NIP-17 message deletion
603// ============================================================================
604
605/// Build a BUD-01 DELETE authorization header (kind-24242, verb=delete).
606async fn build_delete_auth_header<T>(
607    signer: &T,
608    hash: Sha256Hash,
609) -> Result<HeaderValue, String>
610where
611    T: NostrSigner,
612{
613    let expiration = Timestamp::now() + std::time::Duration::from_secs(300);
614    let auth = BlossomAuthorization::new(
615        "Blossom delete authorization".to_string(),
616        expiration,
617        BlossomAuthorizationVerb::Delete,
618        BlossomAuthorizationScope::BlobSha256Hashes(vec![hash]),
619    );
620
621    let auth_event: Event = EventBuilder::blossom_auth(auth)
622        .sign(signer)
623        .await
624        .map_err(|e| format!("Failed to sign auth event: {}", e))?;
625
626    let encoded_auth = base64_simd::STANDARD.encode_to_string(auth_event.as_json());
627    let value = format!("Nostr {}", encoded_auth);
628
629    HeaderValue::try_from(value)
630        .map_err(|e| format!("Failed to create header value: {}", e))
631}
632
633/// Delete a blob from a Blossom server. 2xx and 404 both count as
634/// success (idempotent: "blob is gone" is the goal). 401/403/5xx and
635/// network errors propagate.
636pub async fn delete_blob<T>(
637    signer: T,
638    server_url: &Url,
639    hash: Sha256Hash,
640) -> Result<(), String>
641where
642    T: NostrSigner + Clone,
643{
644    let auth_header = build_delete_auth_header(&signer, hash).await?;
645
646    let mut url = server_url.clone();
647    // BUD-01 DELETE endpoint: `<origin>/<hash>`.
648    url.set_path(&format!("/{}", hash));
649
650    let mut headers = HeaderMap::new();
651    headers.insert(AUTHORIZATION, auth_header);
652
653    let client = crate::net::build_http_client(std::time::Duration::from_secs(30))?;
654
655    let response = client
656        .delete(url)
657        .headers(headers)
658        .send()
659        .await
660        .map_err(|e| format!("Blossom DELETE request failed: {}", e))?;
661
662    let status = response.status();
663    if status.is_success() || status == StatusCode::NOT_FOUND {
664        Ok(())
665    } else {
666        let body = response.text().await.unwrap_or_else(|_| "<no body>".into());
667        // "with status N" phrasing so `parse_status_from_error` can read the
668        // code (the probe uses it to detect deletion-refusal). 404 already
669        // counts as success above (blob gone = effectively deleted).
670        Err(format!("Blossom DELETE failed with status {}: {}", status, body))
671    }
672}
673
674/// Parse a Blossom blob URL into (origin, hash) and DELETE that blob.
675/// Awaitable single-URL variant of `delete_blobs_best_effort` — caller
676/// drives sequencing + per-URL UI feedback.
677pub async fn delete_blob_by_url<T>(signer: T, url_str: &str) -> Result<(), String>
678where
679    T: NostrSigner + Clone,
680{
681    let parsed = Url::parse(url_str)
682        .map_err(|e| format!("Invalid Blossom URL: {}", e))?;
683    let last_segment = parsed
684        .path_segments()
685        .and_then(|segs| segs.rev().find(|s| !s.is_empty()))
686        .ok_or_else(|| "Blossom URL has no path segment".to_string())?;
687    let hash_str = last_segment.split('.').next().unwrap_or("");
688    let hash = Sha256Hash::from_str(hash_str)
689        .map_err(|e| format!("Path is not a SHA-256 hash: {}", e))?;
690
691    let mut origin = parsed.clone();
692    origin.set_path("/");
693    origin.set_query(None);
694    origin.set_fragment(None);
695
696    crate::log_info!("[Blossom] DELETE {} from {}", hash, origin);
697    // Hard ceiling — a black-holed server must not hang the caller's
698    // UI (e.g. the pack creator's "Deleting…" overlay) indefinitely.
699    // 15s is generous for a healthy server and short enough that a
700    // misbehaving one fails over to the next blob in a batch quickly.
701    let timeout = std::time::Duration::from_secs(15);
702    match tokio::time::timeout(timeout, delete_blob(signer, &origin, hash)).await {
703        Ok(Ok(())) => {
704            crate::log_info!("[Blossom] DELETE successful: {} from {}", hash, origin);
705            Ok(())
706        }
707        Ok(Err(e)) => {
708            crate::log_warn!("[Blossom] DELETE failed: {} from {}: {}", hash, origin, e);
709            Err(e)
710        }
711        Err(_) => {
712            let msg = format!("DELETE timed out after {}s", timeout.as_secs());
713            crate::log_warn!("[Blossom] {} ({} from {})", msg, hash, origin);
714            Err(msg)
715        }
716    }
717}
718
719/// Fire-and-forget DELETE for each parseable blob URL. Pairs with
720/// `delete_own_dm` so removing a NIP-17 file message also removes
721/// the ciphertext from the server it was uploaded to.
722pub fn delete_blobs_best_effort<T>(signer: T, urls: Vec<String>)
723where
724    T: NostrSigner + Clone + Send + Sync + 'static,
725{
726    for url_str in urls {
727        let url = match Url::parse(&url_str) {
728            Ok(u) => u,
729            Err(_) => continue,
730        };
731
732        // Last non-empty path segment (trailing-slash URLs leave an empty tail).
733        let last_segment = match url.path_segments()
734            .and_then(|segs| segs.rev().find(|s| !s.is_empty()))
735        {
736            Some(s) => s,
737            None => continue,
738        };
739        // Strip an optional `.ext` suffix some servers append.
740        let hash_str = last_segment.split('.').next().unwrap_or("");
741        let hash = match Sha256Hash::from_str(hash_str) {
742            Ok(h) => h,
743            Err(_) => continue,
744        };
745
746        let mut origin = url.clone();
747        origin.set_path("/");
748        origin.set_query(None);
749        origin.set_fragment(None);
750
751        let signer = signer.clone();
752        tokio::spawn(async move {
753            if let Err(e) = delete_blob(signer, &origin, hash).await {
754                crate::log_warn!("[Blossom delete] {} from {}: {}", hash, origin, e);
755            }
756        });
757    }
758}
759
760/// Probe `(server, application/octet-stream, encrypted=true)` with a
761/// 32-byte random blob to learn whether the server accepts the binary
762/// uploads Vector produces for chat attachments. Single-shot per
763/// (server,mime,encrypted). Successful probes are cleaned up via DELETE.
764pub async fn probe_servers_for_octet_stream<T>(
765    signer: T,
766    server_urls: Vec<String>,
767    session: crate::state::SessionGuard,
768) -> Result<usize, String>
769where
770    T: NostrSigner + Clone,
771{
772    use rand::RngCore;
773    if !session.is_valid() { return Ok(0); }
774    if server_urls.is_empty() { return Ok(0); }
775
776    const PROBE_MIME: &str = "application/octet-stream";
777    let mut payload = vec![0u8; 32];
778    rand::thread_rng().fill_bytes(&mut payload[..]);
779    let payload = Arc::new(payload);
780    let payload_size = payload.len() as u64;
781
782    let mut probed = 0usize;
783    for server_url_str in &server_urls {
784        if !session.is_valid() { return Ok(probed); }
785        if crate::blossom_capabilities::has_fresh_capability_for(server_url_str, PROBE_MIME, true) {
786            continue;
787        }
788        let parsed = match Url::parse(server_url_str) {
789            Ok(u) => u,
790            Err(_) => continue,
791        };
792        // 4s per-server budget bounds worst-case probe pass.
793        let no_op_progress: ProgressCallback = Arc::new(|_, _| Ok(()));
794        match tokio::time::timeout(
795            std::time::Duration::from_secs(4),
796            upload_blob_with_progress(
797                signer.clone(),
798                &parsed,
799                payload.clone(),
800                Some(PROBE_MIME),
801                no_op_progress,
802                Some(0),
803                None,
804                None,
805            ),
806        ).await {
807            Ok(Ok(url)) => {
808                // Race-guard: server may have been disabled/removed
809                // between spawn and now (purge_server already cleared).
810                if !crate::blossom_servers::is_enabled_server(server_url_str) {
811                    if let Some(hash) = extract_hash_from_blossom_url(&url) {
812                        let _ = delete_blob(signer.clone(), &parsed, hash).await;
813                    }
814                    continue;
815                }
816                // Reaching here means the upload succeeded AND the returned hash
817                // matched (upload_attempt's integrity gate) — so the server
818                // accepts our encrypted type and stores it verbatim. Final gate:
819                // it must honor BUD-01 deletion, else removing a message can't
820                // remove its blob. The probe blob is deleted either way (cleanup);
821                // a 4s budget bounds the wait, and only a definitive refusal
822                // (403/405/501) sinks the server — transient failures stay optimistic.
823                let delete_result = match extract_hash_from_blossom_url(&url) {
824                    Some(hash) => tokio::time::timeout(
825                        std::time::Duration::from_secs(4),
826                        delete_blob(signer.clone(), &parsed, hash),
827                    ).await.ok(),
828                    None => None,
829                };
830                let refuses_deletion = matches!(
831                    &delete_result,
832                    Some(Err(e)) if matches!(parse_status_from_error(e), Some(403) | Some(405) | Some(501)),
833                );
834                if refuses_deletion {
835                    if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
836                        server_url_str, PROBE_MIME, true, session,
837                    ) {
838                        crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err);
839                    }
840                    probed += 1;
841                    crate::log_info!("[Blossom Probe] {} refuses deletion; routing around", server_url_str);
842                } else {
843                    if let Err(e) = crate::blossom_capabilities::record_accepted(
844                        server_url_str, PROBE_MIME, true, payload_size, session,
845                    ) {
846                        crate::log_warn!("[Blossom Probe] record_accepted failed: {}", e);
847                    }
848                    probed += 1;
849                    crate::log_info!("[Blossom Probe] {} validated (accepts + verbatim + deletes)", server_url_str);
850                }
851            }
852            Ok(Err(e)) => {
853                let status = parse_status_from_error(&e);
854                // `[INTEGRITY]` = the server accepted but transformed our probe
855                // blob; treat it as unsuitable, same as a hard MIME rejection.
856                if e.contains("[INTEGRITY]") || crate::blossom_capabilities::is_mime_rejection(status, &e) {
857                    if !crate::blossom_servers::is_enabled_server(server_url_str) {
858                        continue;
859                    }
860                    if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
861                        server_url_str, PROBE_MIME, true, session,
862                    ) {
863                        crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err);
864                    }
865                    probed += 1;
866                    crate::log_info!("[Blossom Probe] {} unsuitable; routing around: {}", server_url_str, e);
867                } else {
868                    // Transient — leave reputation unchanged so we re-probe later.
869                    crate::log_debug!("[Blossom Probe] {} transient error (not cached): {}", server_url_str, e);
870                }
871            }
872            Err(_) => {
873                crate::log_debug!("[Blossom Probe] {} timed out, not cached", server_url_str);
874            }
875        }
876    }
877    Ok(probed)
878}
879
880/// Parse the sha256 out of `<origin>/<sha256>[.<ext>][/]`. Skips an
881/// empty trailing segment when the URL came back with a trailing slash.
882fn extract_hash_from_blossom_url(url: &str) -> Option<Sha256Hash> {
883    let parsed = Url::parse(url).ok()?;
884    let last = parsed.path_segments()?.rev().find(|s| !s.is_empty())?;
885    let stem = last.split('.').next()?;
886    Sha256Hash::from_str(stem).ok()
887}
888
889/// Extract the HTTP status from an error string. Anchored to the
890/// `"with status NNN"` shape produced by `upload_blob_with_progress`
891/// so unrelated `status` substrings don't false-match.
892fn parse_status_from_error(msg: &str) -> Option<u16> {
893    let key = "with status ";
894    let i = msg.find(key)?;
895    let tail = &msg[i + key.len()..];
896    let digits: String = tail.chars().take_while(|c| c.is_ascii_digit()).collect();
897    digits.parse::<u16>().ok()
898}
899
900#[cfg(test)]
901mod parse_status_tests {
902    use super::parse_status_from_error;
903
904    #[test]
905    fn extracts_status_code() {
906        assert_eq!(parse_status_from_error("Upload failed with status 500 Internal Server Error: x"), Some(500));
907        assert_eq!(parse_status_from_error("Upload failed with status 413 Payload Too Large"), Some(413));
908        assert_eq!(parse_status_from_error("Upload failed with status 415"), Some(415));
909        // Cloudflare gateway timeouts render as "<unknown status code>" — the failover branch relies
910        // on this still parsing to the numeric code.
911        assert_eq!(parse_status_from_error("Upload failed with status 524 <unknown status code>: gateway"), Some(524));
912    }
913
914    #[test]
915    fn returns_none_when_absent() {
916        assert_eq!(parse_status_from_error("network error: timeout"), None);
917    }
918}
919
920#[cfg(test)]
921mod hash_extract_tests {
922    use super::extract_hash_from_blossom_url;
923
924    const HASH_HEX: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
925
926    #[test]
927    fn plain_url() {
928        let url = format!("https://srv.example/{}", HASH_HEX);
929        assert!(extract_hash_from_blossom_url(&url).is_some());
930    }
931
932    #[test]
933    fn with_extension() {
934        let url = format!("https://srv.example/{}.jpg", HASH_HEX);
935        assert!(extract_hash_from_blossom_url(&url).is_some());
936    }
937
938    #[test]
939    fn trailing_slash_still_resolves() {
940        // Some servers append a trailing slash to the descriptor URL.
941        let url = format!("https://srv.example/{}/", HASH_HEX);
942        assert!(extract_hash_from_blossom_url(&url).is_some());
943    }
944
945    #[test]
946    fn malformed_returns_none() {
947        assert!(extract_hash_from_blossom_url("https://srv.example/").is_none());
948        assert!(extract_hash_from_blossom_url("not a url").is_none());
949        assert!(extract_hash_from_blossom_url("https://srv.example/notahash").is_none());
950    }
951
952    #[test]
953    fn x_sha256_simd_hex_matches_lowerhex() {
954        use nostr_sdk::hashes::{sha256::Hash as Sha256Hash, Hash};
955        // The X-SHA-256 header swapped format!("{:x}") for the SIMD encoder; they MUST agree
956        // byte-for-byte (sha256::Hash displays in forward order — a reversed-display hash type would
957        // silently corrupt the upload header).
958        let hash = Sha256Hash::hash(b"vector blossom x-sha-256 parity check");
959        assert_eq!(
960            crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()),
961            format!("{:x}", hash),
962        );
963    }
964}