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