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