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