use nostr_sdk::{NostrSigner, Url, Event, EventBuilder, Timestamp, JsonUtil};
use nostr_sdk::hashes::{sha256::Hash as Sha256Hash, Hash};
use nostr_blossom::prelude::*;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE};
use reqwest::{Body, StatusCode};
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::mpsc;
use futures_util::Stream;
use std::pin::Pin;
use std::task::{Context, Poll};
pub type ProgressCallback = std::sync::Arc<dyn Fn(Option<u8>, Option<u64>) -> Result<(), String> + Send + Sync>;
struct ProgressTrackingStream {
bytes_sent: Arc<Mutex<u64>>,
inner: mpsc::Receiver<Result<Vec<u8>, std::io::Error>>,
}
impl ProgressTrackingStream {
fn new(data: Arc<Vec<u8>>, bytes_sent: Arc<Mutex<u64>>) -> Self {
let (tx, rx) = mpsc::channel(8);
tokio::spawn(async move {
let chunk_size = 64 * 1024; let mut position = 0;
while position < data.len() {
let end = std::cmp::min(position + chunk_size, data.len());
let chunk = data[position..end].to_vec();
if tx.send(Ok(chunk)).await.is_err() {
break; }
position = end;
}
});
Self {
bytes_sent,
inner: rx,
}
}
}
impl Stream for ProgressTrackingStream {
type Item = Result<Vec<u8>, std::io::Error>;
fn poll_next(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Self::Item>> {
match self.inner.poll_recv(cx) {
Poll::Ready(Some(result)) => {
if let Ok(chunk) = &result {
let mut bytes_sent = self.bytes_sent.lock().unwrap();
*bytes_sent += chunk.len() as u64;
}
Poll::Ready(Some(result))
}
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}
async fn build_auth_header<T>(
signer: &T,
hash: Sha256Hash,
) -> Result<HeaderValue, String>
where
T: NostrSigner,
{
let expiration = Timestamp::now() + std::time::Duration::from_secs(300);
let auth = BlossomAuthorization::new(
"Blossom upload authorization".to_string(),
expiration,
BlossomAuthorizationVerb::Upload,
BlossomAuthorizationScope::BlobSha256Hashes(vec![hash]),
);
let auth_event: Event = EventBuilder::blossom_auth(auth)
.sign(signer)
.await
.map_err(|e| format!("Failed to sign auth event: {}", e))?;
let encoded_auth = base64_simd::STANDARD.encode_to_string(auth_event.as_json());
let value = format!("Nostr {}", encoded_auth);
HeaderValue::try_from(value)
.map_err(|e| format!("Failed to create header value: {}", e))
}
pub async fn upload_blob_with_progress<T>(
signer: T,
server_url: &Url,
file_data: Arc<Vec<u8>>,
mime_type: Option<&str>,
progress_callback: ProgressCallback,
retry_count: Option<u32>,
retry_spacing: Option<std::time::Duration>,
cancel_flag: Option<Arc<AtomicBool>>,
) -> Result<String, String>
where
T: NostrSigner + Clone,
{
let retry_count = retry_count.unwrap_or(0);
let retry_spacing = retry_spacing.unwrap_or(std::time::Duration::from_secs(1));
let mut last_error = None;
for attempt in 0..=retry_count {
if attempt > 0 {
tokio::time::sleep(retry_spacing).await;
}
if let Some(ref flag) = cancel_flag {
if flag.load(Ordering::Relaxed) {
return Err("Upload cancelled".to_string());
}
}
match upload_attempt(
signer.clone(),
server_url,
file_data.clone(),
mime_type,
&progress_callback,
cancel_flag.clone(),
).await {
Ok(url) => return Ok(url),
Err(e) => {
if e == "Upload cancelled" {
return Err(e);
}
crate::log_warn!(
"[Blossom] Attempt {}/{} to {} failed: {}",
attempt + 1, retry_count + 1, server_url, e,
);
let status = parse_status_from_error(&e);
let permanent = crate::blossom_capabilities::is_mime_rejection(status, &e)
|| crate::blossom_capabilities::is_size_rejection(status);
if permanent {
return Err(e);
}
if matches!(status, Some(504 | 520 | 522 | 524)) {
crate::log_warn!(
"[Blossom] {} gateway-timed-out (status {}) on {} bytes; routing to the next server",
server_url, status.unwrap_or(0), file_data.len(),
);
return Err(e);
}
let looks_like_mid_stream_drop = (
e.contains("Upload request failed")
|| e.contains("error sending request")
|| e.contains("connection reset")
|| e.contains("connection closed")
|| e.contains("connection refused")
|| e.contains("body write")
|| e.contains("IncompleteMessage")
|| e.contains("broken pipe")
) && file_data.len() > 8 * 1024 * 1024;
if looks_like_mid_stream_drop {
crate::log_warn!(
"[Blossom] {} dropped the connection mid-upload of {} bytes, treating as permanent",
server_url, file_data.len(),
);
return Err(e);
}
last_error = Some(e);
}
}
}
Err(last_error.unwrap_or_else(|| "No upload attempts were made".to_string()))
}
async fn upload_attempt<T>(
signer: T,
server_url: &Url,
file_data: Arc<Vec<u8>>,
mime_type: Option<&str>,
progress_callback: &ProgressCallback,
cancel_flag: Option<Arc<AtomicBool>>,
) -> Result<String, String>
where
T: NostrSigner,
{
let upload_url = server_url.join("upload")
.map_err(|e| format!("Invalid server URL: {}", e))?;
let total_size = file_data.len() as u64;
let hash = Sha256Hash::hash(&*file_data);
progress_callback(Some(0), Some(0)).map_err(|e| e)?;
let auth_header = build_auth_header(&signer, hash).await?;
let client = crate::net::build_http_client_with_options(
std::time::Duration::from_secs(300),
false,
)?;
{
let mut head_headers = HeaderMap::new();
head_headers.insert(AUTHORIZATION, auth_header.clone());
head_headers.insert(
"X-Content-Length",
HeaderValue::from_str(&total_size.to_string())
.map_err(|e| format!("Invalid X-Content-Length: {}", e))?,
);
head_headers.insert(
"X-SHA-256",
HeaderValue::from_str(&crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()))
.map_err(|e| format!("Invalid X-SHA-256: {}", e))?,
);
if let Some(ct) = mime_type {
head_headers.insert(
"X-Content-Type",
HeaderValue::from_str(ct).map_err(|e| format!("Invalid X-Content-Type: {}", e))?,
);
}
match tokio::time::timeout(
std::time::Duration::from_secs(5),
client.head(upload_url.clone()).headers(head_headers).send(),
).await {
Ok(Ok(resp)) => {
let status = resp.status();
let x_reason = resp.headers().get("X-Reason")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let body = resp.text().await.unwrap_or_default();
let diag = if !body.is_empty() {
if let Some(r) = &x_reason {
format!("{} (X-Reason: {})", body, r)
} else {
body
}
} else if let Some(r) = x_reason {
r
} else {
format!("rejected at preflight ({})", status)
};
let is_413 = status == StatusCode::PAYLOAD_TOO_LARGE;
let is_415 = status == StatusCode::UNSUPPORTED_MEDIA_TYPE;
let mime_hinted = status.is_client_error() && !is_413 && {
crate::blossom_capabilities::is_mime_rejection(Some(status.as_u16()), &diag)
};
if is_413 || is_415 || mime_hinted {
crate::log_warn!(
"[Blossom Preflight] {} REJECTED {} ({} bytes, {}): {}",
server_url, status, total_size,
mime_type.unwrap_or("(no mime)"), diag,
);
return Err(format!(
"Upload failed with status {}: {}",
status, diag,
));
}
crate::log_debug!(
"[Blossom Preflight] {} → {} ({} bytes); proceeding to PUT",
server_url, status, total_size,
);
}
Ok(Err(e)) => {
crate::log_debug!("[Blossom Preflight] {} HEAD failed: {}, falling through to PUT", server_url, e);
}
Err(_) => {
crate::log_debug!("[Blossom Preflight] {} HEAD timed out (5s), falling through to PUT", server_url);
}
}
}
let bytes_sent = Arc::new(Mutex::new(0u64));
let tracking_stream = ProgressTrackingStream::new(file_data, Arc::clone(&bytes_sent));
let body = Body::wrap_stream(tracking_stream);
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, auth_header);
if let Some(ct) = mime_type {
headers.insert(
CONTENT_TYPE,
HeaderValue::from_str(ct).map_err(|e| format!("Invalid content type: {}", e))?
);
}
headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size));
let mut request_future = Box::pin(client
.put(upload_url.clone())
.headers(headers)
.body(body)
.send());
let mut last_percentage = 0;
let mut poll_interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
let response = loop {
tokio::select! {
response = &mut request_future => {
break response.map_err(|e| format!("Upload request failed: {}", e))?;
},
_ = poll_interval.tick() => {
if let Some(ref flag) = cancel_flag {
if flag.load(Ordering::Relaxed) {
return Err("Upload cancelled".to_string());
}
}
let current_bytes = *bytes_sent.lock().unwrap();
let percentage = if total_size > 0 {
((current_bytes as f64 / total_size as f64) * 100.0) as u8
} else {
0
};
if percentage != last_percentage {
if let Err(e) = progress_callback(Some(percentage), Some(current_bytes)) {
return Err(e);
}
last_percentage = percentage;
}
}
}
};
let final_bytes = *bytes_sent.lock().unwrap();
if final_bytes == total_size && last_percentage < 100 {
progress_callback(Some(100), Some(total_size)).map_err(|e| e)?;
}
let status = response.status();
if status.is_success() {
let descriptor: BlobDescriptor = response.json().await
.map_err(|e| format!("Failed to parse response: {}", e))?;
if descriptor.sha256 != hash {
return Err(format!(
"[INTEGRITY] {} transformed the upload (returned {}, expected {})",
server_url, descriptor.sha256, hash,
));
}
Ok(descriptor.url.to_string())
} else {
let x_reason = response.headers().get("X-Reason")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
let display = match (error_text.is_empty(), x_reason) {
(false, Some(r)) => format!("{} (X-Reason: {})", error_text, r),
(false, None) => error_text,
(true, Some(r)) => r,
(true, None) => "Unknown error".to_string(),
};
crate::log_warn!("[Blossom Error] Upload failed with status {}: {}", status, display);
Err(format!("Upload failed with status {}: {}", status, display))
}
}
pub async fn upload_blob<T>(
signer: T,
server_url: &Url,
file_data: Arc<Vec<u8>>,
mime_type: Option<&str>,
) -> Result<String, String>
where
T: NostrSigner,
{
let upload_url = server_url.join("upload")
.map_err(|e| format!("Invalid server URL: {}", e))?;
let hash = Sha256Hash::hash(&*file_data);
let total_size = file_data.len() as u64;
let auth_header = build_auth_header(&signer, hash).await?;
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, auth_header);
if let Some(ct) = mime_type {
headers.insert(
CONTENT_TYPE,
HeaderValue::from_str(ct).map_err(|e| format!("Invalid content type: {}", e))?
);
}
headers.insert(CONTENT_LENGTH, HeaderValue::from(total_size));
let client = crate::net::build_http_client_with_options(
std::time::Duration::from_secs(300),
false,
)?;
let body_data: Vec<u8> = Arc::try_unwrap(file_data)
.unwrap_or_else(|arc| (*arc).clone());
let response = client
.put(upload_url)
.headers(headers)
.body(body_data)
.send()
.await
.map_err(|e| format!("Upload request failed: {}", e))?;
let status = response.status();
if status.is_success() {
let descriptor: BlobDescriptor = response.json().await
.map_err(|e| format!("Failed to parse response: {}", e))?;
if descriptor.sha256 != hash {
return Err(format!(
"[INTEGRITY] {} transformed the upload (returned {}, expected {})",
server_url, descriptor.sha256, hash,
));
}
Ok(descriptor.url.to_string())
} else {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
Err(format!("Upload failed with status {}: {}", status, error_text))
}
}
pub async fn upload_blob_with_failover<T>(
signer: T,
server_urls: Vec<String>,
file_data: Arc<Vec<u8>>,
mime_type: Option<&str>,
) -> Result<String, String>
where
T: NostrSigner + Clone,
{
let mut last_error = String::from("No servers available");
for (index, server_url_str) in server_urls.iter().enumerate() {
let server_url = match Url::parse(server_url_str) {
Ok(url) => url,
Err(e) => {
crate::log_warn!("[Blossom Error] Invalid server URL '{}': {}", server_url_str, e);
last_error = format!("Invalid server URL: {}", e);
continue;
}
};
crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}",
index + 1, server_urls.len(), server_url_str);
match upload_blob(signer.clone(), &server_url, file_data.clone(), mime_type).await {
Ok(url) => {
crate::log_info!("[Blossom] Upload successful to: {}", server_url_str);
return Ok(url);
}
Err(e) => {
crate::log_warn!("[Blossom Error] Upload failed to {}: {}", server_url_str, e);
last_error = e;
}
}
}
Err(format!("All Blossom servers failed. Last error: {}", last_error))
}
pub async fn upload_blob_with_progress_and_failover<T>(
signer: T,
server_urls: Vec<String>,
file_data: Arc<Vec<u8>>,
mime_type: Option<&str>,
is_encrypted: bool,
progress_callback: ProgressCallback,
retry_count: Option<u32>,
retry_spacing: Option<std::time::Duration>,
cancel_flag: Option<Arc<AtomicBool>>,
) -> Result<String, String>
where
T: NostrSigner + Clone,
{
let mut last_error = String::from("No servers available");
let size_bytes = file_data.len() as u64;
let mime_for_routing = mime_type.unwrap_or("application/octet-stream");
let ranked = crate::blossom_capabilities::rank_servers(server_urls, mime_for_routing, is_encrypted, size_bytes);
let upload_session = crate::state::SessionGuard::capture();
for (index, server_url_str) in ranked.iter().enumerate() {
if let Some(ref flag) = cancel_flag {
if flag.load(Ordering::Relaxed) {
return Err("Upload cancelled".to_string());
}
}
let server_url = match Url::parse(server_url_str) {
Ok(url) => url,
Err(e) => {
crate::log_warn!("[Blossom Error] Invalid server URL '{}': {}", server_url_str, e);
last_error = format!("Invalid server URL: {}", e);
continue;
}
};
crate::log_info!("[Blossom] Attempting upload to server {} of {}: {}",
index + 1, ranked.len(), server_url_str);
match upload_blob_with_progress(
signer.clone(),
&server_url,
file_data.clone(),
mime_type,
progress_callback.clone(),
retry_count,
retry_spacing,
cancel_flag.clone(),
).await {
Ok(url) => {
crate::log_info!("[Blossom] Upload successful to: {}", server_url_str);
if let Err(err) = crate::blossom_capabilities::record_accepted(
server_url_str, mime_for_routing, is_encrypted, size_bytes, upload_session,
) {
crate::log_warn!("[Blossom Cap] record_accepted failed: {}", err);
}
return Ok(url);
}
Err(e) => {
if e == "Upload cancelled" {
return Err(e);
}
crate::log_warn!("[Blossom Error] Upload failed to {}: {}", server_url_str, e);
let status = parse_status_from_error(&e);
if e.contains("[INTEGRITY]") || crate::blossom_capabilities::is_mime_rejection(status, &e) {
if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
server_url_str, mime_for_routing, is_encrypted, upload_session,
) {
crate::log_warn!("[Blossom Cap] record_rejected_mime failed: {}", err);
}
} else if crate::blossom_capabilities::is_size_rejection(status) {
if let Err(err) = crate::blossom_capabilities::record_rejected_size(
server_url_str, mime_for_routing, is_encrypted, size_bytes, upload_session,
) {
crate::log_warn!("[Blossom Cap] record_rejected_size failed: {}", err);
}
}
last_error = e;
let _ = progress_callback(Some(0), Some(0));
}
}
}
Err(format!("All Blossom servers failed. Last error: {}", last_error))
}
async fn build_delete_auth_header<T>(
signer: &T,
hash: Sha256Hash,
) -> Result<HeaderValue, String>
where
T: NostrSigner,
{
let expiration = Timestamp::now() + std::time::Duration::from_secs(300);
let auth = BlossomAuthorization::new(
"Blossom delete authorization".to_string(),
expiration,
BlossomAuthorizationVerb::Delete,
BlossomAuthorizationScope::BlobSha256Hashes(vec![hash]),
);
let auth_event: Event = EventBuilder::blossom_auth(auth)
.sign(signer)
.await
.map_err(|e| format!("Failed to sign auth event: {}", e))?;
let encoded_auth = base64_simd::STANDARD.encode_to_string(auth_event.as_json());
let value = format!("Nostr {}", encoded_auth);
HeaderValue::try_from(value)
.map_err(|e| format!("Failed to create header value: {}", e))
}
pub async fn delete_blob<T>(
signer: T,
server_url: &Url,
hash: Sha256Hash,
) -> Result<(), String>
where
T: NostrSigner + Clone,
{
let auth_header = build_delete_auth_header(&signer, hash).await?;
let mut url = server_url.clone();
url.set_path(&format!("/{}", hash));
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, auth_header);
let client = crate::net::build_http_client(std::time::Duration::from_secs(30))?;
let response = client
.delete(url)
.headers(headers)
.send()
.await
.map_err(|e| format!("Blossom DELETE request failed: {}", e))?;
let status = response.status();
if status.is_success() || status == StatusCode::NOT_FOUND {
Ok(())
} else {
let body = response.text().await.unwrap_or_else(|_| "<no body>".into());
Err(format!("Blossom DELETE failed with status {}: {}", status, body))
}
}
pub async fn delete_blob_by_url<T>(signer: T, url_str: &str) -> Result<(), String>
where
T: NostrSigner + Clone,
{
let parsed = Url::parse(url_str)
.map_err(|e| format!("Invalid Blossom URL: {}", e))?;
let last_segment = parsed
.path_segments()
.and_then(|segs| segs.rev().find(|s| !s.is_empty()))
.ok_or_else(|| "Blossom URL has no path segment".to_string())?;
let hash_str = last_segment.split('.').next().unwrap_or("");
let hash = Sha256Hash::from_str(hash_str)
.map_err(|e| format!("Path is not a SHA-256 hash: {}", e))?;
let mut origin = parsed.clone();
origin.set_path("/");
origin.set_query(None);
origin.set_fragment(None);
crate::log_info!("[Blossom] DELETE {} from {}", hash, origin);
let timeout = std::time::Duration::from_secs(15);
match tokio::time::timeout(timeout, delete_blob(signer, &origin, hash)).await {
Ok(Ok(())) => {
crate::log_info!("[Blossom] DELETE successful: {} from {}", hash, origin);
Ok(())
}
Ok(Err(e)) => {
crate::log_warn!("[Blossom] DELETE failed: {} from {}: {}", hash, origin, e);
Err(e)
}
Err(_) => {
let msg = format!("DELETE timed out after {}s", timeout.as_secs());
crate::log_warn!("[Blossom] {} ({} from {})", msg, hash, origin);
Err(msg)
}
}
}
pub fn delete_blobs_best_effort<T>(signer: T, urls: Vec<String>)
where
T: NostrSigner + Clone + Send + Sync + 'static,
{
for url_str in urls {
let url = match Url::parse(&url_str) {
Ok(u) => u,
Err(_) => continue,
};
let last_segment = match url.path_segments()
.and_then(|segs| segs.rev().find(|s| !s.is_empty()))
{
Some(s) => s,
None => continue,
};
let hash_str = last_segment.split('.').next().unwrap_or("");
let hash = match Sha256Hash::from_str(hash_str) {
Ok(h) => h,
Err(_) => continue,
};
let mut origin = url.clone();
origin.set_path("/");
origin.set_query(None);
origin.set_fragment(None);
let signer = signer.clone();
tokio::spawn(async move {
if let Err(e) = delete_blob(signer, &origin, hash).await {
crate::log_warn!("[Blossom delete] {} from {}: {}", hash, origin, e);
}
});
}
}
pub async fn probe_servers_for_octet_stream<T>(
signer: T,
server_urls: Vec<String>,
session: crate::state::SessionGuard,
) -> Result<usize, String>
where
T: NostrSigner + Clone,
{
use rand::RngCore;
if !session.is_valid() { return Ok(0); }
if server_urls.is_empty() { return Ok(0); }
const PROBE_MIME: &str = "application/octet-stream";
let mut payload = vec![0u8; 32];
rand::thread_rng().fill_bytes(&mut payload[..]);
let payload = Arc::new(payload);
let payload_size = payload.len() as u64;
let mut probed = 0usize;
for server_url_str in &server_urls {
if !session.is_valid() { return Ok(probed); }
if crate::blossom_capabilities::has_fresh_capability_for(server_url_str, PROBE_MIME, true) {
continue;
}
let parsed = match Url::parse(server_url_str) {
Ok(u) => u,
Err(_) => continue,
};
let no_op_progress: ProgressCallback = Arc::new(|_, _| Ok(()));
match tokio::time::timeout(
std::time::Duration::from_secs(4),
upload_blob_with_progress(
signer.clone(),
&parsed,
payload.clone(),
Some(PROBE_MIME),
no_op_progress,
Some(0),
None,
None,
),
).await {
Ok(Ok(url)) => {
if !crate::blossom_servers::is_enabled_server(server_url_str) {
if let Some(hash) = extract_hash_from_blossom_url(&url) {
let _ = delete_blob(signer.clone(), &parsed, hash).await;
}
continue;
}
let delete_result = match extract_hash_from_blossom_url(&url) {
Some(hash) => tokio::time::timeout(
std::time::Duration::from_secs(4),
delete_blob(signer.clone(), &parsed, hash),
).await.ok(),
None => None,
};
let refuses_deletion = matches!(
&delete_result,
Some(Err(e)) if matches!(parse_status_from_error(e), Some(403) | Some(405) | Some(501)),
);
if refuses_deletion {
if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
server_url_str, PROBE_MIME, true, session,
) {
crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err);
}
probed += 1;
crate::log_info!("[Blossom Probe] {} refuses deletion; routing around", server_url_str);
} else {
if let Err(e) = crate::blossom_capabilities::record_accepted(
server_url_str, PROBE_MIME, true, payload_size, session,
) {
crate::log_warn!("[Blossom Probe] record_accepted failed: {}", e);
}
probed += 1;
crate::log_info!("[Blossom Probe] {} validated (accepts + verbatim + deletes)", server_url_str);
}
}
Ok(Err(e)) => {
let status = parse_status_from_error(&e);
if e.contains("[INTEGRITY]") || crate::blossom_capabilities::is_mime_rejection(status, &e) {
if !crate::blossom_servers::is_enabled_server(server_url_str) {
continue;
}
if let Err(err) = crate::blossom_capabilities::record_rejected_mime(
server_url_str, PROBE_MIME, true, session,
) {
crate::log_warn!("[Blossom Probe] record_rejected_mime failed: {}", err);
}
probed += 1;
crate::log_info!("[Blossom Probe] {} unsuitable; routing around: {}", server_url_str, e);
} else {
crate::log_debug!("[Blossom Probe] {} transient error (not cached): {}", server_url_str, e);
}
}
Err(_) => {
crate::log_debug!("[Blossom Probe] {} timed out, not cached", server_url_str);
}
}
}
Ok(probed)
}
fn extract_hash_from_blossom_url(url: &str) -> Option<Sha256Hash> {
let parsed = Url::parse(url).ok()?;
let last = parsed.path_segments()?.rev().find(|s| !s.is_empty())?;
let stem = last.split('.').next()?;
Sha256Hash::from_str(stem).ok()
}
fn parse_status_from_error(msg: &str) -> Option<u16> {
let key = "with status ";
let i = msg.find(key)?;
let tail = &msg[i + key.len()..];
let digits: String = tail.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse::<u16>().ok()
}
#[cfg(test)]
mod parse_status_tests {
use super::parse_status_from_error;
#[test]
fn extracts_status_code() {
assert_eq!(parse_status_from_error("Upload failed with status 500 Internal Server Error: x"), Some(500));
assert_eq!(parse_status_from_error("Upload failed with status 413 Payload Too Large"), Some(413));
assert_eq!(parse_status_from_error("Upload failed with status 415"), Some(415));
assert_eq!(parse_status_from_error("Upload failed with status 524 <unknown status code>: gateway"), Some(524));
}
#[test]
fn returns_none_when_absent() {
assert_eq!(parse_status_from_error("network error: timeout"), None);
}
}
#[cfg(test)]
mod hash_extract_tests {
use super::extract_hash_from_blossom_url;
const HASH_HEX: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
#[test]
fn plain_url() {
let url = format!("https://srv.example/{}", HASH_HEX);
assert!(extract_hash_from_blossom_url(&url).is_some());
}
#[test]
fn with_extension() {
let url = format!("https://srv.example/{}.jpg", HASH_HEX);
assert!(extract_hash_from_blossom_url(&url).is_some());
}
#[test]
fn trailing_slash_still_resolves() {
let url = format!("https://srv.example/{}/", HASH_HEX);
assert!(extract_hash_from_blossom_url(&url).is_some());
}
#[test]
fn malformed_returns_none() {
assert!(extract_hash_from_blossom_url("https://srv.example/").is_none());
assert!(extract_hash_from_blossom_url("not a url").is_none());
assert!(extract_hash_from_blossom_url("https://srv.example/notahash").is_none());
}
#[test]
fn x_sha256_simd_hex_matches_lowerhex() {
use nostr_sdk::hashes::{sha256::Hash as Sha256Hash, Hash};
let hash = Sha256Hash::hash(b"vector blossom x-sha-256 parity check");
assert_eq!(
crate::simd::hex::bytes_to_hex_32(&hash.to_byte_array()),
format!("{:x}", hash),
);
}
}