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}