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