use crate::config::{ AlbatrossConfig, RemoteBackupConfig, World26PlusConfig, WorldConfig, WorldType, }; use crate::discord::send_webhook; use crate::error::{AlbatrossError, Result}; use crate::region::Region; use crate::remote::RemoteBackupSite; use crate::remote::file::FileBackup; use crate::remote::ftp::FTPBackup; use crate::remote::sftp::SFTPBackup; use chrono::Utc; use flate2::Compression; use flate2::read::GzDecoder; use flate2::write::GzEncoder; use log::{error, info, warn}; use std::convert::TryFrom; use std::fs::{File, copy, create_dir, create_dir_all, remove_dir_all, rename}; use std::path::{Path, PathBuf}; use std::time::Instant; use tar::Archive; /// Backup a file /// /// # Param /// * `file_name` - file name /// * `world_path` - path to the world folder /// * `backup_path` - path to the backup folder pub fn backup_file(file_name: &str, world_path: &Path, backup_path: &Path) -> Result { let world_path = world_path.join(file_name); let backup_path = backup_path.join(file_name); if !world_path.exists() { warn!("File '{world_path:?}' does not exist."); } Ok(copy(world_path, backup_path)?) } /// Backup a directory /// /// # Param /// * `dir_name` - directory name /// * `world_path` - path to the world folder /// * `backup_path` - path to the backup folder pub fn backup_dir(dir_name: &str, world_path: &Path, backup_path: &Path) -> Result { let src_dir = world_path.join(dir_name); if !src_dir.exists() || !src_dir.is_dir() { warn!("Directory '{dir_name}' does not exist in '{world_path:?}'"); return Ok(0); } let backup_dir_path = backup_path.join(dir_name); create_dir(&backup_dir_path)?; let mut file_count = 0; for entry in src_dir.read_dir()? { let entry = entry?; let mut target = backup_dir_path.clone(); if entry.path().is_dir() { let sub_dir = entry.file_name(); let world_path = world_path.join(dir_name); let backup_path = backup_path.join(dir_name); if !backup_path.exists() { create_dir(&backup_path)?; } file_count += backup_dir(sub_dir.to_str().unwrap(), &world_path, &backup_path)?; } else { target.push(entry.file_name()); copy(entry.path(), target)?; file_count += 1; } } Ok(file_count) } /// Backup the regions /// /// # Param /// * `dir_name` - name of the backup folder /// * `save_radius` - block radius to save /// * `world_path` - path to the world folder /// * `backup_path` - path to the backup folder pub fn backup_region( dir_name: &str, save_radius: u64, world_path: &Path, backup_path: &Path, ) -> Result { let mut count: u64 = 0; let src_dir = world_path.join(dir_name); if !src_dir.exists() { warn!("Region directory '{dir_name}' does not exist in '{world_path:?}'"); return Ok(0); } let backup_dir = backup_path.join(dir_name); create_dir(&backup_dir)?; let save_radius = (save_radius as f64 / 512.0).ceil() as i64; for entry in src_dir.read_dir()? { let entry = entry?; let file_name = entry.file_name().to_str().unwrap().to_string(); if let Ok(region) = Region::try_from(file_name) && region.x.abs() <= save_radius && region.y.abs() <= save_radius { let mut target = backup_dir.clone(); target.push(entry.file_name()); copy(entry.path(), target)?; count += 1; } } Ok(count) } /// Backup a world /// /// # Param /// * `world_path` - path to the world folder /// * `backup_path` - path to the backup folder /// * `world_config` - world config options pub fn backup_world( world_path: &Path, backup_path: &Path, world_config: &WorldConfig, ) -> Result { let backup_path = backup_path.join(&world_config.world_name); create_dir(backup_path.as_path())?; backup_region("poi", world_config.save_radius, world_path, &backup_path)?; let region_count = backup_region("region", world_config.save_radius, world_path, &backup_path)?; Ok(region_count) } /// Backup the overworld /// /// # Param /// * `world_path` - path to the world folder /// * `backup_path` - path to the backup folder /// * `world_config` - world config options pub fn backup_overworld( world_path: &Path, backup_path: &Path, world_config: &WorldConfig, ) -> Result<(u64, u64)> { backup_dir("data", world_path, backup_path)?; backup_dir("stats", world_path, backup_path).ok(); backup_file("level.dat", world_path, backup_path)?; backup_file("level.dat_old", world_path, backup_path).ok(); backup_file("session.lock", world_path, backup_path).ok(); backup_file("uid.dat", world_path, backup_path)?; let player_count = backup_dir("playerdata", world_path, backup_path)?; let region_count = backup_world(world_path, backup_path, world_config)?; Ok((region_count, player_count)) } /// Backup the nether /// /// # Param /// * `world_path` - path to the world folder /// * `backup_path` - path to the backup folder /// * `world_config` - world config options pub fn backup_nether( world_path: &Path, backup_path: &Path, world_config: &WorldConfig, ) -> Result { let nether_path = world_path.join(WorldType::Nether.dim_name()); backup_world(&nether_path, backup_path, world_config) } /// Backup the end /// /// # Param /// * `world_path` - path to the world folder /// * `backup_path` - path to the backup folder /// * `world_config` - world config options pub fn backup_end( world_path: &Path, backup_path: &Path, world_config: &WorldConfig, ) -> Result { let end_path = world_path.join(WorldType::End.dim_name()); backup_world(&end_path, backup_path, world_config) } /// Compress the backup after the files have been copied /// /// # Param /// * `tmp_dir`: tmp directory with the backed up files /// * `output_file`: output archive pub fn compress_backup(tmp_dir: &Path, output_file: &Path) -> Result<()> { let archive = File::create(output_file)?; let enc = GzEncoder::new(archive, Compression::default()); let mut tar_builder = tar::Builder::new(enc); tar_builder.append_dir_all(".", tmp_dir)?; Ok(()) } pub fn uncompress_backup(backup: &Path) -> Result { let backup_file = File::open(backup)?; let dec = GzDecoder::new(backup_file); let mut extract = Archive::new(dec); let extract_path = PathBuf::from("tmp"); extract.unpack(&extract_path)?; Ok(extract_path) } /// Takes an existing backup and converts it to a singleplayer world /// /// # Param /// * config - Albatross config /// * backup - path of the backup to convert /// * output - output path pub fn convert_backup_to_sp(config: &AlbatrossConfig, backup: &Path, output: &Path) -> Result<()> { let extract_path = uncompress_backup(backup)?; if let Some(worlds) = &config.world_config { for world in worlds { let world_type = world.world_type.clone().unwrap_or(WorldType::Overworld); let src = PathBuf::from(&extract_path).join(&world.world_name); let dest = PathBuf::from(&extract_path); match world_type { WorldType::Overworld => { rename(src.clone().join("poi"), dest.clone().join("poi"))?; rename(src.clone().join("region"), dest.clone().join("region"))?; } WorldType::Nether => { rename(src, dest.clone().join("DIM-1"))?; } WorldType::End => { rename(src, dest.clone().join("DIM1"))?; } } } } compress_backup(&extract_path, output)?; remove_dir_all(&extract_path)?; Ok(()) } /// Preform a remote_backup backup, if configured pub fn do_remote_backup( remote_backup_cfg: &RemoteBackupConfig, backup_path: PathBuf, ) -> Result<()> { if let Some(config) = &remote_backup_cfg.sftp { let mut sftp_backup = SFTPBackup::new(config, remote_backup_cfg.backups_to_keep)?; sftp_backup.backup_to_remote(backup_path)?; sftp_backup.cleanup()?; } else if let Some(config) = &remote_backup_cfg.ftp { let mut ftps_backup = FTPBackup::new(config, remote_backup_cfg.backups_to_keep)?; ftps_backup.backup_to_remote(backup_path)?; ftps_backup.cleanup()?; } else if let Some(config) = &remote_backup_cfg.file { let mut file_backup = FileBackup::new(config, remote_backup_cfg.backups_to_keep)?; file_backup.backup_to_remote(backup_path)?; file_backup.cleanup()?; } Ok(()) } /// Backup the configured worlds from a minecraft server /// /// # Params /// * `cfg` - config file pub fn do_backup(cfg: AlbatrossConfig, output: Option) -> Result<()> { let server_base_dir = cfg.backup.minecraft_dir.clone(); let timer = Instant::now(); let time_str = Utc::now().format("%d-%m-%y_%H.%M.%S").to_string(); let backup_name = format!("{time_str}_backup.tar.gz"); let mut output_archive = match output { Some(out_path) => out_path, None => cfg.backup.output_config.path.clone(), }; output_archive.push(backup_name); let mut tmp_dir = cfg.backup.output_config.path.clone(); tmp_dir.push("tmp"); remove_dir_all(&tmp_dir).ok(); create_dir_all(tmp_dir.clone())?; send_webhook("**Albatross is swooping in to backup your worlds!**", &cfg); let backup_res = if let Some(worlds) = &cfg.world_config { backup_worlds(&cfg, server_base_dir, worlds, &tmp_dir) } else if let Some(world_config) = &cfg.world_26_plus_config { backup_v26_plus_world_format(&cfg, world_config, &tmp_dir) } else { Err(AlbatrossError::MissingConfig) }; if let Err(err) = backup_res { send_webhook("Failed to backup worlds", &cfg); error!("Failed to backup worlds: {err}"); return Err(err); } compress_backup(&tmp_dir, &output_archive).map_err(|e| { send_webhook("Failed to compress backup", &cfg); error!("Failed to compress backup: {e}"); e })?; remove_dir_all(&tmp_dir)?; let mut local_backup = FileBackup::new(&cfg.backup.output_config, cfg.backup.backups_to_keep)?; match local_backup.cleanup() { Ok(backups_removed) => { if backups_removed > 0 { let msg = format!( "Albatross mistook **{backups_removed}** of your old backups for some french fries and ate them!! SKRAWWWW" ); send_webhook(msg.as_str(), &cfg); info!("Removing {backups_removed} backups...") } } Err(e) => { send_webhook("Failed to remove old backups!", &cfg); error!("Failed to remove old backups: {e}") } } if let Some(remote_backup_config) = &cfg.remote { match do_remote_backup(remote_backup_config, output_archive) { Ok(_) => { send_webhook("Remote backup completed!", &cfg); } Err(e) => { send_webhook("Remote backup failed!", &cfg); error!("Remote backup failed with error: {e}"); } } } let secs = timer.elapsed().as_secs(); send_webhook( format!("**Full backup completed in {secs}s**! *SKREEEEEEEEEE*").as_str(), &cfg, ); info!("Full backup completed in {secs}s!"); Ok(()) } /// Backup a dimension /// /// # Param /// * `world_path` - path to the world folder /// * `backup_path` - path to the backup folder /// * `world_config` - world config options pub fn backup_dimension( world_path: &Path, backup_path: &Path, world_config: &WorldConfig, ) -> Result { let dimension_path = Path::new("dimensions").join(&world_config.world_name); let backup_path = backup_path.join(&dimension_path); let src_path = world_path.join(dimension_path); create_dir_all(backup_path.as_path())?; backup_dir("data", &src_path, &backup_path)?; let region_count = backup_region("region", world_config.save_radius, &src_path, &backup_path)?; Ok(region_count) } /// Backup v26.1 + version /// /// # Param /// * `world_path` - path to the world folder /// * `backup_path` - path to the backup folder /// * `world_config` - world config options pub fn backup_v26_plus_world_format( cfg: &AlbatrossConfig, world_config: &World26PlusConfig, tmp_dir: &Path, ) -> Result<()> { let world_path = cfg.backup.minecraft_dir.join(&world_config.world_name); let backup_path = tmp_dir.join(&world_config.world_name); if !backup_path.exists() { create_dir(&backup_path)?; } send_webhook( format!("Starting backup **{}**", world_config.world_name).as_str(), cfg, ); // Backup common files backup_dir("data", &world_path, &backup_path)?; backup_dir("datapacks", &world_path, &backup_path)?; backup_file("level.dat", &world_path, &backup_path)?; backup_file("level.dat_old", &world_path, &backup_path).ok(); backup_file("session.lock", &world_path, &backup_path).ok(); let player_count = backup_dir("players", &world_path, &backup_path)?; send_webhook(format!("Backed up {player_count} players").as_str(), cfg); info!("Backed up {player_count} players"); for world_config in &world_config.dimensions { send_webhook( format!("Starting backup of **{}**", world_config.world_name).as_str(), cfg, ); info!( "Starting backup of dimension **{}**", world_config.world_name ); let region_count = backup_dimension(&world_path, &backup_path, world_config)?; send_webhook(format!("{region_count} regions backed up.").as_str(), cfg); info!("{region_count} regions backed up.") } Ok(()) } fn backup_worlds( cfg: &AlbatrossConfig, server_base_dir: PathBuf, worlds: &[WorldConfig], tmp_dir: &Path, ) -> Result<()> { for world in worlds { let mut world_dir = server_base_dir.clone(); let world_name = world.world_name.clone(); let world_type = world.world_type.clone().unwrap_or(WorldType::Overworld); world_dir.push(world_name.clone()); if world_dir.exists() && world_dir.is_dir() { send_webhook(format!("Starting backup of **{world_name}**").as_str(), cfg); info!("Starting backup of {world_name}."); let webhook_msg = match world_type { WorldType::Overworld => { let (region_count, player_count) = backup_overworld(&world_dir.clone(), tmp_dir, world)?; format!("{region_count} regions and {player_count} player files backed up.") } WorldType::Nether => { let region_count = backup_nether(&world_dir, tmp_dir, world)?; format!("{region_count} regions backed up.") } WorldType::End => { let region_count = backup_end(&world_dir, tmp_dir, world)?; format!("{region_count} regions backed up.") } }; send_webhook(&webhook_msg, cfg); info!("{webhook_msg}"); } else { send_webhook(format!("Error: {world_name} not found.").as_str(), cfg); error!("World \"{world_name}\" not found"); } } Ok(()) }