Skip to main content

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 or social graph
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    // Check social graph (nostrdb follow distance)
40    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    // Not in allowed list or social graph
48    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/// Blob descriptor returned by upload and list endpoints
58#[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/// Query parameters for list endpoint
69#[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/// Parsed Nostr authorization event
78#[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>,       // "upload", "delete", "list", "get"
85    pub blob_hashes: Vec<String>,     // x tags
86    pub server: Option<String>,       // server tag
87}
88
89/// Parse and verify Nostr authorization from header
90/// Returns the verified auth or an error response
91pub 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    // Decode base64 event
106    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    // Extract event fields
115    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    // Verify signature
137    if !verify_nostr_signature(&event_json, &pubkey, sig) {
138        return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
139    }
140
141    // Parse tags
142    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    // Validate expiration
170    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    // Validate created_at is not in the future (with 60s tolerance)
182    if created_at > now + 60 {
183        return Err((StatusCode::BAD_REQUEST, "Event created_at is in the future"));
184    }
185
186    // Validate action matches
187    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    // Validate hash if required
196    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
213/// Verify Nostr event signature using secp256k1
214fn verify_nostr_signature(event: &serde_json::Value, pubkey: &str, sig: &str) -> bool {
215    use secp256k1::{Message, Secp256k1, schnorr::Signature, XOnlyPublicKey};
216
217    // Compute event ID (sha256 of serialized event)
218    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    // Parse pubkey and signature
233    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
263/// Escape string for JSON serialization
264fn 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
282/// CORS preflight handler for all Blossom endpoints
283/// Echoes back Access-Control-Request-Headers to allow any headers
284pub async fn cors_preflight(headers: HeaderMap) -> impl IntoResponse {
285    // Echo back requested headers, or use sensible defaults that cover common Blossom headers
286    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    // Always include common headers in addition to what was requested
292    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
307/// HEAD /<sha256> - Check if blob exists
308pub 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    // Blossom only serves raw blobs (not merkle tree structures)
337    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
369/// PUT /upload - Upload a new blob (BUD-02)
370pub async fn upload_blob(
371    State(state): State<AppState>,
372    headers: HeaderMap,
373    body: axum::body::Bytes,
374) -> impl IntoResponse {
375    // Check size limit first (before auth to save resources)
376    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    // Verify authorization
392    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    // Get content type from header
406    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    // Check write access: either in allowed_npubs list OR public_writes is enabled
413    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    // Compute SHA256 of uploaded data
426    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 has x tags, verify hash matches
432    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    // Convert pubkey hex to bytes
443    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    // Store the blob (only track ownership if user is in allowed list)
458    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            // Determine file extension from content type
468            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
495/// DELETE /<sha256> - Delete a blob (BUD-02)
496/// Note: Blob is only fully deleted when ALL owners have removed it
497pub 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    // Convert hash to bytes
516    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    // Verify authorization with hash requirement
529    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    // Convert pubkey hex to bytes
542    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    // Check ownership - user must be one of the owners (O(1) lookup with composite key)
555    match state.store.is_blob_owner(&sha256_bytes, &pubkey_bytes) {
556        Ok(true) => {
557            // User is an owner, proceed with delete
558        }
559        Ok(false) => {
560            // Check if blob exists at all (for proper error message)
561            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    // Remove this user's ownership (blob only deleted when no owners remain)
597    match state.store.delete_blossom_blob(&sha256_bytes, &pubkey_bytes) {
598        Ok(fully_deleted) => {
599            // Return 200 OK whether blob was fully deleted or just removed from user's list
600            // The client doesn't need to know if other owners still exist
601            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
616/// GET /list/<pubkey> - List blobs for a pubkey (BUD-02)
617pub 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    // Validate pubkey format (64 hex chars)
624    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    // Optional auth verification for list
647    let _auth = verify_blossom_auth(&headers, "list", None).ok();
648
649    // Get blobs for this pubkey
650    match state.store.list_blobs_by_pubkey(&pubkey_bytes) {
651        Ok(blobs) => {
652            // Apply filters
653            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            // Sort by uploaded descending (most recent first)
671            filtered.sort_by(|a, b| b.uploaded.cmp(&a.uploaded));
672
673            // Apply limit
674            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
693// Helper functions
694
695fn 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    // Store as raw blob only - no tree creation needed for blossom
715    // This avoids sync_block_on which can deadlock under load
716    state.store.put_blob(data)?;
717
718    // Only track ownership for social graph members
719    // Non-members can upload (if public_writes=true) but can't delete
720    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        // Too short
756        assert!(!is_valid_sha256("e2bab35b5296ec2242ded0a01f6d6723"));
757        // Too long
758        assert!(!is_valid_sha256("e2bab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6aa"));
759        // Invalid chars
760        assert!(!is_valid_sha256("zzbab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6"));
761        // Empty
762        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}