hashtree_cli/server/
blossom.rs

1//! Blossom protocol implementation (BUD-01, BUD-02)
2//!
3//! Implements blob storage endpoints with Nostr-based authentication.
4//! See: https://github.com/hzrd149/blossom
5
6use 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
21/// Blossom authorization event kind (NIP-98 style)
22const BLOSSOM_AUTH_KIND: u16 = 24242;
23
24/// Cache-Control header for immutable content-addressed data (1 year)
25const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
26
27/// Default maximum upload size in bytes (5 MB)
28pub const DEFAULT_MAX_UPLOAD_SIZE: usize = 5 * 1024 * 1024;
29
30/// Check if a pubkey has write access based on allowed_npubs config
31/// Returns Ok(()) if allowed, Err with JSON error body if denied
32fn check_write_access(state: &AppState, pubkey: &str) -> Result<(), Response<Body>> {
33    // Check if pubkey is in the allowed list (converted from npub to hex)
34    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    // Not in allowed list
40    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/// Blob descriptor returned by upload and list endpoints
50#[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/// Query parameters for list endpoint
61#[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/// Parsed Nostr authorization event
70#[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>,       // "upload", "delete", "list", "get"
77    pub blob_hashes: Vec<String>,     // x tags
78    pub server: Option<String>,       // server tag
79}
80
81/// Parse and verify Nostr authorization from header
82/// Returns the verified auth or an error response
83pub 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    // Decode base64 event
98    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    // Extract event fields
107    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    // Verify signature
129    if !verify_nostr_signature(&event_json, &pubkey, sig) {
130        return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
131    }
132
133    // Parse tags
134    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    // Validate expiration
162    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    // Validate created_at is not in the future (with 60s tolerance)
174    if created_at > now + 60 {
175        return Err((StatusCode::BAD_REQUEST, "Event created_at is in the future"));
176    }
177
178    // Validate action matches
179    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    // Validate hash if required
188    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
205/// Verify Nostr event signature using secp256k1
206fn verify_nostr_signature(event: &serde_json::Value, pubkey: &str, sig: &str) -> bool {
207    use secp256k1::{Message, Secp256k1, schnorr::Signature, XOnlyPublicKey};
208
209    // Compute event ID (sha256 of serialized event)
210    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    // Parse pubkey and signature
225    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
255/// Escape string for JSON serialization
256fn 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
274/// CORS preflight handler for all Blossom endpoints
275/// Echoes back Access-Control-Request-Headers to allow any headers
276pub async fn cors_preflight(headers: HeaderMap) -> impl IntoResponse {
277    // Echo back requested headers, or use sensible defaults that cover common Blossom headers
278    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    // Always include common headers in addition to what was requested
284    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
299/// HEAD /<sha256> - Check if blob exists
300pub 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    // Check if blob exists via CID lookup
327    match state.store.get_cid_by_sha256(&sha256_bytes) {
328        Ok(Some(root_hash)) => {
329            // Get file size and mime type
330            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
356/// PUT /upload - Upload a new blob (BUD-02)
357pub async fn upload_blob(
358    State(state): State<AppState>,
359    headers: HeaderMap,
360    body: axum::body::Bytes,
361) -> impl IntoResponse {
362    // Check size limit first (before auth to save resources)
363    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    // Verify authorization
379    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    // Get content type from header
393    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    // Check write access: either in allowed_npubs list OR public_writes is enabled
400    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    // Compute SHA256 of uploaded data
413    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 has x tags, verify hash matches
419    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    // Convert pubkey hex to bytes
430    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    // Store the blob (only track ownership if user is in allowed list)
445    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            // Determine file extension from content type
455            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
482/// DELETE /<sha256> - Delete a blob (BUD-02)
483/// Note: Blob is only fully deleted when ALL owners have removed it
484pub 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    // Convert hash to bytes
503    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    // Verify authorization with hash requirement
516    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    // Convert pubkey hex to bytes
529    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    // Check ownership - user must be one of the owners (O(1) lookup with composite key)
542    match state.store.is_blob_owner(&sha256_bytes, &pubkey_bytes) {
543        Ok(true) => {
544            // User is an owner, proceed with delete
545        }
546        Ok(false) => {
547            // Check if blob exists at all (for proper error message)
548            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    // Remove this user's ownership (blob only deleted when no owners remain)
584    match state.store.delete_blossom_blob(&sha256_bytes, &pubkey_bytes) {
585        Ok(fully_deleted) => {
586            // Return 200 OK whether blob was fully deleted or just removed from user's list
587            // The client doesn't need to know if other owners still exist
588            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
603/// GET /list/<pubkey> - List blobs for a pubkey (BUD-02)
604pub 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    // Validate pubkey format (64 hex chars)
611    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    // Optional auth verification for list
634    let _auth = verify_blossom_auth(&headers, "list", None).ok();
635
636    // Get blobs for this pubkey
637    match state.store.list_blobs_by_pubkey(&pubkey_bytes) {
638        Ok(blobs) => {
639            // Apply filters
640            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            // Sort by uploaded descending (most recent first)
658            filtered.sort_by(|a, b| b.uploaded.cmp(&a.uploaded));
659
660            // Apply limit
661            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
680// Helper functions
681
682fn 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    // Use extension for MIME type if provided, otherwise default to octet-stream
702    // (hashtree doesn't store filenames in tree nodes)
703    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    // Store as raw blob only - no tree creation needed for blossom
719    // This avoids sync_block_on which can deadlock under load
720    state.store.put_blob(data)?;
721
722    // Only track ownership for social graph members
723    // Non-members can upload (if public_writes=true) but can't delete
724    if track_ownership {
725        state.store.set_blob_owner(sha256, pubkey)?;
726    }
727
728    Ok(())
729}
730
731fn mime_to_extension(mime: &str) -> &'static str {
732    match mime {
733        "image/png" => ".png",
734        "image/jpeg" => ".jpg",
735        "image/gif" => ".gif",
736        "image/webp" => ".webp",
737        "image/svg+xml" => ".svg",
738        "video/mp4" => ".mp4",
739        "video/webm" => ".webm",
740        "audio/mpeg" => ".mp3",
741        "audio/ogg" => ".ogg",
742        "application/pdf" => ".pdf",
743        "text/plain" => ".txt",
744        "text/html" => ".html",
745        "application/json" => ".json",
746        _ => "",
747    }
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753
754    #[test]
755    fn test_is_valid_sha256() {
756        assert!(is_valid_sha256("e2bab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6"));
757        assert!(is_valid_sha256("0000000000000000000000000000000000000000000000000000000000000000"));
758
759        // Too short
760        assert!(!is_valid_sha256("e2bab35b5296ec2242ded0a01f6d6723"));
761        // Too long
762        assert!(!is_valid_sha256("e2bab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6aa"));
763        // Invalid chars
764        assert!(!is_valid_sha256("zzbab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6"));
765        // Empty
766        assert!(!is_valid_sha256(""));
767    }
768
769    #[test]
770    fn test_parse_hash_and_extension() {
771        let (hash, ext) = parse_hash_and_extension("abc123.png");
772        assert_eq!(hash, "abc123");
773        assert_eq!(ext, Some(".png"));
774
775        let (hash2, ext2) = parse_hash_and_extension("abc123");
776        assert_eq!(hash2, "abc123");
777        assert_eq!(ext2, None);
778
779        let (hash3, ext3) = parse_hash_and_extension("abc.123.jpg");
780        assert_eq!(hash3, "abc.123");
781        assert_eq!(ext3, Some(".jpg"));
782    }
783
784    #[test]
785    fn test_mime_to_extension() {
786        assert_eq!(mime_to_extension("image/png"), ".png");
787        assert_eq!(mime_to_extension("image/jpeg"), ".jpg");
788        assert_eq!(mime_to_extension("video/mp4"), ".mp4");
789        assert_eq!(mime_to_extension("application/octet-stream"), "");
790        assert_eq!(mime_to_extension("unknown/type"), "");
791    }
792}