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
32#[allow(clippy::result_large_err)]
33fn check_write_access(state: &AppState, pubkey: &str) -> Result<(), Response<Body>> {
34    // Check if pubkey is in the allowed list (converted from npub to hex)
35    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    // Check social graph follow distance.
44    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    // Not in allowed list or social graph
55    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/// Blob descriptor returned by upload and list endpoints
70#[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/// Query parameters for list endpoint
81#[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/// Parsed Nostr authorization event
90#[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>,   // "upload", "delete", "list", "get"
97    pub blob_hashes: Vec<String>, // x tags
98    pub server: Option<String>,   // server tag
99}
100
101/// Parse and verify Nostr authorization from header
102/// Returns the verified auth or an error response
103pub 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    // Decode base64 event
119    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    // Extract event fields
128    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    // Verify signature
153    if !verify_nostr_signature(&event_json, &pubkey, sig) {
154        return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
155    }
156
157    // Parse tags
158    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    // Validate expiration
186    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    // Validate created_at is not in the future (with 60s tolerance)
198    if created_at > now + 60 {
199        return Err((StatusCode::BAD_REQUEST, "Event created_at is in the future"));
200    }
201
202    // Validate action matches
203    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    // Validate hash if required
212    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
229/// Verify Nostr event signature using secp256k1
230fn verify_nostr_signature(event: &serde_json::Value, pubkey: &str, sig: &str) -> bool {
231    use secp256k1::{schnorr::Signature, Message, Secp256k1, XOnlyPublicKey};
232
233    // Compute event ID (sha256 of serialized event)
234    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    // Parse pubkey and signature
249    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
280/// Escape string for JSON serialization
281fn 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
299/// CORS preflight handler for all Blossom endpoints
300/// Echoes back Access-Control-Request-Headers to allow any headers
301pub async fn cors_preflight(headers: HeaderMap) -> impl IntoResponse {
302    // Echo back requested headers, or use sensible defaults that cover common Blossom headers
303    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    // Always include common headers in addition to what was requested
309    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
327/// HEAD /<sha256> - Check if blob exists
328pub 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    // Blossom only serves raw blobs (not merkle tree structures)
359    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
391/// PUT /upload - Upload a new blob (BUD-02)
392pub async fn upload_blob(
393    State(state): State<AppState>,
394    headers: HeaderMap,
395    body: axum::body::Bytes,
396) -> impl IntoResponse {
397    // Check size limit first (before auth to save resources)
398    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    // Verify authorization
414    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    // Get content type from header
428    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    // Check write access: either in allowed_npubs list OR public_writes is enabled
435    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    // Compute SHA256 of uploaded data
448    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 has x tags, verify hash matches
454    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    // Convert pubkey hex to bytes
468    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    // Store the blob (only track ownership if user is in allowed list)
483    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            // Determine file extension from content type
493            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
520/// DELETE /<sha256> - Delete a blob (BUD-02)
521/// Note: Blob is only fully deleted when ALL owners have removed it
522pub 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    // Convert hash to bytes
541    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    // Verify authorization with hash requirement
554    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    // Convert pubkey hex to bytes
567    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    // Check ownership - user must be one of the owners (O(1) lookup with composite key)
580    match state.store.is_blob_owner(&sha256_bytes, &pubkey_bytes) {
581        Ok(true) => {
582            // User is an owner, proceed with delete
583        }
584        Ok(false) => {
585            // Check if blob exists at all (for proper error message)
586            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    // Remove this user's ownership (blob only deleted when no owners remain)
622    match state
623        .store
624        .delete_blossom_blob(&sha256_bytes, &pubkey_bytes)
625    {
626        Ok(fully_deleted) => {
627            // Return 200 OK whether blob was fully deleted or just removed from user's list
628            // The client doesn't need to know if other owners still exist
629            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
647/// GET /list/<pubkey> - List blobs for a pubkey (BUD-02)
648pub 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    // Validate pubkey format (64 hex chars)
655    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    // Optional auth verification for list
680    let _auth = verify_blossom_auth(&headers, "list", None).ok();
681
682    // Get blobs for this pubkey
683    match state.store.list_blobs_by_pubkey(&pubkey_bytes) {
684        Ok(blobs) => {
685            // Apply filters
686            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            // Sort by uploaded descending (most recent first)
704            filtered.sort_by(|a, b| b.uploaded.cmp(&a.uploaded));
705
706            // Apply limit
707            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
726// Helper functions
727
728fn 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    // Store as raw blob only - no tree creation needed for blossom
748    // This avoids sync_block_on which can deadlock under load
749    state.store.put_blob(data)?;
750
751    // Only track ownership for social graph members
752    // Non-members can upload (if public_writes=true) but can't delete
753    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        // Too short
793        assert!(!is_valid_sha256("e2bab35b5296ec2242ded0a01f6d6723"));
794        // Too long
795        assert!(!is_valid_sha256(
796            "e2bab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6aa"
797        ));
798        // Invalid chars
799        assert!(!is_valid_sha256(
800            "zzbab35b5296ec2242ded0a01f6d6723a5cd921239280c0a5f0b5589303336b6"
801        ));
802        // Empty
803        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}