Skip to main content

hashtree_cli/server/
blossom.rs

1//! Blossom protocol implementation (BUD-01, BUD-02)
2//!
3//! Implements blob storage endpoints with Nostr-based authentication.
4//! See: https://github.com/hzrd149/blossom
5
6use axum::{
7    body::{Body, Bytes},
8    extract::{Path, Query, Request, State},
9    http::{header, HeaderMap, Response, StatusCode},
10    middleware::Next,
11    response::IntoResponse,
12    Json,
13};
14use base64::Engine;
15use hashtree_blossom::{
16    batch_upload_hash_list_digest, BatchUploadItem, BlossomClient, BATCH_UPLOAD_HASH_LIST_AUTH_TAG,
17};
18use hashtree_core::from_hex;
19use nostr::Keys;
20use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22use std::collections::HashSet;
23use std::sync::{
24    atomic::{AtomicU64, AtomicUsize, Ordering},
25    Arc, Mutex, OnceLock,
26};
27use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
28use tokio::sync::{mpsc, OwnedSemaphorePermit, Semaphore};
29
30use super::auth::AppState;
31use super::blob_read::{
32    acquire_blob_read, acquire_blob_write, blob_read_timeout, BLOB_READ_BUSY, BLOB_WRITE_BUSY,
33};
34use super::ingest_filter::{
35    content_type_base, is_chk_content_type, validate_untrusted_blob, IngestRejection,
36};
37use super::mime::get_mime_type;
38
39/// Blossom authorization event kind (NIP-98 style)
40const BLOSSOM_AUTH_KIND: u16 = 24242;
41
42/// Cache-Control header for immutable content-addressed data (1 year)
43const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
44const NOT_FOUND_CACHE_CONTROL: &str = "no-store";
45const IMMUTABLE_NOT_FOUND_CACHE_CONTROL: &str = "public, max-age=0, s-maxage=5";
46const OPTIMISTIC_UPLOAD_QUEUE_TIMEOUT_MS_ENV: &str = "HTREE_OPTIMISTIC_UPLOAD_QUEUE_TIMEOUT_MS";
47const DEFAULT_OPTIMISTIC_UPLOAD_QUEUE_TIMEOUT_MS: u64 = 15_000;
48const BLOSSOM_PUBLIC_BASE_URL_ENV: &str = "HTREE_BLOSSOM_PUBLIC_BASE_URL";
49const LEGACY_BLOSSOM_PUBLIC_BASE_URL_ENV: &str = "HASHTREE_BLOSSOM_PUBLIC_BASE_URL";
50
51/// Default maximum upload size in bytes (5 MB)
52pub const DEFAULT_MAX_UPLOAD_SIZE: usize = 5 * 1024 * 1024;
53pub const MAX_SINGLE_UPLOAD_BODY_BYTES: usize = 64 * 1024 * 1024;
54const OPTIMISTIC_UPLOAD_MIN_QUEUE_CHARGE_BYTES: usize = 256 * 1024;
55const MAX_BATCH_UPLOAD_BLOBS: usize = 1024;
56pub const MAX_BATCH_UPLOAD_BYTES: usize = 64 * 1024 * 1024;
57pub const MAX_BATCH_UPLOAD_JSON_BODY_BYTES: usize = 96 * 1024 * 1024;
58const BINARY_BATCH_UPLOAD_MAGIC: &[u8; 8] = b"HTBBV1\0\0";
59const MAX_BINARY_BATCH_CONTENT_TYPE_BYTES: usize = 1024;
60pub const MAX_BATCH_UPLOAD_BINARY_BODY_BYTES: usize = MAX_BATCH_UPLOAD_BYTES
61    + BINARY_BATCH_UPLOAD_MAGIC.len()
62    + 4
63    + (MAX_BATCH_UPLOAD_BLOBS * (32 + 2 + MAX_BINARY_BATCH_CONTENT_TYPE_BYTES + 8));
64const MAX_UPLOAD_CHECK_HASHES: usize = 10_000;
65const SLOW_BATCH_UPLOAD_LOG_MS_ENV: &str = "HTREE_SLOW_BATCH_UPLOAD_LOG_MS";
66const BLOSSOM_REPLICA_UPLOAD_CONCURRENCY_ENV: &str = "HTREE_BLOSSOM_REPLICA_UPLOAD_CONCURRENCY";
67const DEFAULT_BLOSSOM_REPLICA_UPLOAD_CONCURRENCY: usize = 4;
68const BLOSSOM_REPLICA_UPLOAD_ATTEMPTS_ENV: &str = "HTREE_BLOSSOM_REPLICA_UPLOAD_ATTEMPTS";
69const DEFAULT_BLOSSOM_REPLICA_UPLOAD_ATTEMPTS: usize = 3;
70const BLOSSOM_REPLICA_COALESCE_MAX_BLOBS_ENV: &str = "HTREE_BLOSSOM_REPLICA_COALESCE_MAX_BLOBS";
71const DEFAULT_BLOSSOM_REPLICA_COALESCE_MAX_BLOBS: usize = 64;
72const BLOSSOM_REPLICA_COALESCE_MAX_BYTES_ENV: &str = "HTREE_BLOSSOM_REPLICA_COALESCE_MAX_BYTES";
73const DEFAULT_BLOSSOM_REPLICA_COALESCE_MAX_BYTES: usize = 16 * 1024 * 1024;
74const BLOSSOM_REPLICA_COALESCE_FLUSH_MS_ENV: &str = "HTREE_BLOSSOM_REPLICA_COALESCE_FLUSH_MS";
75const DEFAULT_BLOSSOM_REPLICA_COALESCE_FLUSH_MS: u64 = 25;
76const BLOSSOM_REPLICA_COALESCE_QUEUE_JOBS_ENV: &str = "HTREE_BLOSSOM_REPLICA_COALESCE_QUEUE_JOBS";
77const DEFAULT_BLOSSOM_REPLICA_COALESCE_QUEUE_JOBS: usize = 1024;
78
79fn slow_batch_upload_log_ms() -> Option<u128> {
80    std::env::var(SLOW_BATCH_UPLOAD_LOG_MS_ENV)
81        .ok()
82        .and_then(|value| value.parse::<u128>().ok())
83        .filter(|value| *value > 0)
84}
85
86fn blossom_replica_upload_concurrency() -> usize {
87    std::env::var(BLOSSOM_REPLICA_UPLOAD_CONCURRENCY_ENV)
88        .ok()
89        .and_then(|value| value.parse::<usize>().ok())
90        .filter(|value| *value > 0)
91        .unwrap_or(DEFAULT_BLOSSOM_REPLICA_UPLOAD_CONCURRENCY)
92}
93
94fn blossom_replica_upload_attempts() -> usize {
95    std::env::var(BLOSSOM_REPLICA_UPLOAD_ATTEMPTS_ENV)
96        .ok()
97        .and_then(|value| value.parse::<usize>().ok())
98        .filter(|value| *value > 0)
99        .unwrap_or(DEFAULT_BLOSSOM_REPLICA_UPLOAD_ATTEMPTS)
100}
101
102fn blossom_replica_coalesce_max_blobs() -> usize {
103    std::env::var(BLOSSOM_REPLICA_COALESCE_MAX_BLOBS_ENV)
104        .ok()
105        .and_then(|value| value.parse::<usize>().ok())
106        .filter(|value| *value > 0)
107        .unwrap_or(DEFAULT_BLOSSOM_REPLICA_COALESCE_MAX_BLOBS)
108        .min(MAX_BATCH_UPLOAD_BLOBS)
109}
110
111fn blossom_replica_coalesce_max_bytes() -> usize {
112    std::env::var(BLOSSOM_REPLICA_COALESCE_MAX_BYTES_ENV)
113        .ok()
114        .and_then(|value| value.parse::<usize>().ok())
115        .filter(|value| *value > 0)
116        .unwrap_or(DEFAULT_BLOSSOM_REPLICA_COALESCE_MAX_BYTES)
117        .min(MAX_BATCH_UPLOAD_BYTES)
118}
119
120fn blossom_replica_coalesce_flush_delay() -> Duration {
121    let millis = std::env::var(BLOSSOM_REPLICA_COALESCE_FLUSH_MS_ENV)
122        .ok()
123        .and_then(|value| value.parse::<u64>().ok())
124        .unwrap_or(DEFAULT_BLOSSOM_REPLICA_COALESCE_FLUSH_MS);
125    Duration::from_millis(millis)
126}
127
128fn blossom_replica_coalesce_queue_jobs() -> usize {
129    std::env::var(BLOSSOM_REPLICA_COALESCE_QUEUE_JOBS_ENV)
130        .ok()
131        .and_then(|value| value.parse::<usize>().ok())
132        .filter(|value| *value > 0)
133        .unwrap_or(DEFAULT_BLOSSOM_REPLICA_COALESCE_QUEUE_JOBS)
134}
135
136fn blossom_replica_upload_semaphore() -> Arc<Semaphore> {
137    static SEMAPHORE: OnceLock<Arc<Semaphore>> = OnceLock::new();
138    SEMAPHORE
139        .get_or_init(|| Arc::new(Semaphore::new(blossom_replica_upload_concurrency())))
140        .clone()
141}
142
143#[derive(Debug, Clone, Copy)]
144pub(super) struct OptimisticUploadQueueSnapshot {
145    pub enabled: bool,
146    pub max_bytes: usize,
147    pub available_bytes: usize,
148    pub reserved_bytes: usize,
149    pub in_flight: usize,
150    pub queue_timeout_ms: u64,
151}
152
153#[derive(Debug, Clone, Copy)]
154pub(super) struct BlossomUploadReplicaQueueSnapshot {
155    pub enabled: bool,
156    pub target_count: usize,
157    pub max_bytes: usize,
158    pub available_bytes: usize,
159    pub reserved_bytes: usize,
160    pub coalesce_queue_capacity_jobs: usize,
161    pub coalesce_queued_jobs: usize,
162    pub coalesce_max_blobs: usize,
163    pub coalesce_max_bytes: usize,
164    pub coalesce_flush_ms: u64,
165    pub upload_concurrency: usize,
166    pub in_flight_batches: usize,
167    pub accepted_batches: u64,
168    pub accepted_blobs: u64,
169    pub uploaded_blobs: u64,
170    pub replicated_bytes: u64,
171    pub failed_batches: u64,
172    pub skipped_jobs: u64,
173    pub fallback_batches: u64,
174    pub fallback_uploaded_blobs: u64,
175    pub fallback_failed_blobs: u64,
176}
177
178#[derive(Default)]
179struct BlossomUploadReplicaMetrics {
180    coalesce_queued_jobs: AtomicUsize,
181    in_flight_batches: AtomicUsize,
182    accepted_batches: AtomicU64,
183    accepted_blobs: AtomicU64,
184    uploaded_blobs: AtomicU64,
185    replicated_bytes: AtomicU64,
186    failed_batches: AtomicU64,
187    skipped_jobs: AtomicU64,
188    fallback_batches: AtomicU64,
189    fallback_uploaded_blobs: AtomicU64,
190    fallback_failed_blobs: AtomicU64,
191}
192
193fn blossom_upload_replica_metrics() -> &'static BlossomUploadReplicaMetrics {
194    static METRICS: OnceLock<BlossomUploadReplicaMetrics> = OnceLock::new();
195    METRICS.get_or_init(BlossomUploadReplicaMetrics::default)
196}
197
198struct BlossomReplicaInFlightGuard;
199
200impl BlossomReplicaInFlightGuard {
201    fn new() -> Self {
202        blossom_upload_replica_metrics()
203            .in_flight_batches
204            .fetch_add(1, Ordering::Relaxed);
205        Self
206    }
207}
208
209impl Drop for BlossomReplicaInFlightGuard {
210    fn drop(&mut self) {
211        blossom_upload_replica_metrics()
212            .in_flight_batches
213            .fetch_sub(1, Ordering::Relaxed);
214    }
215}
216
217struct PreparedBlossomUploadReplication {
218    servers: Vec<String>,
219    keys: Arc<Keys>,
220    permit: OwnedSemaphorePermit,
221    total_bytes: usize,
222}
223
224struct BlossomReplicaUploadJob {
225    prepared: PreparedBlossomUploadReplication,
226    items: Vec<BatchUploadItem>,
227}
228
229struct BlossomReplicaUploadBatch {
230    servers: Vec<String>,
231    keys: Arc<Keys>,
232    permits: Vec<OwnedSemaphorePermit>,
233    total_bytes: usize,
234    data_bytes: usize,
235    items: Vec<BatchUploadItem>,
236}
237
238impl BlossomReplicaUploadBatch {
239    fn from_job(job: BlossomReplicaUploadJob) -> Self {
240        let PreparedBlossomUploadReplication {
241            servers,
242            keys,
243            permit,
244            total_bytes,
245        } = job.prepared;
246        let data_bytes = job.items.iter().map(|item| item.data.len()).sum();
247        Self {
248            servers,
249            keys,
250            permits: vec![permit],
251            total_bytes,
252            data_bytes,
253            items: job.items,
254        }
255    }
256
257    fn can_append(
258        &self,
259        job: &BlossomReplicaUploadJob,
260        max_blobs: usize,
261        max_bytes: usize,
262    ) -> bool {
263        if self.servers != job.prepared.servers || !Arc::ptr_eq(&self.keys, &job.prepared.keys) {
264            return false;
265        }
266        let job_bytes = job.items.iter().map(|item| item.data.len()).sum::<usize>();
267        self.items.len().saturating_add(job.items.len()) <= max_blobs
268            && self.data_bytes.saturating_add(job_bytes) <= max_bytes
269    }
270
271    fn append(&mut self, job: BlossomReplicaUploadJob) {
272        let PreparedBlossomUploadReplication {
273            permit,
274            total_bytes,
275            ..
276        } = job.prepared;
277        self.permits.push(permit);
278        self.total_bytes = self.total_bytes.saturating_add(total_bytes);
279        self.data_bytes = self
280            .data_bytes
281            .saturating_add(job.items.iter().map(|item| item.data.len()).sum::<usize>());
282        self.items.extend(job.items);
283    }
284
285    fn reached_limits(&self, max_blobs: usize, max_bytes: usize) -> bool {
286        self.items.len() >= max_blobs || self.data_bytes >= max_bytes
287    }
288}
289
290/// Per-server write-behind scheduler for merging adjacent replica uploads.
291pub struct BlossomUploadReplicaScheduler {
292    sender: Mutex<Option<mpsc::Sender<BlossomReplicaUploadJob>>>,
293}
294
295impl BlossomUploadReplicaScheduler {
296    pub fn new() -> Self {
297        Self {
298            sender: Mutex::new(None),
299        }
300    }
301
302    fn schedule(&self, job: BlossomReplicaUploadJob) -> Result<(), BlossomReplicaUploadJob> {
303        let max_blobs = blossom_replica_coalesce_max_blobs();
304        let flush_delay = blossom_replica_coalesce_flush_delay();
305        if max_blobs <= 1 || flush_delay.is_zero() {
306            return Err(job);
307        }
308
309        let mut job = job;
310        for _ in 0..2 {
311            let sender = self.sender();
312            blossom_upload_replica_metrics()
313                .coalesce_queued_jobs
314                .fetch_add(1, Ordering::Relaxed);
315            match sender.try_send(job) {
316                Ok(()) => return Ok(()),
317                Err(mpsc::error::TrySendError::Full(returned)) => {
318                    blossom_upload_replica_metrics()
319                        .coalesce_queued_jobs
320                        .fetch_sub(1, Ordering::Relaxed);
321                    return Err(returned);
322                }
323                Err(mpsc::error::TrySendError::Closed(returned)) => {
324                    blossom_upload_replica_metrics()
325                        .coalesce_queued_jobs
326                        .fetch_sub(1, Ordering::Relaxed);
327                    self.clear_sender();
328                    job = returned;
329                }
330            }
331        }
332        Err(job)
333    }
334
335    fn sender(&self) -> mpsc::Sender<BlossomReplicaUploadJob> {
336        let mut guard = self.sender.lock().unwrap_or_else(|err| err.into_inner());
337        if guard.as_ref().is_some_and(|sender| sender.is_closed()) {
338            *guard = None;
339        }
340        if let Some(sender) = guard.as_ref() {
341            return sender.clone();
342        }
343
344        let (sender, receiver) = mpsc::channel(blossom_replica_coalesce_queue_jobs());
345        tokio::spawn(blossom_replica_coalescer_worker(receiver));
346        *guard = Some(sender.clone());
347        sender
348    }
349
350    fn clear_sender(&self) {
351        let mut guard = self.sender.lock().unwrap_or_else(|err| err.into_inner());
352        *guard = None;
353    }
354}
355
356impl Default for BlossomUploadReplicaScheduler {
357    fn default() -> Self {
358        Self::new()
359    }
360}
361
362pub(super) fn blossom_upload_replica_queue_snapshot(
363    state: &AppState,
364) -> BlossomUploadReplicaQueueSnapshot {
365    let available_bytes = state.blossom_upload_replica_queue.available_permits();
366    let metrics = blossom_upload_replica_metrics();
367    BlossomUploadReplicaQueueSnapshot {
368        enabled: !state.blossom_upload_replicas.is_empty(),
369        target_count: state.blossom_upload_replicas.len(),
370        max_bytes: state.blossom_upload_replica_queue_bytes,
371        available_bytes,
372        reserved_bytes: state
373            .blossom_upload_replica_queue_bytes
374            .saturating_sub(available_bytes),
375        coalesce_queue_capacity_jobs: blossom_replica_coalesce_queue_jobs(),
376        coalesce_queued_jobs: metrics.coalesce_queued_jobs.load(Ordering::Relaxed),
377        coalesce_max_blobs: blossom_replica_coalesce_max_blobs(),
378        coalesce_max_bytes: blossom_replica_coalesce_max_bytes(),
379        coalesce_flush_ms: duration_millis_u64(blossom_replica_coalesce_flush_delay()),
380        upload_concurrency: blossom_replica_upload_concurrency(),
381        in_flight_batches: metrics.in_flight_batches.load(Ordering::Relaxed),
382        accepted_batches: metrics.accepted_batches.load(Ordering::Relaxed),
383        accepted_blobs: metrics.accepted_blobs.load(Ordering::Relaxed),
384        uploaded_blobs: metrics.uploaded_blobs.load(Ordering::Relaxed),
385        replicated_bytes: metrics.replicated_bytes.load(Ordering::Relaxed),
386        failed_batches: metrics.failed_batches.load(Ordering::Relaxed),
387        skipped_jobs: metrics.skipped_jobs.load(Ordering::Relaxed),
388        fallback_batches: metrics.fallback_batches.load(Ordering::Relaxed),
389        fallback_uploaded_blobs: metrics.fallback_uploaded_blobs.load(Ordering::Relaxed),
390        fallback_failed_blobs: metrics.fallback_failed_blobs.load(Ordering::Relaxed),
391    }
392}
393
394/// Check if a pubkey has write access based on allowed_npubs config or social graph
395/// Returns Ok(()) if allowed, Err with JSON error body if denied
396#[allow(clippy::result_large_err)]
397fn check_write_access(state: &AppState, pubkey: &str) -> Result<(), Response<Body>> {
398    // Check if pubkey is in the allowed list (converted from npub to hex)
399    if is_allowed_write_author(state, pubkey) {
400        tracing::debug!(
401            "Blossom write allowed for {}... (allowed writer)",
402            &pubkey[..8.min(pubkey.len())]
403        );
404        return Ok(());
405    }
406
407    // Not in allowed list or social graph
408    tracing::info!(
409        "Blossom write denied for {}... (not in allowed_npubs or social graph)",
410        &pubkey[..8.min(pubkey.len())]
411    );
412    Err(Response::builder()
413        .status(StatusCode::FORBIDDEN)
414        .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
415        .header(header::CONTENT_TYPE, "application/json")
416        .body(Body::from(
417            r#"{"error":"Write access denied. Your pubkey is not in the allowed list."}"#,
418        ))
419        .unwrap())
420}
421
422fn is_allowed_write_author(state: &AppState, pubkey: &str) -> bool {
423    if state.allowed_pubkeys.contains(pubkey) {
424        return true;
425    }
426
427    state
428        .social_graph
429        .as_ref()
430        .map(|sg| sg.check_write_access(pubkey))
431        .unwrap_or(false)
432}
433
434fn can_accept_upload_author(state: &AppState, pubkey: &str) -> bool {
435    state.public_writes || is_allowed_write_author(state, pubkey)
436}
437
438fn validate_upload_payload(
439    body: &[u8],
440    content_type: &str,
441    can_upload_author: bool,
442    require_random_untrusted_ingest: bool,
443) -> Result<(), (StatusCode, String)> {
444    let is_chk_upload = is_chk_content_type(content_type);
445
446    if !is_chk_upload && !can_upload_author {
447        return Err((
448            StatusCode::FORBIDDEN,
449            "Raw media uploads require write access".to_string(),
450        ));
451    }
452
453    if is_chk_upload {
454        let require_random = require_random_untrusted_ingest && !can_upload_author;
455        validate_untrusted_blob(body, require_random)
456            .map_err(|IngestRejection { status, reason }| (status, reason))?;
457    }
458
459    Ok(())
460}
461
462fn blossom_json_error(status: StatusCode, reason: impl Into<String>) -> Response<Body> {
463    let reason = reason.into();
464    Response::builder()
465        .status(status)
466        .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
467        .header("X-Reason", reason.as_str())
468        .header(header::CONTENT_TYPE, "application/json")
469        .body(Body::from(format!(r#"{{"error":"{}"}}"#, reason)))
470        .unwrap()
471}
472
473fn blossom_retryable_json_error(
474    status: StatusCode,
475    reason: impl Into<String>,
476    retry_after_seconds: u64,
477) -> Response<Body> {
478    let reason = reason.into();
479    Response::builder()
480        .status(status)
481        .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
482        .header("X-Reason", reason.as_str())
483        .header(header::RETRY_AFTER, retry_after_seconds.to_string())
484        .header(header::CONTENT_TYPE, "application/json")
485        .body(Body::from(format!(r#"{{"error":"{}"}}"#, reason)))
486        .unwrap()
487}
488
489#[derive(Debug)]
490enum BlobWriteError {
491    Busy(&'static str),
492    Storage(anyhow::Error),
493}
494
495impl std::fmt::Display for BlobWriteError {
496    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
497        match self {
498            Self::Busy(reason) => f.write_str(reason),
499            Self::Storage(error) => write!(f, "{error}"),
500        }
501    }
502}
503
504impl std::error::Error for BlobWriteError {}
505
506impl From<anyhow::Error> for BlobWriteError {
507    fn from(error: anyhow::Error) -> Self {
508        Self::Storage(error)
509    }
510}
511
512fn blob_write_queue_error(reason: &'static str) -> BlobWriteError {
513    if reason == BLOB_WRITE_BUSY {
514        BlobWriteError::Busy(reason)
515    } else {
516        BlobWriteError::Storage(anyhow::anyhow!(reason))
517    }
518}
519
520fn blob_write_error_response(error: BlobWriteError) -> Response<Body> {
521    match error {
522        BlobWriteError::Busy(reason) => {
523            blossom_retryable_json_error(StatusCode::SERVICE_UNAVAILABLE, reason, 2)
524        }
525        BlobWriteError::Storage(error) => Response::builder()
526            .status(StatusCode::INTERNAL_SERVER_ERROR)
527            .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
528            .header("X-Reason", "Storage error")
529            .header(header::CONTENT_TYPE, "application/json")
530            .body(Body::from(format!(r#"{{"error":"{}"}}"#, error)))
531            .unwrap(),
532    }
533}
534
535/// Blob descriptor returned by upload and list endpoints
536#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct BlobDescriptor {
538    pub url: String,
539    pub sha256: String,
540    pub size: u64,
541    #[serde(rename = "type")]
542    pub mime_type: String,
543    pub uploaded: u64,
544}
545
546#[derive(Debug, Deserialize)]
547pub struct BatchUploadBlob {
548    pub sha256: String,
549    #[serde(default, alias = "contentType")]
550    pub content_type: Option<String>,
551    pub data: String,
552}
553
554#[derive(Debug, Deserialize)]
555pub struct BatchUploadRequest {
556    pub blobs: Vec<BatchUploadBlob>,
557}
558
559#[derive(Debug, Serialize, Deserialize)]
560pub struct BatchUploadResponse {
561    pub uploaded: usize,
562    pub blobs: Vec<BlobDescriptor>,
563}
564
565#[derive(Debug)]
566struct DecodedBatchUploadBlob {
567    sha256: String,
568    content_type: Option<String>,
569    data: Vec<u8>,
570}
571
572#[derive(Debug, Deserialize)]
573pub struct UploadCheckRequest {
574    pub hashes: Vec<String>,
575}
576
577#[derive(Debug, Serialize, Deserialize)]
578pub struct UploadCheckResponse {
579    pub count: usize,
580    pub present: String,
581}
582
583/// Query parameters for list endpoint
584#[derive(Debug, Deserialize)]
585pub struct ListQuery {
586    pub since: Option<u64>,
587    pub until: Option<u64>,
588    pub limit: Option<usize>,
589    pub cursor: Option<String>,
590}
591
592/// Parsed Nostr authorization event
593#[derive(Debug)]
594pub struct BlossomAuth {
595    pub pubkey: String,
596    pub kind: u16,
597    pub created_at: u64,
598    pub expiration: Option<u64>,
599    pub action: Option<String>,    // "upload", "delete", "list", "get"
600    pub blob_hashes: Vec<String>,  // x tags
601    pub batch_hashes: Vec<String>, // x-batch tags
602    pub server: Option<String>,    // server tag
603}
604
605/// Parse and verify Nostr authorization from header
606/// Returns the verified auth or an error response
607pub fn verify_blossom_auth(
608    headers: &HeaderMap,
609    required_action: &str,
610    required_hash: Option<&str>,
611) -> Result<BlossomAuth, (StatusCode, &'static str)> {
612    let auth_header = headers
613        .get(header::AUTHORIZATION)
614        .and_then(|v| v.to_str().ok())
615        .ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
616
617    let nostr_event = auth_header.strip_prefix("Nostr ").ok_or((
618        StatusCode::UNAUTHORIZED,
619        "Invalid auth scheme, expected 'Nostr'",
620    ))?;
621
622    // Decode base64 event
623    let engine = base64::engine::general_purpose::STANDARD;
624    let event_bytes = engine
625        .decode(nostr_event)
626        .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid base64 in auth header"))?;
627
628    let event_json: serde_json::Value = serde_json::from_slice(&event_bytes)
629        .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid JSON in auth event"))?;
630
631    // Extract event fields
632    let kind = event_json["kind"]
633        .as_u64()
634        .ok_or((StatusCode::BAD_REQUEST, "Missing kind in event"))?;
635
636    if kind != BLOSSOM_AUTH_KIND as u64 {
637        return Err((
638            StatusCode::BAD_REQUEST,
639            "Invalid event kind, expected 24242",
640        ));
641    }
642
643    let pubkey = event_json["pubkey"]
644        .as_str()
645        .ok_or((StatusCode::BAD_REQUEST, "Missing pubkey in event"))?
646        .to_string();
647
648    let created_at = event_json["created_at"]
649        .as_u64()
650        .ok_or((StatusCode::BAD_REQUEST, "Missing created_at in event"))?;
651
652    let sig = event_json["sig"]
653        .as_str()
654        .ok_or((StatusCode::BAD_REQUEST, "Missing signature in event"))?;
655
656    // Verify signature
657    if !verify_nostr_signature(&event_json, &pubkey, sig) {
658        return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
659    }
660
661    // Parse tags
662    let tags = event_json["tags"]
663        .as_array()
664        .ok_or((StatusCode::BAD_REQUEST, "Missing tags in event"))?;
665
666    let mut expiration: Option<u64> = None;
667    let mut action: Option<String> = None;
668    let mut blob_hashes: Vec<String> = Vec::new();
669    let mut batch_hashes: Vec<String> = Vec::new();
670    let mut server: Option<String> = None;
671
672    for tag in tags {
673        let tag_arr = tag.as_array();
674        if let Some(arr) = tag_arr {
675            if arr.len() >= 2 {
676                let tag_name = arr[0].as_str().unwrap_or("");
677                let tag_value = arr[1].as_str().unwrap_or("");
678
679                match tag_name {
680                    "t" => action = Some(tag_value.to_string()),
681                    "x" => blob_hashes.push(tag_value.to_lowercase()),
682                    BATCH_UPLOAD_HASH_LIST_AUTH_TAG => batch_hashes.push(tag_value.to_lowercase()),
683                    "expiration" => expiration = tag_value.parse().ok(),
684                    "server" => server = Some(tag_value.to_string()),
685                    _ => {}
686                }
687            }
688        }
689    }
690
691    // Validate expiration
692    let now = SystemTime::now()
693        .duration_since(UNIX_EPOCH)
694        .unwrap()
695        .as_secs();
696
697    if let Some(exp) = expiration {
698        if exp < now {
699            return Err((StatusCode::UNAUTHORIZED, "Authorization expired"));
700        }
701    }
702
703    // Validate created_at is not in the future (with 60s tolerance)
704    if created_at > now + 60 {
705        return Err((StatusCode::BAD_REQUEST, "Event created_at is in the future"));
706    }
707
708    // Validate action matches
709    if let Some(ref act) = action {
710        if act != required_action {
711            return Err((StatusCode::FORBIDDEN, "Action mismatch"));
712        }
713    } else {
714        return Err((StatusCode::BAD_REQUEST, "Missing 't' tag for action"));
715    }
716
717    // Validate hash if required
718    if let Some(hash) = required_hash {
719        if !blob_hashes.is_empty() && !blob_hashes.contains(&hash.to_lowercase()) {
720            return Err((StatusCode::FORBIDDEN, "Blob hash not authorized"));
721        }
722    }
723
724    Ok(BlossomAuth {
725        pubkey,
726        kind: kind as u16,
727        created_at,
728        expiration,
729        action,
730        blob_hashes,
731        batch_hashes,
732        server,
733    })
734}
735
736/// Verify Nostr event signature using secp256k1
737fn verify_nostr_signature(event: &serde_json::Value, pubkey: &str, sig: &str) -> bool {
738    use secp256k1::{schnorr::Signature, Message, Secp256k1, XOnlyPublicKey};
739
740    // Compute event ID (sha256 of serialized event)
741    let content = event["content"].as_str().unwrap_or("");
742    let full_serialized = format!(
743        "[0,\"{}\",{},{},{},\"{}\"]",
744        pubkey,
745        event["created_at"],
746        event["kind"],
747        event["tags"],
748        escape_json_string(content),
749    );
750
751    let mut hasher = Sha256::new();
752    hasher.update(full_serialized.as_bytes());
753    let event_id = hasher.finalize();
754
755    // Parse pubkey and signature
756    let pubkey_bytes = match hex::decode(pubkey) {
757        Ok(b) => b,
758        Err(_) => return false,
759    };
760
761    let sig_bytes = match hex::decode(sig) {
762        Ok(b) => b,
763        Err(_) => return false,
764    };
765
766    let secp = Secp256k1::verification_only();
767
768    let xonly_pubkey = match XOnlyPublicKey::from_slice(&pubkey_bytes) {
769        Ok(pk) => pk,
770        Err(_) => return false,
771    };
772
773    let signature = match Signature::from_slice(&sig_bytes) {
774        Ok(s) => s,
775        Err(_) => return false,
776    };
777
778    let message = match Message::from_digest_slice(&event_id) {
779        Ok(m) => m,
780        Err(_) => return false,
781    };
782
783    secp.verify_schnorr(&signature, &message, &xonly_pubkey)
784        .is_ok()
785}
786
787/// Escape string for JSON serialization
788fn escape_json_string(s: &str) -> String {
789    let mut result = String::new();
790    for c in s.chars() {
791        match c {
792            '"' => result.push_str("\\\""),
793            '\\' => result.push_str("\\\\"),
794            '\n' => result.push_str("\\n"),
795            '\r' => result.push_str("\\r"),
796            '\t' => result.push_str("\\t"),
797            c if c.is_control() => {
798                result.push_str(&format!("\\u{:04x}", c as u32));
799            }
800            c => result.push(c),
801        }
802    }
803    result
804}
805
806/// CORS preflight handler for all Blossom endpoints
807/// Echoes back Access-Control-Request-Headers to allow any headers
808pub async fn cors_preflight(headers: HeaderMap) -> impl IntoResponse {
809    // Echo back requested headers, or use sensible defaults that cover common Blossom headers
810    let allowed_headers = headers
811        .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
812        .and_then(|v| v.to_str().ok())
813        .unwrap_or("Authorization, Content-Type, X-SHA-256, x-sha-256");
814
815    // Always include common headers in addition to what was requested
816    let full_allowed = format!(
817        "{}, Authorization, Content-Type, X-SHA-256, x-sha-256, Accept, Cache-Control",
818        allowed_headers
819    );
820
821    Response::builder()
822        .status(StatusCode::NO_CONTENT)
823        .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
824        .header(
825            header::ACCESS_CONTROL_ALLOW_METHODS,
826            "GET, HEAD, POST, PUT, DELETE, OPTIONS",
827        )
828        .header(header::ACCESS_CONTROL_ALLOW_HEADERS, full_allowed)
829        .header(header::ACCESS_CONTROL_MAX_AGE, "86400")
830        .body(Body::empty())
831        .unwrap()
832}
833
834/// HEAD /upload - BUD-06 upload preflight.
835pub async fn head_upload(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
836    let sha256_hex = match first_header_value(&headers, "x-sha-256") {
837        Some(hash) if is_valid_sha256(&hash) => hash.to_ascii_lowercase(),
838        Some(_) => {
839            return Response::builder()
840                .status(StatusCode::BAD_REQUEST)
841                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
842                .header("X-Reason", "Invalid X-SHA-256 header")
843                .body(Body::empty())
844                .unwrap();
845        }
846        None => {
847            return Response::builder()
848                .status(StatusCode::BAD_REQUEST)
849                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
850                .header("X-Reason", "Missing X-SHA-256 header")
851                .body(Body::empty())
852                .unwrap();
853        }
854    };
855
856    let content_length = match first_header_value(&headers, "x-content-length")
857        .or_else(|| first_header_value(&headers, header::CONTENT_LENGTH.as_str()))
858        .and_then(|value| value.parse::<usize>().ok())
859    {
860        Some(length) => length,
861        None => {
862            return Response::builder()
863                .status(StatusCode::BAD_REQUEST)
864                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
865                .header("X-Reason", "Missing or invalid X-Content-Length header")
866                .body(Body::empty())
867                .unwrap();
868        }
869    };
870
871    if content_length > state.max_upload_bytes {
872        return Response::builder()
873            .status(StatusCode::PAYLOAD_TOO_LARGE)
874            .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
875            .header("X-Reason", "Upload exceeds maximum size")
876            .body(Body::empty())
877            .unwrap();
878    }
879
880    let content_type = first_header_value(&headers, "x-content-type")
881        .or_else(|| first_header_value(&headers, header::CONTENT_TYPE.as_str()))
882        .unwrap_or_else(|| "application/octet-stream".to_string());
883
884    if !is_chk_content_type(&content_type_base(&content_type)) {
885        let auth = verify_blossom_auth(&headers, "upload", Some(&sha256_hex));
886        let can_upload_raw = auth
887            .as_ref()
888            .map(|auth| can_accept_upload_author(&state, &auth.pubkey))
889            .unwrap_or(false);
890        if !can_upload_raw {
891            let status = if auth.is_err() {
892                StatusCode::UNAUTHORIZED
893            } else {
894                StatusCode::FORBIDDEN
895            };
896            return Response::builder()
897                .status(status)
898                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
899                .header("X-Reason", "Raw media uploads require write access")
900                .body(Body::empty())
901                .unwrap();
902        }
903    }
904
905    Response::builder()
906        .status(StatusCode::OK)
907        .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
908        .body(Body::empty())
909        .unwrap()
910}
911
912fn encode_upload_check_bitset(bits: &[bool]) -> String {
913    let mut bytes = vec![0u8; bits.len().div_ceil(8)];
914    for (index, present) in bits.iter().enumerate() {
915        if *present {
916            bytes[index / 8] |= 1 << (index % 8);
917        }
918    }
919    base64::engine::general_purpose::STANDARD.encode(bytes)
920}
921
922/// POST /upload/check - Batch-check blob presence for upload planning.
923pub async fn upload_check(
924    State(state): State<AppState>,
925    Json(payload): Json<UploadCheckRequest>,
926) -> impl IntoResponse {
927    if payload.hashes.len() > MAX_UPLOAD_CHECK_HASHES {
928        return blossom_json_error(
929            StatusCode::PAYLOAD_TOO_LARGE,
930            format!("Too many hashes; maximum is {}", MAX_UPLOAD_CHECK_HASHES),
931        );
932    }
933
934    let mut requested = Vec::with_capacity(payload.hashes.len());
935    let mut unique = Vec::new();
936    for hash in payload.hashes {
937        let hash = hash.trim().to_ascii_lowercase();
938        if !is_valid_sha256(&hash) {
939            return blossom_json_error(StatusCode::BAD_REQUEST, "Invalid SHA256 hash");
940        }
941        let bytes: [u8; 32] = match from_hex(&hash) {
942            Ok(bytes) => bytes,
943            Err(_) => return blossom_json_error(StatusCode::BAD_REQUEST, "Invalid SHA256 hash"),
944        };
945        requested.push(bytes);
946        unique.push(bytes);
947    }
948
949    unique.sort_unstable();
950    unique.dedup();
951
952    let existing = if unique.is_empty() {
953        Vec::new()
954    } else {
955        let permit = match acquire_blob_read().await {
956            Ok(permit) => permit,
957            Err(_) => {
958                return blossom_retryable_json_error(
959                    StatusCode::SERVICE_UNAVAILABLE,
960                    BLOB_READ_BUSY,
961                    1,
962                );
963            }
964        };
965        let store = state.store.clone();
966        let lookup_hashes = unique.clone();
967        let lookup = tokio::task::spawn_blocking(move || {
968            store
969                .router()
970                .existing_local_hashes_in_sorted_candidates(&lookup_hashes)
971        });
972        let result = tokio::time::timeout(blob_read_timeout(), lookup).await;
973        drop(permit);
974        match result {
975            Ok(Ok(Ok(existing))) => existing,
976            Ok(Ok(Err(error))) => {
977                tracing::debug!("Blossom upload check failed: {}", error);
978                return blossom_json_error(StatusCode::INTERNAL_SERVER_ERROR, "Storage error");
979            }
980            Ok(Err(error)) => {
981                tracing::debug!("Blossom upload check task failed: {}", error);
982                return blossom_json_error(StatusCode::INTERNAL_SERVER_ERROR, "Storage error");
983            }
984            Err(_) => {
985                return blossom_retryable_json_error(
986                    StatusCode::SERVICE_UNAVAILABLE,
987                    "Blob check timed out",
988                    1,
989                );
990            }
991        }
992    };
993
994    let present_unique: HashSet<[u8; 32]> = unique
995        .into_iter()
996        .zip(existing)
997        .filter_map(|(hash, present)| present.then_some(hash))
998        .collect();
999    let present_bits: Vec<bool> = requested
1000        .iter()
1001        .map(|hash| present_unique.contains(hash))
1002        .collect();
1003
1004    Response::builder()
1005        .status(StatusCode::OK)
1006        .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
1007        .header(header::CONTENT_TYPE, "application/json")
1008        .body(Body::from(
1009            serde_json::to_string(&UploadCheckResponse {
1010                count: present_bits.len(),
1011                present: encode_upload_check_bitset(&present_bits),
1012            })
1013            .unwrap(),
1014        ))
1015        .unwrap()
1016}
1017
1018/// HEAD /<sha256> - Check if blob exists
1019pub async fn head_blob(
1020    State(state): State<AppState>,
1021    Path(id): Path<String>,
1022    connect_info: axum::extract::ConnectInfo<std::net::SocketAddr>,
1023) -> impl IntoResponse {
1024    let is_localhost = connect_info.0.ip().is_loopback();
1025    let (hash_part, ext) = parse_hash_and_extension(&id);
1026
1027    if !is_valid_sha256(hash_part) {
1028        return Response::builder()
1029            .status(StatusCode::BAD_REQUEST)
1030            .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
1031            .header("X-Reason", "Invalid SHA256 hash")
1032            .body(Body::empty())
1033            .unwrap();
1034    }
1035
1036    let sha256_hex = hash_part.to_lowercase();
1037    let sha256_bytes: [u8; 32] = match from_hex(&sha256_hex) {
1038        Ok(b) => b,
1039        Err(_) => {
1040            return Response::builder()
1041                .status(StatusCode::BAD_REQUEST)
1042                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
1043                .header("X-Reason", "Invalid SHA256 format")
1044                .body(Body::empty())
1045                .unwrap();
1046        }
1047    };
1048
1049    // Blossom HEAD only needs metadata; avoid reading the full blob body just to
1050    // answer cache probes and CDN revalidation. The read permit keeps CDN probe
1051    // storms from filling Tokio's blocking thread pool while old blobs without
1052    // metadata are still being normalized.
1053    let blob_size = if let Some(cached) = state.blob_cache.get_size(&sha256_hex) {
1054        Ok(Ok(cached))
1055    } else {
1056        let permit = match acquire_blob_read().await {
1057            Ok(permit) => permit,
1058            Err(_) => {
1059                return Response::builder()
1060                    .status(StatusCode::SERVICE_UNAVAILABLE)
1061                    .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
1062                    .header(header::CACHE_CONTROL, NOT_FOUND_CACHE_CONTROL)
1063                    .header("Retry-After", "1")
1064                    .header("X-Reason", BLOB_READ_BUSY)
1065                    .body(Body::empty())
1066                    .unwrap();
1067            }
1068        };
1069        let store = state.store.clone();
1070        let size_read = tokio::task::spawn_blocking(move || store.blob_size(&sha256_bytes));
1071        let timed = tokio::time::timeout(blob_read_timeout(), size_read).await;
1072        drop(permit);
1073        let result = match timed {
1074            Ok(result) => result.map_err(|_| ()),
1075            Err(_) => Err(()),
1076        };
1077        if let Ok(Ok(size)) = result {
1078            state.blob_cache.put_size(sha256_hex.clone(), size);
1079        }
1080        result
1081    };
1082
1083    match blob_size {
1084        Ok(Ok(Some(size))) => {
1085            let mime_type = ext
1086                .map(|e| get_mime_type(&format!("file{}", e)))
1087                .unwrap_or("application/octet-stream");
1088
1089            let mut builder = Response::builder()
1090                .status(StatusCode::OK)
1091                .header(header::CONTENT_TYPE, mime_type)
1092                .header(header::CONTENT_LENGTH, size)
1093                .header(header::ACCEPT_RANGES, "bytes")
1094                .header(header::CACHE_CONTROL, IMMUTABLE_CACHE_CONTROL)
1095                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*");
1096            if is_localhost {
1097                builder = builder.header("X-Source", "local");
1098            }
1099            builder.body(Body::empty()).unwrap()
1100        }
1101        Ok(Ok(None)) => Response::builder()
1102            .status(StatusCode::NOT_FOUND)
1103            .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
1104            .header(header::CACHE_CONTROL, IMMUTABLE_NOT_FOUND_CACHE_CONTROL)
1105            .header("X-Reason", "Blob not found")
1106            .body(Body::empty())
1107            .unwrap(),
1108        _ => Response::builder()
1109            .status(StatusCode::INTERNAL_SERVER_ERROR)
1110            .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
1111            .body(Body::empty())
1112            .unwrap(),
1113    }
1114}
1115
1116async fn store_blossom_blob_without_blocking_runtime(
1117    state: &AppState,
1118    data: axum::body::Bytes,
1119    pubkey: [u8; 32],
1120    track_ownership: bool,
1121) -> Result<bool, BlobWriteError> {
1122    let mut hasher = Sha256::new();
1123    hasher.update(&data);
1124    let hash_hex = hex::encode(hasher.finalize());
1125    let data_for_cache = data.clone();
1126    let permit = acquire_blob_write().await.map_err(blob_write_queue_error)?;
1127    let store = state.store.clone();
1128    let inserted = tokio::task::spawn_blocking(move || {
1129        let _permit = permit;
1130        let inserted = if track_ownership {
1131            store.put_owned_blob_with_inserted(&data, &pubkey)?.1
1132        } else {
1133            store.put_cached_blob_with_inserted(&data)?.1
1134        };
1135        Ok::<_, anyhow::Error>(inserted)
1136    })
1137    .await
1138    .map_err(|err| anyhow::anyhow!("blob write task failed: {}", err))??;
1139    state
1140        .blob_cache
1141        .put_size(hash_hex.clone(), Some(data_for_cache.len() as u64));
1142    state.blob_cache.put_body(hash_hex, &data_for_cache);
1143    Ok(inserted)
1144}
1145
1146fn upload_descriptor_response(status: StatusCode, descriptor: &BlobDescriptor) -> Response<Body> {
1147    Response::builder()
1148        .status(status)
1149        .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
1150        .header(header::CONTENT_TYPE, "application/json")
1151        .body(Body::from(serde_json::to_string(descriptor).unwrap()))
1152        .unwrap()
1153}
1154
1155fn make_blob_descriptor(
1156    headers: &HeaderMap,
1157    sha256_hex: String,
1158    size: u64,
1159    mime_type: String,
1160    uploaded: u64,
1161) -> BlobDescriptor {
1162    let url = blossom_blob_url(headers, &sha256_hex, &mime_type);
1163    BlobDescriptor {
1164        url,
1165        sha256: sha256_hex,
1166        size,
1167        mime_type,
1168        uploaded,
1169    }
1170}
1171
1172fn blossom_blob_url(headers: &HeaderMap, sha256_hex: &str, mime_type: &str) -> String {
1173    format!(
1174        "{}/{}{}",
1175        blossom_public_base_url(headers),
1176        sha256_hex,
1177        descriptor_extension_for_mime(mime_type)
1178    )
1179}
1180
1181fn descriptor_extension_for_mime(mime_type: &str) -> &'static str {
1182    let base = content_type_base(mime_type);
1183    mime_to_extension(&base)
1184}
1185
1186fn blossom_public_base_url(headers: &HeaderMap) -> String {
1187    if let Some(configured) = configured_public_base_url() {
1188        return configured;
1189    }
1190
1191    let host = first_header_value(headers, "x-forwarded-host")
1192        .and_then(|value| normalize_host(&value))
1193        .or_else(|| {
1194            forwarded_header_param(headers, "host").and_then(|value| normalize_host(&value))
1195        })
1196        .or_else(|| {
1197            first_header_value(headers, header::HOST.as_str())
1198                .and_then(|value| normalize_host(&value))
1199        });
1200
1201    let Some(host) = host else {
1202        return "http://localhost".to_string();
1203    };
1204
1205    let scheme = first_header_value(headers, "x-forwarded-proto")
1206        .and_then(|value| normalize_scheme(&value))
1207        .or_else(|| {
1208            forwarded_header_param(headers, "proto").and_then(|value| normalize_scheme(&value))
1209        })
1210        .or_else(|| cloudflare_visitor_scheme(headers))
1211        .unwrap_or_else(|| default_scheme_for_host(&host).to_string());
1212
1213    format!("{scheme}://{host}")
1214}
1215
1216fn configured_public_base_url() -> Option<String> {
1217    [
1218        BLOSSOM_PUBLIC_BASE_URL_ENV,
1219        LEGACY_BLOSSOM_PUBLIC_BASE_URL_ENV,
1220    ]
1221    .into_iter()
1222    .find_map(|name| {
1223        std::env::var(name)
1224            .ok()
1225            .and_then(|value| normalize_public_base_url(&value))
1226    })
1227}
1228
1229fn normalize_public_base_url(value: &str) -> Option<String> {
1230    let trimmed = value.trim().trim_end_matches('/');
1231    if (trimmed.starts_with("https://") || trimmed.starts_with("http://"))
1232        && !trimmed.contains('?')
1233        && !trimmed.contains('#')
1234    {
1235        Some(trimmed.to_string())
1236    } else {
1237        None
1238    }
1239}
1240
1241fn first_header_value(headers: &HeaderMap, name: &str) -> Option<String> {
1242    let raw = headers.get(name)?.to_str().ok()?;
1243    first_header_component(raw)
1244}
1245
1246fn first_header_component(value: &str) -> Option<String> {
1247    clean_header_value(value.split(',').next().unwrap_or(value))
1248}
1249
1250fn forwarded_header_param(headers: &HeaderMap, name: &str) -> Option<String> {
1251    let raw = headers.get("forwarded")?.to_str().ok()?;
1252    let first = raw.split(',').next().unwrap_or(raw);
1253    for part in first.split(';') {
1254        let Some((key, value)) = part.split_once('=') else {
1255            continue;
1256        };
1257        if key.trim().eq_ignore_ascii_case(name) {
1258            return clean_header_value(value);
1259        }
1260    }
1261    None
1262}
1263
1264fn clean_header_value(value: &str) -> Option<String> {
1265    let trimmed = value.trim().trim_matches('"').trim();
1266    if trimmed.is_empty() || trimmed.chars().any(|ch| ch.is_control()) {
1267        None
1268    } else {
1269        Some(trimmed.to_string())
1270    }
1271}
1272
1273fn normalize_scheme(value: &str) -> Option<String> {
1274    match value.trim().trim_matches('"').to_ascii_lowercase().as_str() {
1275        "http" => Some("http".to_string()),
1276        "https" => Some("https".to_string()),
1277        _ => None,
1278    }
1279}
1280
1281fn normalize_host(value: &str) -> Option<String> {
1282    let mut host = value.trim().trim_matches('"').trim();
1283    if let Some(rest) = host.strip_prefix("http://") {
1284        host = rest;
1285    } else if let Some(rest) = host.strip_prefix("https://") {
1286        host = rest;
1287    }
1288    host = host.split('/').next().unwrap_or(host).trim();
1289    if host.is_empty()
1290        || host
1291            .chars()
1292            .any(|ch| ch.is_control() || ch.is_ascii_whitespace() || ch == '\\')
1293    {
1294        None
1295    } else {
1296        Some(host.to_string())
1297    }
1298}
1299
1300fn cloudflare_visitor_scheme(headers: &HeaderMap) -> Option<String> {
1301    let raw = headers.get("cf-visitor")?.to_str().ok()?;
1302    let parsed: serde_json::Value = serde_json::from_str(raw).ok()?;
1303    parsed
1304        .get("scheme")
1305        .and_then(|value| value.as_str())
1306        .and_then(normalize_scheme)
1307}
1308
1309fn default_scheme_for_host(host: &str) -> &'static str {
1310    let host_without_port = host
1311        .trim_start_matches('[')
1312        .split(']')
1313        .next()
1314        .unwrap_or(host)
1315        .split(':')
1316        .next()
1317        .unwrap_or(host)
1318        .to_ascii_lowercase();
1319    if host_without_port == "localhost"
1320        || host_without_port == "::1"
1321        || host_without_port.starts_with("127.")
1322    {
1323        "http"
1324    } else {
1325        "https"
1326    }
1327}
1328
1329fn optimistic_upload_queue_timeout() -> Duration {
1330    let millis = std::env::var(OPTIMISTIC_UPLOAD_QUEUE_TIMEOUT_MS_ENV)
1331        .ok()
1332        .and_then(|value| value.parse::<u64>().ok())
1333        .filter(|value| *value > 0)
1334        .unwrap_or(DEFAULT_OPTIMISTIC_UPLOAD_QUEUE_TIMEOUT_MS);
1335    Duration::from_millis(millis)
1336}
1337
1338fn optimistic_upload_inflight() -> &'static Mutex<HashSet<String>> {
1339    static INFLIGHT: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
1340    INFLIGHT.get_or_init(|| Mutex::new(HashSet::new()))
1341}
1342
1343fn optimistic_upload_is_inflight(hash_hex: &str) -> bool {
1344    optimistic_upload_inflight()
1345        .lock()
1346        .is_ok_and(|inflight| inflight.contains(hash_hex))
1347}
1348
1349fn mark_optimistic_upload_inflight(hash_hex: &str) -> bool {
1350    optimistic_upload_inflight()
1351        .lock()
1352        .map(|mut inflight| inflight.insert(hash_hex.to_string()))
1353        .unwrap_or(true)
1354}
1355
1356fn clear_optimistic_upload_inflight(hash_hex: &str) {
1357    if let Ok(mut inflight) = optimistic_upload_inflight().lock() {
1358        inflight.remove(hash_hex);
1359    }
1360}
1361
1362pub(super) fn optimistic_upload_queue_snapshot(state: &AppState) -> OptimisticUploadQueueSnapshot {
1363    let max_bytes = state.optimistic_upload_queue_bytes;
1364    let available_bytes = state
1365        .optimistic_upload_queue
1366        .available_permits()
1367        .min(max_bytes);
1368    let in_flight = optimistic_upload_inflight()
1369        .lock()
1370        .map(|inflight| inflight.len())
1371        .unwrap_or(0);
1372
1373    OptimisticUploadQueueSnapshot {
1374        enabled: state.optimistic_blossom_uploads,
1375        max_bytes,
1376        available_bytes,
1377        reserved_bytes: max_bytes.saturating_sub(available_bytes),
1378        in_flight,
1379        queue_timeout_ms: duration_millis_u64(optimistic_upload_queue_timeout()),
1380    }
1381}
1382
1383fn duration_millis_u64(duration: Duration) -> u64 {
1384    duration.as_millis().min(u128::from(u64::MAX)) as u64
1385}
1386
1387fn replica_item(hash: String, data: Vec<u8>, content_type: String) -> BatchUploadItem {
1388    BatchUploadItem {
1389        hash,
1390        data,
1391        content_type: Some(content_type),
1392    }
1393}
1394
1395fn prepare_blossom_upload_replication(
1396    state: &AppState,
1397    total_bytes: usize,
1398) -> Option<PreparedBlossomUploadReplication> {
1399    if state.blossom_upload_replicas.is_empty() {
1400        return None;
1401    }
1402    let permits = match u32::try_from(total_bytes.max(1)) {
1403        Ok(permits) => permits,
1404        Err(_) => {
1405            blossom_upload_replica_metrics()
1406                .skipped_jobs
1407                .fetch_add(1, Ordering::Relaxed);
1408            tracing::warn!(
1409                total_bytes,
1410                "Skipping Blossom write-behind replication because batch is too large"
1411            );
1412            return None;
1413        }
1414    };
1415    let permit = match state
1416        .blossom_upload_replica_queue
1417        .clone()
1418        .try_acquire_many_owned(permits)
1419    {
1420        Ok(permit) => permit,
1421        Err(error) => {
1422            blossom_upload_replica_metrics()
1423                .skipped_jobs
1424                .fetch_add(1, Ordering::Relaxed);
1425            tracing::warn!(
1426                total_bytes,
1427                targets = state.blossom_upload_replicas.len(),
1428                error = %error,
1429                "Skipping Blossom write-behind replication because queue is full"
1430            );
1431            return None;
1432        }
1433    };
1434
1435    let keys = match state.blossom_upload_replica_keys.clone() {
1436        Some(keys) => keys,
1437        None => {
1438            blossom_upload_replica_metrics()
1439                .skipped_jobs
1440                .fetch_add(1, Ordering::Relaxed);
1441            tracing::warn!(
1442                "Skipping Blossom write-behind replication because server keys are unavailable"
1443            );
1444            return None;
1445        }
1446    };
1447    Some(PreparedBlossomUploadReplication {
1448        servers: state.blossom_upload_replicas.clone(),
1449        keys,
1450        permit,
1451        total_bytes,
1452    })
1453}
1454
1455fn schedule_prepared_blossom_upload_replication(
1456    state: &AppState,
1457    prepared: PreparedBlossomUploadReplication,
1458    items: Vec<BatchUploadItem>,
1459) {
1460    if items.is_empty() {
1461        blossom_upload_replica_metrics()
1462            .skipped_jobs
1463            .fetch_add(1, Ordering::Relaxed);
1464        return;
1465    }
1466
1467    let job = BlossomReplicaUploadJob { prepared, items };
1468    let job = match state.blossom_upload_replica_scheduler.schedule(job) {
1469        Ok(()) => return,
1470        Err(job) => job,
1471    };
1472    spawn_blossom_replica_upload_batch(BlossomReplicaUploadBatch::from_job(job));
1473}
1474
1475async fn blossom_replica_coalescer_worker(mut receiver: mpsc::Receiver<BlossomReplicaUploadJob>) {
1476    let max_blobs = blossom_replica_coalesce_max_blobs();
1477    let max_bytes = blossom_replica_coalesce_max_bytes();
1478    let flush_delay = blossom_replica_coalesce_flush_delay();
1479
1480    while let Some(job) = receiver.recv().await {
1481        blossom_upload_replica_metrics()
1482            .coalesce_queued_jobs
1483            .fetch_sub(1, Ordering::Relaxed);
1484        let mut batch = BlossomReplicaUploadBatch::from_job(job);
1485        loop {
1486            if batch.reached_limits(max_blobs, max_bytes) {
1487                break;
1488            }
1489            match tokio::time::timeout(flush_delay, receiver.recv()).await {
1490                Ok(Some(next_job)) => {
1491                    blossom_upload_replica_metrics()
1492                        .coalesce_queued_jobs
1493                        .fetch_sub(1, Ordering::Relaxed);
1494                    if batch.can_append(&next_job, max_blobs, max_bytes) {
1495                        batch.append(next_job);
1496                    } else {
1497                        spawn_blossom_replica_upload_batch(batch);
1498                        batch = BlossomReplicaUploadBatch::from_job(next_job);
1499                    }
1500                }
1501                Ok(None) => {
1502                    spawn_blossom_replica_upload_batch(batch);
1503                    return;
1504                }
1505                Err(_) => break,
1506            }
1507        }
1508        spawn_blossom_replica_upload_batch(batch);
1509    }
1510}
1511
1512fn spawn_blossom_replica_upload_batch(batch: BlossomReplicaUploadBatch) {
1513    tokio::spawn(async move {
1514        let _in_flight = BlossomReplicaInFlightGuard::new();
1515        let BlossomReplicaUploadBatch {
1516            servers,
1517            keys,
1518            permits: _permits,
1519            total_bytes,
1520            data_bytes: _,
1521            items,
1522        } = batch;
1523        let upload_limit = blossom_replica_upload_semaphore();
1524        let _upload_permit = match upload_limit.acquire().await {
1525            Ok(permit) => permit,
1526            Err(error) => {
1527                blossom_upload_replica_metrics()
1528                    .failed_batches
1529                    .fetch_add(1, Ordering::Relaxed);
1530                tracing::warn!(
1531                    error = %error,
1532                    "Skipping Blossom write-behind replication because upload limiter is closed"
1533                );
1534                return;
1535            }
1536        };
1537        let attempts = blossom_replica_upload_attempts();
1538        let client = BlossomClient::new((*keys).clone()).with_write_servers(servers.clone());
1539        for server in servers {
1540            for attempt in 1..=attempts {
1541                match client.upload_batch_to_server(&server, &items).await {
1542                    Ok(Some(result)) => {
1543                        let metrics = blossom_upload_replica_metrics();
1544                        metrics.accepted_batches.fetch_add(1, Ordering::Relaxed);
1545                        metrics
1546                            .accepted_blobs
1547                            .fetch_add(result.accepted as u64, Ordering::Relaxed);
1548                        metrics
1549                            .uploaded_blobs
1550                            .fetch_add(result.uploaded as u64, Ordering::Relaxed);
1551                        metrics
1552                            .replicated_bytes
1553                            .fetch_add(total_bytes as u64, Ordering::Relaxed);
1554                        tracing::debug!(
1555                            target = %server,
1556                            accepted = result.accepted,
1557                            uploaded = result.uploaded,
1558                            total = items.len(),
1559                            bytes = total_bytes,
1560                            "Replicated Blossom upload batch"
1561                        );
1562                        break;
1563                    }
1564                    Ok(None) => {
1565                        replicate_items_individually(&client, &server, &items, total_bytes).await;
1566                        break;
1567                    }
1568                    Err(error) if attempt < attempts => {
1569                        tracing::warn!(
1570                            target = %server,
1571                            error = %error,
1572                            attempt,
1573                            attempts,
1574                            total = items.len(),
1575                            bytes = total_bytes,
1576                            "Blossom write-behind replication retrying"
1577                        );
1578                        tokio::time::sleep(Duration::from_millis(250 * attempt as u64)).await;
1579                    }
1580                    Err(error) => {
1581                        blossom_upload_replica_metrics()
1582                            .failed_batches
1583                            .fetch_add(1, Ordering::Relaxed);
1584                        tracing::warn!(
1585                            target = %server,
1586                            error = %error,
1587                            attempt,
1588                            attempts,
1589                            total = items.len(),
1590                            bytes = total_bytes,
1591                            "Blossom write-behind replication failed"
1592                        );
1593                        break;
1594                    }
1595                }
1596            }
1597        }
1598    });
1599}
1600
1601async fn replicate_items_individually(
1602    client: &BlossomClient,
1603    server: &str,
1604    items: &[BatchUploadItem],
1605    total_bytes: usize,
1606) {
1607    let mut uploaded = 0usize;
1608    let mut skipped = 0usize;
1609    let mut failed = 0usize;
1610    let server_list = [server.to_string()];
1611    for item in items {
1612        match client
1613            .upload_to_selected_servers(&item.data, &server_list)
1614            .await
1615        {
1616            Ok((_hash, successes)) if successes > 0 => uploaded += 1,
1617            Ok((_hash, _)) => skipped += 1,
1618            Err(error) => {
1619                failed += 1;
1620                tracing::warn!(
1621                    target = %server,
1622                    hash = %item.hash,
1623                    error = %error,
1624                    "Blossom write-behind item replication failed"
1625                );
1626            }
1627        }
1628    }
1629    let metrics = blossom_upload_replica_metrics();
1630    metrics.fallback_batches.fetch_add(1, Ordering::Relaxed);
1631    metrics
1632        .fallback_uploaded_blobs
1633        .fetch_add(uploaded as u64, Ordering::Relaxed);
1634    metrics
1635        .fallback_failed_blobs
1636        .fetch_add(failed as u64, Ordering::Relaxed);
1637    if failed == 0 {
1638        metrics.accepted_batches.fetch_add(1, Ordering::Relaxed);
1639        metrics
1640            .accepted_blobs
1641            .fetch_add(items.len() as u64, Ordering::Relaxed);
1642        metrics
1643            .uploaded_blobs
1644            .fetch_add(uploaded as u64, Ordering::Relaxed);
1645        metrics
1646            .replicated_bytes
1647            .fetch_add(total_bytes as u64, Ordering::Relaxed);
1648    } else {
1649        metrics.failed_batches.fetch_add(1, Ordering::Relaxed);
1650    }
1651    tracing::debug!(
1652        target = %server,
1653        uploaded,
1654        skipped,
1655        failed,
1656        total = items.len(),
1657        bytes = total_bytes,
1658        "Replicated Blossom upload items without batch support"
1659    );
1660}
1661
1662async fn acquire_optimistic_upload_queue(
1663    state: &AppState,
1664    permits: u32,
1665) -> Result<tokio::sync::OwnedSemaphorePermit, &'static str> {
1666    match tokio::time::timeout(
1667        optimistic_upload_queue_timeout(),
1668        state
1669            .optimistic_upload_queue
1670            .clone()
1671            .acquire_many_owned(permits),
1672    )
1673    .await
1674    {
1675        Ok(Ok(permit)) => Ok(permit),
1676        Ok(Err(_)) => Err("Optimistic upload queue is closed"),
1677        Err(_) => Err("Optimistic upload queue is full"),
1678    }
1679}
1680
1681async fn uploaded_blob_already_exists(
1682    state: &AppState,
1683    sha256_hash: [u8; 32],
1684    sha256_hex: &str,
1685) -> Result<bool, String> {
1686    if let Some(Some(_)) = state.blob_cache.get_size(sha256_hex) {
1687        return Ok(true);
1688    }
1689
1690    let permit = acquire_blob_read().await.map_err(str::to_string)?;
1691    let store = state.store.clone();
1692    let size_read = tokio::task::spawn_blocking(move || {
1693        store
1694            .blob_size(&sha256_hash)
1695            .map_err(|error| error.to_string())
1696    });
1697
1698    let result = tokio::time::timeout(blob_read_timeout(), size_read).await;
1699    drop(permit);
1700    match result {
1701        Ok(Ok(Ok(size))) => {
1702            state.blob_cache.put_size(sha256_hex.to_string(), size);
1703            Ok(size.is_some())
1704        }
1705        Ok(Ok(Err(error))) => Err(error),
1706        Ok(Err(error)) => Err(format!("blob existence task failed: {}", error)),
1707        Err(_) => Err("blob existence check timed out".to_string()),
1708    }
1709}
1710
1711async fn set_existing_blob_owner_without_body_write(
1712    state: AppState,
1713    sha256_hash: [u8; 32],
1714    pubkey: [u8; 32],
1715) -> anyhow::Result<()> {
1716    tokio::task::spawn_blocking(move || state.store.set_blob_owner(&sha256_hash, &pubkey))
1717        .await
1718        .map_err(|error| anyhow::anyhow!("blob owner task failed: {}", error))??;
1719    Ok(())
1720}
1721
1722/// PUT /upload - Upload a new blob (BUD-02)
1723pub async fn upload_blob(
1724    State(state): State<AppState>,
1725    headers: HeaderMap,
1726    body: axum::body::Bytes,
1727) -> impl IntoResponse {
1728    // Check size limit first (before auth to save resources)
1729    let max_size = state.max_upload_bytes;
1730    if body.len() > max_size {
1731        return Response::builder()
1732            .status(StatusCode::PAYLOAD_TOO_LARGE)
1733            .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
1734            .header(header::CONTENT_TYPE, "application/json")
1735            .body(Body::from(format!(
1736                r#"{{"error":"Upload size {} bytes exceeds maximum {} bytes ({} MB)"}}"#,
1737                body.len(),
1738                max_size,
1739                max_size / 1024 / 1024
1740            )))
1741            .unwrap();
1742    }
1743
1744    // Verify authorization
1745    let auth = match verify_blossom_auth(&headers, "upload", None) {
1746        Ok(a) => a,
1747        Err((status, reason)) => {
1748            return Response::builder()
1749                .status(status)
1750                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
1751                .header("X-Reason", reason)
1752                .header(header::CONTENT_TYPE, "application/json")
1753                .body(Body::from(format!(r#"{{"error":"{}"}}"#, reason)))
1754                .unwrap();
1755        }
1756    };
1757
1758    // Get content type from header
1759    let content_type = headers
1760        .get(header::CONTENT_TYPE)
1761        .and_then(|v| v.to_str().ok())
1762        .unwrap_or("application/octet-stream")
1763        .to_string();
1764
1765    // Check write access: either in allowed_npubs/social graph OR public_writes is enabled.
1766    let is_allowed_author = is_allowed_write_author(&state, &auth.pubkey);
1767    let can_upload = can_accept_upload_author(&state, &auth.pubkey);
1768    if !can_upload {
1769        let _ = check_write_access(&state, &auth.pubkey);
1770        return Response::builder()
1771            .status(StatusCode::FORBIDDEN)
1772            .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
1773            .header(header::CONTENT_TYPE, "application/json")
1774            .body(Body::from(r#"{"error":"Write access denied. Your pubkey is not in the allowed list and public writes are disabled."}"#))
1775            .unwrap();
1776    }
1777
1778    if let Err((status, reason)) = validate_upload_payload(
1779        &body,
1780        &content_type,
1781        can_upload,
1782        state.require_random_untrusted_ingest,
1783    ) {
1784        return blossom_json_error(status, reason);
1785    }
1786
1787    // Compute SHA256 of uploaded data
1788    let mut hasher = Sha256::new();
1789    hasher.update(&body);
1790    let sha256_hash: [u8; 32] = hasher.finalize().into();
1791    let sha256_hex = hex::encode(sha256_hash);
1792
1793    // If auth has x tags, verify hash matches
1794    if !auth.blob_hashes.is_empty() && !auth.blob_hashes.contains(&sha256_hex) {
1795        return Response::builder()
1796            .status(StatusCode::FORBIDDEN)
1797            .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
1798            .header(
1799                "X-Reason",
1800                "Uploaded blob hash does not match authorized hash",
1801            )
1802            .header(header::CONTENT_TYPE, "application/json")
1803            .body(Body::from(r#"{"error":"Hash mismatch"}"#))
1804            .unwrap();
1805    }
1806
1807    // Convert pubkey hex to bytes
1808    let pubkey_bytes = match from_hex(&auth.pubkey) {
1809        Ok(b) => b,
1810        Err(_) => {
1811            return Response::builder()
1812                .status(StatusCode::BAD_REQUEST)
1813                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
1814                .header("X-Reason", "Invalid pubkey format")
1815                .body(Body::empty())
1816                .unwrap();
1817        }
1818    };
1819
1820    let size = body.len() as u64;
1821    let now = SystemTime::now()
1822        .duration_since(UNIX_EPOCH)
1823        .unwrap()
1824        .as_secs();
1825    let descriptor = make_blob_descriptor(&headers, sha256_hex.clone(), size, content_type, now);
1826
1827    if state.optimistic_blossom_uploads && optimistic_upload_is_inflight(&sha256_hex) {
1828        return upload_descriptor_response(StatusCode::ACCEPTED, &descriptor);
1829    }
1830
1831    // Store public-write blobs in cache storage unless the writer is explicitly
1832    // allowed, so untrusted public uploads do not become protected owned data.
1833    if state.optimistic_blossom_uploads {
1834        let queued_bytes = body.len().max(OPTIMISTIC_UPLOAD_MIN_QUEUE_CHARGE_BYTES);
1835        if queued_bytes <= state.optimistic_upload_queue_bytes {
1836            let permits = queued_bytes as u32;
1837            let marked_inflight = mark_optimistic_upload_inflight(&sha256_hex);
1838            if !marked_inflight {
1839                return upload_descriptor_response(StatusCode::ACCEPTED, &descriptor);
1840            }
1841            let permit = match acquire_optimistic_upload_queue(&state, permits).await {
1842                Ok(permit) => permit,
1843                Err(_) => {
1844                    clear_optimistic_upload_inflight(&sha256_hex);
1845                    return blossom_retryable_json_error(
1846                        StatusCode::SERVICE_UNAVAILABLE,
1847                        "Optimistic upload queue is full",
1848                        2,
1849                    );
1850                }
1851            };
1852            let state_for_write = state.clone();
1853            let hash_for_log = sha256_hex.clone();
1854            let replica_body = body.clone();
1855            let replica_content_type = descriptor.mime_type.clone();
1856            tokio::spawn(async move {
1857                let _permit = permit;
1858                match store_blossom_blob_without_blocking_runtime(
1859                    &state_for_write,
1860                    body,
1861                    pubkey_bytes,
1862                    is_allowed_author,
1863                )
1864                .await
1865                {
1866                    Ok(inserted) => {
1867                        if inserted {
1868                            if let Some(replication) = prepare_blossom_upload_replication(
1869                                &state_for_write,
1870                                replica_body.len(),
1871                            ) {
1872                                let replication_item = replica_item(
1873                                    hash_for_log.clone(),
1874                                    replica_body.to_vec(),
1875                                    replica_content_type,
1876                                );
1877                                schedule_prepared_blossom_upload_replication(
1878                                    &state_for_write,
1879                                    replication,
1880                                    vec![replication_item],
1881                                );
1882                            }
1883                        }
1884                    }
1885                    Err(error) => {
1886                        tracing::error!(
1887                            "Background Blossom storage failed for {}: {:#}",
1888                            hash_for_log,
1889                            error
1890                        );
1891                    }
1892                }
1893                clear_optimistic_upload_inflight(&hash_for_log);
1894            });
1895
1896            return upload_descriptor_response(StatusCode::ACCEPTED, &descriptor);
1897        }
1898
1899        tracing::warn!(
1900            "Blossom upload {} is larger than optimistic queue budget {}; storing synchronously",
1901            queued_bytes,
1902            state.optimistic_upload_queue_bytes
1903        );
1904    }
1905
1906    match uploaded_blob_already_exists(&state, sha256_hash, &sha256_hex).await {
1907        Ok(true) => {
1908            if is_allowed_author {
1909                if let Err(error) = set_existing_blob_owner_without_body_write(
1910                    state.clone(),
1911                    sha256_hash,
1912                    pubkey_bytes,
1913                )
1914                .await
1915                {
1916                    return Response::builder()
1917                        .status(StatusCode::INTERNAL_SERVER_ERROR)
1918                        .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
1919                        .header("X-Reason", "Storage error")
1920                        .header(header::CONTENT_TYPE, "application/json")
1921                        .body(Body::from(format!(r#"{{"error":"{}"}}"#, error)))
1922                        .unwrap();
1923                }
1924            }
1925            return upload_descriptor_response(StatusCode::OK, &descriptor);
1926        }
1927        Ok(false) => {}
1928        Err(error) => {
1929            tracing::debug!(
1930                "Could not preflight Blossom upload {} before synchronous storage: {}",
1931                sha256_hex,
1932                error
1933            );
1934        }
1935    }
1936
1937    let store_result = store_blossom_blob_without_blocking_runtime(
1938        &state,
1939        body.clone(),
1940        pubkey_bytes,
1941        is_allowed_author,
1942    )
1943    .await;
1944
1945    match store_result {
1946        Ok(inserted) => {
1947            if inserted {
1948                if let Some(replication) = prepare_blossom_upload_replication(&state, body.len()) {
1949                    let replication_item = replica_item(
1950                        sha256_hex.clone(),
1951                        body.to_vec(),
1952                        descriptor.mime_type.clone(),
1953                    );
1954                    schedule_prepared_blossom_upload_replication(
1955                        &state,
1956                        replication,
1957                        vec![replication_item],
1958                    );
1959                }
1960            }
1961            upload_descriptor_response(StatusCode::CREATED, &descriptor)
1962        }
1963        Err(error) => blob_write_error_response(error),
1964    }
1965}
1966
1967fn take_binary_batch_bytes<'a>(
1968    body: &'a [u8],
1969    cursor: &mut usize,
1970    len: usize,
1971) -> Result<&'a [u8], (StatusCode, String)> {
1972    let end = cursor.checked_add(len).ok_or_else(|| {
1973        (
1974            StatusCode::PAYLOAD_TOO_LARGE,
1975            "Binary batch field length overflow".to_string(),
1976        )
1977    })?;
1978    if end > body.len() {
1979        return Err((
1980            StatusCode::BAD_REQUEST,
1981            "Binary batch body is truncated".to_string(),
1982        ));
1983    }
1984    let slice = &body[*cursor..end];
1985    *cursor = end;
1986    Ok(slice)
1987}
1988
1989fn read_binary_batch_u16(body: &[u8], cursor: &mut usize) -> Result<u16, (StatusCode, String)> {
1990    let bytes = take_binary_batch_bytes(body, cursor, 2)?;
1991    Ok(u16::from_be_bytes([bytes[0], bytes[1]]))
1992}
1993
1994fn read_binary_batch_u32(body: &[u8], cursor: &mut usize) -> Result<u32, (StatusCode, String)> {
1995    let bytes = take_binary_batch_bytes(body, cursor, 4)?;
1996    Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
1997}
1998
1999fn read_binary_batch_u64(body: &[u8], cursor: &mut usize) -> Result<u64, (StatusCode, String)> {
2000    let bytes = take_binary_batch_bytes(body, cursor, 8)?;
2001    Ok(u64::from_be_bytes([
2002        bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
2003    ]))
2004}
2005
2006fn parse_binary_batch_upload(
2007    body: &[u8],
2008) -> Result<Vec<DecodedBatchUploadBlob>, (StatusCode, String)> {
2009    let mut cursor = 0usize;
2010    let magic = take_binary_batch_bytes(body, &mut cursor, BINARY_BATCH_UPLOAD_MAGIC.len())?;
2011    if magic != BINARY_BATCH_UPLOAD_MAGIC {
2012        return Err((
2013            StatusCode::BAD_REQUEST,
2014            "Invalid binary batch magic".to_string(),
2015        ));
2016    }
2017
2018    let count = read_binary_batch_u32(body, &mut cursor)? as usize;
2019    if count == 0 {
2020        return Err((StatusCode::BAD_REQUEST, "Batch is empty".to_string()));
2021    }
2022    if count > MAX_BATCH_UPLOAD_BLOBS {
2023        return Err((
2024            StatusCode::PAYLOAD_TOO_LARGE,
2025            "Batch contains too many blobs".to_string(),
2026        ));
2027    }
2028
2029    let mut total_bytes = 0usize;
2030    let mut blobs = Vec::with_capacity(count);
2031    for _ in 0..count {
2032        let hash = take_binary_batch_bytes(body, &mut cursor, 32)?;
2033        let content_type_len = read_binary_batch_u16(body, &mut cursor)? as usize;
2034        if content_type_len > MAX_BINARY_BATCH_CONTENT_TYPE_BYTES {
2035            return Err((
2036                StatusCode::PAYLOAD_TOO_LARGE,
2037                "Binary batch content type is too long".to_string(),
2038            ));
2039        }
2040        let data_len =
2041            usize::try_from(read_binary_batch_u64(body, &mut cursor)?).map_err(|_| {
2042                (
2043                    StatusCode::PAYLOAD_TOO_LARGE,
2044                    "Binary batch blob is too large".to_string(),
2045                )
2046            })?;
2047        total_bytes = total_bytes.checked_add(data_len).ok_or_else(|| {
2048            (
2049                StatusCode::PAYLOAD_TOO_LARGE,
2050                "Batch exceeds maximum upload size".to_string(),
2051            )
2052        })?;
2053        if total_bytes > MAX_BATCH_UPLOAD_BYTES {
2054            return Err((
2055                StatusCode::PAYLOAD_TOO_LARGE,
2056                "Batch exceeds maximum upload size".to_string(),
2057            ));
2058        }
2059
2060        let content_type = if content_type_len == 0 {
2061            None
2062        } else {
2063            let bytes = take_binary_batch_bytes(body, &mut cursor, content_type_len)?;
2064            Some(
2065                std::str::from_utf8(bytes)
2066                    .map_err(|_| {
2067                        (
2068                            StatusCode::BAD_REQUEST,
2069                            "Binary batch content type is not UTF-8".to_string(),
2070                        )
2071                    })?
2072                    .to_string(),
2073            )
2074        };
2075        let data = take_binary_batch_bytes(body, &mut cursor, data_len)?.to_vec();
2076        blobs.push(DecodedBatchUploadBlob {
2077            sha256: hex::encode(hash),
2078            content_type,
2079            data,
2080        });
2081    }
2082
2083    if cursor != body.len() {
2084        return Err((
2085            StatusCode::BAD_REQUEST,
2086            "Binary batch body has trailing bytes".to_string(),
2087        ));
2088    }
2089
2090    Ok(blobs)
2091}
2092
2093fn blossom_auth_error_response(status: StatusCode, reason: &'static str) -> Response<Body> {
2094    Response::builder()
2095        .status(status)
2096        .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2097        .header("X-Reason", reason)
2098        .header(header::CONTENT_TYPE, "application/json")
2099        .body(Body::from(format!(r#"{{"error":"{}"}}"#, reason)))
2100        .unwrap()
2101}
2102
2103fn verify_upload_batch_auth(headers: &HeaderMap) -> Result<BlossomAuth, Box<Response<Body>>> {
2104    verify_blossom_auth(headers, "upload", None)
2105        .map_err(|(status, reason)| Box::new(blossom_auth_error_response(status, reason)))
2106}
2107
2108pub async fn require_upload_auth_middleware(request: Request, next: Next) -> Response<Body> {
2109    if let Err(response) = verify_upload_batch_auth(request.headers()) {
2110        return *response;
2111    }
2112    next.run(request).await
2113}
2114
2115async fn upload_decoded_blob_batch(
2116    state: AppState,
2117    headers: HeaderMap,
2118    auth: BlossomAuth,
2119    blobs: Vec<DecodedBatchUploadBlob>,
2120    started_at: Instant,
2121    encoding: &'static str,
2122) -> Response<Body> {
2123    let slow_log_ms = slow_batch_upload_log_ms();
2124    let payload_blobs = blobs.len();
2125    if blobs.is_empty() {
2126        return blossom_json_error(StatusCode::BAD_REQUEST, "Batch is empty");
2127    }
2128    if blobs.len() > MAX_BATCH_UPLOAD_BLOBS {
2129        return blossom_json_error(
2130            StatusCode::PAYLOAD_TOO_LARGE,
2131            "Batch contains too many blobs",
2132        );
2133    }
2134
2135    let auth_ms = started_at.elapsed().as_millis();
2136
2137    let is_allowed_author = is_allowed_write_author(&state, &auth.pubkey);
2138    let can_upload = can_accept_upload_author(&state, &auth.pubkey);
2139    if !can_upload {
2140        let _ = check_write_access(&state, &auth.pubkey);
2141        return blossom_json_error(StatusCode::FORBIDDEN, "Write access denied");
2142    }
2143
2144    let pubkey_bytes = match from_hex(&auth.pubkey) {
2145        Ok(bytes) => bytes,
2146        Err(_) => return blossom_json_error(StatusCode::BAD_REQUEST, "Invalid pubkey format"),
2147    };
2148
2149    let now = SystemTime::now()
2150        .duration_since(UNIX_EPOCH)
2151        .unwrap()
2152        .as_secs();
2153    let mut total_bytes = 0usize;
2154    let mut items = Vec::with_capacity(payload_blobs);
2155    let mut replica_specs = Vec::with_capacity(payload_blobs);
2156    let mut descriptors = Vec::with_capacity(payload_blobs);
2157    let mut decode_hash_ms = 0u128;
2158    let mut validate_ms = 0u128;
2159
2160    if auth.blob_hashes.is_empty() && !auth.batch_hashes.is_empty() {
2161        let batch_hashes = blobs
2162            .iter()
2163            .map(|blob| blob.sha256.to_lowercase())
2164            .collect::<Vec<_>>();
2165        let batch_digest =
2166            match batch_upload_hash_list_digest(batch_hashes.iter().map(String::as_str)) {
2167                Ok(digest) => digest,
2168                Err(_) => return blossom_json_error(StatusCode::BAD_REQUEST, "Invalid blob hash"),
2169            };
2170        if !auth.batch_hashes.contains(&batch_digest) {
2171            return blossom_json_error(
2172                StatusCode::FORBIDDEN,
2173                "Batch hash list does not match authorization",
2174            );
2175        }
2176    }
2177
2178    for blob in blobs {
2179        let decode_started = Instant::now();
2180        let sha256_hex = blob.sha256.to_lowercase();
2181        let expected_hash: [u8; 32] = match from_hex(&sha256_hex) {
2182            Ok(hash) => hash,
2183            Err(_) => return blossom_json_error(StatusCode::BAD_REQUEST, "Invalid blob hash"),
2184        };
2185        if !auth.blob_hashes.is_empty() && !auth.blob_hashes.contains(&sha256_hex) {
2186            return blossom_json_error(
2187                StatusCode::FORBIDDEN,
2188                "Uploaded blob hash does not match authorized hash",
2189            );
2190        }
2191
2192        let data = blob.data;
2193        if data.len() > state.max_upload_bytes {
2194            return blossom_json_error(
2195                StatusCode::PAYLOAD_TOO_LARGE,
2196                "Blob exceeds maximum upload size",
2197            );
2198        }
2199        total_bytes = total_bytes.saturating_add(data.len());
2200        if total_bytes > MAX_BATCH_UPLOAD_BYTES {
2201            return blossom_json_error(
2202                StatusCode::PAYLOAD_TOO_LARGE,
2203                "Batch exceeds maximum upload size",
2204            );
2205        }
2206
2207        let mut hasher = Sha256::new();
2208        hasher.update(&data);
2209        let actual_hash: [u8; 32] = hasher.finalize().into();
2210        if actual_hash != expected_hash {
2211            return blossom_json_error(StatusCode::FORBIDDEN, "Hash mismatch");
2212        }
2213        decode_hash_ms += decode_started.elapsed().as_millis();
2214
2215        let validate_started = Instant::now();
2216        let content_type = blob
2217            .content_type
2218            .as_deref()
2219            .map(content_type_base)
2220            .unwrap_or_else(|| "application/octet-stream".to_string());
2221        if let Err((status, reason)) = validate_upload_payload(
2222            &data,
2223            &content_type,
2224            can_upload,
2225            state.require_random_untrusted_ingest,
2226        ) {
2227            return blossom_json_error(status, reason);
2228        }
2229        validate_ms += validate_started.elapsed().as_millis();
2230
2231        descriptors.push(make_blob_descriptor(
2232            &headers,
2233            sha256_hex.clone(),
2234            data.len() as u64,
2235            content_type.clone(),
2236            now,
2237        ));
2238        replica_specs.push((sha256_hex, content_type));
2239        items.push((actual_hash, data));
2240    }
2241    let prepare_ms = started_at.elapsed().as_millis();
2242
2243    let permit = match acquire_blob_write().await {
2244        Ok(permit) => permit,
2245        Err(reason) => return blob_write_error_response(blob_write_queue_error(reason)),
2246    };
2247    let store = state.store.clone();
2248    let store_started = Instant::now();
2249    let stored = tokio::task::spawn_blocking(move || {
2250        let _permit = permit;
2251        let report = if is_allowed_author {
2252            store.put_owned_blobs_report(&items, &pubkey_bytes)
2253        } else {
2254            store.put_cached_blobs_report(&items)
2255        }?;
2256        Ok::<_, anyhow::Error>((report, items))
2257    })
2258    .await
2259    .map_err(|error| anyhow::anyhow!("blob batch write task failed: {}", error));
2260    let store_ms = store_started.elapsed().as_millis();
2261    let total_ms = started_at.elapsed().as_millis();
2262
2263    match stored {
2264        Ok(Ok((report, items))) => {
2265            for ((sha256_hex, _), (_, data)) in replica_specs.iter().zip(&items) {
2266                state
2267                    .blob_cache
2268                    .put_size(sha256_hex.clone(), Some(data.len() as u64));
2269                state.blob_cache.put_body(sha256_hex.clone(), data);
2270            }
2271            let uploaded = report.inserted;
2272            if uploaded > 0 {
2273                let inserted: HashSet<_> = report.inserted_hashes.iter().copied().collect();
2274                let replica_items = replica_specs
2275                    .iter()
2276                    .zip(items.iter())
2277                    .filter(|&((_, _), (hash, _))| inserted.contains(hash))
2278                    .map(|((sha256_hex, content_type), (_, data))| {
2279                        replica_item(sha256_hex.clone(), data.clone(), content_type.clone())
2280                    })
2281                    .collect::<Vec<_>>();
2282                let replication =
2283                    usize::try_from(report.inserted_bytes)
2284                        .ok()
2285                        .and_then(|inserted_bytes| {
2286                            prepare_blossom_upload_replication(&state, inserted_bytes)
2287                        });
2288                if let Some(replication) = replication {
2289                    schedule_prepared_blossom_upload_replication(
2290                        &state,
2291                        replication,
2292                        replica_items,
2293                    );
2294                }
2295            }
2296            if slow_log_ms.is_some_and(|threshold| total_ms >= threshold) {
2297                tracing::warn!(
2298                    blobs = payload_blobs,
2299                    uploaded,
2300                    total_bytes,
2301                    total_ms,
2302                    auth_ms,
2303                    prepare_ms,
2304                    decode_hash_ms,
2305                    validate_ms,
2306                    store_ms,
2307                    encoding,
2308                    allowed_author = is_allowed_author,
2309                    "slow Blossom batch upload"
2310                );
2311            }
2312            Response::builder()
2313                .status(StatusCode::OK)
2314                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2315                .header(header::CONTENT_TYPE, "application/json")
2316                .body(Body::from(
2317                    serde_json::to_string(&BatchUploadResponse {
2318                        uploaded,
2319                        blobs: descriptors,
2320                    })
2321                    .unwrap(),
2322                ))
2323                .unwrap()
2324        }
2325        Ok(Err(error)) | Err(error) => blob_write_error_response(error.into()),
2326    }
2327}
2328
2329/// POST /upload/batch - Upload multiple blobs with one auth event and one storage batch.
2330pub async fn upload_blob_batch(
2331    State(state): State<AppState>,
2332    headers: HeaderMap,
2333    Json(payload): Json<BatchUploadRequest>,
2334) -> impl IntoResponse {
2335    let started_at = Instant::now();
2336    let auth = match verify_upload_batch_auth(&headers) {
2337        Ok(auth) => auth,
2338        Err(response) => return *response,
2339    };
2340    let mut blobs = Vec::with_capacity(payload.blobs.len());
2341    for blob in payload.blobs {
2342        let data = match base64::engine::general_purpose::STANDARD.decode(blob.data.as_bytes()) {
2343            Ok(data) => data,
2344            Err(_) => return blossom_json_error(StatusCode::BAD_REQUEST, "Invalid blob data"),
2345        };
2346        blobs.push(DecodedBatchUploadBlob {
2347            sha256: blob.sha256,
2348            content_type: blob.content_type,
2349            data,
2350        });
2351    }
2352    upload_decoded_blob_batch(state, headers, auth, blobs, started_at, "json").await
2353}
2354
2355/// POST /upload/batch-binary - Upload a binary encoded blob batch.
2356pub async fn upload_blob_batch_binary(
2357    State(state): State<AppState>,
2358    headers: HeaderMap,
2359    body: Bytes,
2360) -> impl IntoResponse {
2361    let started_at = Instant::now();
2362    let auth = match verify_upload_batch_auth(&headers) {
2363        Ok(auth) => auth,
2364        Err(response) => return *response,
2365    };
2366    let blobs = match parse_binary_batch_upload(&body) {
2367        Ok(blobs) => blobs,
2368        Err((status, reason)) => return blossom_json_error(status, reason),
2369    };
2370    upload_decoded_blob_batch(state, headers, auth, blobs, started_at, "binary").await
2371}
2372
2373/// DELETE /<sha256> - Delete a blob (BUD-02)
2374/// Note: Blob is only fully deleted when ALL owners have removed it
2375pub async fn delete_blob(
2376    State(state): State<AppState>,
2377    Path(id): Path<String>,
2378    headers: HeaderMap,
2379) -> impl IntoResponse {
2380    let (hash_part, _) = parse_hash_and_extension(&id);
2381
2382    if !is_valid_sha256(hash_part) {
2383        return Response::builder()
2384            .status(StatusCode::BAD_REQUEST)
2385            .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2386            .header("X-Reason", "Invalid SHA256 hash")
2387            .body(Body::empty())
2388            .unwrap();
2389    }
2390
2391    let sha256_hex = hash_part.to_lowercase();
2392
2393    // Convert hash to bytes
2394    let sha256_bytes = match from_hex(&sha256_hex) {
2395        Ok(b) => b,
2396        Err(_) => {
2397            return Response::builder()
2398                .status(StatusCode::BAD_REQUEST)
2399                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2400                .header("X-Reason", "Invalid SHA256 hash format")
2401                .body(Body::empty())
2402                .unwrap();
2403        }
2404    };
2405
2406    // Verify authorization with hash requirement
2407    let auth = match verify_blossom_auth(&headers, "delete", Some(&sha256_hex)) {
2408        Ok(a) => a,
2409        Err((status, reason)) => {
2410            return Response::builder()
2411                .status(status)
2412                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2413                .header("X-Reason", reason)
2414                .body(Body::empty())
2415                .unwrap();
2416        }
2417    };
2418
2419    // Convert pubkey hex to bytes
2420    let pubkey_bytes = match from_hex(&auth.pubkey) {
2421        Ok(b) => b,
2422        Err(_) => {
2423            return Response::builder()
2424                .status(StatusCode::BAD_REQUEST)
2425                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2426                .header("X-Reason", "Invalid pubkey format")
2427                .body(Body::empty())
2428                .unwrap();
2429        }
2430    };
2431
2432    // Check ownership - user must be one of the owners (O(1) lookup with composite key)
2433    match state.store.is_blob_owner(&sha256_bytes, &pubkey_bytes) {
2434        Ok(true) => {
2435            // User is an owner, proceed with delete
2436        }
2437        Ok(false) => {
2438            // Check if blob exists at all (for proper error message)
2439            match state.store.blob_has_owners(&sha256_bytes) {
2440                Ok(true) => {
2441                    return Response::builder()
2442                        .status(StatusCode::FORBIDDEN)
2443                        .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2444                        .header("X-Reason", "Not a blob owner")
2445                        .body(Body::empty())
2446                        .unwrap();
2447                }
2448                Ok(false) => {
2449                    return Response::builder()
2450                        .status(StatusCode::NOT_FOUND)
2451                        .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2452                        .header(header::CACHE_CONTROL, NOT_FOUND_CACHE_CONTROL)
2453                        .header("X-Reason", "Blob not found")
2454                        .body(Body::empty())
2455                        .unwrap();
2456                }
2457                Err(_) => {
2458                    return Response::builder()
2459                        .status(StatusCode::INTERNAL_SERVER_ERROR)
2460                        .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2461                        .body(Body::empty())
2462                        .unwrap();
2463                }
2464            }
2465        }
2466        Err(_) => {
2467            return Response::builder()
2468                .status(StatusCode::INTERNAL_SERVER_ERROR)
2469                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2470                .body(Body::empty())
2471                .unwrap();
2472        }
2473    }
2474
2475    // Remove this user's ownership (blob only deleted when no owners remain)
2476    match state
2477        .store
2478        .delete_blossom_blob(&sha256_bytes, &pubkey_bytes)
2479    {
2480        Ok(fully_deleted) => {
2481            // Return 200 OK whether blob was fully deleted or just removed from user's list
2482            // The client doesn't need to know if other owners still exist
2483            Response::builder()
2484                .status(StatusCode::OK)
2485                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2486                .header(
2487                    "X-Blob-Deleted",
2488                    if fully_deleted { "true" } else { "false" },
2489                )
2490                .body(Body::empty())
2491                .unwrap()
2492        }
2493        Err(_) => Response::builder()
2494            .status(StatusCode::INTERNAL_SERVER_ERROR)
2495            .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2496            .body(Body::empty())
2497            .unwrap(),
2498    }
2499}
2500
2501/// GET /list/<pubkey> - List blobs for a pubkey (BUD-02)
2502pub async fn list_blobs(
2503    State(state): State<AppState>,
2504    Path(pubkey): Path<String>,
2505    Query(query): Query<ListQuery>,
2506    headers: HeaderMap,
2507) -> impl IntoResponse {
2508    // Validate pubkey format (64 hex chars)
2509    if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
2510        return Response::builder()
2511            .status(StatusCode::BAD_REQUEST)
2512            .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2513            .header("X-Reason", "Invalid pubkey format")
2514            .header(header::CONTENT_TYPE, "application/json")
2515            .body(Body::from("[]"))
2516            .unwrap();
2517    }
2518
2519    let pubkey_hex = pubkey.to_lowercase();
2520    let pubkey_bytes: [u8; 32] = match from_hex(&pubkey_hex) {
2521        Ok(b) => b,
2522        Err(_) => {
2523            return Response::builder()
2524                .status(StatusCode::BAD_REQUEST)
2525                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2526                .header("X-Reason", "Invalid pubkey format")
2527                .header(header::CONTENT_TYPE, "application/json")
2528                .body(Body::from("[]"))
2529                .unwrap();
2530        }
2531    };
2532
2533    let auth = match verify_blossom_auth(&headers, "list", None) {
2534        Ok(auth) => auth,
2535        Err((status, reason)) => {
2536            return Response::builder()
2537                .status(status)
2538                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2539                .header("X-Reason", reason)
2540                .header(header::CONTENT_TYPE, "application/json")
2541                .body(Body::from("[]"))
2542                .unwrap();
2543        }
2544    };
2545
2546    if !auth.pubkey.eq_ignore_ascii_case(&pubkey_hex) {
2547        return Response::builder()
2548            .status(StatusCode::FORBIDDEN)
2549            .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2550            .header("X-Reason", "Pubkey mismatch")
2551            .header(header::CONTENT_TYPE, "application/json")
2552            .body(Body::from("[]"))
2553            .unwrap();
2554    }
2555
2556    // Get blobs for this pubkey
2557    match state.store.list_blobs_by_pubkey(&pubkey_bytes) {
2558        Ok(blobs) => {
2559            // Apply filters
2560            let mut filtered: Vec<_> = blobs
2561                .into_iter()
2562                .filter(|b| {
2563                    if let Some(since) = query.since {
2564                        if b.uploaded < since {
2565                            return false;
2566                        }
2567                    }
2568                    if let Some(until) = query.until {
2569                        if b.uploaded > until {
2570                            return false;
2571                        }
2572                    }
2573                    true
2574                })
2575                .collect();
2576
2577            // Sort by uploaded descending (most recent first)
2578            filtered.sort_by_key(|descriptor| std::cmp::Reverse(descriptor.uploaded));
2579
2580            // Apply limit
2581            let limit = query.limit.unwrap_or(100).min(1000);
2582            filtered.truncate(limit);
2583
2584            let descriptors: Vec<_> = filtered
2585                .into_iter()
2586                .map(|mut descriptor| {
2587                    descriptor.url =
2588                        blossom_blob_url(&headers, &descriptor.sha256, &descriptor.mime_type);
2589                    descriptor
2590                })
2591                .collect();
2592
2593            Response::builder()
2594                .status(StatusCode::OK)
2595                .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2596                .header(header::CONTENT_TYPE, "application/json")
2597                .body(Body::from(serde_json::to_string(&descriptors).unwrap()))
2598                .unwrap()
2599        }
2600        Err(_) => Response::builder()
2601            .status(StatusCode::INTERNAL_SERVER_ERROR)
2602            .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
2603            .header(header::CONTENT_TYPE, "application/json")
2604            .body(Body::from("[]"))
2605            .unwrap(),
2606    }
2607}
2608
2609// Helper functions
2610
2611fn parse_hash_and_extension(id: &str) -> (&str, Option<&str>) {
2612    if let Some(dot_pos) = id.rfind('.') {
2613        (&id[..dot_pos], Some(&id[dot_pos..]))
2614    } else {
2615        (id, None)
2616    }
2617}
2618
2619fn is_valid_sha256(s: &str) -> bool {
2620    s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
2621}
2622
2623#[cfg(test)]
2624fn store_blossom_blob(
2625    state: &AppState,
2626    data: &[u8],
2627    _sha256: &[u8; 32],
2628    pubkey: &[u8; 32],
2629    track_ownership: bool,
2630) -> anyhow::Result<()> {
2631    if track_ownership {
2632        state.store.put_owned_blob(data, pubkey)?;
2633    } else {
2634        state.store.put_cached_blob(data)?;
2635    }
2636
2637    Ok(())
2638}
2639
2640fn mime_to_extension(mime: &str) -> &'static str {
2641    match mime {
2642        "image/png" => ".png",
2643        "image/jpeg" => ".jpg",
2644        "image/gif" => ".gif",
2645        "image/webp" => ".webp",
2646        "image/svg+xml" => ".svg",
2647        "video/mp4" => ".mp4",
2648        "video/webm" => ".webm",
2649        "audio/mpeg" => ".mp3",
2650        "audio/ogg" => ".ogg",
2651        "application/pdf" => ".pdf",
2652        "text/plain" => ".txt",
2653        "text/html" => ".html",
2654        "application/json" => ".json",
2655        _ => ".bin",
2656    }
2657}
2658
2659#[cfg(test)]
2660mod tests {
2661    use super::*;
2662    use crate::server::auth::WsRelayState;
2663    use crate::storage::HashtreeStore;
2664    use crate::test_support::{test_env_lock, EnvVarGuard};
2665    use axum::response::IntoResponse;
2666    use axum::{routing::post, Router};
2667    use base64::Engine;
2668    use hashtree_core::sha256;
2669    use std::collections::HashSet;
2670    use std::sync::{Arc, Mutex as StdMutex};
2671    use std::time::Duration;
2672    use tempfile::TempDir;
2673
2674    fn test_app_state(store: Arc<HashtreeStore>) -> AppState {
2675        AppState {
2676            store,
2677            auth: None,
2678            daemon_started_at: 1_700_000_000,
2679            peer_mode: crate::config::ServerMode::Normal,
2680            hash_get_enabled: true,
2681            fips_endpoint: None,
2682            fips_blob_resolver: None,
2683            fetch_from_fips_peers: true,
2684            ws_relay: Arc::new(WsRelayState::new()),
2685            max_upload_bytes: 5 * 1024 * 1024,
2686            public_writes: true,
2687            public_plaintext_reads: true,
2688            require_random_untrusted_ingest: true,
2689            optimistic_blossom_uploads: false,
2690            optimistic_upload_queue_bytes: 256 * 1024 * 1024,
2691            optimistic_upload_queue: Arc::new(tokio::sync::Semaphore::new(256 * 1024 * 1024)),
2692            allowed_pubkeys: HashSet::new(),
2693            upstream_blossom: Vec::new(),
2694            upstream_http_client: super::super::new_upstream_http_client(),
2695            upstream_blossom_miss_cache: Arc::new(StdMutex::new(crate::server::new_lookup_cache())),
2696            upstream_blossom_fetch_metrics: Arc::new(
2697                crate::server::auth::UpstreamBlossomFetchMetrics::default(),
2698            ),
2699            blossom_upload_replicas: Vec::new(),
2700            blossom_upload_replica_queue_bytes: 256 * 1024 * 1024,
2701            blossom_upload_replica_queue: Arc::new(tokio::sync::Semaphore::new(256 * 1024 * 1024)),
2702            blossom_upload_replica_keys: None,
2703            blossom_upload_replica_scheduler: Arc::new(BlossomUploadReplicaScheduler::new()),
2704            social_graph: None,
2705            social_graph_store: None,
2706            social_graph_root: None,
2707            socialgraph_snapshot_public: false,
2708            nostr_relay: None,
2709            nostr_provider: None,
2710            nostr_relay_urls: Vec::new(),
2711            tree_root_cache: Arc::new(StdMutex::new(std::collections::HashMap::new())),
2712            inflight_blob_fetches: Arc::new(tokio::sync::Mutex::new(
2713                std::collections::HashMap::new(),
2714            )),
2715            inflight_blob_reads: Arc::new(
2716                tokio::sync::Mutex::new(std::collections::HashMap::new()),
2717            ),
2718            blob_cache: Arc::new(crate::blob_cache::BlobCache::for_tests()),
2719            directory_listing_cache: Arc::new(StdMutex::new(crate::server::new_lookup_cache())),
2720            resolved_path_cache: Arc::new(StdMutex::new(crate::server::new_lookup_cache())),
2721            thumbnail_path_cache: Arc::new(StdMutex::new(crate::server::new_lookup_cache())),
2722            cid_size_cache: Arc::new(StdMutex::new(crate::server::new_lookup_cache())),
2723        }
2724    }
2725
2726    async fn receive_replication<T>(receiver: &mut tokio::sync::mpsc::UnboundedReceiver<T>) -> T {
2727        tokio::time::timeout(Duration::from_secs(10), receiver.recv())
2728            .await
2729            .expect("replication request timed out")
2730            .expect("replication channel closed")
2731    }
2732
2733    fn create_upload_auth_header(keys: &nostr::Keys) -> String {
2734        use nostr::{EventBuilder, Kind, Tag, TagKind, Timestamp};
2735
2736        let now = Timestamp::now();
2737        let event = EventBuilder::new(Kind::Custom(BLOSSOM_AUTH_KIND), "")
2738            .tags(vec![
2739                Tag::custom(TagKind::Custom("t".into()), vec!["upload".to_string()]),
2740                Tag::custom(
2741                    TagKind::Custom("expiration".into()),
2742                    vec![(now.as_secs() + 300).to_string()],
2743                ),
2744            ])
2745            .custom_created_at(now)
2746            .sign_with_keys(keys)
2747            .expect("sign blossom auth");
2748        let json = serde_json::to_vec(&event).expect("serialize auth event");
2749        format!(
2750            "Nostr {}",
2751            base64::engine::general_purpose::STANDARD.encode(json)
2752        )
2753    }
2754
2755    fn create_batch_upload_auth_header(keys: &nostr::Keys, hashes: &[String]) -> String {
2756        use nostr::{EventBuilder, Kind, Tag, TagKind, Timestamp};
2757
2758        let now = Timestamp::now();
2759        let event = EventBuilder::new(Kind::Custom(BLOSSOM_AUTH_KIND), "")
2760            .tags(vec![
2761                Tag::custom(TagKind::Custom("t".into()), vec!["upload".to_string()]),
2762                Tag::custom(
2763                    TagKind::Custom(BATCH_UPLOAD_HASH_LIST_AUTH_TAG.into()),
2764                    vec![
2765                        batch_upload_hash_list_digest(hashes.iter().map(String::as_str))
2766                            .expect("batch hash list digest"),
2767                    ],
2768                ),
2769                Tag::custom(
2770                    TagKind::Custom("expiration".into()),
2771                    vec![(now.as_secs() + 300).to_string()],
2772                ),
2773            ])
2774            .custom_created_at(now)
2775            .sign_with_keys(keys)
2776            .expect("sign blossom batch auth");
2777        let json = serde_json::to_vec(&event).expect("serialize batch auth event");
2778        format!(
2779            "Nostr {}",
2780            base64::engine::general_purpose::STANDARD.encode(json)
2781        )
2782    }
2783
2784    fn create_list_auth_header(keys: &nostr::Keys) -> String {
2785        use nostr::{EventBuilder, Kind, Tag, TagKind, Timestamp};
2786
2787        let now = Timestamp::now();
2788        let event = EventBuilder::new(Kind::Custom(BLOSSOM_AUTH_KIND), "")
2789            .tags(vec![
2790                Tag::custom(TagKind::Custom("t".into()), vec!["list".to_string()]),
2791                Tag::custom(
2792                    TagKind::Custom("expiration".into()),
2793                    vec![(now.as_secs() + 300).to_string()],
2794                ),
2795            ])
2796            .custom_created_at(now)
2797            .sign_with_keys(keys)
2798            .expect("sign blossom list auth");
2799        let json = serde_json::to_vec(&event).expect("serialize list auth event");
2800        format!(
2801            "Nostr {}",
2802            base64::engine::general_purpose::STANDARD.encode(json)
2803        )
2804    }
2805
2806    fn hosted_headers() -> HeaderMap {
2807        let mut headers = HeaderMap::new();
2808        headers.insert(header::HOST, "origin.internal".parse().unwrap());
2809        headers.insert("x-forwarded-host", "cdn.iris.to".parse().unwrap());
2810        headers.insert("x-forwarded-proto", "https".parse().unwrap());
2811        headers
2812    }
2813
2814    async fn read_descriptor(response: axum::response::Response) -> BlobDescriptor {
2815        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2816            .await
2817            .expect("read descriptor body");
2818        serde_json::from_slice(&body).expect("parse descriptor")
2819    }
2820
2821    fn upload_check_bits(response: UploadCheckResponse) -> Vec<bool> {
2822        let bytes = base64::engine::general_purpose::STANDARD
2823            .decode(response.present)
2824            .expect("decode upload check bitset");
2825        (0..response.count)
2826            .map(|index| bytes[index / 8] & (1 << (index % 8)) != 0)
2827            .collect()
2828    }
2829
2830    fn binary_batch_body(items: &[(&[u8], Option<&str>)]) -> Bytes {
2831        let mut body = Vec::new();
2832        body.extend_from_slice(BINARY_BATCH_UPLOAD_MAGIC);
2833        body.extend_from_slice(&(items.len() as u32).to_be_bytes());
2834        for (data, content_type) in items {
2835            body.extend_from_slice(&sha256(data));
2836            let content_type = content_type.unwrap_or("");
2837            body.extend_from_slice(&(content_type.len() as u16).to_be_bytes());
2838            body.extend_from_slice(&(*data).len().to_be_bytes());
2839            body.extend_from_slice(content_type.as_bytes());
2840            body.extend_from_slice(data);
2841        }
2842        Bytes::from(body)
2843    }
2844
2845    #[test]
2846    fn test_is_valid_sha256() {
2847        assert!(is_valid_sha256(
2848            "e2bab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6"
2849        ));
2850        assert!(is_valid_sha256(
2851            "0000000000000000000000000000000000000000000000000000000000000000"
2852        ));
2853
2854        // Too short
2855        assert!(!is_valid_sha256("e2bab35b5296ec2242ded0a01f6d6723"));
2856        // Too long
2857        assert!(!is_valid_sha256(
2858            "e2bab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6aa"
2859        ));
2860        // Invalid chars
2861        assert!(!is_valid_sha256(
2862            "zzbab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6"
2863        ));
2864        // Empty
2865        assert!(!is_valid_sha256(""));
2866    }
2867
2868    #[test]
2869    fn test_parse_hash_and_extension() {
2870        let (hash, ext) = parse_hash_and_extension("abc123.png");
2871        assert_eq!(hash, "abc123");
2872        assert_eq!(ext, Some(".png"));
2873
2874        let (hash2, ext2) = parse_hash_and_extension("abc123");
2875        assert_eq!(hash2, "abc123");
2876        assert_eq!(ext2, None);
2877
2878        let (hash3, ext3) = parse_hash_and_extension("abc.123.jpg");
2879        assert_eq!(hash3, "abc.123");
2880        assert_eq!(ext3, Some(".jpg"));
2881    }
2882
2883    #[test]
2884    fn test_mime_to_extension() {
2885        assert_eq!(mime_to_extension("image/png"), ".png");
2886        assert_eq!(mime_to_extension("image/jpeg"), ".jpg");
2887        assert_eq!(mime_to_extension("video/mp4"), ".mp4");
2888        assert_eq!(mime_to_extension("application/octet-stream"), ".bin");
2889        assert_eq!(mime_to_extension("unknown/type"), ".bin");
2890    }
2891
2892    #[test]
2893    fn blossom_blob_url_uses_forwarded_public_origin_and_extension() {
2894        let headers = hosted_headers();
2895        let hash = "00".repeat(32);
2896
2897        assert_eq!(
2898            blossom_blob_url(&headers, &hash, "application/octet-stream"),
2899            format!("https://cdn.iris.to/{hash}.bin")
2900        );
2901        assert_eq!(
2902            blossom_blob_url(&headers, &hash, "image/png"),
2903            format!("https://cdn.iris.to/{hash}.png")
2904        );
2905    }
2906
2907    #[tokio::test]
2908    async fn upload_check_reports_present_hashes_in_request_order() {
2909        let temp_dir = TempDir::new().expect("temp dir");
2910        let store = Arc::new(
2911            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
2912        );
2913
2914        let present = b"present blob";
2915        let missing = b"missing blob";
2916        let present_hash = sha256(present);
2917        let missing_hash = sha256(missing);
2918        store.put_cached_blob(present).expect("seed blob");
2919
2920        let state = test_app_state(store);
2921        let response = upload_check(
2922            State(state),
2923            Json(UploadCheckRequest {
2924                hashes: vec![
2925                    hex::encode(missing_hash),
2926                    hex::encode(present_hash),
2927                    hex::encode(present_hash),
2928                ],
2929            }),
2930        )
2931        .await
2932        .into_response();
2933
2934        assert_eq!(response.status(), StatusCode::OK);
2935        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2936            .await
2937            .expect("read response body");
2938        let parsed: UploadCheckResponse =
2939            serde_json::from_slice(&body).expect("parse upload check response");
2940        assert_eq!(upload_check_bits(parsed), vec![false, true, true]);
2941    }
2942
2943    #[tokio::test]
2944    async fn upload_check_rejects_invalid_hash() {
2945        let temp_dir = TempDir::new().expect("temp dir");
2946        let store = Arc::new(
2947            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
2948        );
2949        let state = test_app_state(store);
2950        let response = upload_check(
2951            State(state),
2952            Json(UploadCheckRequest {
2953                hashes: vec!["not-a-sha256".to_string()],
2954            }),
2955        )
2956        .await
2957        .into_response();
2958
2959        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2960    }
2961
2962    #[tokio::test]
2963    async fn upload_check_rejects_too_many_hashes() {
2964        let temp_dir = TempDir::new().expect("temp dir");
2965        let store = Arc::new(
2966            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
2967        );
2968        let state = test_app_state(store);
2969        let response = upload_check(
2970            State(state),
2971            Json(UploadCheckRequest {
2972                hashes: vec!["00".repeat(32); MAX_UPLOAD_CHECK_HASHES + 1],
2973            }),
2974        )
2975        .await
2976        .into_response();
2977
2978        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
2979    }
2980
2981    #[tokio::test]
2982    async fn upload_blob_batch_binary_replaces_cached_misses_after_commit() {
2983        let temp_dir = TempDir::new().expect("temp dir");
2984        let store = Arc::new(
2985            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
2986        );
2987        let state = test_app_state(Arc::clone(&store));
2988        let keys = nostr::Keys::generate();
2989        let mut headers = hosted_headers();
2990        headers.insert(
2991            header::AUTHORIZATION,
2992            create_upload_auth_header(&keys)
2993                .parse()
2994                .expect("auth header value"),
2995        );
2996        headers.insert(
2997            header::CONTENT_TYPE,
2998            "application/vnd.hashtree.blossom.batch.v1"
2999                .parse()
3000                .expect("content type header value"),
3001        );
3002
3003        let first = (0u8..=255).collect::<Vec<_>>();
3004        let second = (0u8..=255).map(|byte| byte ^ 0xaa).collect::<Vec<_>>();
3005        let first_hash = sha256(&first);
3006        let second_hash = sha256(&second);
3007        let body = binary_batch_body(&[
3008            (&first, Some("application/octet-stream")),
3009            (&second, Some("application/octet-stream")),
3010        ]);
3011
3012        let first_hash_hex = hex::encode(first_hash);
3013        let client = axum::extract::ConnectInfo("127.0.0.1:12345".parse().unwrap());
3014        let miss = head_blob(
3015            State(state.clone()),
3016            Path(format!("{first_hash_hex}.bin")),
3017            client,
3018        )
3019        .await
3020        .into_response();
3021        assert_eq!(miss.status(), StatusCode::NOT_FOUND);
3022
3023        let response = upload_blob_batch_binary(State(state.clone()), headers, body)
3024            .await
3025            .into_response();
3026
3027        assert_eq!(response.status(), StatusCode::OK);
3028        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3029            .await
3030            .expect("read batch response");
3031        let parsed: BatchUploadResponse =
3032            serde_json::from_slice(&body).expect("parse batch response");
3033        assert_eq!(parsed.uploaded, 2);
3034        assert_eq!(parsed.blobs.len(), 2);
3035        assert_eq!(parsed.blobs[0].sha256, hex::encode(first_hash));
3036        assert_eq!(parsed.blobs[1].sha256, hex::encode(second_hash));
3037        assert!(store.blob_exists(&first_hash).expect("first exists"));
3038        assert!(store.blob_exists(&second_hash).expect("second exists"));
3039
3040        let immediate_head = head_blob(State(state), Path(format!("{first_hash_hex}.bin")), client)
3041            .await
3042            .into_response();
3043        assert_eq!(
3044            immediate_head.status(),
3045            StatusCode::OK,
3046            "a committed batch write must replace a cached preflight miss",
3047        );
3048    }
3049
3050    #[tokio::test]
3051    async fn upload_blob_batch_binary_accepts_compact_batch_auth() {
3052        let temp_dir = TempDir::new().expect("temp dir");
3053        let store = Arc::new(
3054            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
3055        );
3056        let state = test_app_state(Arc::clone(&store));
3057        let keys = nostr::Keys::generate();
3058        let first = (0u8..=255).collect::<Vec<_>>();
3059        let second = (0u8..=255).map(|byte| byte ^ 0xaa).collect::<Vec<_>>();
3060        let first_hash = sha256(&first);
3061        let second_hash = sha256(&second);
3062        let hashes = vec![hex::encode(first_hash), hex::encode(second_hash)];
3063        let mut headers = hosted_headers();
3064        headers.insert(
3065            header::AUTHORIZATION,
3066            create_batch_upload_auth_header(&keys, &hashes)
3067                .parse()
3068                .expect("auth header value"),
3069        );
3070        headers.insert(
3071            header::CONTENT_TYPE,
3072            "application/vnd.hashtree.blossom.batch.v1"
3073                .parse()
3074                .expect("content type header value"),
3075        );
3076        let body = binary_batch_body(&[
3077            (&first, Some("application/octet-stream")),
3078            (&second, Some("application/octet-stream")),
3079        ]);
3080
3081        let response = upload_blob_batch_binary(State(state), headers, body)
3082            .await
3083            .into_response();
3084
3085        assert_eq!(response.status(), StatusCode::OK);
3086        assert!(store.blob_exists(&first_hash).expect("first exists"));
3087        assert!(store.blob_exists(&second_hash).expect("second exists"));
3088    }
3089
3090    #[tokio::test]
3091    async fn upload_blob_batch_binary_rejects_mismatched_compact_batch_auth() {
3092        let temp_dir = TempDir::new().expect("temp dir");
3093        let store = Arc::new(
3094            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
3095        );
3096        let state = test_app_state(Arc::clone(&store));
3097        let keys = nostr::Keys::generate();
3098        let first = (0u8..=255).collect::<Vec<_>>();
3099        let second = (0u8..=255).map(|byte| byte ^ 0xaa).collect::<Vec<_>>();
3100        let wrong_hashes = vec![hex::encode(sha256(&first)), "00".repeat(32)];
3101        let mut headers = hosted_headers();
3102        headers.insert(
3103            header::AUTHORIZATION,
3104            create_batch_upload_auth_header(&keys, &wrong_hashes)
3105                .parse()
3106                .expect("auth header value"),
3107        );
3108        headers.insert(
3109            header::CONTENT_TYPE,
3110            "application/vnd.hashtree.blossom.batch.v1"
3111                .parse()
3112                .expect("content type header value"),
3113        );
3114        let body = binary_batch_body(&[
3115            (&first, Some("application/octet-stream")),
3116            (&second, Some("application/octet-stream")),
3117        ]);
3118
3119        let response = upload_blob_batch_binary(State(state), headers, body)
3120            .await
3121            .into_response();
3122
3123        assert_eq!(response.status(), StatusCode::FORBIDDEN);
3124        assert!(!store.blob_exists(&sha256(&first)).expect("first absent"));
3125        assert!(!store.blob_exists(&sha256(&second)).expect("second absent"));
3126    }
3127
3128    #[tokio::test]
3129    async fn upload_blob_batch_rejects_missing_auth_before_decoding_payload() {
3130        let temp_dir = TempDir::new().expect("temp dir");
3131        let store = Arc::new(
3132            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
3133        );
3134        let state = test_app_state(store);
3135        let payload = BatchUploadRequest {
3136            blobs: vec![BatchUploadBlob {
3137                sha256: "00".repeat(32),
3138                content_type: None,
3139                data: "not-base64".to_string(),
3140            }],
3141        };
3142
3143        let response = upload_blob_batch(State(state), HeaderMap::new(), Json(payload))
3144            .await
3145            .into_response();
3146
3147        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
3148    }
3149
3150    #[tokio::test]
3151    async fn upload_blob_batch_binary_rejects_missing_auth_before_parsing_body() {
3152        let temp_dir = TempDir::new().expect("temp dir");
3153        let store = Arc::new(
3154            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
3155        );
3156        let state = test_app_state(store);
3157
3158        let response = upload_blob_batch_binary(
3159            State(state),
3160            HeaderMap::new(),
3161            Bytes::from_static(b"not a binary batch"),
3162        )
3163        .await
3164        .into_response();
3165
3166        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
3167    }
3168
3169    #[tokio::test(flavor = "current_thread")]
3170    async fn upload_blob_batch_binary_replicates_only_new_blobs() {
3171        let _lock = test_env_lock().lock().await;
3172        let config_dir = TempDir::new().expect("config dir");
3173        let _guard = EnvVarGuard::set("HTREE_CONFIG_DIR", config_dir.path());
3174
3175        let first = (0u8..=255).collect::<Vec<_>>();
3176        let second = (0u8..=255).map(|byte| byte ^ 0x55).collect::<Vec<_>>();
3177        let first_hash = sha256(&first);
3178        let second_hash = sha256(&second);
3179        let second_hash_hex = hex::encode(second_hash);
3180
3181        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<String>>();
3182        let replica_router = Router::new().route(
3183            "/upload/batch-binary",
3184            post(move |body: Bytes| {
3185                let tx = tx.clone();
3186                async move {
3187                    let blobs = parse_binary_batch_upload(&body).expect("parse replica batch");
3188                    let hashes = blobs
3189                        .iter()
3190                        .map(|blob| blob.sha256.clone())
3191                        .collect::<Vec<_>>();
3192                    let _ = tx.send(hashes.clone());
3193                    Json(serde_json::json!({
3194                        "uploaded": hashes.len(),
3195                        "blobs": hashes.into_iter().map(|sha256| serde_json::json!({ "sha256": sha256 })).collect::<Vec<_>>(),
3196                    }))
3197                }
3198            }),
3199        );
3200        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
3201            .await
3202            .expect("bind replica");
3203        let replica_addr = listener.local_addr().expect("replica addr");
3204        let _server_task =
3205            tokio::spawn(async move { axum::serve(listener, replica_router).await.unwrap() });
3206
3207        let temp_dir = TempDir::new().expect("temp dir");
3208        let store = Arc::new(
3209            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
3210        );
3211        store
3212            .put_cached_blobs(&[(first_hash, first.clone())])
3213            .expect("prestore first blob");
3214        let mut state = test_app_state(Arc::clone(&store));
3215        state.require_random_untrusted_ingest = false;
3216        state.blossom_upload_replicas = vec![format!("http://{replica_addr}")];
3217        state.blossom_upload_replica_keys = Some(Arc::new(nostr::Keys::generate()));
3218
3219        let keys = nostr::Keys::generate();
3220        let mut headers = hosted_headers();
3221        headers.insert(
3222            header::AUTHORIZATION,
3223            create_upload_auth_header(&keys)
3224                .parse()
3225                .expect("auth header value"),
3226        );
3227        headers.insert(
3228            header::CONTENT_TYPE,
3229            "application/vnd.hashtree.blossom.batch.v1"
3230                .parse()
3231                .expect("content type header value"),
3232        );
3233        let body = binary_batch_body(&[
3234            (&first, Some("application/octet-stream")),
3235            (&second, Some("application/octet-stream")),
3236        ]);
3237
3238        let response = upload_blob_batch_binary(State(state), headers, body)
3239            .await
3240            .into_response();
3241
3242        assert_eq!(response.status(), StatusCode::OK);
3243        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3244            .await
3245            .expect("read batch response");
3246        let parsed: BatchUploadResponse =
3247            serde_json::from_slice(&body).expect("parse batch response");
3248        assert_eq!(parsed.uploaded, 1);
3249        let replicated = receive_replication(&mut rx).await;
3250        assert_eq!(replicated, vec![second_hash_hex]);
3251        assert!(
3252            tokio::time::timeout(Duration::from_millis(100), rx.recv())
3253                .await
3254                .is_err(),
3255            "duplicate blob should not trigger a second replication batch"
3256        );
3257    }
3258
3259    #[tokio::test(flavor = "current_thread")]
3260    async fn upload_replication_coalesces_adjacent_binary_batches() {
3261        let _lock = test_env_lock().lock().await;
3262        let config_dir = TempDir::new().expect("config dir");
3263        let _guard = EnvVarGuard::set("HTREE_CONFIG_DIR", config_dir.path());
3264        let _flush_guard = EnvVarGuard::set("HTREE_BLOSSOM_REPLICA_COALESCE_FLUSH_MS", "2000");
3265        let _blobs_guard = EnvVarGuard::set("HTREE_BLOSSOM_REPLICA_COALESCE_MAX_BLOBS", "8");
3266        let _bytes_guard = EnvVarGuard::set("HTREE_BLOSSOM_REPLICA_COALESCE_MAX_BYTES", "1048576");
3267
3268        let first_a = b"coalesced-replication-first-a".to_vec();
3269        let first_b = b"coalesced-replication-first-b".to_vec();
3270        let second_a = b"coalesced-replication-second-a".to_vec();
3271        let second_b = b"coalesced-replication-second-b".to_vec();
3272        let expected_hashes = [&first_a, &first_b, &second_a, &second_b]
3273            .into_iter()
3274            .map(|data| hex::encode(sha256(data)))
3275            .collect::<HashSet<_>>();
3276
3277        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<String>>();
3278        let replica_router = Router::new().route(
3279            "/upload/batch-binary",
3280            post(move |body: Bytes| {
3281                let tx = tx.clone();
3282                async move {
3283                    let blobs = parse_binary_batch_upload(&body).expect("parse replica batch");
3284                    let hashes = blobs
3285                        .iter()
3286                        .map(|blob| blob.sha256.clone())
3287                        .collect::<Vec<_>>();
3288                    let _ = tx.send(hashes.clone());
3289                    Json(serde_json::json!({
3290                        "uploaded": hashes.len(),
3291                        "blobs": hashes.into_iter().map(|sha256| serde_json::json!({ "sha256": sha256 })).collect::<Vec<_>>(),
3292                    }))
3293                }
3294            }),
3295        );
3296        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
3297            .await
3298            .expect("bind replica");
3299        let replica_addr = listener.local_addr().expect("replica addr");
3300        let _server_task =
3301            tokio::spawn(async move { axum::serve(listener, replica_router).await.unwrap() });
3302
3303        let temp_dir = TempDir::new().expect("temp dir");
3304        let store = Arc::new(
3305            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
3306        );
3307        let mut state = test_app_state(store);
3308        state.require_random_untrusted_ingest = false;
3309        state.blossom_upload_replicas = vec![format!("http://{replica_addr}")];
3310        state.blossom_upload_replica_keys = Some(Arc::new(nostr::Keys::generate()));
3311        let metrics_before = blossom_upload_replica_queue_snapshot(&state);
3312
3313        let keys = nostr::Keys::generate();
3314        let mut headers = hosted_headers();
3315        headers.insert(
3316            header::AUTHORIZATION,
3317            create_upload_auth_header(&keys)
3318                .parse()
3319                .expect("auth header value"),
3320        );
3321        headers.insert(
3322            header::CONTENT_TYPE,
3323            "application/vnd.hashtree.blossom.batch.v1"
3324                .parse()
3325                .expect("content type header value"),
3326        );
3327        let first_body = binary_batch_body(&[
3328            (&first_a, Some("application/octet-stream")),
3329            (&first_b, Some("application/octet-stream")),
3330        ]);
3331        let second_body = binary_batch_body(&[
3332            (&second_a, Some("application/octet-stream")),
3333            (&second_b, Some("application/octet-stream")),
3334        ]);
3335
3336        let first_response =
3337            upload_blob_batch_binary(State(state.clone()), headers.clone(), first_body)
3338                .await
3339                .into_response();
3340        assert_eq!(first_response.status(), StatusCode::OK);
3341        let second_response = upload_blob_batch_binary(State(state.clone()), headers, second_body)
3342            .await
3343            .into_response();
3344        assert_eq!(second_response.status(), StatusCode::OK);
3345
3346        let replicated = receive_replication(&mut rx).await;
3347        let replicated_hashes = replicated.into_iter().collect::<HashSet<_>>();
3348        assert_eq!(replicated_hashes, expected_hashes);
3349        assert!(
3350            tokio::time::timeout(Duration::from_millis(150), rx.recv())
3351                .await
3352                .is_err(),
3353            "adjacent batches should be merged into one replica request"
3354        );
3355        let metrics_after = blossom_upload_replica_queue_snapshot(&state);
3356        assert!(
3357            metrics_after.accepted_batches > metrics_before.accepted_batches,
3358            "coalesced replication should increment accepted batch metrics"
3359        );
3360        assert!(
3361            metrics_after.accepted_blobs >= metrics_before.accepted_blobs + 4,
3362            "coalesced replication should increment accepted blob metrics"
3363        );
3364    }
3365
3366    #[test]
3367    fn binary_batch_parser_rejects_trailing_bytes() {
3368        let data = (0u8..=255).collect::<Vec<_>>();
3369        let mut body = binary_batch_body(&[(&data, None)]).to_vec();
3370        body.push(0);
3371
3372        let error = parse_binary_batch_upload(&body).expect_err("trailing bytes rejected");
3373
3374        assert_eq!(error.0, StatusCode::BAD_REQUEST);
3375        assert!(error.1.contains("trailing"));
3376    }
3377
3378    #[tokio::test]
3379    async fn head_upload_accepts_valid_bud06_preflight() {
3380        let temp_dir = TempDir::new().expect("temp dir");
3381        let store = Arc::new(
3382            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
3383        );
3384        let state = test_app_state(store);
3385        let mut headers = hosted_headers();
3386        headers.insert("x-sha-256", "00".repeat(32).parse().unwrap());
3387        headers.insert("x-content-length", "16".parse().unwrap());
3388        headers.insert(
3389            "x-content-type",
3390            "application/octet-stream".parse().unwrap(),
3391        );
3392
3393        let response = head_upload(State(state), headers).await.into_response();
3394
3395        assert_eq!(response.status(), StatusCode::OK);
3396    }
3397
3398    #[tokio::test]
3399    async fn upload_blob_returns_bud02_statuses_and_public_descriptor_url() {
3400        let temp_dir = TempDir::new().expect("temp dir");
3401        let store = Arc::new(
3402            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
3403        );
3404        let state = test_app_state(store);
3405        let keys = nostr::Keys::generate();
3406        let mut headers = hosted_headers();
3407        headers.insert(
3408            header::AUTHORIZATION,
3409            create_upload_auth_header(&keys)
3410                .parse()
3411                .expect("auth header value"),
3412        );
3413        headers.insert(
3414            header::CONTENT_TYPE,
3415            "application/octet-stream"
3416                .parse()
3417                .expect("content type header value"),
3418        );
3419
3420        let body = axum::body::Bytes::from((0u8..=255).collect::<Vec<_>>());
3421        let hash_hex = hex::encode(sha256(&body));
3422        let first = upload_blob(State(state.clone()), headers.clone(), body.clone())
3423            .await
3424            .into_response();
3425        assert_eq!(first.status(), StatusCode::CREATED);
3426        let first_descriptor = read_descriptor(first).await;
3427        assert_eq!(
3428            first_descriptor.url,
3429            format!("https://cdn.iris.to/{hash_hex}.bin")
3430        );
3431        assert_eq!(first_descriptor.sha256, hash_hex);
3432
3433        let second = upload_blob(State(state), headers, body)
3434            .await
3435            .into_response();
3436        assert_eq!(second.status(), StatusCode::OK);
3437        let second_descriptor = read_descriptor(second).await;
3438        assert_eq!(second_descriptor.url, first_descriptor.url);
3439    }
3440
3441    #[tokio::test(flavor = "current_thread")]
3442    async fn upload_blob_replicates_to_configured_blossom_target() {
3443        let _lock = test_env_lock().lock().await;
3444        let config_dir = TempDir::new().expect("config dir");
3445        let _guard = EnvVarGuard::set("HTREE_CONFIG_DIR", config_dir.path());
3446
3447        let data = Bytes::from_static(b"write-behind-replication-data");
3448        let expected_hash = hex::encode(sha256(data.as_ref()));
3449        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<usize>();
3450        let response_hash = expected_hash.clone();
3451        let replica_router = Router::new().route(
3452            "/upload/batch-binary",
3453            post(move |body: Bytes| {
3454                let tx = tx.clone();
3455                let response_hash = response_hash.clone();
3456                async move {
3457                    let _ = tx.send(body.len());
3458                    Json(serde_json::json!({
3459                        "uploaded": 1,
3460                        "blobs": [{"sha256": response_hash}],
3461                    }))
3462                }
3463            }),
3464        );
3465        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
3466            .await
3467            .expect("bind replica");
3468        let replica_addr = listener.local_addr().expect("replica addr");
3469        let _server_task =
3470            tokio::spawn(async move { axum::serve(listener, replica_router).await.unwrap() });
3471
3472        let temp = TempDir::new().expect("tempdir");
3473        let store = Arc::new(HashtreeStore::new(temp.path()).expect("store"));
3474        let mut state = test_app_state(store);
3475        state.require_random_untrusted_ingest = false;
3476        state.blossom_upload_replicas = vec![format!("http://{replica_addr}")];
3477        state.blossom_upload_replica_keys = Some(Arc::new(nostr::Keys::generate()));
3478
3479        let keys = nostr::Keys::generate();
3480        let mut headers = HeaderMap::new();
3481        headers.insert(
3482            header::AUTHORIZATION,
3483            create_upload_auth_header(&keys).parse().unwrap(),
3484        );
3485        headers.insert(
3486            header::CONTENT_TYPE,
3487            "application/octet-stream".parse().unwrap(),
3488        );
3489
3490        let response = upload_blob(State(state), headers, data)
3491            .await
3492            .into_response();
3493        assert_eq!(response.status(), StatusCode::CREATED);
3494        let replicated = receive_replication(&mut rx).await;
3495        assert!(replicated > 0);
3496        assert_eq!(expected_hash.len(), 64);
3497    }
3498
3499    #[tokio::test(flavor = "current_thread")]
3500    async fn upload_blob_duplicate_does_not_replicate_to_configured_blossom_target() {
3501        let _lock = test_env_lock().lock().await;
3502        let config_dir = TempDir::new().expect("config dir");
3503        let _guard = EnvVarGuard::set("HTREE_CONFIG_DIR", config_dir.path());
3504
3505        let data = Bytes::from_static(b"write-behind-duplicate-raw-data");
3506        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<usize>();
3507        let response_hash = hex::encode(sha256(data.as_ref()));
3508        let replica_router = Router::new().route(
3509            "/upload/batch-binary",
3510            post(move |body: Bytes| {
3511                let tx = tx.clone();
3512                let response_hash = response_hash.clone();
3513                async move {
3514                    let _ = tx.send(body.len());
3515                    Json(serde_json::json!({
3516                        "uploaded": 1,
3517                        "blobs": [{"sha256": response_hash}],
3518                    }))
3519                }
3520            }),
3521        );
3522        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
3523            .await
3524            .expect("bind replica");
3525        let replica_addr = listener.local_addr().expect("replica addr");
3526        let _server_task =
3527            tokio::spawn(async move { axum::serve(listener, replica_router).await.unwrap() });
3528
3529        let temp = TempDir::new().expect("tempdir");
3530        let store = Arc::new(HashtreeStore::new(temp.path()).expect("store"));
3531        store.put_cached_blob(&data).expect("seed duplicate blob");
3532        let mut state = test_app_state(store);
3533        state.require_random_untrusted_ingest = false;
3534        state.blossom_upload_replicas = vec![format!("http://{replica_addr}")];
3535        state.blossom_upload_replica_keys = Some(Arc::new(nostr::Keys::generate()));
3536
3537        let keys = nostr::Keys::generate();
3538        let mut headers = HeaderMap::new();
3539        headers.insert(
3540            header::AUTHORIZATION,
3541            create_upload_auth_header(&keys).parse().unwrap(),
3542        );
3543        headers.insert(
3544            header::CONTENT_TYPE,
3545            "application/octet-stream".parse().unwrap(),
3546        );
3547
3548        let response = upload_blob(State(state), headers, data)
3549            .await
3550            .into_response();
3551        assert_eq!(response.status(), StatusCode::OK);
3552        assert!(
3553            tokio::time::timeout(Duration::from_millis(100), rx.recv())
3554                .await
3555                .is_err(),
3556            "duplicate raw upload should not trigger write-behind replication"
3557        );
3558    }
3559
3560    #[tokio::test]
3561    async fn list_blobs_returns_public_descriptor_urls_with_extensions() {
3562        let temp_dir = TempDir::new().expect("temp dir");
3563        let store = Arc::new(
3564            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
3565        );
3566        let keys = nostr::Keys::generate();
3567        let pubkey_hex = keys.public_key().to_hex();
3568        let pubkey_bytes: [u8; 32] = from_hex(&pubkey_hex).expect("pubkey bytes");
3569        let body = (0u8..=255).collect::<Vec<_>>();
3570        let hash_hex = store
3571            .put_owned_blob(&body, &pubkey_bytes)
3572            .expect("store owned blob");
3573        let state = test_app_state(store);
3574        let mut headers = hosted_headers();
3575        headers.insert(
3576            header::AUTHORIZATION,
3577            create_list_auth_header(&keys)
3578                .parse()
3579                .expect("auth header value"),
3580        );
3581
3582        let response = list_blobs(
3583            State(state),
3584            Path(pubkey_hex),
3585            Query(ListQuery {
3586                since: None,
3587                until: None,
3588                limit: None,
3589                cursor: None,
3590            }),
3591            headers,
3592        )
3593        .await
3594        .into_response();
3595
3596        assert_eq!(response.status(), StatusCode::OK);
3597        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3598            .await
3599            .expect("read list body");
3600        let descriptors: Vec<BlobDescriptor> =
3601            serde_json::from_slice(&body).expect("parse descriptor list");
3602        assert_eq!(descriptors.len(), 1);
3603        assert_eq!(
3604            descriptors[0].url,
3605            format!("https://cdn.iris.to/{hash_hex}.bin")
3606        );
3607    }
3608
3609    #[tokio::test]
3610    async fn optimistic_uploads_return_accepted_and_store_in_background() {
3611        let temp_dir = TempDir::new().expect("temp dir");
3612        let store = Arc::new(
3613            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
3614        );
3615        let mut state = test_app_state(Arc::clone(&store));
3616        state.optimistic_blossom_uploads = true;
3617
3618        let keys = nostr::Keys::generate();
3619        let mut headers = HeaderMap::new();
3620        headers.insert(
3621            header::AUTHORIZATION,
3622            create_upload_auth_header(&keys)
3623                .parse()
3624                .expect("auth header value"),
3625        );
3626        headers.insert(
3627            header::CONTENT_TYPE,
3628            "application/octet-stream"
3629                .parse()
3630                .expect("content type header value"),
3631        );
3632
3633        let body = axum::body::Bytes::from((0u8..=255).map(|byte| byte ^ 0x55).collect::<Vec<_>>());
3634        let hash = sha256(&body);
3635        let response = upload_blob(State(state), headers, body)
3636            .await
3637            .into_response();
3638        assert_eq!(response.status(), StatusCode::ACCEPTED);
3639
3640        for _ in 0..50 {
3641            if store.blob_exists(&hash).expect("blob exists check") {
3642                return;
3643            }
3644            tokio::time::sleep(Duration::from_millis(10)).await;
3645        }
3646
3647        panic!("optimistic upload was not stored in the background");
3648    }
3649
3650    #[tokio::test]
3651    async fn optimistic_upload_existing_blob_skips_queue() {
3652        let temp_dir = TempDir::new().expect("temp dir");
3653        let store = Arc::new(
3654            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
3655        );
3656        let body = axum::body::Bytes::from(
3657            (0u16..=255)
3658                .map(|value| ((value * 73 + 19) % 256) as u8)
3659                .collect::<Vec<_>>(),
3660        );
3661        store.put_cached_blob(&body).expect("seed blob");
3662
3663        let mut state = test_app_state(Arc::clone(&store));
3664        state.optimistic_blossom_uploads = true;
3665        state.optimistic_upload_queue_bytes = 1;
3666        state.optimistic_upload_queue = Arc::new(tokio::sync::Semaphore::new(1));
3667
3668        let keys = nostr::Keys::generate();
3669        let mut headers = HeaderMap::new();
3670        headers.insert(
3671            header::AUTHORIZATION,
3672            create_upload_auth_header(&keys)
3673                .parse()
3674                .expect("auth header value"),
3675        );
3676        headers.insert(
3677            header::CONTENT_TYPE,
3678            "application/octet-stream"
3679                .parse()
3680                .expect("content type header value"),
3681        );
3682
3683        let response = upload_blob(State(state), headers, body)
3684            .await
3685            .into_response();
3686        assert_eq!(response.status(), StatusCode::OK);
3687    }
3688
3689    #[tokio::test]
3690    async fn optimistic_upload_existing_blob_uses_queue_before_preflight_when_queue_has_room() {
3691        let temp_dir = TempDir::new().expect("temp dir");
3692        let store = Arc::new(
3693            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
3694        );
3695        let body = axum::body::Bytes::from((0u8..=255).rev().collect::<Vec<_>>());
3696        let hash_hex = hex::encode(sha256(&body));
3697        store.put_cached_blob(&body).expect("seed blob");
3698
3699        let mut state = test_app_state(store);
3700        state.optimistic_blossom_uploads = true;
3701
3702        let keys = nostr::Keys::generate();
3703        let mut headers = HeaderMap::new();
3704        headers.insert(
3705            header::AUTHORIZATION,
3706            create_upload_auth_header(&keys)
3707                .parse()
3708                .expect("auth header value"),
3709        );
3710        headers.insert(
3711            header::CONTENT_TYPE,
3712            "application/octet-stream"
3713                .parse()
3714                .expect("content type header value"),
3715        );
3716
3717        let response = upload_blob(State(state), headers, body)
3718            .await
3719            .into_response();
3720        assert_eq!(response.status(), StatusCode::ACCEPTED);
3721
3722        for _ in 0..50 {
3723            if !optimistic_upload_is_inflight(&hash_hex) {
3724                return;
3725            }
3726            tokio::time::sleep(Duration::from_millis(10)).await;
3727        }
3728
3729        clear_optimistic_upload_inflight(&hash_hex);
3730        panic!("optimistic upload in-flight marker was not cleared");
3731    }
3732
3733    #[tokio::test]
3734    async fn optimistic_upload_existing_blob_does_not_replicate_duplicate() {
3735        let _lock = test_env_lock().lock().await;
3736        let config_dir = TempDir::new().expect("config dir");
3737        let _guard = EnvVarGuard::set("HTREE_CONFIG_DIR", config_dir.path());
3738
3739        let temp_dir = TempDir::new().expect("temp dir");
3740        let store = Arc::new(
3741            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
3742        );
3743        let body = axum::body::Bytes::from((0u8..=255).map(|byte| byte ^ 0xaa).collect::<Vec<_>>());
3744        let hash_hex = hex::encode(sha256(&body));
3745        store.put_cached_blob(&body).expect("seed blob");
3746
3747        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<usize>();
3748        let response_hash = hash_hex.clone();
3749        let replica_router = Router::new().route(
3750            "/upload/batch-binary",
3751            post(move |body: Bytes| {
3752                let tx = tx.clone();
3753                let response_hash = response_hash.clone();
3754                async move {
3755                    let _ = tx.send(body.len());
3756                    Json(serde_json::json!({
3757                        "uploaded": 1,
3758                        "blobs": [{"sha256": response_hash}],
3759                    }))
3760                }
3761            }),
3762        );
3763        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
3764            .await
3765            .expect("bind replica");
3766        let replica_addr = listener.local_addr().expect("replica addr");
3767        let _server_task =
3768            tokio::spawn(async move { axum::serve(listener, replica_router).await.unwrap() });
3769
3770        let mut state = test_app_state(store);
3771        state.optimistic_blossom_uploads = true;
3772        state.require_random_untrusted_ingest = false;
3773        state.blossom_upload_replicas = vec![format!("http://{replica_addr}")];
3774        state.blossom_upload_replica_keys = Some(Arc::new(nostr::Keys::generate()));
3775
3776        let keys = nostr::Keys::generate();
3777        let mut headers = HeaderMap::new();
3778        headers.insert(
3779            header::AUTHORIZATION,
3780            create_upload_auth_header(&keys)
3781                .parse()
3782                .expect("auth header value"),
3783        );
3784        headers.insert(
3785            header::CONTENT_TYPE,
3786            "application/octet-stream"
3787                .parse()
3788                .expect("content type header value"),
3789        );
3790
3791        let response = upload_blob(State(state), headers, body)
3792            .await
3793            .into_response();
3794        assert_eq!(response.status(), StatusCode::ACCEPTED);
3795
3796        for _ in 0..50 {
3797            if !optimistic_upload_is_inflight(&hash_hex) {
3798                break;
3799            }
3800            tokio::time::sleep(Duration::from_millis(10)).await;
3801        }
3802        clear_optimistic_upload_inflight(&hash_hex);
3803
3804        assert!(
3805            tokio::time::timeout(Duration::from_millis(100), rx.recv())
3806                .await
3807                .is_err(),
3808            "optimistic duplicate upload should not trigger write-behind replication"
3809        );
3810    }
3811
3812    #[tokio::test]
3813    async fn optimistic_upload_inflight_duplicate_skips_queue() {
3814        let temp_dir = TempDir::new().expect("temp dir");
3815        let store = Arc::new(
3816            HashtreeStore::with_options(temp_dir.path(), None, 128 * 1024 * 1024).expect("store"),
3817        );
3818        let body = axum::body::Bytes::from((0u8..=255).collect::<Vec<_>>());
3819        let hash_hex = hex::encode(sha256(&body));
3820        assert!(mark_optimistic_upload_inflight(&hash_hex));
3821
3822        let mut state = test_app_state(store);
3823        state.optimistic_blossom_uploads = true;
3824        state.optimistic_upload_queue_bytes = 1;
3825        state.optimistic_upload_queue = Arc::new(tokio::sync::Semaphore::new(1));
3826
3827        let keys = nostr::Keys::generate();
3828        let mut headers = HeaderMap::new();
3829        headers.insert(
3830            header::AUTHORIZATION,
3831            create_upload_auth_header(&keys)
3832                .parse()
3833                .expect("auth header value"),
3834        );
3835        headers.insert(
3836            header::CONTENT_TYPE,
3837            "application/octet-stream"
3838                .parse()
3839                .expect("content type header value"),
3840        );
3841
3842        let response = upload_blob(State(state), headers, body)
3843            .await
3844            .into_response();
3845        clear_optimistic_upload_inflight(&hash_hex);
3846        assert_eq!(response.status(), StatusCode::ACCEPTED);
3847    }
3848
3849    #[test]
3850    fn public_writes_accept_unlisted_authors_for_uploads() {
3851        let temp_dir = TempDir::new().expect("temp dir");
3852        let store =
3853            Arc::new(HashtreeStore::with_options(temp_dir.path(), None, 700).expect("store"));
3854        let mut state = test_app_state(store);
3855        let pubkey = "ea4fe79e57f209309bffed2f92f0b95b59d3d1cb4e8892444398aeea7ee317ed";
3856
3857        state.public_writes = true;
3858        assert!(can_accept_upload_author(&state, pubkey));
3859        assert!(!is_allowed_write_author(&state, pubkey));
3860
3861        state.public_writes = false;
3862        assert!(!can_accept_upload_author(&state, pubkey));
3863    }
3864
3865    #[test]
3866    fn public_write_trust_allows_octet_stream_and_raw_media_payloads() {
3867        let encrypted_block: Vec<u8> = (0..=255).collect();
3868
3869        assert_eq!(
3870            validate_upload_payload(&encrypted_block, "application/octet-stream", false, true,),
3871            Ok(())
3872        );
3873
3874        assert_eq!(
3875            validate_upload_payload(b"audio bytes", "audio/mpeg", true, true,),
3876            Ok(())
3877        );
3878
3879        assert_eq!(
3880            validate_upload_payload(b"audio bytes", "audio/mpeg", false, true,),
3881            Err((
3882                StatusCode::FORBIDDEN,
3883                "Raw media uploads require write access".to_string(),
3884            ))
3885        );
3886    }
3887
3888    #[test]
3889    fn authenticated_chk_uploads_skip_entropy_heuristic() {
3890        let low_unique_block: Vec<u8> = (0..256).map(|i| (i % 139) as u8).collect();
3891
3892        assert_eq!(
3893            validate_upload_payload(&low_unique_block, "application/octet-stream", true, true,),
3894            Ok(())
3895        );
3896
3897        assert_eq!(
3898            validate_upload_payload(&low_unique_block, "application/octet-stream", false, true,),
3899            Err((
3900                StatusCode::UNSUPPORTED_MEDIA_TYPE,
3901                "Data not encrypted. Unique: 139 (min: 140)".to_string(),
3902            ))
3903        );
3904    }
3905
3906    #[test]
3907    fn unowned_public_uploads_use_cache_storage_semantics() {
3908        let temp_dir = TempDir::new().expect("temp dir");
3909        let store =
3910            Arc::new(HashtreeStore::with_options(temp_dir.path(), None, 700).expect("store"));
3911        let state = test_app_state(Arc::clone(&store));
3912
3913        let owned = vec![1u8; 280];
3914        let owned_hash = sha256(&owned);
3915        store_blossom_blob(&state, &owned, &owned_hash, &[2u8; 32], true).expect("owned upload");
3916
3917        let public_upload = vec![3u8; 280];
3918        let public_hash = sha256(&public_upload);
3919        store_blossom_blob(&state, &public_upload, &public_hash, &[4u8; 32], false)
3920            .expect("public upload");
3921
3922        let replacement = vec![5u8; 280];
3923        let replacement_hash = sha256(&replacement);
3924        state
3925            .store
3926            .put_cached_blob(&replacement)
3927            .expect("replacement cached blob");
3928
3929        assert!(state.store.blob_exists(&owned_hash).expect("owned exists"));
3930        assert!(!state
3931            .store
3932            .blob_exists(&public_hash)
3933            .expect("public upload evicted"));
3934        assert!(state
3935            .store
3936            .blob_exists(&replacement_hash)
3937            .expect("replacement exists"));
3938        assert!(state
3939            .store
3940            .is_blob_owner(&owned_hash, &[2u8; 32])
3941            .expect("owned tracked"));
3942        assert!(!state
3943            .store
3944            .blob_has_owners(&public_hash)
3945            .expect("public upload unowned"));
3946    }
3947
3948    #[test]
3949    fn owned_blossom_uploads_are_rejected_when_storage_limit_is_full() {
3950        let temp_dir = TempDir::new().expect("temp dir");
3951        let store =
3952            Arc::new(HashtreeStore::with_options(temp_dir.path(), None, 500).expect("store"));
3953        let state = test_app_state(Arc::clone(&store));
3954
3955        let first = vec![1u8; 300];
3956        let first_hash = sha256(&first);
3957        let owner = [2u8; 32];
3958        store_blossom_blob(&state, &first, &first_hash, &owner, true).expect("first upload");
3959
3960        let second = vec![3u8; 300];
3961        let second_hash = sha256(&second);
3962        let error = store_blossom_blob(&state, &second, &second_hash, &owner, true)
3963            .expect_err("second owned upload should exceed the storage limit");
3964
3965        assert!(
3966            error.to_string().contains("storage limit"),
3967            "unexpected error: {error}"
3968        );
3969        assert!(state
3970            .store
3971            .blob_exists(&first_hash)
3972            .expect("first blob remains"));
3973        assert!(!state
3974            .store
3975            .blob_exists(&second_hash)
3976            .expect("second blob rejected"));
3977        assert!(state
3978            .store
3979            .is_blob_owner(&first_hash, &owner)
3980            .expect("first owner tracked"));
3981        assert!(!state
3982            .store
3983            .is_blob_owner(&second_hash, &owner)
3984            .expect("second owner not tracked"));
3985    }
3986}