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 = verify_blossom_auth(&headers, "list", None).ok();
681
682 match state.store.list_blobs_by_pubkey(&pubkey_bytes) {
684 Ok(blobs) => {
685 let mut filtered: Vec<_> = blobs
687 .into_iter()
688 .filter(|b| {
689 if let Some(since) = query.since {
690 if b.uploaded < since {
691 return false;
692 }
693 }
694 if let Some(until) = query.until {
695 if b.uploaded > until {
696 return false;
697 }
698 }
699 true
700 })
701 .collect();
702
703 filtered.sort_by(|a, b| b.uploaded.cmp(&a.uploaded));
705
706 let limit = query.limit.unwrap_or(100).min(1000);
708 filtered.truncate(limit);
709
710 Response::builder()
711 .status(StatusCode::OK)
712 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
713 .header(header::CONTENT_TYPE, "application/json")
714 .body(Body::from(serde_json::to_string(&filtered).unwrap()))
715 .unwrap()
716 }
717 Err(_) => Response::builder()
718 .status(StatusCode::INTERNAL_SERVER_ERROR)
719 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
720 .header(header::CONTENT_TYPE, "application/json")
721 .body(Body::from("[]"))
722 .unwrap(),
723 }
724}
725
726fn parse_hash_and_extension(id: &str) -> (&str, Option<&str>) {
729 if let Some(dot_pos) = id.rfind('.') {
730 (&id[..dot_pos], Some(&id[dot_pos..]))
731 } else {
732 (id, None)
733 }
734}
735
736fn is_valid_sha256(s: &str) -> bool {
737 s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
738}
739
740fn store_blossom_blob(
741 state: &AppState,
742 data: &[u8],
743 sha256: &[u8; 32],
744 pubkey: &[u8; 32],
745 track_ownership: bool,
746) -> anyhow::Result<()> {
747 state.store.put_blob(data)?;
750
751 if track_ownership {
754 state.store.set_blob_owner(sha256, pubkey)?;
755 }
756
757 Ok(())
758}
759
760fn mime_to_extension(mime: &str) -> &'static str {
761 match mime {
762 "image/png" => ".png",
763 "image/jpeg" => ".jpg",
764 "image/gif" => ".gif",
765 "image/webp" => ".webp",
766 "image/svg+xml" => ".svg",
767 "video/mp4" => ".mp4",
768 "video/webm" => ".webm",
769 "audio/mpeg" => ".mp3",
770 "audio/ogg" => ".ogg",
771 "application/pdf" => ".pdf",
772 "text/plain" => ".txt",
773 "text/html" => ".html",
774 "application/json" => ".json",
775 _ => "",
776 }
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782
783 #[test]
784 fn test_is_valid_sha256() {
785 assert!(is_valid_sha256(
786 "e2bab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6"
787 ));
788 assert!(is_valid_sha256(
789 "0000000000000000000000000000000000000000000000000000000000000000"
790 ));
791
792 assert!(!is_valid_sha256("e2bab35b5296ec2242ded0a01f6d6723"));
794 assert!(!is_valid_sha256(
796 "e2bab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6aa"
797 ));
798 assert!(!is_valid_sha256(
800 "zzbab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6"
801 ));
802 assert!(!is_valid_sha256(""));
804 }
805
806 #[test]
807 fn test_parse_hash_and_extension() {
808 let (hash, ext) = parse_hash_and_extension("abc123.png");
809 assert_eq!(hash, "abc123");
810 assert_eq!(ext, Some(".png"));
811
812 let (hash2, ext2) = parse_hash_and_extension("abc123");
813 assert_eq!(hash2, "abc123");
814 assert_eq!(ext2, None);
815
816 let (hash3, ext3) = parse_hash_and_extension("abc.123.jpg");
817 assert_eq!(hash3, "abc.123");
818 assert_eq!(ext3, Some(".jpg"));
819 }
820
821 #[test]
822 fn test_mime_to_extension() {
823 assert_eq!(mime_to_extension("image/png"), ".png");
824 assert_eq!(mime_to_extension("image/jpeg"), ".jpg");
825 assert_eq!(mime_to_extension("video/mp4"), ".mp4");
826 assert_eq!(mime_to_extension("application/octet-stream"), "");
827 assert_eq!(mime_to_extension("unknown/type"), "");
828 }
829}