use crate::config::Config; use crate::db::Db; use chrono::Utc; use std::fs::File; use std::path::{Path, PathBuf}; use tar::{Builder, Header}; use tracing::{error, info}; use zstd::Encoder; #[derive(serde::Serialize, serde::Deserialize)] struct UserBackupMetadata { id: i64, username: String, password_hash: String, status: String, created_at: String, account_type: String, metadata: Option, quotas: UserBackupQuotas, } #[derive(serde::Serialize, serde::Deserialize)] struct UserBackupQuotas { max_urls: i64, max_landings: i64, max_api_tokens: i64, max_storage_mb: i64, } pub async fn run( username: String, out: Option, data_dir: Option, mut config: Config, ) -> Result<(), Box> { if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } let db = Db::init(&config)?; let username_clean = username.trim().to_lowercase(); // 1. Get user details from users.db let user_details = { let conn = db.users.lock().unwrap(); crate::db::users::get_user_by_username(&conn, &username_clean)? }; let user = match user_details { Some(u) => u, None => { error!("User '{}' not found", username_clean); return Ok(()); } }; let user_id = user.id; // 2. Fetch user's quotas let quotas = { let conn = db.users.lock().unwrap(); conn.query_row( "SELECT max_urls, max_landings, max_api_tokens, max_storage_mb FROM quotas WHERE user_id = ?1;", [user_id], |row| { Ok(UserBackupQuotas { max_urls: row.get(0)?, max_landings: row.get(1)?, max_api_tokens: row.get(2)?, max_storage_mb: row.get(3)?, }) } )? }; // 3. Define output path let tar_path = match out { Some(p) => PathBuf::from(p), None => { if !config.backup_dir.exists() { std::fs::create_dir_all(&config.backup_dir)?; } config.backup_dir.join(format!( "{}-{}.tar.zst", username_clean, Utc::now().format("%Y%m%d") )) } }; info!( "Backing up user {} (ID: {}) to {:?}", username_clean, user_id, tar_path ); // 4. Force checkpoint on user's databases let user_dir = config.data_dir.join("users").join(user_id.to_string()); if let Ok(c) = rusqlite::Connection::open(user_dir.join("content.db")) { let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); } if let Ok(c) = rusqlite::Connection::open(user_dir.join("analytics.db")) { let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); } if let Ok(c) = rusqlite::Connection::open(user_dir.join("profile.db")) { let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []); } // 5. Create tar.zst archive let file = File::create(&tar_path)?; let zst_enc = Encoder::new(file, 3)?; let mut tar = Builder::new(zst_enc); // Write metadata.json directly into tar let metadata_obj = UserBackupMetadata { id: user.id, username: user.username, password_hash: user.password_hash, status: user.status, created_at: user.created_at, account_type: user.account_type, metadata: user.metadata, quotas, }; let metadata_bytes = serde_json::to_vec_pretty(&metadata_obj)?; let mut header = Header::new_gnu(); header.set_size(metadata_bytes.len() as u64); header.set_path("metadata.json")?; header.set_mode(0o644); header.set_cksum(); tar.append(&header, &metadata_bytes[..])?; // Append database files let mut append_file = |name_in_archive: &str, path_on_disk: &Path| -> Result<(), Box> { if path_on_disk.exists() { let mut file = File::open(path_on_disk)?; tar.append_file(name_in_archive, &mut file)?; } Ok(()) }; append_file("content.db", &user_dir.join("content.db"))?; append_file("analytics.db", &user_dir.join("analytics.db"))?; append_file("profile.db", &user_dir.join("profile.db"))?; tar.into_inner()?.finish()?; info!("User backup generated successfully at {:?}", tar_path); Ok(()) }