Skip to main content

vector_core/
blossom.rs

1use crate::signer::VectorSigner;
2use nostr_sdk::prelude::{Event, FinalizeEventAsync, Timestamp, Url};
3use bitcoin_hashes::sha256::Hash as Sha256Hash;
4use nostr_blossom::prelude::*;
5use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE};
6use reqwest::{Body, StatusCode};
7use std::str::FromStr;
8use std::sync::{Arc, Mutex};
9use std::sync::atomic::{AtomicBool, Ordering};
10use tokio::sync::mpsc;
11use futures_util::Stream;
12use std::pin::Pin;
13use std::task::{Context, Poll};
14
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. NOT bound: this pumps
29        // bytes already in hand into a channel and never touches account state.
30        // spawn-detached: byte-pump into a channel; the bytes are already in hand.
31        tokio::spawn(async move {
32            let chunk_size = 64 * 1024; // 64 KB chunks - only unavoidable copy
33            let mut position = 0;
34
35            while position < data.len() {
36                let end = std::cmp::min(position + chunk_size, data.len());
37                let chunk = data[position..end].to_vec();
38
39                // Send chunk through channel
40                if tx.send(Ok(chunk)).await.is_err() {
41                    break; // Receiver was dropped
42                }
43
44                position = end;
45            }
46        });
47
48        Self {
49            bytes_sent,
50            inner: rx,
51        }
52    }
53}
54
55impl Stream for ProgressTrackingStream {
56    type Item = Result<Vec<u8>, std::io::Error>;
57
58    fn poll_next(
59        mut self: Pin<&mut Self>,
60        cx: &mut Context<'_>,
61    ) -> Poll<Option<Self::Item>> {
62        match self.inner.poll_recv(cx) {
63            Poll::Ready(Some(result)) => {
64                // Update the bytes sent counter
65                if let Ok(chunk) = &result {
66                    let mut bytes_sent = self.bytes_sent.lock().unwrap();
67                    *bytes_sent += chunk.len() as u64;
68                }
69                Poll::Ready(Some(result))
70            }
71            Poll::Ready(None) => Poll::Ready(None),
72            Poll::Pending => Poll::Pending,
73        }
74    }
75}
76
77/// Builds the Blossom authorization header
78async fn build_auth_header<T>(
79    signer: &T,
80    hash: Sha256Hash,
81) -> Result<HeaderValue, String>
82where
83    T: VectorSigner,
84{
85    // Create Blossom authorization
86    let expiration = Timestamp::now() + std::time::Duration::from_secs(300);
87    let auth = BlossomAuthorization::new(
88        "Blossom upload authorization".to_string(),
89        expiration,
90        BlossomAuthorizationVerb::Upload,
91        BlossomAuthorizationScope::BlobSha256Hashes(vec![hash]),
92    );
93
94    // Sign the authorization event
95    let auth_event: Event = auth
96        .finalize_async(signer)
97        .await
98        .map_err(|e| format!("Failed to sign auth event: {}", e))?;
99
100    // Encode as base64
101    let encoded_auth = base64_simd::STANDARD.encode_to_string(auth_event.as_json());
102    let value = format!("Nostr {}", encoded_auth);
103
104    HeaderValue::try_from(value)
105        .map_err(|e| format!("Failed to create header value: {}", e))
106}
107
108/// Upload to a single Blossom server with progress callbacks.
109/// `retry_count` defaults to 0; `retry_spacing` defaults to 1s.
110pub async fn upload_blob_with_progress<T>(
111    signer: T,
112    server_url: &Url,
113    file_data: Arc<Vec<u8>>,
114    mime_type: Option<&str>,
115    progress_callback: ProgressCallback,
116    retry_count: Option<u32>,
117    retry_spacing: Option<std::time::Duration>,
118    cancel_flag: Option<Arc<AtomicBool>>,
119) -> Result<String, String>
120where
121    T: VectorSigner + Clone,
122{
123    let retry_count = retry_count.unwrap_or(0);
124    let retry_spacing = retry_spacing.unwrap_or(std::time::Duration::from_secs(1));
125
126    let mut last_error = None;
127
128    for attempt in 0..=retry_count {
129        if attempt > 0 {
130            tokio::time::sleep(retry_spacing).await;
131        }
132
133        if let Some(ref flag) = cancel_flag {
134            if flag.load(Ordering::Relaxed) {
135                return Err("Upload cancelled".to_string());
136            }
137        }
138
139        match upload_attempt(
140            signer.clone(),
141            server_url,
142            file_data.clone(),
143            mime_type,
144            &progress_callback,
145            cancel_flag.clone(),
146        ).await {
147            Ok(url) => return Ok(url),
148            Err(e) => {
149                if e == "Upload cancelled" {
150                    return Err(e);
151                }
152                crate::log_warn!(
153                    "[Blossom] Attempt {}/{} to {} failed: {}",
154                    attempt + 1, retry_count + 1, server_url, e,
155                );
156                // Deterministic rejections (413/415 etc.) — outer failover handles them.
157                let status = parse_status_from_error(&e);
158                let permanent = crate::blossom_capabilities::is_mime_rejection(status, &e)
159                    || crate::blossom_capabilities::is_size_rejection(status);
160                if permanent {
161                    return Err(e);
162                }
163                // Cloudflare 52x (520 unknown / 521 down / 522 timeout / 523 unreachable /
164                // 524 timeout / 525 TLS-handshake-failed / 526 bad-cert) + 504: the origin
165                // can't ingest the upload, and retrying the same server just repeats the
166                // failure, so route around to the next server immediately.
167                if matches!(status, Some(504 | 520 | 521 | 522 | 523 | 524 | 525 | 526)) {
168                    crate::log_warn!(
169                        "[Blossom] {} origin unreachable (status {}) on {} bytes; routing to the next server",
170                        server_url, status.unwrap_or(0), file_data.len(),
171                    );
172                    return Err(e);
173                }
174                // On large uploads, mid-stream drops are almost always a
175                // size policy; don't burn retries. Below 8MB, treat as a
176                // genuine transient blip and retry.
177                let looks_like_mid_stream_drop = (
178                    e.contains("Upload request failed")
179                    || e.contains("error sending request")
180                    || e.contains("connection reset")
181                    || e.contains("connection closed")
182                    || e.contains("connection refused")
183                    || e.contains("body write")
184                    || e.contains("IncompleteMessage")
185                    || e.contains("broken pipe")
186                ) && file_data.len() > 8 * 1024 * 1024;
187                if looks_like_mid_stream_drop {
188                    crate::log_warn!(
189                        "[Blossom] {} dropped the connection mid-upload of {} bytes, treating as permanent",
190                        server_url, file_data.len(),
191                    );
192                    return Err(e);
193                }
194                last_error = Some(e);
195            }
196        }
197    }
198
199    // All attempts failed, return the last error
200    Err(last_error.unwrap_or_else(|| "No upload attempts were made".to_string()))
201}
202
203/// Internal function that performs a single upload attempt with progress tracking
204async fn upload_attempt<T>(
205    signer: T,
206    server_url: &Url,
207    file_data: Arc<Vec<u8>>,
208    mime_type: Option<&str>,
209    progress_callback: &ProgressCallback,
210    cancel_flag: Option<Arc<AtomicBool>>,
211) -> Result<String, String>
212where
213    T: VectorSigner,
214{
215    let upload_url = server_url.join("upload")
216        .map_err(|e| format!("Invalid server URL: {}", e))?;
217
218    let total_size = file_data.len() as u64;
219    let hash = Sha256Hash::hash(&*file_data);
220
221    progress_callback(Some(0), Some(0)).map_err(|e| e)?;
222
223    // One auth event covers both HEAD preflight and PUT.
224    let auth_header = build_auth_header(&signer, hash).await?;
225
226    // Redirects disabled: a 3xx mid-PUT would re-issue as GET and drop the body.
227    let client = crate::net::build_http_client_with_options(
228        std::time::Duration::from_secs(300),
229        None,
230        false,
231    )?;
232
233    // BUD-06 preflight (best-effort; non-supporting servers 404/405).
234    {
235        let mut head_headers = HeaderMap::new();
236        head_headers.insert(AUTHORIZATION, auth_header.clone());
237        head_headers.insert(
238            "X-Content-Length",
239            HeaderValue::from_str(&total_size.to_string())
240                .map_err(|e| format!("Invalid X-Content-Length: {}", e))?,
241        );
242        // BUD-06 requires lowercase hex. SIMD encode of the 32-byte digest (sha256::Hash displays
243        // in forward byte order, matching to_byte_array — see the parity test).
244        head_headers.insert(
245            "X-SHA-256",
246            HeaderValue::from_str(&crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()))
247                .map_err(|e| format!("Invalid X-SHA-256: {}", e))?,
248        );
249        if let Some(ct) = mime_type {
250            head_headers.insert(
251                "X-Content-Type",
252                HeaderValue::from_str(ct).map_err(|e| format!("Invalid X-Content-Type: {}", e))?,
253            );
254        }
255        match tokio::time::timeout(
256            std::time::Duration::from_secs(5),
257            client.head(upload_url.clone()).headers(head_headers).send(),
258        ).await {
259            Ok(Ok(resp)) => {
260                let status = resp.status();
261                // BUD-02: X-Reason is display-only. Body IS fed to the classifier
262                // to catch non-compliant servers that 400 instead of 415.
263                let x_reason = resp.headers().get("X-Reason")
264                    .and_then(|v| v.to_str().ok())
265                    .map(|s| s.to_string());
266                let body = resp.text().await.unwrap_or_default();
267                let diag = if !body.is_empty() {
268                    if let Some(r) = &x_reason {
269                        format!("{} (X-Reason: {})", body, r)
270                    } else {
271                        body
272                    }
273                } else if let Some(r) = x_reason {
274                    r
275                } else {
276                    format!("rejected at preflight ({})", status)
277                };
278                let is_413 = status == StatusCode::PAYLOAD_TOO_LARGE;
279                let is_415 = status == StatusCode::UNSUPPORTED_MEDIA_TYPE;
280                let mime_hinted = status.is_client_error() && !is_413 && {
281                    crate::blossom_capabilities::is_mime_rejection(Some(status.as_u16()), &diag)
282                };
283                if is_413 || is_415 || mime_hinted {
284                    crate::log_warn!(
285                        "[Blossom Preflight] {} REJECTED {} ({} bytes, {}): {}",
286                        server_url, status, total_size,
287                        mime_type.unwrap_or("(no mime)"), diag,
288                    );
289                    return Err(format!(
290                        "Upload failed with status {}: {}",
291                        status, diag,
292                    ));
293                }
294                crate::log_debug!(
295                    "[Blossom Preflight] {} → {} ({} bytes); proceeding to PUT",
296                    server_url, status, total_size,
297                );
298            }
299            Ok(Err(e)) => {
300                crate::log_debug!("[Blossom Preflight] {} HEAD failed: {}, falling through to PUT", server_url, e);
301            }
302            Err(_) => {
303                crate::log_debug!("[Blossom Preflight] {} HEAD timed out (5s), falling through to PUT", server_url);
304            }
305        }
306    }
307
308    let bytes_sent = Arc::new(Mutex::new(0u64));
309    let tracking_stream = ProgressTrackingStream::new(file_data, Arc::clone(&bytes_sent));
310    let body = Body::wrap_stream(tracking_stream);
311
312    let mut headers = HeaderMap::new();
313    headers.insert(AUTHORIZATION, auth_header);
314    if let Some(ct) = mime_type {
315        headers.insert(
316            CONTENT_TYPE,
317            HeaderValue::from_str(ct).map_err(|e| format!("Invalid content type: {}", e))?
318        );
319    }
320    // `Body::wrap_stream` is unknown-length so reqwest would default to
321    // chunked encoding and omit Content-Length — some servers (e.g.
322    // blossom.data.haus) then 411.
323    headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size));
324
325    let mut request_future = Box::pin(client
326        .put(upload_url.clone())
327        .headers(headers)
328        .body(body)
329        .send());
330
331    let mut last_percentage = 0;
332    let mut poll_interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
333
334    let response = loop {
335        tokio::select! {
336            response = &mut request_future => {
337                break response.map_err(|e| format!("Upload request failed: {}", e))?;
338            },
339            _ = poll_interval.tick() => {
340                if let Some(ref flag) = cancel_flag {
341                    if flag.load(Ordering::Relaxed) {
342                        return Err("Upload cancelled".to_string());
343                    }
344                }
345
346                let current_bytes = *bytes_sent.lock().unwrap();
347                let percentage = if total_size > 0 {
348                    ((current_bytes as f64 / total_size as f64) * 100.0) as u8
349                } else {
350                    0
351                };
352
353                if percentage != last_percentage {
354                    if let Err(e) = progress_callback(Some(percentage), Some(current_bytes)) {
355                        return Err(e);
356                    }
357                    last_percentage = percentage;
358                }
359            }
360        }
361    };
362
363    let final_bytes = *bytes_sent.lock().unwrap();
364    if final_bytes == total_size && last_percentage < 100 {
365        progress_callback(Some(100), Some(total_size)).map_err(|e| e)?;
366    }
367
368    // BUD-02: accept any 2xx (200 OK or 201 Created).
369    let status = response.status();
370    if status.is_success() {
371        let descriptor: BlobDescriptor = response.json().await
372            .map_err(|e| format!("Failed to parse response: {}", e))?;
373        // Integrity gate: a compliant server stores our bytes verbatim, so the
374        // returned descriptor hash MUST equal what we uploaded. A mismatch means
375        // the server transformed/re-encoded the blob, which is fatal for an
376        // encrypted upload (corrupts the ciphertext). `[INTEGRITY]` marks it so
377        // the failover loop routes around the server like a hard rejection.
378        if descriptor.sha256 != hash {
379            return Err(format!(
380                "[INTEGRITY] {} transformed the upload (returned {}, expected {})",
381                server_url, descriptor.sha256, hash,
382            ));
383        }
384        Ok(descriptor.url.to_string())
385    } else {
386        // BUD-02: X-Reason is display-only; body feeds the classifier.
387        let x_reason = response.headers().get("X-Reason")
388            .and_then(|v| v.to_str().ok())
389            .map(|s| s.to_string());
390        let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
391        let display = match (error_text.is_empty(), x_reason) {
392            (false, Some(r)) => format!("{} (X-Reason: {})", error_text, r),
393            (false, None)    => error_text,
394            (true, Some(r))  => r,
395            (true, None)     => "Unknown error".to_string(),
396        };
397        crate::log_net_fail!("[Blossom] upload rejected: HTTP {} — {}", status, display);
398        Err(format!("Upload failed with status {}: {}", status, display))
399    }
400}
401
402/// Simple upload without progress tracking. `read_timeout` fast-fails a dead server
403/// (see `build_http_client_with_options`); pass it for small uploads (emoji, avatars)
404/// so failover is quick, `None` for large blobs whose server may go quiet mid-store.
405pub async fn upload_blob<T>(
406    signer: T,
407    server_url: &Url,
408    file_data: Arc<Vec<u8>>,
409    mime_type: Option<&str>,
410    read_timeout: Option<std::time::Duration>,
411) -> Result<String, String>
412where
413    T: VectorSigner,
414{
415    let upload_url = server_url.join("upload")
416        .map_err(|e| format!("Invalid server URL: {}", e))?;
417
418    let hash = Sha256Hash::hash(&*file_data);
419    let total_size = file_data.len() as u64;
420
421    let auth_header = build_auth_header(&signer, hash).await?;
422
423    let mut headers = HeaderMap::new();
424    headers.insert(AUTHORIZATION, auth_header);
425    if let Some(ct) = mime_type {
426        headers.insert(
427            CONTENT_TYPE,
428            HeaderValue::from_str(ct).map_err(|e| format!("Invalid content type: {}", e))?
429        );
430    }
431    headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size));
432
433    // Redirects disabled so a 3xx mid-PUT doesn't re-issue as GET.
434    let client = crate::net::build_http_client_with_options(
435        std::time::Duration::from_secs(300),
436        read_timeout,
437        false,
438    )?;
439
440    let body_data: Vec<u8> = Arc::try_unwrap(file_data)
441        .unwrap_or_else(|arc| (*arc).clone());
442    let response = client
443        .put(upload_url)
444        .headers(headers)
445        .body(body_data)
446        .send()
447        .await
448        .map_err(|e| format!("Upload request failed: {}", e))?;
449
450    // BUD-02: accept any 2xx (200 OK or 201 Created).
451    let status = response.status();
452    if status.is_success() {
453        let descriptor: BlobDescriptor = response.json().await
454            .map_err(|e| format!("Failed to parse response: {}", e))?;
455        // Integrity gate (see upload_attempt): reject a server that returns a
456        // different hash than we uploaded — it re-encoded the blob.
457        if descriptor.sha256 != hash {
458            return Err(format!(
459                "[INTEGRITY] {} transformed the upload (returned {}, expected {})",
460                server_url, descriptor.sha256, hash,
461            ));
462        }
463        Ok(descriptor.url.to_string())
464    } else {
465        let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
466        Err(format!("Upload failed with status {}: {}", status, error_text))
467    }
468}
469
470/// Post-upload liveness gate: confirm the server actually SERVES the blob it
471/// just ACKed. Some servers 2xx an upload they dedupe against a stale index
472/// or quietly drop — the ACK is worthless, only a cache-cold fetch tells the
473/// truth. A definitive 404/410 (after a short grace retry) fails the server;
474/// anything else passes, so a flaky HEAD can't sink a good upload.
475async fn uploaded_blob_serves(url: &str) -> bool {
476    let client = match crate::net::build_http_client(std::time::Duration::from_secs(10)) {
477        Ok(c) => c,
478        Err(_) => return true,
479    };
480    for attempt in 0..2 {
481        if attempt > 0 {
482            tokio::time::sleep(std::time::Duration::from_secs(1)).await;
483        }
484        match client.head(url).send().await {
485            Ok(resp) => {
486                let status = resp.status();
487                if status == StatusCode::NOT_FOUND || status == StatusCode::GONE {
488                    continue;
489                }
490                return true;
491            }
492            Err(_) => {
493                crate::log_net_info!("[Blossom] {} unreachable for post-upload verify — assuming stored", url);
494                return true;
495            }
496        }
497    }
498    crate::log_net_fail!("[Blossom] {} ACKed the upload but serves 404/410 — treating as dropped", url);
499    false
500}
501
502/// Upload to multiple Blossom servers with failover, in input order.
503///
504/// **Does NOT participate in the capability cache.** Used by the
505/// marketplace (plaintext mini-app uploads); for high-volume callers
506/// prefer `upload_blob_with_progress_and_failover` so they benefit
507/// from cache-aware routing.
508pub async fn upload_blob_with_failover<T>(
509    signer: T,
510    server_urls: Vec<String>,
511    file_data: Arc<Vec<u8>>,
512    mime_type: Option<&str>,
513    read_timeout: Option<std::time::Duration>,
514) -> Result<String, String>
515where
516    T: VectorSigner + Clone,
517{
518    let mut last_error = String::from("No servers available");
519
520    for (index, server_url_str) in server_urls.iter().enumerate() {
521        let server_url = match Url::parse(server_url_str) {
522            Ok(url) => url,
523            Err(e) => {
524                crate::log_net_fail!("[Blossom] invalid server URL '{}': {}", server_url_str, e);
525                last_error = format!("Invalid server URL: {}", e);
526                continue;
527            }
528        };
529
530        crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}",
531            index + 1, server_urls.len(), server_url_str);
532
533        match upload_blob(signer.clone(), &server_url, file_data.clone(), mime_type, read_timeout).await {
534            Ok(url) => {
535                if !uploaded_blob_serves(&url).await {
536                    crate::log_warn!(
537                        "[Blossom Error] {} ACKed the upload but does not serve {} — failing over",
538                        server_url_str, url,
539                    );
540                    last_error = format!("{} accepted the upload but the blob is not retrievable", server_url_str);
541                    continue;
542                }
543                crate::log_net_info!("[Blossom] upload OK via {}", server_url_str);
544                return Ok(url);
545            }
546            Err(e) => {
547                crate::log_net_fail!("[Blossom] upload failed to {}: {}", server_url_str, e);
548                last_error = e;
549            }
550        }
551    }
552
553    crate::log_net_fail!("[Blossom] ALL servers failed; last error: {}", last_error);
554    Err(format!("All Blossom servers failed. Last error: {}", last_error))
555}
556
557/// Upload with progress + failover, cache-aware routing, and capability learning.
558pub async fn upload_blob_with_progress_and_failover<T>(
559    signer: T,
560    server_urls: Vec<String>,
561    file_data: Arc<Vec<u8>>,
562    mime_type: Option<&str>,
563    is_encrypted: bool,
564    progress_callback: ProgressCallback,
565    retry_count: Option<u32>,
566    retry_spacing: Option<std::time::Duration>,
567    cancel_flag: Option<Arc<AtomicBool>>,
568) -> Result<String, String>
569where
570    T: VectorSigner + Clone,
571{
572    let mut last_error = String::from("No servers available");
573
574    // Known-good first, unknown second, MIME-rejected last. Stable within
575    // tier so the user's BUD-03 trust order wins ties.
576    let size_bytes = file_data.len() as u64;
577    let mime_for_routing = mime_type.unwrap_or("application/octet-stream");
578    let ranked = crate::blossom_capabilities::rank_servers(server_urls, mime_for_routing, is_encrypted, size_bytes);
579    // Pin capability writes to the account that started the upload.
580
581    for (index, server_url_str) in ranked.iter().enumerate() {
582        if let Some(ref flag) = cancel_flag {
583            if flag.load(Ordering::Relaxed) {
584                return Err("Upload cancelled".to_string());
585            }
586        }
587
588        let server_url = match Url::parse(server_url_str) {
589            Ok(url) => url,
590            Err(e) => {
591                crate::log_net_fail!("[Blossom] invalid server URL '{}': {}", server_url_str, e);
592                last_error = format!("Invalid server URL: {}", e);
593                continue;
594            }
595        };
596
597        crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}",
598            index + 1, ranked.len(), server_url_str);
599
600        match upload_blob_with_progress(
601            signer.clone(),
602            &server_url,
603            file_data.clone(),
604            mime_type,
605            progress_callback.clone(),
606            retry_count,
607            retry_spacing,
608            cancel_flag.clone(),
609        ).await {
610            Ok(url) => {
611                if !uploaded_blob_serves(&url).await {
612                    crate::log_warn!(
613                        "[Blossom Error] {} ACKed the upload but does not serve {} — failing over",
614                        server_url_str, url,
615                    );
616                    last_error = format!("{} accepted the upload but the blob is not retrievable", server_url_str);
617                    let _ = progress_callback(Some(0), Some(0));
618                    continue;
619                }
620                crate::log_net_info!("[Blossom] upload OK via {}", server_url_str);
621                if let Err(err) = crate::blossom_capabilities::record_accepted(
622                    server_url_str, mime_for_routing, is_encrypted, size_bytes,
623                ) {
624                    crate::log_warn!("[Blossom Cap] record_accepted failed: {}", err);
625                }
626                return Ok(url);
627            }
628            Err(e) => {
629                if e == "Upload cancelled" {
630                    return Err(e);
631                }
632                crate::log_net_fail!("[Blossom] upload failed to {}: {}", server_url_str, e);
633                let status = parse_status_from_error(&e);
634                // `[INTEGRITY]` = server stored a different hash (transformed the
635                // blob); route around it exactly like a hard MIME rejection.
636                if e.contains("[INTEGRITY]") || crate::blossom_capabilities::is_mime_rejection(status, &e) {
637                    if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
638                        server_url_str, mime_for_routing, is_encrypted,
639                    ) {
640                        crate::log_warn!("[Blossom Cap] record_rejected_mime failed: {}", err);
641                    }
642                } else if crate::blossom_capabilities::is_size_rejection(status) {
643                    if let Err(err) = crate::blossom_capabilities::record_rejected_size(
644                        server_url_str, mime_for_routing, is_encrypted, size_bytes,
645                    ) {
646                        crate::log_warn!("[Blossom Cap] record_rejected_size failed: {}", err);
647                    }
648                }
649                // Mid-stream drops aren't cached (too ambiguous); only
650                // an explicit 413 sets min_rejected_size.
651                last_error = e;
652                let _ = progress_callback(Some(0), Some(0));
653            }
654        }
655    }
656
657    crate::log_net_fail!("[Blossom] ALL servers failed; last error: {}", last_error);
658    Err(format!("All Blossom servers failed. Last error: {}", last_error))
659}
660
661// ============================================================================
662// Blossom DELETE — paired with NIP-17 message deletion
663// ============================================================================
664
665/// Build a BUD-01 DELETE authorization header (kind-24242, verb=delete).
666async fn build_delete_auth_header<T>(
667    signer: &T,
668    hash: Sha256Hash,
669) -> Result<HeaderValue, String>
670where
671    T: VectorSigner,
672{
673    let expiration = Timestamp::now() + std::time::Duration::from_secs(300);
674    let auth = BlossomAuthorization::new(
675        "Blossom delete authorization".to_string(),
676        expiration,
677        BlossomAuthorizationVerb::Delete,
678        BlossomAuthorizationScope::BlobSha256Hashes(vec![hash]),
679    );
680
681    let auth_event: Event = auth
682        .finalize_async(signer)
683        .await
684        .map_err(|e| format!("Failed to sign auth event: {}", e))?;
685
686    let encoded_auth = base64_simd::STANDARD.encode_to_string(auth_event.as_json());
687    let value = format!("Nostr {}", encoded_auth);
688
689    HeaderValue::try_from(value)
690        .map_err(|e| format!("Failed to create header value: {}", e))
691}
692
693/// Delete a blob from a Blossom server. 2xx and 404 both count as
694/// success (idempotent: "blob is gone" is the goal). 401/403/5xx and
695/// network errors propagate.
696pub async fn delete_blob<T>(
697    signer: T,
698    server_url: &Url,
699    hash: Sha256Hash,
700) -> Result<(), String>
701where
702    T: VectorSigner + Clone,
703{
704    let auth_header = build_delete_auth_header(&signer, hash).await?;
705
706    let mut url = server_url.clone();
707    // BUD-01 DELETE endpoint: `<origin>/<hash>`.
708    url.set_path(&format!("/{}", hash));
709
710    let mut headers = HeaderMap::new();
711    headers.insert(AUTHORIZATION, auth_header);
712
713    let client = crate::net::build_http_client(std::time::Duration::from_secs(30))?;
714
715    let response = client
716        .delete(url)
717        .headers(headers)
718        .send()
719        .await
720        .map_err(|e| format!("Blossom DELETE request failed: {}", e))?;
721
722    let status = response.status();
723    if status.is_success() || status == StatusCode::NOT_FOUND {
724        Ok(())
725    } else {
726        let body = response.text().await.unwrap_or_else(|_| "<no body>".into());
727        // "with status N" phrasing so `parse_status_from_error` can read the
728        // code (the probe uses it to detect deletion-refusal). 404 already
729        // counts as success above (blob gone = effectively deleted).
730        Err(format!("Blossom DELETE failed with status {}: {}", status, body))
731    }
732}
733
734/// Parse a Blossom blob URL into its server origin and SHA-256 hash
735/// (last non-empty path segment, optional `.ext` stripped).
736pub fn parse_blob_url(url_str: &str) -> Result<(Url, Sha256Hash), String> {
737    let parsed = Url::parse(url_str)
738        .map_err(|e| format!("Invalid Blossom URL: {}", e))?;
739    let last_segment = parsed
740        .path_segments()
741        .and_then(|segs| segs.rev().find(|s| !s.is_empty()))
742        .ok_or_else(|| "Blossom URL has no path segment".to_string())?;
743    let hash_str = last_segment.split('.').next().unwrap_or("");
744    let hash = Sha256Hash::from_str(hash_str)
745        .map_err(|e| format!("Path is not a SHA-256 hash: {}", e))?;
746    let mut origin = parsed.clone();
747    origin.set_path("/");
748    origin.set_query(None);
749    origin.set_fragment(None);
750    Ok((origin, hash))
751}
752
753/// Verify a downloaded body against its URL's content address.
754/// `Some(true)` = bytes match the blob hash, `Some(false)` = the source served
755/// the wrong bytes, `None` = the URL carries no content address to check.
756pub fn verify_blob_content(url_str: &str, bytes: &[u8]) -> Option<bool> {
757    let (_, expected) = parse_blob_url(url_str).ok()?;
758    Some(Sha256Hash::hash(bytes) == expected)
759}
760
761/// Parse a Blossom blob URL into (origin, hash) and DELETE that blob.
762/// Awaitable single-URL variant of `delete_blobs_best_effort` — caller
763/// drives sequencing + per-URL UI feedback.
764pub async fn delete_blob_by_url<T>(signer: T, url_str: &str) -> Result<(), String>
765where
766    T: VectorSigner + Clone,
767{
768    let (origin, hash) = parse_blob_url(url_str)?;
769
770    crate::log_info!("[Blossom] DELETE {} from {}", hash, origin);
771    // Hard ceiling — a black-holed server must not hang the caller's
772    // UI (e.g. the pack creator's "Deleting…" overlay) indefinitely.
773    // 15s is generous for a healthy server and short enough that a
774    // misbehaving one fails over to the next blob in a batch quickly.
775    let timeout = std::time::Duration::from_secs(15);
776    match tokio::time::timeout(timeout, delete_blob(signer, &origin, hash)).await {
777        Ok(Ok(())) => {
778            crate::log_info!("[Blossom] DELETE successful: {} from {}", hash, origin);
779            Ok(())
780        }
781        Ok(Err(e)) => {
782            crate::log_warn!("[Blossom] DELETE failed: {} from {}: {}", hash, origin, e);
783            Err(e)
784        }
785        Err(_) => {
786            let msg = format!("DELETE timed out after {}s", timeout.as_secs());
787            crate::log_warn!("[Blossom] {} ({} from {})", msg, hash, origin);
788            Err(msg)
789        }
790    }
791}
792
793/// Derive BUD-03 hash-swap candidates: the same content-address on each of
794/// `servers`. Blossom URLs are `<origin>/<sha256>[.ext]`, so any server in
795/// the author's list may serve a blob whose embedded URLs have all died.
796/// Servers matching `primary_url`'s origin (already tried) are skipped.
797pub fn hash_swap_candidates(primary_url: &str, servers: &[String]) -> Vec<String> {
798    let Ok((primary_origin, hash)) = parse_blob_url(primary_url) else {
799        return Vec::new();
800    };
801    // Extension from the parsed path segment, never the raw string — a raw
802    // rsplit would drag the query/fragment along into every derived URL.
803    let ext = Url::parse(primary_url)
804        .ok()
805        .and_then(|u| {
806            u.path_segments()
807                .and_then(|segs| segs.rev().find(|s| !s.is_empty()).map(|s| s.to_string()))
808        })
809        .and_then(|leaf| leaf.split_once('.').map(|(_, e)| e.to_string()))
810        .filter(|e| !e.is_empty() && e.len() <= 8 && e.bytes().all(|b| b.is_ascii_alphanumeric()));
811    let leaf = match ext {
812        Some(e) => format!("{}.{}", hash, e),
813        None => hash.to_string(),
814    };
815    servers
816        .iter()
817        .filter_map(|s| {
818            let base = Url::parse(&format!("{}/", s.trim_end_matches('/'))).ok()?;
819            if base.origin() == primary_origin.origin() {
820                return None;
821            }
822            base.join(&leaf).ok().map(|u| u.to_string())
823        })
824        .collect()
825}
826
827/// BUD-04: ask `target_server` to mirror the blob at `source_url` by pulling
828/// it server-to-server (the client sends one small JSON request, never the
829/// bytes). Authorized by the same hash-scoped upload event as a direct
830/// upload. The mirror's ACK gets the same serve-check as an upload ACK.
831/// Returns the mirror's URL for the blob.
832pub async fn mirror_blob<T>(signer: T, target_server: &Url, source_url: &str) -> Result<String, String>
833where
834    T: VectorSigner + Clone,
835{
836    let (source_origin, hash) = parse_blob_url(source_url)?;
837    if target_server.origin() == source_origin.origin() {
838        return Err("Mirror target is the source server".to_string());
839    }
840    let auth_header = build_auth_header(&signer, hash).await?;
841    let mirror_url = target_server
842        .join("mirror")
843        .map_err(|e| format!("Invalid mirror URL: {}", e))?;
844
845    let client = crate::net::build_http_client(std::time::Duration::from_secs(30))?;
846    let response = client
847        .put(mirror_url)
848        .header(AUTHORIZATION, auth_header)
849        .header(CONTENT_TYPE, "application/json")
850        .body(format!("{{\"url\":{}}}", serde_json::to_string(source_url).map_err(|e| e.to_string())?))
851        .send()
852        .await
853        .map_err(|e| format!("Mirror request failed: {}", e))?;
854
855    let status = response.status();
856    if !status.is_success() {
857        let body = response.text().await.unwrap_or_else(|_| "<no body>".into());
858        return Err(format!("Mirror failed with status {}: {}", status, body));
859    }
860    let descriptor: BlobDescriptor = response
861        .json()
862        .await
863        .map_err(|e| format!("Failed to parse mirror response: {}", e))?;
864    if descriptor.sha256 != hash {
865        return Err(format!(
866            "[INTEGRITY] {} mirrored a different hash (returned {}, expected {})",
867            target_server, descriptor.sha256, hash,
868        ));
869    }
870    let url = descriptor.url.to_string();
871    // BUD-04: the mirror serves the blob ITSELF. A descriptor pointing at a
872    // foreign origin, a downgraded scheme, or carrying a query string is a
873    // protocol violation (or a tracking beacon) — never embed it in messages.
874    let same_origin = Url::parse(&url)
875        .map(|u| u.origin() == target_server.origin() && u.query().is_none())
876        .unwrap_or(false);
877    if !same_origin {
878        return Err(format!("{} returned a foreign descriptor URL: {}", target_server, url));
879    }
880    if !uploaded_blob_serves(&url).await {
881        return Err(format!("{} ACKed the mirror but does not serve it", target_server));
882    }
883    Ok(url)
884}
885
886/// Best-effort BUD-04 fan-out: mirror `source_url` onto up to `max_mirrors`
887/// of the user's other servers, concurrently, under one wall-clock `budget`.
888/// Returns only the mirror URLs that verifiably serve — the caller embeds
889/// these as NIP-17 / imeta `fallback` sources. Never fails the send: an
890/// empty Vec just means the message ships with no fallbacks.
891pub async fn mirror_blob_to_servers<T>(
892    signer: T,
893    source_url: &str,
894    server_urls: Vec<String>,
895    max_mirrors: usize,
896    budget: std::time::Duration,
897) -> Vec<String>
898where
899    T: VectorSigner + Clone,
900{
901    let source_origin = match parse_blob_url(source_url) {
902        Ok((origin, _)) => origin,
903        Err(e) => {
904            crate::log_debug!("[Blossom Mirror] unparseable source {}: {}", source_url, e);
905            return Vec::new();
906        }
907    };
908    let targets: Vec<Url> = server_urls
909        .iter()
910        .filter_map(|s| Url::parse(s).ok())
911        .filter(|u| u.origin() != source_origin.origin())
912        .take(max_mirrors)
913        .collect();
914    if targets.is_empty() {
915        return Vec::new();
916    }
917
918    let futures = targets.into_iter().map(|target| {
919        let signer = signer.clone();
920        let source = source_url.to_string();
921        async move {
922            // Per-target budget: a hung server cancels only itself. A mirror
923            // that completed must keep its result — an unrecorded live copy
924            // is invisible to every future deletion sweep.
925            match tokio::time::timeout(budget, mirror_blob(signer, &target, &source)).await {
926                Ok(Ok(url)) => {
927                    crate::log_net_info!("[Blossom Mirror] {} now serves {}", target, url);
928                    Some(url)
929                }
930                Ok(Err(e)) => {
931                    crate::log_net_fail!("[Blossom Mirror] {} refused: {}", target, e);
932                    None
933                }
934                Err(_) => {
935                    crate::log_net_fail!("[Blossom Mirror] {} exceeded {:?} budget", target, budget);
936                    None
937                }
938            }
939        }
940    });
941    futures_util::future::join_all(futures)
942        .await
943        .into_iter()
944        .flatten()
945        .collect()
946}
947
948/// Fire-and-forget DELETE for each parseable blob URL. Pairs with
949/// `delete_own_dm` so removing a NIP-17 file message also removes
950/// the ciphertext from the server it was uploaded to.
951pub fn delete_blobs_best_effort<T>(signer: T, urls: Vec<String>)
952where
953    T: VectorSigner + Clone + Send + Sync + 'static,
954{
955    for url_str in urls {
956        let url = match Url::parse(&url_str) {
957            Ok(u) => u,
958            Err(_) => continue,
959        };
960
961        // Last non-empty path segment (trailing-slash URLs leave an empty tail).
962        let last_segment = match url.path_segments()
963            .and_then(|segs| segs.rev().find(|s| !s.is_empty()))
964        {
965            Some(s) => s,
966            None => continue,
967        };
968        // Strip an optional `.ext` suffix some servers append.
969        let hash_str = last_segment.split('.').next().unwrap_or("");
970        let hash = match Sha256Hash::from_str(hash_str) {
971            Ok(h) => h,
972            Err(_) => continue,
973        };
974
975        let mut origin = url.clone();
976        origin.set_path("/");
977        origin.set_query(None);
978        origin.set_fragment(None);
979
980        let signer = signer.clone();
981        // spawn-detached: deleting one probe blob from a server — signer in hand, no account storage.
982        tokio::spawn(async move {
983            if let Err(e) = delete_blob(signer, &origin, hash).await {
984                crate::log_warn!("[Blossom delete] {} from {}: {}", hash, origin, e);
985            }
986        });
987    }
988}
989
990/// Probe `(server, application/octet-stream, encrypted=true)` with a
991/// 32-byte random blob to learn whether the server accepts the binary
992/// uploads Vector produces for chat attachments. Single-shot per
993/// (server,mime,encrypted). Successful probes are cleaned up via DELETE.
994pub async fn probe_servers_for_octet_stream<T>(
995    signer: T,
996    server_urls: Vec<String>,
997) -> Result<usize, String>
998where
999    T: VectorSigner + Clone,
1000{
1001    use rand::RngCore;
1002    if server_urls.is_empty() { return Ok(0); }
1003
1004    const PROBE_MIME: &str = "application/octet-stream";
1005    let mut payload = vec![0u8; 32];
1006    rand::thread_rng().fill_bytes(&mut payload[..]);
1007    let payload = Arc::new(payload);
1008    let payload_size = payload.len() as u64;
1009
1010    let mut probed = 0usize;
1011    for server_url_str in &server_urls {
1012        if crate::blossom_capabilities::has_fresh_capability_for(server_url_str, PROBE_MIME, true) {
1013            continue;
1014        }
1015        let parsed = match Url::parse(server_url_str) {
1016            Ok(u) => u,
1017            Err(_) => continue,
1018        };
1019        // 4s per-server budget bounds worst-case probe pass.
1020        let no_op_progress: ProgressCallback = Arc::new(|_, _| Ok(()));
1021        match tokio::time::timeout(
1022            std::time::Duration::from_secs(4),
1023            upload_blob_with_progress(
1024                signer.clone(),
1025                &parsed,
1026                payload.clone(),
1027                Some(PROBE_MIME),
1028                no_op_progress,
1029                Some(0),
1030                None,
1031                None,
1032            ),
1033        ).await {
1034            Ok(Ok(url)) => {
1035                // Race-guard: server may have been disabled/removed
1036                // between spawn and now (purge_server already cleared).
1037                if !crate::blossom_servers::is_enabled_server(server_url_str) {
1038                    if let Some(hash) = extract_hash_from_blossom_url(&url) {
1039                        let _ = delete_blob(signer.clone(), &parsed, hash).await;
1040                    }
1041                    continue;
1042                }
1043                // Reaching here means the upload succeeded AND the returned hash
1044                // matched (upload_attempt's integrity gate) — so the server
1045                // accepts our encrypted type and stores it verbatim. Final gate:
1046                // it must honor BUD-01 deletion, else removing a message can't
1047                // remove its blob. The probe blob is deleted either way (cleanup);
1048                // a 4s budget bounds the wait, and only a definitive refusal
1049                // (403/405/501) sinks the server — transient failures stay optimistic.
1050                let delete_result = match extract_hash_from_blossom_url(&url) {
1051                    Some(hash) => tokio::time::timeout(
1052                        std::time::Duration::from_secs(4),
1053                        delete_blob(signer.clone(), &parsed, hash),
1054                    ).await.ok(),
1055                    None => None,
1056                };
1057                let refuses_deletion = matches!(
1058                    &delete_result,
1059                    Some(Err(e)) if matches!(parse_status_from_error(e), Some(403) | Some(405) | Some(501)),
1060                );
1061                if refuses_deletion {
1062                    if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
1063                        server_url_str, PROBE_MIME, true,
1064                    ) {
1065                        crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err);
1066                    }
1067                    probed += 1;
1068                    crate::log_info!("[Blossom Probe] {} refuses deletion; routing around", server_url_str);
1069                } else {
1070                    if let Err(e) = crate::blossom_capabilities::record_accepted(
1071                        server_url_str, PROBE_MIME, true, payload_size,
1072                    ) {
1073                        crate::log_warn!("[Blossom Probe] record_accepted failed: {}", e);
1074                    }
1075                    probed += 1;
1076                    crate::log_info!("[Blossom Probe] {} validated (accepts + verbatim + deletes)", server_url_str);
1077                }
1078            }
1079            Ok(Err(e)) => {
1080                let status = parse_status_from_error(&e);
1081                // `[INTEGRITY]` = the server accepted but transformed our probe
1082                // blob; treat it as unsuitable, same as a hard MIME rejection.
1083                if e.contains("[INTEGRITY]") || crate::blossom_capabilities::is_mime_rejection(status, &e) {
1084                    if !crate::blossom_servers::is_enabled_server(server_url_str) {
1085                        continue;
1086                    }
1087                    if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
1088                        server_url_str, PROBE_MIME, true,
1089                    ) {
1090                        crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err);
1091                    }
1092                    probed += 1;
1093                    crate::log_info!("[Blossom Probe] {} unsuitable; routing around: {}", server_url_str, e);
1094                } else {
1095                    // Transient — leave reputation unchanged so we re-probe later.
1096                    crate::log_debug!("[Blossom Probe] {} transient error (not cached): {}", server_url_str, e);
1097                }
1098            }
1099            Err(_) => {
1100                crate::log_debug!("[Blossom Probe] {} timed out, not cached", server_url_str);
1101            }
1102        }
1103    }
1104    Ok(probed)
1105}
1106
1107/// Parse the sha256 out of `<origin>/<sha256>[.<ext>][/]`. Skips an
1108/// empty trailing segment when the URL came back with a trailing slash.
1109fn extract_hash_from_blossom_url(url: &str) -> Option<Sha256Hash> {
1110    let parsed = Url::parse(url).ok()?;
1111    let last = parsed.path_segments()?.rev().find(|s| !s.is_empty())?;
1112    let stem = last.split('.').next()?;
1113    Sha256Hash::from_str(stem).ok()
1114}
1115
1116/// Extract the HTTP status from an error string. Anchored to the
1117/// `"with status NNN"` shape produced by `upload_blob_with_progress`
1118/// so unrelated `status` substrings don't false-match.
1119fn parse_status_from_error(msg: &str) -> Option<u16> {
1120    let key = "with status ";
1121    let i = msg.find(key)?;
1122    let tail = &msg[i + key.len()..];
1123    let digits: String = tail.chars().take_while(|c| c.is_ascii_digit()).collect();
1124    digits.parse::<u16>().ok()
1125}
1126
1127#[cfg(test)]
1128mod parse_status_tests {
1129    use super::parse_status_from_error;
1130
1131    #[test]
1132    fn extracts_status_code() {
1133        assert_eq!(parse_status_from_error("Upload failed with status 500 Internal Server Error: x"), Some(500));
1134        assert_eq!(parse_status_from_error("Upload failed with status 413 Payload Too Large"), Some(413));
1135        assert_eq!(parse_status_from_error("Upload failed with status 415"), Some(415));
1136        // Cloudflare gateway timeouts render as "<unknown status code>" — the failover branch relies
1137        // on this still parsing to the numeric code.
1138        assert_eq!(parse_status_from_error("Upload failed with status 524 <unknown status code>: gateway"), Some(524));
1139    }
1140
1141    #[test]
1142    fn returns_none_when_absent() {
1143        assert_eq!(parse_status_from_error("network error: timeout"), None);
1144    }
1145}
1146
1147#[cfg(test)]
1148mod hash_swap_tests {
1149    use super::hash_swap_candidates;
1150
1151    const HASH: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
1152
1153    #[test]
1154    fn derives_clean_leaves_and_skips_the_source_origin() {
1155        // Query + fragment on the primary must NOT leak into derived URLs,
1156        // the source origin is skipped, junk servers are dropped.
1157        let primary = format!("https://a.example/{}.png?utm=track#frag", HASH);
1158        let servers = vec![
1159            "https://a.example".to_string(),
1160            "https://b.example".to_string(),
1161            "not a url".to_string(),
1162        ];
1163        assert_eq!(
1164            hash_swap_candidates(&primary, &servers),
1165            vec![format!("https://b.example/{}.png", HASH)],
1166        );
1167    }
1168
1169    #[test]
1170    fn extensionless_primary_yields_the_bare_hash() {
1171        let primary = format!("https://a.example/{}", HASH);
1172        assert_eq!(
1173            hash_swap_candidates(&primary, &["https://b.example/".to_string()]),
1174            vec![format!("https://b.example/{}", HASH)],
1175        );
1176    }
1177
1178    #[test]
1179    fn unparseable_primary_yields_nothing() {
1180        assert!(hash_swap_candidates("https://a.example/not-a-hash.png", &["https://b.example".to_string()]).is_empty());
1181    }
1182}
1183
1184#[cfg(test)]
1185mod hash_extract_tests {
1186    use super::extract_hash_from_blossom_url;
1187
1188    const HASH_HEX: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
1189
1190    #[test]
1191    fn plain_url() {
1192        let url = format!("https://srv.example/{}", HASH_HEX);
1193        assert!(extract_hash_from_blossom_url(&url).is_some());
1194    }
1195
1196    #[test]
1197    fn with_extension() {
1198        let url = format!("https://srv.example/{}.jpg", HASH_HEX);
1199        assert!(extract_hash_from_blossom_url(&url).is_some());
1200    }
1201
1202    #[test]
1203    fn trailing_slash_still_resolves() {
1204        // Some servers append a trailing slash to the descriptor URL.
1205        let url = format!("https://srv.example/{}/", HASH_HEX);
1206        assert!(extract_hash_from_blossom_url(&url).is_some());
1207    }
1208
1209    #[test]
1210    fn malformed_returns_none() {
1211        assert!(extract_hash_from_blossom_url("https://srv.example/").is_none());
1212        assert!(extract_hash_from_blossom_url("not a url").is_none());
1213        assert!(extract_hash_from_blossom_url("https://srv.example/notahash").is_none());
1214    }
1215
1216    #[test]
1217    fn x_sha256_simd_hex_matches_lowerhex() {
1218        use bitcoin_hashes::sha256::Hash as Sha256Hash;
1219        // The X-SHA-256 header swapped format!("{:x}") for the SIMD encoder; they MUST agree
1220        // byte-for-byte (sha256::Hash displays in forward order — a reversed-display hash type would
1221        // silently corrupt the upload header).
1222        let hash = Sha256Hash::hash(b"vector blossom x-sha-256 parity check");
1223        assert_eq!(
1224            crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()),
1225            format!("{:x}", hash),
1226        );
1227    }
1228}