1use 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
38const BLOSSOM_AUTH_KIND: u16 = 24242;
40
41const 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
50pub 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
283pub 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#[allow(clippy::result_large_err)]
390fn check_write_access(state: &AppState, pubkey: &str) -> Result<(), Response<Body>> {
391 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 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#[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#[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#[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>, pub blob_hashes: Vec<String>, pub batch_hashes: Vec<String>, pub server: Option<String>, }
597
598pub 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 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 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 if !verify_nostr_signature(&event_json, &pubkey, sig) {
651 return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
652 }
653
654 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 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 if created_at > now + 60 {
698 return Err((StatusCode::BAD_REQUEST, "Event created_at is in the future"));
699 }
700
701 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 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
729fn verify_nostr_signature(event: &serde_json::Value, pubkey: &str, sig: &str) -> bool {
731 use secp256k1::{schnorr::Signature, Message, Secp256k1, XOnlyPublicKey};
732
733 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 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
780fn 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
799pub async fn cors_preflight(headers: HeaderMap) -> impl IntoResponse {
802 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 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
827pub 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
915pub 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
1011pub 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 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
1715pub async fn upload_blob(
1717 State(state): State<AppState>,
1718 headers: HeaderMap,
1719 body: axum::body::Bytes,
1720) -> impl IntoResponse {
1721 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 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 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 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 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.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 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 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
2306pub 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
2328pub 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
2342pub 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 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 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 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 match state.store.is_blob_owner(&sha256_bytes, &pubkey_bytes) {
2403 Ok(true) => {
2404 }
2406 Ok(false) => {
2407 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 match state
2446 .store
2447 .delete_blossom_blob(&sha256_bytes, &pubkey_bytes)
2448 {
2449 Ok(fully_deleted) => {
2450 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
2470pub 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 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 match state.store.list_blobs_by_pubkey(&pubkey_bytes) {
2527 Ok(blobs) => {
2528 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 filtered.sort_by(|a, b| b.uploaded.cmp(&a.uploaded));
2548
2549 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
2578fn 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 assert!(!is_valid_sha256("e2bab35b5296ec2242ded0a01f6d6723"));
2818 assert!(!is_valid_sha256(
2820 "e2bab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6aa"
2821 ));
2822 assert!(!is_valid_sha256(
2824 "zzbab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6"
2825 ));
2826 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}