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