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