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