Skip to main content

vector_core/
blossom.rs

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
15/// Progress callback function type
16pub type ProgressCallback = std::sync::Arc<dyn Fn(Option<u8>, Option<u64>) -> Result<(), String> + Send + Sync>;
17
18/// Custom upload stream that tracks progress
19struct 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); // Buffer size of 8 chunks
27
28        // Spawn a background task to feed the stream
29        tokio::spawn(async move {
30            let chunk_size = 64 * 1024; // 64 KB chunks - only unavoidable copy
31            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                // Send chunk through channel
38                if tx.send(Ok(chunk)).await.is_err() {
39                    break; // Receiver was dropped
40                }
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                // Update the bytes sent counter
63                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
75/// Builds the Blossom authorization header
76async fn build_auth_header<T>(
77    signer: &T,
78    hash: Sha256Hash,
79) -> Result<HeaderValue, String>
80where
81    T: VectorSigner,
82{
83    // Create Blossom authorization
84    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    // Sign the authorization event
93    let auth_event: Event = auth
94        .finalize_async(signer)
95        .await
96        .map_err(|e| format!("Failed to sign auth event: {}", e))?;
97
98    // Encode as base64
99    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
106/// Upload to a single Blossom server with progress callbacks.
107/// `retry_count` defaults to 0; `retry_spacing` defaults to 1s.
108pub 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                // Deterministic rejections (413/415 etc.) — outer failover handles them.
155                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                // Cloudflare 52x (520 unknown / 521 down / 522 timeout / 523 unreachable /
162                // 524 timeout / 525 TLS-handshake-failed / 526 bad-cert) + 504: the origin
163                // can't ingest the upload, and retrying the same server just repeats the
164                // failure, so route around to the next server immediately.
165                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                // On large uploads, mid-stream drops are almost always a
173                // size policy; don't burn retries. Below 8MB, treat as a
174                // genuine transient blip and retry.
175                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    // All attempts failed, return the last error
198    Err(last_error.unwrap_or_else(|| "No upload attempts were made".to_string()))
199}
200
201/// Internal function that performs a single upload attempt with progress tracking
202async 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    // One auth event covers both HEAD preflight and PUT.
222    let auth_header = build_auth_header(&signer, hash).await?;
223
224    // Redirects disabled: a 3xx mid-PUT would re-issue as GET and drop the body.
225    let client = crate::net::build_http_client_with_options(
226        std::time::Duration::from_secs(300),
227        None,
228        false,
229    )?;
230
231    // BUD-06 preflight (best-effort; non-supporting servers 404/405).
232    {
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        // BUD-06 requires lowercase hex. SIMD encode of the 32-byte digest (sha256::Hash displays
241        // in forward byte order, matching to_byte_array — see the parity test).
242        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                // BUD-02: X-Reason is display-only. Body IS fed to the classifier
260                // to catch non-compliant servers that 400 instead of 415.
261                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    // `Body::wrap_stream` is unknown-length so reqwest would default to
319    // chunked encoding and omit Content-Length — some servers (e.g.
320    // blossom.data.haus) then 411.
321    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    // BUD-02: accept any 2xx (200 OK or 201 Created).
367    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        // Integrity gate: a compliant server stores our bytes verbatim, so the
372        // returned descriptor hash MUST equal what we uploaded. A mismatch means
373        // the server transformed/re-encoded the blob, which is fatal for an
374        // encrypted upload (corrupts the ciphertext). `[INTEGRITY]` marks it so
375        // the failover loop routes around the server like a hard rejection.
376        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        // BUD-02: X-Reason is display-only; body feeds the classifier.
385        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
400/// Simple upload without progress tracking. `read_timeout` fast-fails a dead server
401/// (see `build_http_client_with_options`); pass it for small uploads (emoji, avatars)
402/// so failover is quick, `None` for large blobs whose server may go quiet mid-store.
403pub 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    // Redirects disabled so a 3xx mid-PUT doesn't re-issue as GET.
432    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    // BUD-02: accept any 2xx (200 OK or 201 Created).
449    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        // Integrity gate (see upload_attempt): reject a server that returns a
454        // different hash than we uploaded — it re-encoded the blob.
455        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
468/// Post-upload liveness gate: confirm the server actually SERVES the blob it
469/// just ACKed. Some servers 2xx an upload they dedupe against a stale index
470/// or quietly drop — the ACK is worthless, only a cache-cold fetch tells the
471/// truth. A definitive 404/410 (after a short grace retry) fails the server;
472/// anything else passes, so a flaky HEAD can't sink a good upload.
473async 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
500/// Upload to multiple Blossom servers with failover, in input order.
501///
502/// **Does NOT participate in the capability cache.** Used by the
503/// marketplace (plaintext mini-app uploads); for high-volume callers
504/// prefer `upload_blob_with_progress_and_failover` so they benefit
505/// from cache-aware routing.
506pub 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
555/// Upload with progress + failover, cache-aware routing, and capability learning.
556pub 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    // Known-good first, unknown second, MIME-rejected last. Stable within
573    // tier so the user's BUD-03 trust order wins ties.
574    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    // Pin capability writes to the account that started the upload.
578    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                // `[INTEGRITY]` = server stored a different hash (transformed the
634                // blob); route around it exactly like a hard MIME rejection.
635                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                // Mid-stream drops aren't cached (too ambiguous); only
649                // an explicit 413 sets min_rejected_size.
650                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
660// ============================================================================
661// Blossom DELETE — paired with NIP-17 message deletion
662// ============================================================================
663
664/// Build a BUD-01 DELETE authorization header (kind-24242, verb=delete).
665async 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
692/// Delete a blob from a Blossom server. 2xx and 404 both count as
693/// success (idempotent: "blob is gone" is the goal). 401/403/5xx and
694/// network errors propagate.
695pub 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    // BUD-01 DELETE endpoint: `<origin>/<hash>`.
707    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        // "with status N" phrasing so `parse_status_from_error` can read the
727        // code (the probe uses it to detect deletion-refusal). 404 already
728        // counts as success above (blob gone = effectively deleted).
729        Err(format!("Blossom DELETE failed with status {}: {}", status, body))
730    }
731}
732
733/// Parse a Blossom blob URL into its server origin and SHA-256 hash
734/// (last non-empty path segment, optional `.ext` stripped).
735pub 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
752/// Verify a downloaded body against its URL's content address.
753/// `Some(true)` = bytes match the blob hash, `Some(false)` = the source served
754/// the wrong bytes, `None` = the URL carries no content address to check.
755pub 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
760/// Parse a Blossom blob URL into (origin, hash) and DELETE that blob.
761/// Awaitable single-URL variant of `delete_blobs_best_effort` — caller
762/// drives sequencing + per-URL UI feedback.
763pub 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    // Hard ceiling — a black-holed server must not hang the caller's
771    // UI (e.g. the pack creator's "Deleting…" overlay) indefinitely.
772    // 15s is generous for a healthy server and short enough that a
773    // misbehaving one fails over to the next blob in a batch quickly.
774    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
792/// Derive BUD-03 hash-swap candidates: the same content-address on each of
793/// `servers`. Blossom URLs are `<origin>/<sha256>[.ext]`, so any server in
794/// the author's list may serve a blob whose embedded URLs have all died.
795/// Servers matching `primary_url`'s origin (already tried) are skipped.
796pub 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    // Extension from the parsed path segment, never the raw string — a raw
801    // rsplit would drag the query/fragment along into every derived URL.
802    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
826/// BUD-04: ask `target_server` to mirror the blob at `source_url` by pulling
827/// it server-to-server (the client sends one small JSON request, never the
828/// bytes). Authorized by the same hash-scoped upload event as a direct
829/// upload. The mirror's ACK gets the same serve-check as an upload ACK.
830/// Returns the mirror's URL for the blob.
831pub 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    // BUD-04: the mirror serves the blob ITSELF. A descriptor pointing at a
871    // foreign origin, a downgraded scheme, or carrying a query string is a
872    // protocol violation (or a tracking beacon) — never embed it in messages.
873    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
885/// Best-effort BUD-04 fan-out: mirror `source_url` onto up to `max_mirrors`
886/// of the user's other servers, concurrently, under one wall-clock `budget`.
887/// Returns only the mirror URLs that verifiably serve — the caller embeds
888/// these as NIP-17 / imeta `fallback` sources. Never fails the send: an
889/// empty Vec just means the message ships with no fallbacks.
890pub 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            // Per-target budget: a hung server cancels only itself. A mirror
922            // that completed must keep its result — an unrecorded live copy
923            // is invisible to every future deletion sweep.
924            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
947/// Fire-and-forget DELETE for each parseable blob URL. Pairs with
948/// `delete_own_dm` so removing a NIP-17 file message also removes
949/// the ciphertext from the server it was uploaded to.
950pub 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        // Last non-empty path segment (trailing-slash URLs leave an empty tail).
961        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        // Strip an optional `.ext` suffix some servers append.
968        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
988/// Probe `(server, application/octet-stream, encrypted=true)` with a
989/// 32-byte random blob to learn whether the server accepts the binary
990/// uploads Vector produces for chat attachments. Single-shot per
991/// (server,mime,encrypted). Successful probes are cleaned up via DELETE.
992pub 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        // 4s per-server budget bounds worst-case probe pass.
1021        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                // Race-guard: server may have been disabled/removed
1037                // between spawn and now (purge_server already cleared).
1038                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                // Reaching here means the upload succeeded AND the returned hash
1045                // matched (upload_attempt's integrity gate) — so the server
1046                // accepts our encrypted type and stores it verbatim. Final gate:
1047                // it must honor BUD-01 deletion, else removing a message can't
1048                // remove its blob. The probe blob is deleted either way (cleanup);
1049                // a 4s budget bounds the wait, and only a definitive refusal
1050                // (403/405/501) sinks the server — transient failures stay optimistic.
1051                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                // `[INTEGRITY]` = the server accepted but transformed our probe
1083                // blob; treat it as unsuitable, same as a hard MIME rejection.
1084                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                    // Transient — leave reputation unchanged so we re-probe later.
1097                    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
1108/// Parse the sha256 out of `<origin>/<sha256>[.<ext>][/]`. Skips an
1109/// empty trailing segment when the URL came back with a trailing slash.
1110fn 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
1117/// Extract the HTTP status from an error string. Anchored to the
1118/// `"with status NNN"` shape produced by `upload_blob_with_progress`
1119/// so unrelated `status` substrings don't false-match.
1120fn 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        // Cloudflare gateway timeouts render as "<unknown status code>" — the failover branch relies
1138        // on this still parsing to the numeric code.
1139        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        // Query + fragment on the primary must NOT leak into derived URLs,
1157        // the source origin is skipped, junk servers are dropped.
1158        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        // Some servers append a trailing slash to the descriptor URL.
1206        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        // The X-SHA-256 header swapped format!("{:x}") for the SIMD encoder; they MUST agree
1221        // byte-for-byte (sha256::Hash displays in forward order — a reversed-display hash type would
1222        // silently corrupt the upload header).
1223        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}