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