1use axum::{
7 body::Body,
8 extract::{Path, Query, State},
9 http::{header, HeaderMap, Response, StatusCode},
10 response::IntoResponse,
11};
12use base64::Engine;
13use hashtree_core::from_hex;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use super::auth::AppState;
19use super::mime::get_mime_type;
20
21const BLOSSOM_AUTH_KIND: u16 = 24242;
23
24const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
26
27pub const DEFAULT_MAX_UPLOAD_SIZE: usize = 5 * 1024 * 1024;
29
30#[allow(clippy::result_large_err)]
33fn check_write_access(state: &AppState, pubkey: &str) -> Result<(), Response<Body>> {
34 if state.allowed_pubkeys.contains(pubkey) {
36 tracing::debug!(
37 "Blossom write allowed for {}... (allowed npub)",
38 &pubkey[..8.min(pubkey.len())]
39 );
40 return Ok(());
41 }
42
43 if let Some(ref sg) = state.social_graph {
45 if sg.check_write_access(pubkey) {
46 tracing::debug!(
47 "Blossom write allowed for {}... (social graph)",
48 &pubkey[..8.min(pubkey.len())]
49 );
50 return Ok(());
51 }
52 }
53
54 tracing::info!(
56 "Blossom write denied for {}... (not in allowed_npubs or social graph)",
57 &pubkey[..8.min(pubkey.len())]
58 );
59 Err(Response::builder()
60 .status(StatusCode::FORBIDDEN)
61 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
62 .header(header::CONTENT_TYPE, "application/json")
63 .body(Body::from(
64 r#"{"error":"Write access denied. Your pubkey is not in the allowed list."}"#,
65 ))
66 .unwrap())
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct BlobDescriptor {
72 pub url: String,
73 pub sha256: String,
74 pub size: u64,
75 #[serde(rename = "type")]
76 pub mime_type: String,
77 pub uploaded: u64,
78}
79
80#[derive(Debug, Deserialize)]
82pub struct ListQuery {
83 pub since: Option<u64>,
84 pub until: Option<u64>,
85 pub limit: Option<usize>,
86 pub cursor: Option<String>,
87}
88
89#[derive(Debug)]
91pub struct BlossomAuth {
92 pub pubkey: String,
93 pub kind: u16,
94 pub created_at: u64,
95 pub expiration: Option<u64>,
96 pub action: Option<String>, pub blob_hashes: Vec<String>, pub server: Option<String>, }
100
101pub fn verify_blossom_auth(
104 headers: &HeaderMap,
105 required_action: &str,
106 required_hash: Option<&str>,
107) -> Result<BlossomAuth, (StatusCode, &'static str)> {
108 let auth_header = headers
109 .get(header::AUTHORIZATION)
110 .and_then(|v| v.to_str().ok())
111 .ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
112
113 let nostr_event = auth_header.strip_prefix("Nostr ").ok_or((
114 StatusCode::UNAUTHORIZED,
115 "Invalid auth scheme, expected 'Nostr'",
116 ))?;
117
118 let engine = base64::engine::general_purpose::STANDARD;
120 let event_bytes = engine
121 .decode(nostr_event)
122 .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid base64 in auth header"))?;
123
124 let event_json: serde_json::Value = serde_json::from_slice(&event_bytes)
125 .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid JSON in auth event"))?;
126
127 let kind = event_json["kind"]
129 .as_u64()
130 .ok_or((StatusCode::BAD_REQUEST, "Missing kind in event"))?;
131
132 if kind != BLOSSOM_AUTH_KIND as u64 {
133 return Err((
134 StatusCode::BAD_REQUEST,
135 "Invalid event kind, expected 24242",
136 ));
137 }
138
139 let pubkey = event_json["pubkey"]
140 .as_str()
141 .ok_or((StatusCode::BAD_REQUEST, "Missing pubkey in event"))?
142 .to_string();
143
144 let created_at = event_json["created_at"]
145 .as_u64()
146 .ok_or((StatusCode::BAD_REQUEST, "Missing created_at in event"))?;
147
148 let sig = event_json["sig"]
149 .as_str()
150 .ok_or((StatusCode::BAD_REQUEST, "Missing signature in event"))?;
151
152 if !verify_nostr_signature(&event_json, &pubkey, sig) {
154 return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
155 }
156
157 let tags = event_json["tags"]
159 .as_array()
160 .ok_or((StatusCode::BAD_REQUEST, "Missing tags in event"))?;
161
162 let mut expiration: Option<u64> = None;
163 let mut action: Option<String> = None;
164 let mut blob_hashes: Vec<String> = Vec::new();
165 let mut server: Option<String> = None;
166
167 for tag in tags {
168 let tag_arr = tag.as_array();
169 if let Some(arr) = tag_arr {
170 if arr.len() >= 2 {
171 let tag_name = arr[0].as_str().unwrap_or("");
172 let tag_value = arr[1].as_str().unwrap_or("");
173
174 match tag_name {
175 "t" => action = Some(tag_value.to_string()),
176 "x" => blob_hashes.push(tag_value.to_lowercase()),
177 "expiration" => expiration = tag_value.parse().ok(),
178 "server" => server = Some(tag_value.to_string()),
179 _ => {}
180 }
181 }
182 }
183 }
184
185 let now = SystemTime::now()
187 .duration_since(UNIX_EPOCH)
188 .unwrap()
189 .as_secs();
190
191 if let Some(exp) = expiration {
192 if exp < now {
193 return Err((StatusCode::UNAUTHORIZED, "Authorization expired"));
194 }
195 }
196
197 if created_at > now + 60 {
199 return Err((StatusCode::BAD_REQUEST, "Event created_at is in the future"));
200 }
201
202 if let Some(ref act) = action {
204 if act != required_action {
205 return Err((StatusCode::FORBIDDEN, "Action mismatch"));
206 }
207 } else {
208 return Err((StatusCode::BAD_REQUEST, "Missing 't' tag for action"));
209 }
210
211 if let Some(hash) = required_hash {
213 if !blob_hashes.is_empty() && !blob_hashes.contains(&hash.to_lowercase()) {
214 return Err((StatusCode::FORBIDDEN, "Blob hash not authorized"));
215 }
216 }
217
218 Ok(BlossomAuth {
219 pubkey,
220 kind: kind as u16,
221 created_at,
222 expiration,
223 action,
224 blob_hashes,
225 server,
226 })
227}
228
229fn verify_nostr_signature(event: &serde_json::Value, pubkey: &str, sig: &str) -> bool {
231 use secp256k1::{schnorr::Signature, Message, Secp256k1, XOnlyPublicKey};
232
233 let content = event["content"].as_str().unwrap_or("");
235 let full_serialized = format!(
236 "[0,\"{}\",{},{},{},\"{}\"]",
237 pubkey,
238 event["created_at"],
239 event["kind"],
240 event["tags"],
241 escape_json_string(content),
242 );
243
244 let mut hasher = Sha256::new();
245 hasher.update(full_serialized.as_bytes());
246 let event_id = hasher.finalize();
247
248 let pubkey_bytes = match hex::decode(pubkey) {
250 Ok(b) => b,
251 Err(_) => return false,
252 };
253
254 let sig_bytes = match hex::decode(sig) {
255 Ok(b) => b,
256 Err(_) => return false,
257 };
258
259 let secp = Secp256k1::verification_only();
260
261 let xonly_pubkey = match XOnlyPublicKey::from_slice(&pubkey_bytes) {
262 Ok(pk) => pk,
263 Err(_) => return false,
264 };
265
266 let signature = match Signature::from_slice(&sig_bytes) {
267 Ok(s) => s,
268 Err(_) => return false,
269 };
270
271 let message = match Message::from_digest_slice(&event_id) {
272 Ok(m) => m,
273 Err(_) => return false,
274 };
275
276 secp.verify_schnorr(&signature, &message, &xonly_pubkey)
277 .is_ok()
278}
279
280fn escape_json_string(s: &str) -> String {
282 let mut result = String::new();
283 for c in s.chars() {
284 match c {
285 '"' => result.push_str("\\\""),
286 '\\' => result.push_str("\\\\"),
287 '\n' => result.push_str("\\n"),
288 '\r' => result.push_str("\\r"),
289 '\t' => result.push_str("\\t"),
290 c if c.is_control() => {
291 result.push_str(&format!("\\u{:04x}", c as u32));
292 }
293 c => result.push(c),
294 }
295 }
296 result
297}
298
299pub async fn cors_preflight(headers: HeaderMap) -> impl IntoResponse {
302 let allowed_headers = headers
304 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
305 .and_then(|v| v.to_str().ok())
306 .unwrap_or("Authorization, Content-Type, X-SHA-256, x-sha-256");
307
308 let full_allowed = format!(
310 "{}, Authorization, Content-Type, X-SHA-256, x-sha-256, Accept, Cache-Control",
311 allowed_headers
312 );
313
314 Response::builder()
315 .status(StatusCode::NO_CONTENT)
316 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
317 .header(
318 header::ACCESS_CONTROL_ALLOW_METHODS,
319 "GET, HEAD, PUT, DELETE, OPTIONS",
320 )
321 .header(header::ACCESS_CONTROL_ALLOW_HEADERS, full_allowed)
322 .header(header::ACCESS_CONTROL_MAX_AGE, "86400")
323 .body(Body::empty())
324 .unwrap()
325}
326
327pub async fn head_blob(
329 State(state): State<AppState>,
330 Path(id): Path<String>,
331 connect_info: axum::extract::ConnectInfo<std::net::SocketAddr>,
332) -> impl IntoResponse {
333 let is_localhost = connect_info.0.ip().is_loopback();
334 let (hash_part, ext) = parse_hash_and_extension(&id);
335
336 if !is_valid_sha256(hash_part) {
337 return Response::builder()
338 .status(StatusCode::BAD_REQUEST)
339 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
340 .header("X-Reason", "Invalid SHA256 hash")
341 .body(Body::empty())
342 .unwrap();
343 }
344
345 let sha256_hex = hash_part.to_lowercase();
346 let sha256_bytes: [u8; 32] = match from_hex(&sha256_hex) {
347 Ok(b) => b,
348 Err(_) => {
349 return Response::builder()
350 .status(StatusCode::BAD_REQUEST)
351 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
352 .header("X-Reason", "Invalid SHA256 format")
353 .body(Body::empty())
354 .unwrap()
355 }
356 };
357
358 match state.store.get_blob(&sha256_bytes) {
360 Ok(Some(data)) => {
361 let mime_type = ext
362 .map(|e| get_mime_type(&format!("file{}", e)))
363 .unwrap_or("application/octet-stream");
364
365 let mut builder = Response::builder()
366 .status(StatusCode::OK)
367 .header(header::CONTENT_TYPE, mime_type)
368 .header(header::CONTENT_LENGTH, data.len())
369 .header(header::ACCEPT_RANGES, "bytes")
370 .header(header::CACHE_CONTROL, IMMUTABLE_CACHE_CONTROL)
371 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*");
372 if is_localhost {
373 builder = builder.header("X-Source", "local");
374 }
375 builder.body(Body::empty()).unwrap()
376 }
377 Ok(None) => Response::builder()
378 .status(StatusCode::NOT_FOUND)
379 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
380 .header("X-Reason", "Blob not found")
381 .body(Body::empty())
382 .unwrap(),
383 Err(_) => Response::builder()
384 .status(StatusCode::INTERNAL_SERVER_ERROR)
385 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
386 .body(Body::empty())
387 .unwrap(),
388 }
389}
390
391pub async fn upload_blob(
393 State(state): State<AppState>,
394 headers: HeaderMap,
395 body: axum::body::Bytes,
396) -> impl IntoResponse {
397 let max_size = state.max_upload_bytes;
399 if body.len() > max_size {
400 return Response::builder()
401 .status(StatusCode::PAYLOAD_TOO_LARGE)
402 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
403 .header(header::CONTENT_TYPE, "application/json")
404 .body(Body::from(format!(
405 r#"{{"error":"Upload size {} bytes exceeds maximum {} bytes ({} MB)"}}"#,
406 body.len(),
407 max_size,
408 max_size / 1024 / 1024
409 )))
410 .unwrap();
411 }
412
413 let auth = match verify_blossom_auth(&headers, "upload", None) {
415 Ok(a) => a,
416 Err((status, reason)) => {
417 return Response::builder()
418 .status(status)
419 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
420 .header("X-Reason", reason)
421 .header(header::CONTENT_TYPE, "application/json")
422 .body(Body::from(format!(r#"{{"error":"{}"}}"#, reason)))
423 .unwrap();
424 }
425 };
426
427 let content_type = headers
429 .get(header::CONTENT_TYPE)
430 .and_then(|v| v.to_str().ok())
431 .unwrap_or("application/octet-stream")
432 .to_string();
433
434 let is_allowed = check_write_access(&state, &auth.pubkey).is_ok();
436 let can_upload = is_allowed || state.public_writes;
437
438 if !can_upload {
439 return Response::builder()
440 .status(StatusCode::FORBIDDEN)
441 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
442 .header(header::CONTENT_TYPE, "application/json")
443 .body(Body::from(r#"{"error":"Write access denied. Your pubkey is not in the allowed list and public writes are disabled."}"#))
444 .unwrap();
445 }
446
447 let mut hasher = Sha256::new();
449 hasher.update(&body);
450 let sha256_hash: [u8; 32] = hasher.finalize().into();
451 let sha256_hex = hex::encode(sha256_hash);
452
453 if !auth.blob_hashes.is_empty() && !auth.blob_hashes.contains(&sha256_hex) {
455 return Response::builder()
456 .status(StatusCode::FORBIDDEN)
457 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
458 .header(
459 "X-Reason",
460 "Uploaded blob hash does not match authorized hash",
461 )
462 .header(header::CONTENT_TYPE, "application/json")
463 .body(Body::from(r#"{"error":"Hash mismatch"}"#))
464 .unwrap();
465 }
466
467 let pubkey_bytes = match from_hex(&auth.pubkey) {
469 Ok(b) => b,
470 Err(_) => {
471 return Response::builder()
472 .status(StatusCode::BAD_REQUEST)
473 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
474 .header("X-Reason", "Invalid pubkey format")
475 .body(Body::empty())
476 .unwrap();
477 }
478 };
479
480 let size = body.len() as u64;
481
482 let store_result = store_blossom_blob(&state, &body, &sha256_hash, &pubkey_bytes, is_allowed);
484
485 match store_result {
486 Ok(()) => {
487 let now = SystemTime::now()
488 .duration_since(UNIX_EPOCH)
489 .unwrap()
490 .as_secs();
491
492 let ext = mime_to_extension(&content_type);
494
495 let descriptor = BlobDescriptor {
496 url: format!("/{}{}", sha256_hex, ext),
497 sha256: sha256_hex,
498 size,
499 mime_type: content_type,
500 uploaded: now,
501 };
502
503 Response::builder()
504 .status(StatusCode::OK)
505 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
506 .header(header::CONTENT_TYPE, "application/json")
507 .body(Body::from(serde_json::to_string(&descriptor).unwrap()))
508 .unwrap()
509 }
510 Err(e) => Response::builder()
511 .status(StatusCode::INTERNAL_SERVER_ERROR)
512 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
513 .header("X-Reason", "Storage error")
514 .header(header::CONTENT_TYPE, "application/json")
515 .body(Body::from(format!(r#"{{"error":"{}"}}"#, e)))
516 .unwrap(),
517 }
518}
519
520pub async fn delete_blob(
523 State(state): State<AppState>,
524 Path(id): Path<String>,
525 headers: HeaderMap,
526) -> impl IntoResponse {
527 let (hash_part, _) = parse_hash_and_extension(&id);
528
529 if !is_valid_sha256(hash_part) {
530 return Response::builder()
531 .status(StatusCode::BAD_REQUEST)
532 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
533 .header("X-Reason", "Invalid SHA256 hash")
534 .body(Body::empty())
535 .unwrap();
536 }
537
538 let sha256_hex = hash_part.to_lowercase();
539
540 let sha256_bytes = match from_hex(&sha256_hex) {
542 Ok(b) => b,
543 Err(_) => {
544 return Response::builder()
545 .status(StatusCode::BAD_REQUEST)
546 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
547 .header("X-Reason", "Invalid SHA256 hash format")
548 .body(Body::empty())
549 .unwrap();
550 }
551 };
552
553 let auth = match verify_blossom_auth(&headers, "delete", Some(&sha256_hex)) {
555 Ok(a) => a,
556 Err((status, reason)) => {
557 return Response::builder()
558 .status(status)
559 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
560 .header("X-Reason", reason)
561 .body(Body::empty())
562 .unwrap();
563 }
564 };
565
566 let pubkey_bytes = match from_hex(&auth.pubkey) {
568 Ok(b) => b,
569 Err(_) => {
570 return Response::builder()
571 .status(StatusCode::BAD_REQUEST)
572 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
573 .header("X-Reason", "Invalid pubkey format")
574 .body(Body::empty())
575 .unwrap();
576 }
577 };
578
579 match state.store.is_blob_owner(&sha256_bytes, &pubkey_bytes) {
581 Ok(true) => {
582 }
584 Ok(false) => {
585 match state.store.blob_has_owners(&sha256_bytes) {
587 Ok(true) => {
588 return Response::builder()
589 .status(StatusCode::FORBIDDEN)
590 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
591 .header("X-Reason", "Not a blob owner")
592 .body(Body::empty())
593 .unwrap();
594 }
595 Ok(false) => {
596 return Response::builder()
597 .status(StatusCode::NOT_FOUND)
598 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
599 .header("X-Reason", "Blob not found")
600 .body(Body::empty())
601 .unwrap();
602 }
603 Err(_) => {
604 return Response::builder()
605 .status(StatusCode::INTERNAL_SERVER_ERROR)
606 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
607 .body(Body::empty())
608 .unwrap();
609 }
610 }
611 }
612 Err(_) => {
613 return Response::builder()
614 .status(StatusCode::INTERNAL_SERVER_ERROR)
615 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
616 .body(Body::empty())
617 .unwrap();
618 }
619 }
620
621 match state
623 .store
624 .delete_blossom_blob(&sha256_bytes, &pubkey_bytes)
625 {
626 Ok(fully_deleted) => {
627 Response::builder()
630 .status(StatusCode::OK)
631 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
632 .header(
633 "X-Blob-Deleted",
634 if fully_deleted { "true" } else { "false" },
635 )
636 .body(Body::empty())
637 .unwrap()
638 }
639 Err(_) => Response::builder()
640 .status(StatusCode::INTERNAL_SERVER_ERROR)
641 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
642 .body(Body::empty())
643 .unwrap(),
644 }
645}
646
647pub async fn list_blobs(
649 State(state): State<AppState>,
650 Path(pubkey): Path<String>,
651 Query(query): Query<ListQuery>,
652 headers: HeaderMap,
653) -> impl IntoResponse {
654 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
656 return Response::builder()
657 .status(StatusCode::BAD_REQUEST)
658 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
659 .header("X-Reason", "Invalid pubkey format")
660 .header(header::CONTENT_TYPE, "application/json")
661 .body(Body::from("[]"))
662 .unwrap();
663 }
664
665 let pubkey_hex = pubkey.to_lowercase();
666 let pubkey_bytes: [u8; 32] = match from_hex(&pubkey_hex) {
667 Ok(b) => b,
668 Err(_) => {
669 return Response::builder()
670 .status(StatusCode::BAD_REQUEST)
671 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
672 .header("X-Reason", "Invalid pubkey format")
673 .header(header::CONTENT_TYPE, "application/json")
674 .body(Body::from("[]"))
675 .unwrap()
676 }
677 };
678
679 let auth = match verify_blossom_auth(&headers, "list", None) {
680 Ok(auth) => auth,
681 Err((status, reason)) => {
682 return Response::builder()
683 .status(status)
684 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
685 .header("X-Reason", reason)
686 .header(header::CONTENT_TYPE, "application/json")
687 .body(Body::from("[]"))
688 .unwrap();
689 }
690 };
691
692 if !auth.pubkey.eq_ignore_ascii_case(&pubkey_hex) {
693 return Response::builder()
694 .status(StatusCode::FORBIDDEN)
695 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
696 .header("X-Reason", "Pubkey mismatch")
697 .header(header::CONTENT_TYPE, "application/json")
698 .body(Body::from("[]"))
699 .unwrap();
700 }
701
702 match state.store.list_blobs_by_pubkey(&pubkey_bytes) {
704 Ok(blobs) => {
705 let mut filtered: Vec<_> = blobs
707 .into_iter()
708 .filter(|b| {
709 if let Some(since) = query.since {
710 if b.uploaded < since {
711 return false;
712 }
713 }
714 if let Some(until) = query.until {
715 if b.uploaded > until {
716 return false;
717 }
718 }
719 true
720 })
721 .collect();
722
723 filtered.sort_by(|a, b| b.uploaded.cmp(&a.uploaded));
725
726 let limit = query.limit.unwrap_or(100).min(1000);
728 filtered.truncate(limit);
729
730 Response::builder()
731 .status(StatusCode::OK)
732 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
733 .header(header::CONTENT_TYPE, "application/json")
734 .body(Body::from(serde_json::to_string(&filtered).unwrap()))
735 .unwrap()
736 }
737 Err(_) => Response::builder()
738 .status(StatusCode::INTERNAL_SERVER_ERROR)
739 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
740 .header(header::CONTENT_TYPE, "application/json")
741 .body(Body::from("[]"))
742 .unwrap(),
743 }
744}
745
746fn parse_hash_and_extension(id: &str) -> (&str, Option<&str>) {
749 if let Some(dot_pos) = id.rfind('.') {
750 (&id[..dot_pos], Some(&id[dot_pos..]))
751 } else {
752 (id, None)
753 }
754}
755
756fn is_valid_sha256(s: &str) -> bool {
757 s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
758}
759
760fn store_blossom_blob(
761 state: &AppState,
762 data: &[u8],
763 _sha256: &[u8; 32],
764 pubkey: &[u8; 32],
765 track_ownership: bool,
766) -> anyhow::Result<()> {
767 if track_ownership {
770 state.store.put_owned_blob(data, pubkey)?;
771 } else {
772 state.store.put_cached_blob(data)?;
773 }
774
775 Ok(())
776}
777
778fn mime_to_extension(mime: &str) -> &'static str {
779 match mime {
780 "image/png" => ".png",
781 "image/jpeg" => ".jpg",
782 "image/gif" => ".gif",
783 "image/webp" => ".webp",
784 "image/svg+xml" => ".svg",
785 "video/mp4" => ".mp4",
786 "video/webm" => ".webm",
787 "audio/mpeg" => ".mp3",
788 "audio/ogg" => ".ogg",
789 "application/pdf" => ".pdf",
790 "text/plain" => ".txt",
791 "text/html" => ".html",
792 "application/json" => ".json",
793 _ => "",
794 }
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800 use crate::server::auth::WsRelayState;
801 use crate::storage::HashtreeStore;
802 use hashtree_core::sha256;
803 use std::collections::HashSet;
804 use std::sync::{Arc, Mutex as StdMutex};
805 use tempfile::TempDir;
806
807 fn test_app_state(store: Arc<HashtreeStore>) -> AppState {
808 AppState {
809 store,
810 auth: None,
811 peer_mode: crate::config::ServerMode::Normal,
812 hash_get_enabled: true,
813 webrtc_peers: None,
814 ws_relay: Arc::new(WsRelayState::new()),
815 max_upload_bytes: 5 * 1024 * 1024,
816 public_writes: true,
817 allowed_pubkeys: HashSet::new(),
818 upstream_blossom: Vec::new(),
819 social_graph: None,
820 social_graph_store: None,
821 social_graph_root: None,
822 socialgraph_snapshot_public: false,
823 nostr_relay: None,
824 nostr_relay_urls: Vec::new(),
825 tree_root_cache: Arc::new(StdMutex::new(std::collections::HashMap::new())),
826 inflight_blob_fetches: Arc::new(tokio::sync::Mutex::new(
827 std::collections::HashMap::new(),
828 )),
829 directory_listing_cache: Arc::new(StdMutex::new(crate::server::new_lookup_cache())),
830 resolved_path_cache: Arc::new(StdMutex::new(crate::server::new_lookup_cache())),
831 thumbnail_path_cache: Arc::new(StdMutex::new(crate::server::new_lookup_cache())),
832 cid_size_cache: Arc::new(StdMutex::new(crate::server::new_lookup_cache())),
833 }
834 }
835
836 #[test]
837 fn test_is_valid_sha256() {
838 assert!(is_valid_sha256(
839 "e2bab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6"
840 ));
841 assert!(is_valid_sha256(
842 "0000000000000000000000000000000000000000000000000000000000000000"
843 ));
844
845 assert!(!is_valid_sha256("e2bab35b5296ec2242ded0a01f6d6723"));
847 assert!(!is_valid_sha256(
849 "e2bab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6aa"
850 ));
851 assert!(!is_valid_sha256(
853 "zzbab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6"
854 ));
855 assert!(!is_valid_sha256(""));
857 }
858
859 #[test]
860 fn test_parse_hash_and_extension() {
861 let (hash, ext) = parse_hash_and_extension("abc123.png");
862 assert_eq!(hash, "abc123");
863 assert_eq!(ext, Some(".png"));
864
865 let (hash2, ext2) = parse_hash_and_extension("abc123");
866 assert_eq!(hash2, "abc123");
867 assert_eq!(ext2, None);
868
869 let (hash3, ext3) = parse_hash_and_extension("abc.123.jpg");
870 assert_eq!(hash3, "abc.123");
871 assert_eq!(ext3, Some(".jpg"));
872 }
873
874 #[test]
875 fn test_mime_to_extension() {
876 assert_eq!(mime_to_extension("image/png"), ".png");
877 assert_eq!(mime_to_extension("image/jpeg"), ".jpg");
878 assert_eq!(mime_to_extension("video/mp4"), ".mp4");
879 assert_eq!(mime_to_extension("application/octet-stream"), "");
880 assert_eq!(mime_to_extension("unknown/type"), "");
881 }
882
883 #[test]
884 fn unowned_public_uploads_use_cache_storage_semantics() {
885 let temp_dir = TempDir::new().expect("temp dir");
886 let store =
887 Arc::new(HashtreeStore::with_options(temp_dir.path(), None, 700).expect("store"));
888 let state = test_app_state(Arc::clone(&store));
889
890 let owned = vec![1u8; 280];
891 let owned_hash = sha256(&owned);
892 store_blossom_blob(&state, &owned, &owned_hash, &[2u8; 32], true).expect("owned upload");
893
894 let public_upload = vec![3u8; 280];
895 let public_hash = sha256(&public_upload);
896 store_blossom_blob(&state, &public_upload, &public_hash, &[4u8; 32], false)
897 .expect("public upload");
898
899 let replacement = vec![5u8; 280];
900 let replacement_hash = sha256(&replacement);
901 state
902 .store
903 .put_cached_blob(&replacement)
904 .expect("replacement cached blob");
905
906 assert!(state.store.blob_exists(&owned_hash).expect("owned exists"));
907 assert!(!state
908 .store
909 .blob_exists(&public_hash)
910 .expect("public upload evicted"));
911 assert!(state
912 .store
913 .blob_exists(&replacement_hash)
914 .expect("replacement exists"));
915 assert!(state
916 .store
917 .is_blob_owner(&owned_hash, &[2u8; 32])
918 .expect("owned tracked"));
919 assert!(!state
920 .store
921 .blob_has_owners(&public_hash)
922 .expect("public upload unowned"));
923 }
924
925 #[test]
926 fn owned_blossom_uploads_are_rejected_when_storage_limit_is_full() {
927 let temp_dir = TempDir::new().expect("temp dir");
928 let store =
929 Arc::new(HashtreeStore::with_options(temp_dir.path(), None, 500).expect("store"));
930 let state = test_app_state(Arc::clone(&store));
931
932 let first = vec![1u8; 300];
933 let first_hash = sha256(&first);
934 let owner = [2u8; 32];
935 store_blossom_blob(&state, &first, &first_hash, &owner, true).expect("first upload");
936
937 let second = vec![3u8; 300];
938 let second_hash = sha256(&second);
939 let error = store_blossom_blob(&state, &second, &second_hash, &owner, true)
940 .expect_err("second owned upload should exceed the storage limit");
941
942 assert!(
943 error.to_string().contains("storage limit"),
944 "unexpected error: {error}"
945 );
946 assert!(state
947 .store
948 .blob_exists(&first_hash)
949 .expect("first blob remains"));
950 assert!(!state
951 .store
952 .blob_exists(&second_hash)
953 .expect("second blob rejected"));
954 assert!(state
955 .store
956 .is_blob_owner(&first_hash, &owner)
957 .expect("first owner tracked"));
958 assert!(!state
959 .store
960 .is_blob_owner(&second_hash, &owner)
961 .expect("second owner not tracked"));
962 }
963}