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