use crate::api::error::ApiError; use crate::api::{EmptyResponse, EnabledResponse}; use crate::auth::BearerAuth; use crate::scheduled::generate_full_backup; use crate::state::AppState; use crate::storage::BackupStorage; use axum::{ Json, extract::{Query, State}, http::StatusCode, response::{IntoResponse, Response}, }; use cid::Cid; use serde::{Deserialize, Serialize}; use serde_json::json; use std::str::FromStr; use tracing::{error, info, warn}; #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct BackupInfo { pub id: String, pub repo_rev: String, pub repo_root_cid: String, pub block_count: i32, pub size_bytes: i64, pub created_at: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ListBackupsOutput { pub backups: Vec, pub backup_enabled: bool, } pub async fn list_backups(State(state): State, auth: BearerAuth) -> Response { let user = match sqlx::query!( "SELECT id, backup_enabled FROM users WHERE did = $1", auth.0.did.as_str() ) .fetch_optional(&state.db) .await { Ok(Some(u)) => u, Ok(None) => { return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error fetching user: {:?}", e); return ApiError::InternalError(None).into_response(); } }; let backups = match sqlx::query!( r#" SELECT id, repo_rev, repo_root_cid, block_count, size_bytes, created_at FROM account_backups WHERE user_id = $1 ORDER BY created_at DESC "#, user.id ) .fetch_all(&state.db) .await { Ok(rows) => rows, Err(e) => { error!("DB error fetching backups: {:?}", e); return ApiError::InternalError(None).into_response(); } }; let backup_list: Vec = backups .into_iter() .map(|b| BackupInfo { id: b.id.to_string(), repo_rev: b.repo_rev, repo_root_cid: b.repo_root_cid, block_count: b.block_count, size_bytes: b.size_bytes, created_at: b.created_at.to_rfc3339(), }) .collect(); ( StatusCode::OK, Json(ListBackupsOutput { backups: backup_list, backup_enabled: user.backup_enabled, }), ) .into_response() } #[derive(Deserialize)] pub struct GetBackupQuery { pub id: String, } pub async fn get_backup( State(state): State, auth: BearerAuth, Query(query): Query, ) -> Response { let backup_id = match uuid::Uuid::parse_str(&query.id) { Ok(id) => id, Err(_) => { return ApiError::InvalidRequest("Invalid backup ID".into()).into_response(); } }; let backup = match sqlx::query!( r#" SELECT ab.storage_key, ab.repo_rev FROM account_backups ab JOIN users u ON u.id = ab.user_id WHERE ab.id = $1 AND u.did = $2 "#, backup_id, auth.0.did.as_str() ) .fetch_optional(&state.db) .await { Ok(Some(b)) => b, Ok(None) => { return ApiError::BackupNotFound.into_response(); } Err(e) => { error!("DB error fetching backup: {:?}", e); return ApiError::InternalError(None).into_response(); } }; let backup_storage = match state.backup_storage.as_ref() { Some(storage) => storage, None => { return ApiError::BackupsDisabled.into_response(); } }; let car_bytes = match backup_storage.get_backup(&backup.storage_key).await { Ok(bytes) => bytes, Err(e) => { error!("Failed to fetch backup from storage: {:?}", e); return ApiError::InternalError(Some("Failed to retrieve backup".into())) .into_response(); } }; ( StatusCode::OK, [ (axum::http::header::CONTENT_TYPE, "application/vnd.ipld.car"), ( axum::http::header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{}.car\"", backup.repo_rev), ), ], car_bytes, ) .into_response() } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateBackupOutput { pub id: String, pub repo_rev: String, pub size_bytes: i64, pub block_count: i32, } pub async fn create_backup(State(state): State, auth: BearerAuth) -> Response { let backup_storage = match state.backup_storage.as_ref() { Some(storage) => storage, None => { return ApiError::BackupsDisabled.into_response(); } }; let user = match sqlx::query!( r#" SELECT u.id, u.did, u.backup_enabled, u.deactivated_at, r.repo_root_cid, r.repo_rev FROM users u JOIN repos r ON r.user_id = u.id WHERE u.did = $1 "#, auth.0.did.as_str() ) .fetch_optional(&state.db) .await { Ok(Some(u)) => u, Ok(None) => { return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error fetching user: {:?}", e); return ApiError::InternalError(None).into_response(); } }; if user.deactivated_at.is_some() { return ApiError::AccountDeactivated.into_response(); } let repo_rev = match &user.repo_rev { Some(rev) => rev.clone(), None => { return ApiError::RepoNotReady.into_response(); } }; let head_cid = match Cid::from_str(&user.repo_root_cid) { Ok(c) => c, Err(_) => { return ApiError::InternalError(Some("Invalid repo root CID".into())).into_response(); } }; let car_bytes = match generate_full_backup(&state.db, &state.block_store, user.id, &head_cid).await { Ok(bytes) => bytes, Err(e) => { error!("Failed to generate CAR: {:?}", e); return ApiError::InternalError(Some("Failed to generate backup".into())) .into_response(); } }; let block_count = crate::scheduled::count_car_blocks(&car_bytes); let size_bytes = car_bytes.len() as i64; let storage_key = match backup_storage .put_backup(&user.did, &repo_rev, &car_bytes) .await { Ok(key) => key, Err(e) => { error!("Failed to upload backup: {:?}", e); return ApiError::InternalError(Some("Failed to store backup".into())).into_response(); } }; let backup_id = match sqlx::query_scalar!( r#" INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id "#, user.id, storage_key, user.repo_root_cid, repo_rev, block_count, size_bytes ) .fetch_one(&state.db) .await { Ok(id) => id, Err(e) => { error!("DB error inserting backup: {:?}", e); if let Err(rollback_err) = backup_storage.delete_backup(&storage_key).await { error!( storage_key = %storage_key, error = %rollback_err, "Failed to rollback orphaned backup from S3" ); } return ApiError::InternalError(Some("Failed to record backup".into())).into_response(); } }; info!( did = %user.did, rev = %repo_rev, size_bytes, "Created manual backup" ); let retention = BackupStorage::retention_count(); if let Err(e) = cleanup_old_backups(&state.db, backup_storage, user.id, retention).await { warn!(did = %user.did, error = %e, "Failed to cleanup old backups after manual backup"); } ( StatusCode::OK, Json(CreateBackupOutput { id: backup_id.to_string(), repo_rev, size_bytes, block_count, }), ) .into_response() } async fn cleanup_old_backups( db: &sqlx::PgPool, backup_storage: &BackupStorage, user_id: uuid::Uuid, retention_count: u32, ) -> Result<(), String> { let old_backups = sqlx::query!( r#" SELECT id, storage_key FROM account_backups WHERE user_id = $1 ORDER BY created_at DESC OFFSET $2 "#, user_id, retention_count as i64 ) .fetch_all(db) .await .map_err(|e| format!("DB error fetching old backups: {}", e))?; for backup in old_backups { if let Err(e) = backup_storage.delete_backup(&backup.storage_key).await { warn!( storage_key = %backup.storage_key, error = %e, "Failed to delete old backup from storage, skipping DB cleanup to avoid orphan" ); continue; } sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id) .execute(db) .await .map_err(|e| format!("Failed to delete old backup record: {}", e))?; } Ok(()) } #[derive(Deserialize)] pub struct DeleteBackupQuery { pub id: String, } pub async fn delete_backup( State(state): State, auth: BearerAuth, Query(query): Query, ) -> Response { let backup_id = match uuid::Uuid::parse_str(&query.id) { Ok(id) => id, Err(_) => { return ApiError::InvalidRequest("Invalid backup ID".into()).into_response(); } }; let backup = match sqlx::query!( r#" SELECT ab.id, ab.storage_key, u.deactivated_at FROM account_backups ab JOIN users u ON u.id = ab.user_id WHERE ab.id = $1 AND u.did = $2 "#, backup_id, auth.0.did.as_str() ) .fetch_optional(&state.db) .await { Ok(Some(b)) => b, Ok(None) => { return ApiError::BackupNotFound.into_response(); } Err(e) => { error!("DB error fetching backup: {:?}", e); return ApiError::InternalError(None).into_response(); } }; if backup.deactivated_at.is_some() { return ApiError::AccountDeactivated.into_response(); } if let Some(backup_storage) = state.backup_storage.as_ref() && let Err(e) = backup_storage.delete_backup(&backup.storage_key).await { warn!( storage_key = %backup.storage_key, error = %e, "Failed to delete backup from storage (continuing anyway)" ); } if let Err(e) = sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id) .execute(&state.db) .await { error!("DB error deleting backup: {:?}", e); return ApiError::InternalError(Some("Failed to delete backup".into())).into_response(); } info!(did = %auth.0.did, backup_id = %backup_id, "Deleted backup"); EmptyResponse::ok().into_response() } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SetBackupEnabledInput { pub enabled: bool, } pub async fn set_backup_enabled( State(state): State, auth: BearerAuth, Json(input): Json, ) -> Response { let user = match sqlx::query!( "SELECT deactivated_at FROM users WHERE did = $1", auth.0.did.as_str() ) .fetch_optional(&state.db) .await { Ok(Some(u)) => u, Ok(None) => { return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error fetching user: {:?}", e); return ApiError::InternalError(None).into_response(); } }; if user.deactivated_at.is_some() { return ApiError::AccountDeactivated.into_response(); } if let Err(e) = sqlx::query!( "UPDATE users SET backup_enabled = $1 WHERE did = $2", input.enabled, auth.0.did.as_str() ) .execute(&state.db) .await { error!("DB error updating backup_enabled: {:?}", e); return ApiError::InternalError(Some("Failed to update setting".into())).into_response(); } info!(did = %auth.0.did, enabled = input.enabled, "Updated backup_enabled setting"); EnabledResponse::response(input.enabled).into_response() } pub async fn export_blobs(State(state): State, auth: BearerAuth) -> Response { let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth.0.did.as_str()) .fetch_optional(&state.db) .await { Ok(Some(u)) => u, Ok(None) => { return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error fetching user: {:?}", e); return ApiError::InternalError(None).into_response(); } }; let blobs = match sqlx::query!( r#" SELECT DISTINCT b.cid, b.storage_key, b.mime_type FROM blobs b JOIN record_blobs rb ON rb.blob_cid = b.cid WHERE rb.repo_id = $1 "#, user.id ) .fetch_all(&state.db) .await { Ok(rows) => rows, Err(e) => { error!("DB error fetching blobs: {:?}", e); return ApiError::InternalError(None).into_response(); } }; if blobs.is_empty() { return ( StatusCode::OK, [ (axum::http::header::CONTENT_TYPE, "application/zip"), ( axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"blobs.zip\"", ), ], Vec::::new(), ) .into_response(); } let mut zip_buffer = std::io::Cursor::new(Vec::new()); { let mut zip = zip::ZipWriter::new(&mut zip_buffer); let options = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated); let mut exported: Vec = Vec::new(); let mut skipped: Vec = Vec::new(); for blob in &blobs { let blob_data = match state.blob_store.get(&blob.storage_key).await { Ok(data) => data, Err(e) => { warn!(cid = %blob.cid, error = %e, "Failed to fetch blob, skipping"); skipped.push(json!({ "cid": blob.cid, "mimeType": blob.mime_type, "reason": "fetch_failed" })); continue; } }; let extension = mime_to_extension(&blob.mime_type); let filename = format!("{}{}", blob.cid, extension); if let Err(e) = zip.start_file(&filename, options) { warn!(filename = %filename, error = %e, "Failed to start zip file entry"); skipped.push(json!({ "cid": blob.cid, "mimeType": blob.mime_type, "reason": "zip_entry_failed" })); continue; } if let Err(e) = std::io::Write::write_all(&mut zip, &blob_data) { warn!(filename = %filename, error = %e, "Failed to write blob to zip"); skipped.push(json!({ "cid": blob.cid, "mimeType": blob.mime_type, "reason": "write_failed" })); continue; } exported.push(json!({ "cid": blob.cid, "filename": filename, "mimeType": blob.mime_type, "sizeBytes": blob_data.len() })); } let manifest = json!({ "exportedAt": chrono::Utc::now().to_rfc3339(), "totalBlobs": blobs.len(), "exportedCount": exported.len(), "skippedCount": skipped.len(), "exported": exported, "skipped": skipped }); if zip.start_file("manifest.json", options).is_ok() { let _ = std::io::Write::write_all( &mut zip, serde_json::to_string_pretty(&manifest) .unwrap_or_else(|_| "{}".to_string()) .as_bytes(), ); } if let Err(e) = zip.finish() { error!("Failed to finish zip: {:?}", e); return ApiError::InternalError(Some("Failed to create zip file".into())) .into_response(); } } let zip_bytes = zip_buffer.into_inner(); info!(did = %auth.0.did, blob_count = blobs.len(), size_bytes = zip_bytes.len(), "Exported blobs"); ( StatusCode::OK, [ (axum::http::header::CONTENT_TYPE, "application/zip"), ( axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"blobs.zip\"", ), ], zip_bytes, ) .into_response() } fn mime_to_extension(mime_type: &str) -> &'static str { match mime_type { "application/font-sfnt" => ".otf", "application/font-tdpfr" => ".pfr", "application/font-woff" => ".woff", "application/gzip" => ".gz", "application/json" => ".json", "application/json5" => ".json5", "application/jsonml+json" => ".jsonml", "application/octet-stream" => ".bin", "application/pdf" => ".pdf", "application/zip" => ".zip", "audio/aac" => ".aac", "audio/ac3" => ".ac3", "audio/aiff" => ".aiff", "audio/annodex" => ".axa", "audio/audible" => ".aa", "audio/basic" => ".au", "audio/flac" => ".flac", "audio/m4a" => ".m4a", "audio/m4b" => ".m4b", "audio/m4p" => ".m4p", "audio/mid" => ".mid", "audio/midi" => ".midi", "audio/mp4" => ".mp4a", "audio/mpeg" => ".mp3", "audio/ogg" => ".ogg", "audio/s3m" => ".s3m", "audio/scpls" => ".pls", "audio/silk" => ".sil", "audio/vnd.audible.aax" => ".aax", "audio/vnd.dece.audio" => ".uva", "audio/vnd.digital-winds" => ".eol", "audio/vnd.dlna.adts" => ".adt", "audio/vnd.dra" => ".dra", "audio/vnd.dts" => ".dts", "audio/vnd.dts.hd" => ".dtshd", "audio/vnd.lucent.voice" => ".lvp", "audio/vnd.ms-playready.media.pya" => ".pya", "audio/vnd.nuera.ecelp4800" => ".ecelp4800", "audio/vnd.nuera.ecelp7470" => ".ecelp7470", "audio/vnd.nuera.ecelp9600" => ".ecelp9600", "audio/vnd.rip" => ".rip", "audio/wav" => ".wav", "audio/webm" => ".weba", "audio/x-caf" => ".caf", "audio/x-gsm" => ".gsm", "audio/x-m4r" => ".m4r", "audio/x-matroska" => ".mka", "audio/x-mpegurl" => ".m3u", "audio/x-ms-wax" => ".wax", "audio/x-ms-wma" => ".wma", "audio/x-pn-realaudio" => ".ra", "audio/x-pn-realaudio-plugin" => ".rpm", "audio/x-sd2" => ".sd2", "audio/x-smd" => ".smd", "audio/xm" => ".xm", "font/collection" => ".ttc", "font/ttf" => ".ttf", "font/woff" => ".woff", "font/woff2" => ".woff2", "image/apng" => ".apng", "image/avif" => ".avif", "image/avif-sequence" => ".avifs", "image/bmp" => ".bmp", "image/cgm" => ".cgm", "image/cis-cod" => ".cod", "image/g3fax" => ".g3", "image/gif" => ".gif", "image/heic" => ".heic", "image/heic-sequence" => ".heics", "image/heif" => ".heif", "image/heif-sequence" => ".heifs", "image/ief" => ".ief", "image/jp2" => ".jp2", "image/jpeg" => ".jpg", "image/jpm" => ".jpm", "image/jpx" => ".jpf", "image/jxl" => ".jxl", "image/ktx" => ".ktx", "image/pict" => ".pct", "image/png" => ".png", "image/prs.btif" => ".btif", "image/qoi" => ".qoi", "image/sgi" => ".sgi", "image/svg+xml" => ".svg", "image/tiff" => ".tiff", "image/vnd.dece.graphic" => ".uvg", "image/vnd.djvu" => ".djv", "image/vnd.fastbidsheet" => ".fbs", "image/vnd.fpx" => ".fpx", "image/vnd.fst" => ".fst", "image/vnd.fujixerox.edmics-mmr" => ".mmr", "image/vnd.fujixerox.edmics-rlc" => ".rlc", "image/vnd.ms-modi" => ".mdi", "image/vnd.ms-photo" => ".wdp", "image/vnd.net-fpx" => ".npx", "image/vnd.radiance" => ".hdr", "image/vnd.rn-realflash" => ".rf", "image/vnd.wap.wbmp" => ".wbmp", "image/vnd.xiff" => ".xif", "image/webp" => ".webp", "image/x-3ds" => ".3ds", "image/x-adobe-dng" => ".dng", "image/x-canon-cr2" => ".cr2", "image/x-canon-cr3" => ".cr3", "image/x-canon-crw" => ".crw", "image/x-cmu-raster" => ".ras", "image/x-cmx" => ".cmx", "image/x-epson-erf" => ".erf", "image/x-freehand" => ".fh", "image/x-fuji-raf" => ".raf", "image/x-icon" => ".ico", "image/x-jg" => ".art", "image/x-jng" => ".jng", "image/x-kodak-dcr" => ".dcr", "image/x-kodak-k25" => ".k25", "image/x-kodak-kdc" => ".kdc", "image/x-macpaint" => ".mac", "image/x-minolta-mrw" => ".mrw", "image/x-mrsid-image" => ".sid", "image/x-nikon-nef" => ".nef", "image/x-nikon-nrw" => ".nrw", "image/x-olympus-orf" => ".orf", "image/x-panasonic-rw" => ".raw", "image/x-panasonic-rw2" => ".rw2", "image/x-pentax-pef" => ".pef", "image/x-portable-anymap" => ".pnm", "image/x-portable-bitmap" => ".pbm", "image/x-portable-graymap" => ".pgm", "image/x-portable-pixmap" => ".ppm", "image/x-qoi" => ".qoi", "image/x-quicktime" => ".qti", "image/x-rgb" => ".rgb", "image/x-sigma-x3f" => ".x3f", "image/x-sony-arw" => ".arw", "image/x-sony-sr2" => ".sr2", "image/x-sony-srf" => ".srf", "image/x-tga" => ".tga", "image/x-xbitmap" => ".xbm", "image/x-xcf" => ".xcf", "image/x-xpixmap" => ".xpm", "image/x-xwindowdump" => ".xwd", "model/gltf+json" => ".gltf", "model/gltf-binary" => ".glb", "model/iges" => ".igs", "model/mesh" => ".msh", "model/vnd.collada+xml" => ".dae", "model/vnd.gdl" => ".gdl", "model/vnd.gtw" => ".gtw", "model/vnd.vtu" => ".vtu", "model/vrml" => ".vrml", "model/x3d+binary" => ".x3db", "model/x3d+vrml" => ".x3dv", "model/x3d+xml" => ".x3d", "text/css" => ".css", "text/html" => ".html", "text/plain" => ".txt", "video/3gpp" => ".3gp", "video/3gpp2" => ".3g2", "video/annodex" => ".axv", "video/divx" => ".divx", "video/h261" => ".h261", "video/h263" => ".h263", "video/h264" => ".h264", "video/jpeg" => ".jpgv", "video/jpm" => ".jpgm", "video/mj2" => ".mj2", "video/mp4" => ".mp4", "video/mpeg" => ".mpg", "video/ogg" => ".ogv", "video/quicktime" => ".mov", "video/vnd.dece.hd" => ".uvh", "video/vnd.dece.mobile" => ".uvm", "video/vnd.dece.pd" => ".uvp", "video/vnd.dece.sd" => ".uvs", "video/vnd.dece.video" => ".uvv", "video/vnd.dlna.mpeg-tts" => ".ts", "video/vnd.dvb.file" => ".dvb", "video/vnd.fvt" => ".fvt", "video/vnd.mpegurl" => ".m4u", "video/vnd.ms-playready.media.pyv" => ".pyv", "video/vnd.uvvu.mp4" => ".uvu", "video/vnd.vivo" => ".viv", "video/webm" => ".webm", "video/x-dv" => ".dv", "video/x-f4v" => ".f4v", "video/x-fli" => ".fli", "video/x-flv" => ".flv", "video/x-ivf" => ".ivf", "video/x-la-asf" => ".lsf", "video/x-m4v" => ".m4v", "video/x-matroska" => ".mkv", "video/x-mng" => ".mng", "video/x-ms-asf" => ".asf", "video/x-ms-vob" => ".vob", "video/x-ms-wm" => ".wm", "video/x-ms-wmp" => ".wmp", "video/x-ms-wmv" => ".wmv", "video/x-ms-wmx" => ".wmx", "video/x-ms-wvx" => ".wvx", "video/x-msvideo" => ".avi", "video/x-sgi-movie" => ".movie", "video/x-smv" => ".smv", _ => ".bin", } }