1use axum::{
7 body::Body,
8 extract::{Path, Query, State},
9 http::{header, HeaderMap, Response, StatusCode},
10 response::IntoResponse,
11};
12use base64::Engine;
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use super::auth::AppState;
18use super::mime::get_mime_type;
19
20const BLOSSOM_AUTH_KIND: u16 = 24242;
22
23const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
25
26pub const DEFAULT_MAX_UPLOAD_SIZE: usize = 5 * 1024 * 1024;
28
29fn check_write_access(state: &AppState, pubkey: &str) -> Result<(), Response<Body>> {
32 if state.allowed_pubkeys.contains(pubkey) {
34 tracing::debug!("Blossom write allowed for {}... (allowed npub)", &pubkey[..8.min(pubkey.len())]);
35 return Ok(());
36 }
37
38 tracing::info!("Blossom write denied for {}... (not in allowed_npubs)", &pubkey[..8.min(pubkey.len())]);
40 Err(Response::builder()
41 .status(StatusCode::FORBIDDEN)
42 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
43 .header(header::CONTENT_TYPE, "application/json")
44 .body(Body::from(r#"{"error":"Write access denied. Your pubkey is not in the allowed list."}"#))
45 .unwrap())
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct BlobDescriptor {
51 pub url: String,
52 pub sha256: String,
53 pub size: u64,
54 #[serde(rename = "type")]
55 pub mime_type: String,
56 pub uploaded: u64,
57}
58
59#[derive(Debug, Deserialize)]
61pub struct ListQuery {
62 pub since: Option<u64>,
63 pub until: Option<u64>,
64 pub limit: Option<usize>,
65 pub cursor: Option<String>,
66}
67
68#[derive(Debug)]
70pub struct BlossomAuth {
71 pub pubkey: String,
72 pub kind: u16,
73 pub created_at: u64,
74 pub expiration: Option<u64>,
75 pub action: Option<String>, pub blob_hashes: Vec<String>, pub server: Option<String>, }
79
80pub fn verify_blossom_auth(
83 headers: &HeaderMap,
84 required_action: &str,
85 required_hash: Option<&str>,
86) -> Result<BlossomAuth, (StatusCode, &'static str)> {
87 let auth_header = headers
88 .get(header::AUTHORIZATION)
89 .and_then(|v| v.to_str().ok())
90 .ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
91
92 let nostr_event = auth_header
93 .strip_prefix("Nostr ")
94 .ok_or((StatusCode::UNAUTHORIZED, "Invalid auth scheme, expected 'Nostr'"))?;
95
96 let engine = base64::engine::general_purpose::STANDARD;
98 let event_bytes = engine
99 .decode(nostr_event)
100 .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid base64 in auth header"))?;
101
102 let event_json: serde_json::Value = serde_json::from_slice(&event_bytes)
103 .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid JSON in auth event"))?;
104
105 let kind = event_json["kind"]
107 .as_u64()
108 .ok_or((StatusCode::BAD_REQUEST, "Missing kind in event"))?;
109
110 if kind != BLOSSOM_AUTH_KIND as u64 {
111 return Err((StatusCode::BAD_REQUEST, "Invalid event kind, expected 24242"));
112 }
113
114 let pubkey = event_json["pubkey"]
115 .as_str()
116 .ok_or((StatusCode::BAD_REQUEST, "Missing pubkey in event"))?
117 .to_string();
118
119 let created_at = event_json["created_at"]
120 .as_u64()
121 .ok_or((StatusCode::BAD_REQUEST, "Missing created_at in event"))?;
122
123 let sig = event_json["sig"]
124 .as_str()
125 .ok_or((StatusCode::BAD_REQUEST, "Missing signature in event"))?;
126
127 if !verify_nostr_signature(&event_json, &pubkey, sig) {
129 return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
130 }
131
132 let tags = event_json["tags"]
134 .as_array()
135 .ok_or((StatusCode::BAD_REQUEST, "Missing tags in event"))?;
136
137 let mut expiration: Option<u64> = None;
138 let mut action: Option<String> = None;
139 let mut blob_hashes: Vec<String> = Vec::new();
140 let mut server: Option<String> = None;
141
142 for tag in tags {
143 let tag_arr = tag.as_array();
144 if let Some(arr) = tag_arr {
145 if arr.len() >= 2 {
146 let tag_name = arr[0].as_str().unwrap_or("");
147 let tag_value = arr[1].as_str().unwrap_or("");
148
149 match tag_name {
150 "t" => action = Some(tag_value.to_string()),
151 "x" => blob_hashes.push(tag_value.to_lowercase()),
152 "expiration" => expiration = tag_value.parse().ok(),
153 "server" => server = Some(tag_value.to_string()),
154 _ => {}
155 }
156 }
157 }
158 }
159
160 let now = SystemTime::now()
162 .duration_since(UNIX_EPOCH)
163 .unwrap()
164 .as_secs();
165
166 if let Some(exp) = expiration {
167 if exp < now {
168 return Err((StatusCode::UNAUTHORIZED, "Authorization expired"));
169 }
170 }
171
172 if created_at > now + 60 {
174 return Err((StatusCode::BAD_REQUEST, "Event created_at is in the future"));
175 }
176
177 if let Some(ref act) = action {
179 if act != required_action {
180 return Err((StatusCode::FORBIDDEN, "Action mismatch"));
181 }
182 } else {
183 return Err((StatusCode::BAD_REQUEST, "Missing 't' tag for action"));
184 }
185
186 if let Some(hash) = required_hash {
188 if !blob_hashes.is_empty() && !blob_hashes.contains(&hash.to_lowercase()) {
189 return Err((StatusCode::FORBIDDEN, "Blob hash not authorized"));
190 }
191 }
192
193 Ok(BlossomAuth {
194 pubkey,
195 kind: kind as u16,
196 created_at,
197 expiration,
198 action,
199 blob_hashes,
200 server,
201 })
202}
203
204fn verify_nostr_signature(event: &serde_json::Value, pubkey: &str, sig: &str) -> bool {
206 use secp256k1::{Message, Secp256k1, schnorr::Signature, XOnlyPublicKey};
207
208 let content = event["content"].as_str().unwrap_or("");
210 let full_serialized = format!(
211 "[0,\"{}\",{},{},{},\"{}\"]",
212 pubkey,
213 event["created_at"],
214 event["kind"],
215 event["tags"],
216 escape_json_string(content),
217 );
218
219 let mut hasher = Sha256::new();
220 hasher.update(full_serialized.as_bytes());
221 let event_id = hasher.finalize();
222
223 let pubkey_bytes = match hex::decode(pubkey) {
225 Ok(b) => b,
226 Err(_) => return false,
227 };
228
229 let sig_bytes = match hex::decode(sig) {
230 Ok(b) => b,
231 Err(_) => return false,
232 };
233
234 let secp = Secp256k1::verification_only();
235
236 let xonly_pubkey = match XOnlyPublicKey::from_slice(&pubkey_bytes) {
237 Ok(pk) => pk,
238 Err(_) => return false,
239 };
240
241 let signature = match Signature::from_slice(&sig_bytes) {
242 Ok(s) => s,
243 Err(_) => return false,
244 };
245
246 let message = match Message::from_digest_slice(&event_id) {
247 Ok(m) => m,
248 Err(_) => return false,
249 };
250
251 secp.verify_schnorr(&signature, &message, &xonly_pubkey).is_ok()
252}
253
254fn escape_json_string(s: &str) -> String {
256 let mut result = String::new();
257 for c in s.chars() {
258 match c {
259 '"' => result.push_str("\\\""),
260 '\\' => result.push_str("\\\\"),
261 '\n' => result.push_str("\\n"),
262 '\r' => result.push_str("\\r"),
263 '\t' => result.push_str("\\t"),
264 c if c.is_control() => {
265 result.push_str(&format!("\\u{:04x}", c as u32));
266 }
267 c => result.push(c),
268 }
269 }
270 result
271}
272
273pub async fn cors_preflight(headers: HeaderMap) -> impl IntoResponse {
276 let allowed_headers = headers
278 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
279 .and_then(|v| v.to_str().ok())
280 .unwrap_or("Authorization, Content-Type, X-SHA-256, x-sha-256");
281
282 let full_allowed = format!(
284 "{}, Authorization, Content-Type, X-SHA-256, x-sha-256, Accept, Cache-Control",
285 allowed_headers
286 );
287
288 Response::builder()
289 .status(StatusCode::NO_CONTENT)
290 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
291 .header(header::ACCESS_CONTROL_ALLOW_METHODS, "GET, HEAD, PUT, DELETE, OPTIONS")
292 .header(header::ACCESS_CONTROL_ALLOW_HEADERS, full_allowed)
293 .header(header::ACCESS_CONTROL_MAX_AGE, "86400")
294 .body(Body::empty())
295 .unwrap()
296}
297
298pub async fn head_blob(
300 State(state): State<AppState>,
301 Path(id): Path<String>,
302) -> impl IntoResponse {
303 let (hash_part, ext) = parse_hash_and_extension(&id);
304
305 if !is_valid_sha256(&hash_part) {
306 return Response::builder()
307 .status(StatusCode::BAD_REQUEST)
308 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
309 .header("X-Reason", "Invalid SHA256 hash")
310 .body(Body::empty())
311 .unwrap();
312 }
313
314 let sha256_hex = hash_part.to_lowercase();
315
316 match state.store.get_cid_by_sha256(&sha256_hex) {
318 Ok(Some(cid)) => {
319 let (size, mime_type) = get_blob_metadata(&state, &cid, ext);
321
322 Response::builder()
323 .status(StatusCode::OK)
324 .header(header::CONTENT_TYPE, mime_type)
325 .header(header::CONTENT_LENGTH, size)
326 .header(header::ACCEPT_RANGES, "bytes")
327 .header(header::CACHE_CONTROL, IMMUTABLE_CACHE_CONTROL)
328 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
329 .body(Body::empty())
330 .unwrap()
331 }
332 Ok(None) => Response::builder()
333 .status(StatusCode::NOT_FOUND)
334 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
335 .header("X-Reason", "Blob not found")
336 .body(Body::empty())
337 .unwrap(),
338 Err(_) => Response::builder()
339 .status(StatusCode::INTERNAL_SERVER_ERROR)
340 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
341 .body(Body::empty())
342 .unwrap(),
343 }
344}
345
346pub async fn upload_blob(
348 State(state): State<AppState>,
349 headers: HeaderMap,
350 body: axum::body::Bytes,
351) -> impl IntoResponse {
352 let max_size = state.max_upload_bytes;
354 if body.len() > max_size {
355 return Response::builder()
356 .status(StatusCode::PAYLOAD_TOO_LARGE)
357 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
358 .header(header::CONTENT_TYPE, "application/json")
359 .body(Body::from(format!(
360 r#"{{"error":"Upload size {} bytes exceeds maximum {} bytes ({} MB)"}}"#,
361 body.len(),
362 max_size,
363 max_size / 1024 / 1024
364 )))
365 .unwrap();
366 }
367
368 let auth = match verify_blossom_auth(&headers, "upload", None) {
370 Ok(a) => a,
371 Err((status, reason)) => {
372 return Response::builder()
373 .status(status)
374 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
375 .header("X-Reason", reason)
376 .header(header::CONTENT_TYPE, "application/json")
377 .body(Body::from(format!(r#"{{"error":"{}"}}"#, reason)))
378 .unwrap();
379 }
380 };
381
382 let content_type = headers
384 .get(header::CONTENT_TYPE)
385 .and_then(|v| v.to_str().ok())
386 .unwrap_or("application/octet-stream")
387 .to_string();
388
389 let is_allowed = check_write_access(&state, &auth.pubkey).is_ok();
391 let can_upload = is_allowed || state.public_writes;
392
393 if !can_upload {
394 return Response::builder()
395 .status(StatusCode::FORBIDDEN)
396 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
397 .header(header::CONTENT_TYPE, "application/json")
398 .body(Body::from(r#"{"error":"Write access denied. Your pubkey is not in the allowed list and public writes are disabled."}"#))
399 .unwrap();
400 }
401
402 let mut hasher = Sha256::new();
404 hasher.update(&body);
405 let sha256_bytes = hasher.finalize();
406 let sha256_hex = hex::encode(sha256_bytes);
407
408 if !auth.blob_hashes.is_empty() && !auth.blob_hashes.contains(&sha256_hex) {
410 return Response::builder()
411 .status(StatusCode::FORBIDDEN)
412 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
413 .header("X-Reason", "Uploaded blob hash does not match authorized hash")
414 .header(header::CONTENT_TYPE, "application/json")
415 .body(Body::from(r#"{"error":"Hash mismatch"}"#))
416 .unwrap();
417 }
418
419 let size = body.len() as u64;
420
421 let store_result = store_blossom_blob(&state, &body, &sha256_hex, &auth.pubkey, is_allowed);
423
424 match store_result {
425 Ok(()) => {
426 let now = SystemTime::now()
427 .duration_since(UNIX_EPOCH)
428 .unwrap()
429 .as_secs();
430
431 let ext = mime_to_extension(&content_type);
433
434 let descriptor = BlobDescriptor {
435 url: format!("/{}{}", sha256_hex, ext),
436 sha256: sha256_hex,
437 size,
438 mime_type: content_type,
439 uploaded: now,
440 };
441
442 Response::builder()
443 .status(StatusCode::OK)
444 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
445 .header(header::CONTENT_TYPE, "application/json")
446 .body(Body::from(serde_json::to_string(&descriptor).unwrap()))
447 .unwrap()
448 }
449 Err(e) => Response::builder()
450 .status(StatusCode::INTERNAL_SERVER_ERROR)
451 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
452 .header("X-Reason", "Storage error")
453 .header(header::CONTENT_TYPE, "application/json")
454 .body(Body::from(format!(r#"{{"error":"{}"}}"#, e)))
455 .unwrap(),
456 }
457}
458
459pub async fn delete_blob(
462 State(state): State<AppState>,
463 Path(id): Path<String>,
464 headers: HeaderMap,
465) -> impl IntoResponse {
466 let (hash_part, _) = parse_hash_and_extension(&id);
467
468 if !is_valid_sha256(&hash_part) {
469 return Response::builder()
470 .status(StatusCode::BAD_REQUEST)
471 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
472 .header("X-Reason", "Invalid SHA256 hash")
473 .body(Body::empty())
474 .unwrap();
475 }
476
477 let sha256_hex = hash_part.to_lowercase();
478
479 let auth = match verify_blossom_auth(&headers, "delete", Some(&sha256_hex)) {
481 Ok(a) => a,
482 Err((status, reason)) => {
483 return Response::builder()
484 .status(status)
485 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
486 .header("X-Reason", reason)
487 .body(Body::empty())
488 .unwrap();
489 }
490 };
491
492 match state.store.is_blob_owner(&sha256_hex, &auth.pubkey) {
494 Ok(true) => {
495 }
497 Ok(false) => {
498 match state.store.blob_has_owners(&sha256_hex) {
500 Ok(true) => {
501 return Response::builder()
502 .status(StatusCode::FORBIDDEN)
503 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
504 .header("X-Reason", "Not a blob owner")
505 .body(Body::empty())
506 .unwrap();
507 }
508 Ok(false) => {
509 return Response::builder()
510 .status(StatusCode::NOT_FOUND)
511 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
512 .header("X-Reason", "Blob not found")
513 .body(Body::empty())
514 .unwrap();
515 }
516 Err(_) => {
517 return Response::builder()
518 .status(StatusCode::INTERNAL_SERVER_ERROR)
519 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
520 .body(Body::empty())
521 .unwrap();
522 }
523 }
524 }
525 Err(_) => {
526 return Response::builder()
527 .status(StatusCode::INTERNAL_SERVER_ERROR)
528 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
529 .body(Body::empty())
530 .unwrap();
531 }
532 }
533
534 match state.store.delete_blossom_blob(&sha256_hex, &auth.pubkey) {
536 Ok(fully_deleted) => {
537 Response::builder()
540 .status(StatusCode::OK)
541 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
542 .header("X-Blob-Deleted", if fully_deleted { "true" } else { "false" })
543 .body(Body::empty())
544 .unwrap()
545 }
546 Err(_) => Response::builder()
547 .status(StatusCode::INTERNAL_SERVER_ERROR)
548 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
549 .body(Body::empty())
550 .unwrap(),
551 }
552}
553
554pub async fn list_blobs(
556 State(state): State<AppState>,
557 Path(pubkey): Path<String>,
558 Query(query): Query<ListQuery>,
559 headers: HeaderMap,
560) -> impl IntoResponse {
561 if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
563 return Response::builder()
564 .status(StatusCode::BAD_REQUEST)
565 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
566 .header("X-Reason", "Invalid pubkey format")
567 .header(header::CONTENT_TYPE, "application/json")
568 .body(Body::from("[]"))
569 .unwrap();
570 }
571
572 let pubkey_hex = pubkey.to_lowercase();
573
574 let _auth = verify_blossom_auth(&headers, "list", None).ok();
576
577 match state.store.list_blobs_by_pubkey(&pubkey_hex) {
579 Ok(blobs) => {
580 let mut filtered: Vec<_> = blobs
582 .into_iter()
583 .filter(|b| {
584 if let Some(since) = query.since {
585 if b.uploaded < since {
586 return false;
587 }
588 }
589 if let Some(until) = query.until {
590 if b.uploaded > until {
591 return false;
592 }
593 }
594 true
595 })
596 .collect();
597
598 filtered.sort_by(|a, b| b.uploaded.cmp(&a.uploaded));
600
601 let limit = query.limit.unwrap_or(100).min(1000);
603 filtered.truncate(limit);
604
605 Response::builder()
606 .status(StatusCode::OK)
607 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
608 .header(header::CONTENT_TYPE, "application/json")
609 .body(Body::from(serde_json::to_string(&filtered).unwrap()))
610 .unwrap()
611 }
612 Err(_) => Response::builder()
613 .status(StatusCode::INTERNAL_SERVER_ERROR)
614 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
615 .header(header::CONTENT_TYPE, "application/json")
616 .body(Body::from("[]"))
617 .unwrap(),
618 }
619}
620
621fn parse_hash_and_extension(id: &str) -> (&str, Option<&str>) {
624 if let Some(dot_pos) = id.rfind('.') {
625 (&id[..dot_pos], Some(&id[dot_pos..]))
626 } else {
627 (id, None)
628 }
629}
630
631fn is_valid_sha256(s: &str) -> bool {
632 s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
633}
634
635fn get_blob_metadata(state: &AppState, cid: &str, ext: Option<&str>) -> (u64, String) {
636 let size = state
637 .store
638 .get_file_chunk_metadata(cid)
639 .ok()
640 .flatten()
641 .map(|m| m.total_size)
642 .unwrap_or(0);
643
644 let mime_type = ext
647 .map(|e| get_mime_type(&format!("file{}", e)))
648 .unwrap_or("application/octet-stream")
649 .to_string();
650
651 (size, mime_type)
652}
653
654fn store_blossom_blob(
655 state: &AppState,
656 data: &[u8],
657 sha256_hex: &str,
658 pubkey: &str,
659 track_ownership: bool,
660) -> anyhow::Result<()> {
661 state.store.put_blob(data)?;
663
664 let temp_dir = tempfile::tempdir()?;
666 let temp_file = temp_dir.path().join(format!("{}.bin", sha256_hex));
667 std::fs::write(&temp_file, data)?;
668
669 let _cid = state.store.upload_file_no_pin(&temp_file)?;
671
672 if track_ownership {
675 state.store.set_blob_owner(sha256_hex, pubkey)?;
676 }
677
678 Ok(())
679}
680
681fn mime_to_extension(mime: &str) -> &'static str {
682 match mime {
683 "image/png" => ".png",
684 "image/jpeg" => ".jpg",
685 "image/gif" => ".gif",
686 "image/webp" => ".webp",
687 "image/svg+xml" => ".svg",
688 "video/mp4" => ".mp4",
689 "video/webm" => ".webm",
690 "audio/mpeg" => ".mp3",
691 "audio/ogg" => ".ogg",
692 "application/pdf" => ".pdf",
693 "text/plain" => ".txt",
694 "text/html" => ".html",
695 "application/json" => ".json",
696 _ => "",
697 }
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 #[test]
705 fn test_is_valid_sha256() {
706 assert!(is_valid_sha256("e2bab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6"));
707 assert!(is_valid_sha256("0000000000000000000000000000000000000000000000000000000000000000"));
708
709 assert!(!is_valid_sha256("e2bab35b5296ec2242ded0a01f6d6723"));
711 assert!(!is_valid_sha256("e2bab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6aa"));
713 assert!(!is_valid_sha256("zzbab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6"));
715 assert!(!is_valid_sha256(""));
717 }
718
719 #[test]
720 fn test_parse_hash_and_extension() {
721 let (hash, ext) = parse_hash_and_extension("abc123.png");
722 assert_eq!(hash, "abc123");
723 assert_eq!(ext, Some(".png"));
724
725 let (hash2, ext2) = parse_hash_and_extension("abc123");
726 assert_eq!(hash2, "abc123");
727 assert_eq!(ext2, None);
728
729 let (hash3, ext3) = parse_hash_and_extension("abc.123.jpg");
730 assert_eq!(hash3, "abc.123");
731 assert_eq!(ext3, Some(".jpg"));
732 }
733
734 #[test]
735 fn test_mime_to_extension() {
736 assert_eq!(mime_to_extension("image/png"), ".png");
737 assert_eq!(mime_to_extension("image/jpeg"), ".jpg");
738 assert_eq!(mime_to_extension("video/mp4"), ".mp4");
739 assert_eq!(mime_to_extension("application/octet-stream"), "");
740 assert_eq!(mime_to_extension("unknown/type"), "");
741 }
742}