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