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