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}