this repo has no description
1use crate::auth::BearerAuth; 2use crate::scheduled::generate_full_backup; 3use crate::state::AppState; 4use crate::storage::BackupStorage; 5use axum::{ 6 Json, 7 extract::{Query, State}, 8 http::StatusCode, 9 response::{IntoResponse, Response}, 10}; 11use cid::Cid; 12use serde::{Deserialize, Serialize}; 13use serde_json::json; 14use std::str::FromStr; 15use tracing::{error, info, warn}; 16 17#[derive(Serialize)] 18#[serde(rename_all = "camelCase")] 19pub struct BackupInfo { 20 pub id: String, 21 pub repo_rev: String, 22 pub repo_root_cid: String, 23 pub block_count: i32, 24 pub size_bytes: i64, 25 pub created_at: String, 26} 27 28#[derive(Serialize)] 29#[serde(rename_all = "camelCase")] 30pub struct ListBackupsOutput { 31 pub backups: Vec<BackupInfo>, 32 pub backup_enabled: bool, 33} 34 35pub async fn list_backups(State(state): State<AppState>, auth: BearerAuth) -> Response { 36 let user = match sqlx::query!( 37 "SELECT id, backup_enabled FROM users WHERE did = $1", 38 auth.0.did 39 ) 40 .fetch_optional(&state.db) 41 .await 42 { 43 Ok(Some(u)) => u, 44 Ok(None) => { 45 return ( 46 StatusCode::NOT_FOUND, 47 Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 48 ) 49 .into_response(); 50 } 51 Err(e) => { 52 error!("DB error fetching user: {:?}", e); 53 return ( 54 StatusCode::INTERNAL_SERVER_ERROR, 55 Json(json!({"error": "InternalError", "message": "Database error"})), 56 ) 57 .into_response(); 58 } 59 }; 60 61 let backups = match sqlx::query!( 62 r#" 63 SELECT id, repo_rev, repo_root_cid, block_count, size_bytes, created_at 64 FROM account_backups 65 WHERE user_id = $1 66 ORDER BY created_at DESC 67 "#, 68 user.id 69 ) 70 .fetch_all(&state.db) 71 .await 72 { 73 Ok(rows) => rows, 74 Err(e) => { 75 error!("DB error fetching backups: {:?}", e); 76 return ( 77 StatusCode::INTERNAL_SERVER_ERROR, 78 Json(json!({"error": "InternalError", "message": "Database error"})), 79 ) 80 .into_response(); 81 } 82 }; 83 84 let backup_list: Vec<BackupInfo> = backups 85 .into_iter() 86 .map(|b| BackupInfo { 87 id: b.id.to_string(), 88 repo_rev: b.repo_rev, 89 repo_root_cid: b.repo_root_cid, 90 block_count: b.block_count, 91 size_bytes: b.size_bytes, 92 created_at: b.created_at.to_rfc3339(), 93 }) 94 .collect(); 95 96 ( 97 StatusCode::OK, 98 Json(ListBackupsOutput { 99 backups: backup_list, 100 backup_enabled: user.backup_enabled, 101 }), 102 ) 103 .into_response() 104} 105 106#[derive(Deserialize)] 107pub struct GetBackupQuery { 108 pub id: String, 109} 110 111pub async fn get_backup( 112 State(state): State<AppState>, 113 auth: BearerAuth, 114 Query(query): Query<GetBackupQuery>, 115) -> Response { 116 let backup_id = match uuid::Uuid::parse_str(&query.id) { 117 Ok(id) => id, 118 Err(_) => { 119 return ( 120 StatusCode::BAD_REQUEST, 121 Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})), 122 ) 123 .into_response(); 124 } 125 }; 126 127 let backup = match sqlx::query!( 128 r#" 129 SELECT ab.storage_key, ab.repo_rev 130 FROM account_backups ab 131 JOIN users u ON u.id = ab.user_id 132 WHERE ab.id = $1 AND u.did = $2 133 "#, 134 backup_id, 135 auth.0.did 136 ) 137 .fetch_optional(&state.db) 138 .await 139 { 140 Ok(Some(b)) => b, 141 Ok(None) => { 142 return ( 143 StatusCode::NOT_FOUND, 144 Json(json!({"error": "BackupNotFound", "message": "Backup not found"})), 145 ) 146 .into_response(); 147 } 148 Err(e) => { 149 error!("DB error fetching backup: {:?}", e); 150 return ( 151 StatusCode::INTERNAL_SERVER_ERROR, 152 Json(json!({"error": "InternalError", "message": "Database error"})), 153 ) 154 .into_response(); 155 } 156 }; 157 158 let backup_storage = match state.backup_storage.as_ref() { 159 Some(storage) => storage, 160 None => { 161 return ( 162 StatusCode::SERVICE_UNAVAILABLE, 163 Json( 164 json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}), 165 ), 166 ) 167 .into_response(); 168 } 169 }; 170 171 let car_bytes = match backup_storage.get_backup(&backup.storage_key).await { 172 Ok(bytes) => bytes, 173 Err(e) => { 174 error!("Failed to fetch backup from storage: {:?}", e); 175 return ( 176 StatusCode::INTERNAL_SERVER_ERROR, 177 Json(json!({"error": "InternalError", "message": "Failed to retrieve backup"})), 178 ) 179 .into_response(); 180 } 181 }; 182 183 ( 184 StatusCode::OK, 185 [ 186 (axum::http::header::CONTENT_TYPE, "application/vnd.ipld.car"), 187 ( 188 axum::http::header::CONTENT_DISPOSITION, 189 &format!("attachment; filename=\"{}.car\"", backup.repo_rev), 190 ), 191 ], 192 car_bytes, 193 ) 194 .into_response() 195} 196 197#[derive(Serialize)] 198#[serde(rename_all = "camelCase")] 199pub struct CreateBackupOutput { 200 pub id: String, 201 pub repo_rev: String, 202 pub size_bytes: i64, 203 pub block_count: i32, 204} 205 206pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> Response { 207 let backup_storage = match state.backup_storage.as_ref() { 208 Some(storage) => storage, 209 None => { 210 return ( 211 StatusCode::SERVICE_UNAVAILABLE, 212 Json( 213 json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}), 214 ), 215 ) 216 .into_response(); 217 } 218 }; 219 220 let user = match sqlx::query!( 221 r#" 222 SELECT u.id, u.did, u.backup_enabled, u.deactivated_at, r.repo_root_cid, r.repo_rev 223 FROM users u 224 JOIN repos r ON r.user_id = u.id 225 WHERE u.did = $1 226 "#, 227 auth.0.did 228 ) 229 .fetch_optional(&state.db) 230 .await 231 { 232 Ok(Some(u)) => u, 233 Ok(None) => { 234 return ( 235 StatusCode::NOT_FOUND, 236 Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 237 ) 238 .into_response(); 239 } 240 Err(e) => { 241 error!("DB error fetching user: {:?}", e); 242 return ( 243 StatusCode::INTERNAL_SERVER_ERROR, 244 Json(json!({"error": "InternalError", "message": "Database error"})), 245 ) 246 .into_response(); 247 } 248 }; 249 250 if user.deactivated_at.is_some() { 251 return ( 252 StatusCode::BAD_REQUEST, 253 Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})), 254 ) 255 .into_response(); 256 } 257 258 let repo_rev = match &user.repo_rev { 259 Some(rev) => rev.clone(), 260 None => { 261 return ( 262 StatusCode::BAD_REQUEST, 263 Json( 264 json!({"error": "RepoNotReady", "message": "Repository not ready for backup"}), 265 ), 266 ) 267 .into_response(); 268 } 269 }; 270 271 let head_cid = match Cid::from_str(&user.repo_root_cid) { 272 Ok(c) => c, 273 Err(_) => { 274 return ( 275 StatusCode::INTERNAL_SERVER_ERROR, 276 Json(json!({"error": "InternalError", "message": "Invalid repo root CID"})), 277 ) 278 .into_response(); 279 } 280 }; 281 282 let car_bytes = match generate_full_backup(&state.block_store, &head_cid).await { 283 Ok(bytes) => bytes, 284 Err(e) => { 285 error!("Failed to generate CAR: {:?}", e); 286 return ( 287 StatusCode::INTERNAL_SERVER_ERROR, 288 Json(json!({"error": "InternalError", "message": "Failed to generate backup"})), 289 ) 290 .into_response(); 291 } 292 }; 293 294 let block_count = crate::scheduled::count_car_blocks(&car_bytes); 295 let size_bytes = car_bytes.len() as i64; 296 297 let storage_key = match backup_storage 298 .put_backup(&user.did, &repo_rev, &car_bytes) 299 .await 300 { 301 Ok(key) => key, 302 Err(e) => { 303 error!("Failed to upload backup: {:?}", e); 304 return ( 305 StatusCode::INTERNAL_SERVER_ERROR, 306 Json(json!({"error": "InternalError", "message": "Failed to store backup"})), 307 ) 308 .into_response(); 309 } 310 }; 311 312 let backup_id = match sqlx::query_scalar!( 313 r#" 314 INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes) 315 VALUES ($1, $2, $3, $4, $5, $6) 316 RETURNING id 317 "#, 318 user.id, 319 storage_key, 320 user.repo_root_cid, 321 repo_rev, 322 block_count, 323 size_bytes 324 ) 325 .fetch_one(&state.db) 326 .await 327 { 328 Ok(id) => id, 329 Err(e) => { 330 error!("DB error inserting backup: {:?}", e); 331 if let Err(rollback_err) = backup_storage.delete_backup(&storage_key).await { 332 error!( 333 storage_key = %storage_key, 334 error = %rollback_err, 335 "Failed to rollback orphaned backup from S3" 336 ); 337 } 338 return ( 339 StatusCode::INTERNAL_SERVER_ERROR, 340 Json(json!({"error": "InternalError", "message": "Failed to record backup"})), 341 ) 342 .into_response(); 343 } 344 }; 345 346 info!( 347 did = %user.did, 348 rev = %repo_rev, 349 size_bytes, 350 "Created manual backup" 351 ); 352 353 let retention = BackupStorage::retention_count(); 354 if let Err(e) = cleanup_old_backups(&state.db, backup_storage, user.id, retention).await { 355 warn!(did = %user.did, error = %e, "Failed to cleanup old backups after manual backup"); 356 } 357 358 ( 359 StatusCode::OK, 360 Json(CreateBackupOutput { 361 id: backup_id.to_string(), 362 repo_rev, 363 size_bytes, 364 block_count, 365 }), 366 ) 367 .into_response() 368} 369 370async fn cleanup_old_backups( 371 db: &sqlx::PgPool, 372 backup_storage: &BackupStorage, 373 user_id: uuid::Uuid, 374 retention_count: u32, 375) -> Result<(), String> { 376 let old_backups = sqlx::query!( 377 r#" 378 SELECT id, storage_key 379 FROM account_backups 380 WHERE user_id = $1 381 ORDER BY created_at DESC 382 OFFSET $2 383 "#, 384 user_id, 385 retention_count as i64 386 ) 387 .fetch_all(db) 388 .await 389 .map_err(|e| format!("DB error fetching old backups: {}", e))?; 390 391 for backup in old_backups { 392 if let Err(e) = backup_storage.delete_backup(&backup.storage_key).await { 393 warn!( 394 storage_key = %backup.storage_key, 395 error = %e, 396 "Failed to delete old backup from storage, skipping DB cleanup to avoid orphan" 397 ); 398 continue; 399 } 400 401 sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id) 402 .execute(db) 403 .await 404 .map_err(|e| format!("Failed to delete old backup record: {}", e))?; 405 } 406 407 Ok(()) 408} 409 410#[derive(Deserialize)] 411pub struct DeleteBackupQuery { 412 pub id: String, 413} 414 415pub async fn delete_backup( 416 State(state): State<AppState>, 417 auth: BearerAuth, 418 Query(query): Query<DeleteBackupQuery>, 419) -> Response { 420 let backup_id = match uuid::Uuid::parse_str(&query.id) { 421 Ok(id) => id, 422 Err(_) => { 423 return ( 424 StatusCode::BAD_REQUEST, 425 Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})), 426 ) 427 .into_response(); 428 } 429 }; 430 431 let backup = match sqlx::query!( 432 r#" 433 SELECT ab.id, ab.storage_key, u.deactivated_at 434 FROM account_backups ab 435 JOIN users u ON u.id = ab.user_id 436 WHERE ab.id = $1 AND u.did = $2 437 "#, 438 backup_id, 439 auth.0.did 440 ) 441 .fetch_optional(&state.db) 442 .await 443 { 444 Ok(Some(b)) => b, 445 Ok(None) => { 446 return ( 447 StatusCode::NOT_FOUND, 448 Json(json!({"error": "BackupNotFound", "message": "Backup not found"})), 449 ) 450 .into_response(); 451 } 452 Err(e) => { 453 error!("DB error fetching backup: {:?}", e); 454 return ( 455 StatusCode::INTERNAL_SERVER_ERROR, 456 Json(json!({"error": "InternalError", "message": "Database error"})), 457 ) 458 .into_response(); 459 } 460 }; 461 462 if backup.deactivated_at.is_some() { 463 return ( 464 StatusCode::BAD_REQUEST, 465 Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})), 466 ) 467 .into_response(); 468 } 469 470 if let Some(backup_storage) = state.backup_storage.as_ref() 471 && let Err(e) = backup_storage.delete_backup(&backup.storage_key).await 472 { 473 warn!( 474 storage_key = %backup.storage_key, 475 error = %e, 476 "Failed to delete backup from storage (continuing anyway)" 477 ); 478 } 479 480 if let Err(e) = sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id) 481 .execute(&state.db) 482 .await 483 { 484 error!("DB error deleting backup: {:?}", e); 485 return ( 486 StatusCode::INTERNAL_SERVER_ERROR, 487 Json(json!({"error": "InternalError", "message": "Failed to delete backup"})), 488 ) 489 .into_response(); 490 } 491 492 info!(did = %auth.0.did, backup_id = %backup_id, "Deleted backup"); 493 494 (StatusCode::OK, Json(json!({}))).into_response() 495} 496 497#[derive(Deserialize)] 498#[serde(rename_all = "camelCase")] 499pub struct SetBackupEnabledInput { 500 pub enabled: bool, 501} 502 503pub async fn set_backup_enabled( 504 State(state): State<AppState>, 505 auth: BearerAuth, 506 Json(input): Json<SetBackupEnabledInput>, 507) -> Response { 508 let user = match sqlx::query!( 509 "SELECT deactivated_at FROM users WHERE did = $1", 510 auth.0.did 511 ) 512 .fetch_optional(&state.db) 513 .await 514 { 515 Ok(Some(u)) => u, 516 Ok(None) => { 517 return ( 518 StatusCode::NOT_FOUND, 519 Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 520 ) 521 .into_response(); 522 } 523 Err(e) => { 524 error!("DB error fetching user: {:?}", e); 525 return ( 526 StatusCode::INTERNAL_SERVER_ERROR, 527 Json(json!({"error": "InternalError", "message": "Database error"})), 528 ) 529 .into_response(); 530 } 531 }; 532 533 if user.deactivated_at.is_some() { 534 return ( 535 StatusCode::BAD_REQUEST, 536 Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})), 537 ) 538 .into_response(); 539 } 540 541 if let Err(e) = sqlx::query!( 542 "UPDATE users SET backup_enabled = $1 WHERE did = $2", 543 input.enabled, 544 auth.0.did 545 ) 546 .execute(&state.db) 547 .await 548 { 549 error!("DB error updating backup_enabled: {:?}", e); 550 return ( 551 StatusCode::INTERNAL_SERVER_ERROR, 552 Json(json!({"error": "InternalError", "message": "Failed to update setting"})), 553 ) 554 .into_response(); 555 } 556 557 info!(did = %auth.0.did, enabled = input.enabled, "Updated backup_enabled setting"); 558 559 (StatusCode::OK, Json(json!({"enabled": input.enabled}))).into_response() 560} 561 562pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Response { 563 let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth.0.did) 564 .fetch_optional(&state.db) 565 .await 566 { 567 Ok(Some(u)) => u, 568 Ok(None) => { 569 return ( 570 StatusCode::NOT_FOUND, 571 Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 572 ) 573 .into_response(); 574 } 575 Err(e) => { 576 error!("DB error fetching user: {:?}", e); 577 return ( 578 StatusCode::INTERNAL_SERVER_ERROR, 579 Json(json!({"error": "InternalError", "message": "Database error"})), 580 ) 581 .into_response(); 582 } 583 }; 584 585 let blobs = match sqlx::query!( 586 r#" 587 SELECT DISTINCT b.cid, b.storage_key, b.mime_type 588 FROM blobs b 589 JOIN record_blobs rb ON rb.blob_cid = b.cid 590 WHERE rb.repo_id = $1 591 "#, 592 user.id 593 ) 594 .fetch_all(&state.db) 595 .await 596 { 597 Ok(rows) => rows, 598 Err(e) => { 599 error!("DB error fetching blobs: {:?}", e); 600 return ( 601 StatusCode::INTERNAL_SERVER_ERROR, 602 Json(json!({"error": "InternalError", "message": "Database error"})), 603 ) 604 .into_response(); 605 } 606 }; 607 608 if blobs.is_empty() { 609 return ( 610 StatusCode::OK, 611 [ 612 (axum::http::header::CONTENT_TYPE, "application/zip"), 613 ( 614 axum::http::header::CONTENT_DISPOSITION, 615 "attachment; filename=\"blobs.zip\"", 616 ), 617 ], 618 Vec::<u8>::new(), 619 ) 620 .into_response(); 621 } 622 623 let mut zip_buffer = std::io::Cursor::new(Vec::new()); 624 { 625 let mut zip = zip::ZipWriter::new(&mut zip_buffer); 626 627 let options = zip::write::SimpleFileOptions::default() 628 .compression_method(zip::CompressionMethod::Deflated); 629 630 let mut exported: Vec<serde_json::Value> = Vec::new(); 631 let mut skipped: Vec<serde_json::Value> = Vec::new(); 632 633 for blob in &blobs { 634 let blob_data = match state.blob_store.get(&blob.storage_key).await { 635 Ok(data) => data, 636 Err(e) => { 637 warn!(cid = %blob.cid, error = %e, "Failed to fetch blob, skipping"); 638 skipped.push(json!({ 639 "cid": blob.cid, 640 "mimeType": blob.mime_type, 641 "reason": "fetch_failed" 642 })); 643 continue; 644 } 645 }; 646 647 let extension = mime_to_extension(&blob.mime_type); 648 let filename = format!("{}{}", blob.cid, extension); 649 650 if let Err(e) = zip.start_file(&filename, options) { 651 warn!(filename = %filename, error = %e, "Failed to start zip file entry"); 652 skipped.push(json!({ 653 "cid": blob.cid, 654 "mimeType": blob.mime_type, 655 "reason": "zip_entry_failed" 656 })); 657 continue; 658 } 659 660 if let Err(e) = std::io::Write::write_all(&mut zip, &blob_data) { 661 warn!(filename = %filename, error = %e, "Failed to write blob to zip"); 662 skipped.push(json!({ 663 "cid": blob.cid, 664 "mimeType": blob.mime_type, 665 "reason": "write_failed" 666 })); 667 continue; 668 } 669 670 exported.push(json!({ 671 "cid": blob.cid, 672 "filename": filename, 673 "mimeType": blob.mime_type, 674 "sizeBytes": blob_data.len() 675 })); 676 } 677 678 let manifest = json!({ 679 "exportedAt": chrono::Utc::now().to_rfc3339(), 680 "totalBlobs": blobs.len(), 681 "exportedCount": exported.len(), 682 "skippedCount": skipped.len(), 683 "exported": exported, 684 "skipped": skipped 685 }); 686 687 if zip.start_file("manifest.json", options).is_ok() { 688 let _ = std::io::Write::write_all( 689 &mut zip, 690 serde_json::to_string_pretty(&manifest) 691 .unwrap_or_else(|_| "{}".to_string()) 692 .as_bytes(), 693 ); 694 } 695 696 if let Err(e) = zip.finish() { 697 error!("Failed to finish zip: {:?}", e); 698 return ( 699 StatusCode::INTERNAL_SERVER_ERROR, 700 Json(json!({"error": "InternalError", "message": "Failed to create zip file"})), 701 ) 702 .into_response(); 703 } 704 } 705 706 let zip_bytes = zip_buffer.into_inner(); 707 708 info!(did = %auth.0.did, blob_count = blobs.len(), size_bytes = zip_bytes.len(), "Exported blobs"); 709 710 ( 711 StatusCode::OK, 712 [ 713 (axum::http::header::CONTENT_TYPE, "application/zip"), 714 ( 715 axum::http::header::CONTENT_DISPOSITION, 716 "attachment; filename=\"blobs.zip\"", 717 ), 718 ], 719 zip_bytes, 720 ) 721 .into_response() 722} 723 724fn mime_to_extension(mime_type: &str) -> &'static str { 725 match mime_type { 726 "application/font-sfnt" => ".otf", 727 "application/font-tdpfr" => ".pfr", 728 "application/font-woff" => ".woff", 729 "application/gzip" => ".gz", 730 "application/json" => ".json", 731 "application/json5" => ".json5", 732 "application/jsonml+json" => ".jsonml", 733 "application/octet-stream" => ".bin", 734 "application/pdf" => ".pdf", 735 "application/zip" => ".zip", 736 "audio/aac" => ".aac", 737 "audio/ac3" => ".ac3", 738 "audio/aiff" => ".aiff", 739 "audio/annodex" => ".axa", 740 "audio/audible" => ".aa", 741 "audio/basic" => ".au", 742 "audio/flac" => ".flac", 743 "audio/m4a" => ".m4a", 744 "audio/m4b" => ".m4b", 745 "audio/m4p" => ".m4p", 746 "audio/mid" => ".mid", 747 "audio/midi" => ".midi", 748 "audio/mp4" => ".mp4a", 749 "audio/mpeg" => ".mp3", 750 "audio/ogg" => ".ogg", 751 "audio/s3m" => ".s3m", 752 "audio/scpls" => ".pls", 753 "audio/silk" => ".sil", 754 "audio/vnd.audible.aax" => ".aax", 755 "audio/vnd.dece.audio" => ".uva", 756 "audio/vnd.digital-winds" => ".eol", 757 "audio/vnd.dlna.adts" => ".adt", 758 "audio/vnd.dra" => ".dra", 759 "audio/vnd.dts" => ".dts", 760 "audio/vnd.dts.hd" => ".dtshd", 761 "audio/vnd.lucent.voice" => ".lvp", 762 "audio/vnd.ms-playready.media.pya" => ".pya", 763 "audio/vnd.nuera.ecelp4800" => ".ecelp4800", 764 "audio/vnd.nuera.ecelp7470" => ".ecelp7470", 765 "audio/vnd.nuera.ecelp9600" => ".ecelp9600", 766 "audio/vnd.rip" => ".rip", 767 "audio/wav" => ".wav", 768 "audio/webm" => ".weba", 769 "audio/x-caf" => ".caf", 770 "audio/x-gsm" => ".gsm", 771 "audio/x-m4r" => ".m4r", 772 "audio/x-matroska" => ".mka", 773 "audio/x-mpegurl" => ".m3u", 774 "audio/x-ms-wax" => ".wax", 775 "audio/x-ms-wma" => ".wma", 776 "audio/x-pn-realaudio" => ".ra", 777 "audio/x-pn-realaudio-plugin" => ".rpm", 778 "audio/x-sd2" => ".sd2", 779 "audio/x-smd" => ".smd", 780 "audio/xm" => ".xm", 781 "font/collection" => ".ttc", 782 "font/ttf" => ".ttf", 783 "font/woff" => ".woff", 784 "font/woff2" => ".woff2", 785 "image/apng" => ".apng", 786 "image/avif" => ".avif", 787 "image/avif-sequence" => ".avifs", 788 "image/bmp" => ".bmp", 789 "image/cgm" => ".cgm", 790 "image/cis-cod" => ".cod", 791 "image/g3fax" => ".g3", 792 "image/gif" => ".gif", 793 "image/heic" => ".heic", 794 "image/heic-sequence" => ".heics", 795 "image/heif" => ".heif", 796 "image/heif-sequence" => ".heifs", 797 "image/ief" => ".ief", 798 "image/jp2" => ".jp2", 799 "image/jpeg" => ".jpg", 800 "image/jpm" => ".jpm", 801 "image/jpx" => ".jpf", 802 "image/jxl" => ".jxl", 803 "image/ktx" => ".ktx", 804 "image/pict" => ".pct", 805 "image/png" => ".png", 806 "image/prs.btif" => ".btif", 807 "image/qoi" => ".qoi", 808 "image/sgi" => ".sgi", 809 "image/svg+xml" => ".svg", 810 "image/tiff" => ".tiff", 811 "image/vnd.dece.graphic" => ".uvg", 812 "image/vnd.djvu" => ".djv", 813 "image/vnd.fastbidsheet" => ".fbs", 814 "image/vnd.fpx" => ".fpx", 815 "image/vnd.fst" => ".fst", 816 "image/vnd.fujixerox.edmics-mmr" => ".mmr", 817 "image/vnd.fujixerox.edmics-rlc" => ".rlc", 818 "image/vnd.ms-modi" => ".mdi", 819 "image/vnd.ms-photo" => ".wdp", 820 "image/vnd.net-fpx" => ".npx", 821 "image/vnd.radiance" => ".hdr", 822 "image/vnd.rn-realflash" => ".rf", 823 "image/vnd.wap.wbmp" => ".wbmp", 824 "image/vnd.xiff" => ".xif", 825 "image/webp" => ".webp", 826 "image/x-3ds" => ".3ds", 827 "image/x-adobe-dng" => ".dng", 828 "image/x-canon-cr2" => ".cr2", 829 "image/x-canon-cr3" => ".cr3", 830 "image/x-canon-crw" => ".crw", 831 "image/x-cmu-raster" => ".ras", 832 "image/x-cmx" => ".cmx", 833 "image/x-epson-erf" => ".erf", 834 "image/x-freehand" => ".fh", 835 "image/x-fuji-raf" => ".raf", 836 "image/x-icon" => ".ico", 837 "image/x-jg" => ".art", 838 "image/x-jng" => ".jng", 839 "image/x-kodak-dcr" => ".dcr", 840 "image/x-kodak-k25" => ".k25", 841 "image/x-kodak-kdc" => ".kdc", 842 "image/x-macpaint" => ".mac", 843 "image/x-minolta-mrw" => ".mrw", 844 "image/x-mrsid-image" => ".sid", 845 "image/x-nikon-nef" => ".nef", 846 "image/x-nikon-nrw" => ".nrw", 847 "image/x-olympus-orf" => ".orf", 848 "image/x-panasonic-rw" => ".raw", 849 "image/x-panasonic-rw2" => ".rw2", 850 "image/x-pentax-pef" => ".pef", 851 "image/x-portable-anymap" => ".pnm", 852 "image/x-portable-bitmap" => ".pbm", 853 "image/x-portable-graymap" => ".pgm", 854 "image/x-portable-pixmap" => ".ppm", 855 "image/x-qoi" => ".qoi", 856 "image/x-quicktime" => ".qti", 857 "image/x-rgb" => ".rgb", 858 "image/x-sigma-x3f" => ".x3f", 859 "image/x-sony-arw" => ".arw", 860 "image/x-sony-sr2" => ".sr2", 861 "image/x-sony-srf" => ".srf", 862 "image/x-tga" => ".tga", 863 "image/x-xbitmap" => ".xbm", 864 "image/x-xcf" => ".xcf", 865 "image/x-xpixmap" => ".xpm", 866 "image/x-xwindowdump" => ".xwd", 867 "model/gltf+json" => ".gltf", 868 "model/gltf-binary" => ".glb", 869 "model/iges" => ".igs", 870 "model/mesh" => ".msh", 871 "model/vnd.collada+xml" => ".dae", 872 "model/vnd.gdl" => ".gdl", 873 "model/vnd.gtw" => ".gtw", 874 "model/vnd.vtu" => ".vtu", 875 "model/vrml" => ".vrml", 876 "model/x3d+binary" => ".x3db", 877 "model/x3d+vrml" => ".x3dv", 878 "model/x3d+xml" => ".x3d", 879 "text/css" => ".css", 880 "text/html" => ".html", 881 "text/plain" => ".txt", 882 "video/3gpp" => ".3gp", 883 "video/3gpp2" => ".3g2", 884 "video/annodex" => ".axv", 885 "video/divx" => ".divx", 886 "video/h261" => ".h261", 887 "video/h263" => ".h263", 888 "video/h264" => ".h264", 889 "video/jpeg" => ".jpgv", 890 "video/jpm" => ".jpgm", 891 "video/mj2" => ".mj2", 892 "video/mp4" => ".mp4", 893 "video/mpeg" => ".mpg", 894 "video/ogg" => ".ogv", 895 "video/quicktime" => ".mov", 896 "video/vnd.dece.hd" => ".uvh", 897 "video/vnd.dece.mobile" => ".uvm", 898 "video/vnd.dece.pd" => ".uvp", 899 "video/vnd.dece.sd" => ".uvs", 900 "video/vnd.dece.video" => ".uvv", 901 "video/vnd.dlna.mpeg-tts" => ".ts", 902 "video/vnd.dvb.file" => ".dvb", 903 "video/vnd.fvt" => ".fvt", 904 "video/vnd.mpegurl" => ".m4u", 905 "video/vnd.ms-playready.media.pyv" => ".pyv", 906 "video/vnd.uvvu.mp4" => ".uvu", 907 "video/vnd.vivo" => ".viv", 908 "video/webm" => ".webm", 909 "video/x-dv" => ".dv", 910 "video/x-f4v" => ".f4v", 911 "video/x-fli" => ".fli", 912 "video/x-flv" => ".flv", 913 "video/x-ivf" => ".ivf", 914 "video/x-la-asf" => ".lsf", 915 "video/x-m4v" => ".m4v", 916 "video/x-matroska" => ".mkv", 917 "video/x-mng" => ".mng", 918 "video/x-ms-asf" => ".asf", 919 "video/x-ms-vob" => ".vob", 920 "video/x-ms-wm" => ".wm", 921 "video/x-ms-wmp" => ".wmp", 922 "video/x-ms-wmv" => ".wmv", 923 "video/x-ms-wmx" => ".wmx", 924 "video/x-ms-wvx" => ".wvx", 925 "video/x-msvideo" => ".avi", 926 "video/x-sgi-movie" => ".movie", 927 "video/x-smv" => ".smv", 928 _ => ".bin", 929 } 930}