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