diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..b19916a --- /dev/null +++ b/.drone.yml @@ -0,0 +1,42 @@ +--- +kind: pipeline +name: compliance +type: docker + +trigger: + event: + - pull_request + +steps: + - name: build + pull: always + image: rust:1.46.0 + commands: + - cargo build --verbose + +--- +kind: pipeline +name: release +type: docker + +trigger: + branch: + - master + event: + - push + +steps: + - name: build + pull: always + image: rust:1.46.0 + commands: + - cargo build --verbose --release + - name: gitea-release + pull: always + image: jolheiser/drone-gitea-main:latest + settings: + token: + from_secret: gitea_token + base: https://git.etztech.xyz + files: + - "target/release/albatross" \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index bfe8758..c28b380 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,5 +1,20 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +[[package]] +name = "addr2line" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6a2d3371669ab3ca9797670853d61402b03d0b4b9ebf33d677dfa720203072" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee2a4ec343196209d6594e19543ae87a39f96d5534d7174822a3ad825dd6ed7e" + [[package]] name = "adler32" version = "1.0.4" @@ -17,17 +32,17 @@ dependencies = [ [[package]] name = "albatross" -version = "0.1.0" +version = "0.2.0" dependencies = [ "chrono", - "clap", "config", + "discord-hooks-rs", "flate2", "log", "regex", "reqwest", - "serde 1.0.111", - "serde_derive", + "serde 1.0.117", + "structopt", "tar", ] @@ -57,6 +72,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d" +[[package]] +name = "backtrace" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707b586e0e2f247cbde68cdd2c3ce69ea7b7be43e1c5b426e37c9319c4b9838e" +dependencies = [ + "addr2line", + "cfg-if 1.0.0", + "libc", + "miniz_oxide 0.4.3", + "object", + "rustc-demangle", +] + [[package]] name = "base64" version = "0.12.1" @@ -93,6 +122,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + [[package]] name = "chrono" version = "0.4.11" @@ -128,7 +163,7 @@ dependencies = [ "lazy_static 1.4.0", "nom", "rust-ini", - "serde 1.0.111", + "serde 1.0.117", "serde-hjson", "serde_json", "toml", @@ -157,7 +192,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba125de2af0df55319f41944744ad91c71113bf74a4646efff39afe1f6842db1" dependencies = [ - "cfg-if", + "cfg-if 0.1.10", +] + +[[package]] +name = "discord-hooks-rs" +version = "0.1.0" +source = "git+https://github.com/joeyahines/discord-hooks-rs#70307a7a5d00b8c48c0dc10402c7d8325836539a" +dependencies = [ + "failure", + "serde 1.0.117", + "serde_json", ] [[package]] @@ -172,7 +217,29 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8ac63f94732332f44fe654443c46f6375d1939684c17b0afb6cb56b0456e171" dependencies = [ - "cfg-if", + "cfg-if 0.1.10", +] + +[[package]] +name = "failure" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d32e9bd16cc02eae7db7ef620b392808b89f6a5e16bb3497d159c6b92a0f4f86" +dependencies = [ + "backtrace", + "failure_derive", +] + +[[package]] +name = "failure_derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4da3c766cd7a0db8242e326e9e4e081edd567072893ed320008189715366a4" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", ] [[package]] @@ -181,7 +248,7 @@ version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "affc17579b132fc2461adf7c575cc6e8b134ebca52c51f5411388965227dc695" dependencies = [ - "cfg-if", + "cfg-if 0.1.10", "libc", "redox_syscall", "winapi 0.3.8", @@ -193,10 +260,10 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cfff41391129e0a856d6d822600b8d71179d46879e310417eb9c762eb178b42" dependencies = [ - "cfg-if", + "cfg-if 0.1.10", "crc32fast", "libc", - "miniz_oxide", + "miniz_oxide 0.3.6", ] [[package]] @@ -293,11 +360,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7abc8dd8451921606d809ba32e95b6111925cd2906060d2dcc29c070220503eb" dependencies = [ - "cfg-if", + "cfg-if 0.1.10", "libc", "wasi", ] +[[package]] +name = "gimli" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf91faf136cb47367fa430cd46e37a788775e7fa104f8b4bcb3861dc389b724" + [[package]] name = "h2" version = "0.2.5" @@ -317,6 +390,15 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "heck" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20564e78d53d2bb135c343b3f47714a56af2061f1c928fdb541dc7b9fdd94205" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "hermit-abi" version = "0.1.13" @@ -484,7 +566,7 @@ version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14b6052be84e6b71ab17edffc2eeabf5c2c3ae1fdb464aae35ac50c67a44e1f7" dependencies = [ - "cfg-if", + "cfg-if 0.1.10", ] [[package]] @@ -524,13 +606,23 @@ dependencies = [ "adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2d26ec3309788e423cfbf68ad1800f061638098d76a83681af979dc4eda19d" +dependencies = [ + "adler", + "autocfg", +] + [[package]] name = "mio" version = "0.6.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fce347092656428bc8eaf6201042cb551b8d67855af7374542a92a0fbfcac430" dependencies = [ - "cfg-if", + "cfg-if 0.1.10", "fuchsia-zircon", "fuchsia-zircon-sys", "iovec", @@ -579,7 +671,7 @@ version = "0.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ba7c918ac76704fb42afcbbb43891e72731f3dcca3bef2a19786297baf14af7" dependencies = [ - "cfg-if", + "cfg-if 0.1.10", "libc", "winapi 0.3.8", ] @@ -632,6 +724,12 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37fd5004feb2ce328a52b0b3d01dbf4ffff72583493900ed15f22d4111c51693" + [[package]] name = "once_cell" version = "1.4.0" @@ -645,7 +743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cee6d85f4cb4c4f59a6a85d5b68a233d280c82e29e822913b9c8b129fbf20bdd" dependencies = [ "bitflags", - "cfg-if", + "cfg-if 0.1.10", "foreign-types", "lazy_static 1.4.0", "libc", @@ -722,10 +820,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "237a5ed80e274dbc66f86bd59c1e25edc039660be53194b5fe0a482e0f2612ea" [[package]] -name = "proc-macro2" -version = "1.0.18" +name = "proc-macro-error" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beae6331a816b1f65d04c45b078fd8e6c93e8071771f41b8163255bbd8d7c8fa" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check 0.9.2", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check 0.9.2", +] + +[[package]] +name = "proc-macro2" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" dependencies = [ "unicode-xid", ] @@ -836,7 +958,7 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", - "serde 1.0.111", + "serde 1.0.117", "serde_json", "serde_urlencoded", "tokio", @@ -854,6 +976,12 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e52c148ef37f8c375d49d5a73aa70713125b7f19095948a923f80afdeb22ec2" +[[package]] +name = "rustc-demangle" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e3bad0ee36814ca07d7968269dd4b7ec89ec2da10c4bb613928d3077083c232" + [[package]] name = "ryu" version = "1.0.5" @@ -901,9 +1029,12 @@ checksum = "9dad3f759919b92c3068c696c15c3d17238234498bbdcc80f2c469606f948ac8" [[package]] name = "serde" -version = "1.0.111" +version = "1.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9124df5b40cbd380080b2cc6ab894c040a3070d995f5c9dc77e18c34a8ae37d" +checksum = "b88fa983de7720629c9387e9f517353ed404164b1e482c970a90c1a4aaf7dc1a" +dependencies = [ + "serde_derive", +] [[package]] name = "serde-hjson" @@ -920,9 +1051,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.111" +version = "1.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f2c3ac8e6ca1e9c80b8be1023940162bf81ae3cffbb1809474152f2ce1eb250" +checksum = "cbd1ae72adb44aab48f325a02444a5fc079349a8d804c1fc922aed3f7454c74e" dependencies = [ "proc-macro2", "quote", @@ -937,7 +1068,7 @@ checksum = "993948e75b189211a9b31a7528f950c6adc21f9720b6438ff80a7fa2f864cea2" dependencies = [ "itoa", "ryu", - "serde 1.0.111", + "serde 1.0.117", ] [[package]] @@ -957,7 +1088,7 @@ checksum = "9ec5d77e2d4c73717816afac02670d5c4f534ea95ed430442cad02e7a6e32c97" dependencies = [ "dtoa", "itoa", - "serde 1.0.111", + "serde 1.0.117", "url", ] @@ -979,7 +1110,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03088793f677dce356f3ccc2edb1b314ad191ab702a5de3faf49304f7e104918" dependencies = [ - "cfg-if", + "cfg-if 0.1.10", "libc", "redox_syscall", "winapi 0.3.8", @@ -992,16 +1123,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" [[package]] -name = "syn" -version = "1.0.30" +name = "structopt" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93a56fabc59dce20fe48b6c832cc249c713e7ed88fa28b0ee0a3bfcaae5fe4e2" +checksum = "126d630294ec449fae0b16f964e35bf3c74f940da9dca17ee9b905f7b3112eb8" +dependencies = [ + "clap", + "lazy_static 1.4.0", + "structopt-derive", +] + +[[package]] +name = "structopt-derive" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65e51c492f9e23a220534971ff5afc14037289de430e3c83f9daf6a1b6ae91e8" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ad5de3220ea04da322618ded2c42233d02baca219d6f160a3e9c87cda16c942" dependencies = [ "proc-macro2", "quote", "unicode-xid", ] +[[package]] +name = "synstructure" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b834f2d66f734cb897113e34aaff2f1ab4719ca946f9a7358dba8f8064148701" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + [[package]] name = "tar" version = "0.4.28" @@ -1020,7 +1187,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a6e24d9338a0a5be79593e2fa15a648add6138caa803e2d5bc782c371732ca9" dependencies = [ - "cfg-if", + "cfg-if 0.1.10", "libc", "rand", "redox_syscall", @@ -1104,7 +1271,7 @@ version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758664fc71a3a69038656bee8b6be6477d2a6c315a6b81f7081f591bffa4111f" dependencies = [ - "serde 1.0.111", + "serde 1.0.117", ] [[package]] @@ -1146,6 +1313,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83e153d1053cbb5a118eeff7fd5be06ed99153f00dbcd8ae310c5fb2b22edc0" + [[package]] name = "unicode-width" version = "0.1.7" @@ -1215,8 +1388,8 @@ version = "0.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c2dc4aa152834bc334f506c1a06b866416a8b6697d5c9f75b9a689c8486def0" dependencies = [ - "cfg-if", - "serde 1.0.111", + "cfg-if 0.1.10", + "serde 1.0.117", "serde_json", "wasm-bindgen-macro", ] @@ -1242,7 +1415,7 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64487204d863f109eb77e8462189d111f27cb5712cc9fdb3461297a76963a2f6" dependencies = [ - "cfg-if", + "cfg-if 0.1.10", "js-sys", "wasm-bindgen", "web-sys", diff --git a/Cargo.toml b/Cargo.toml index c597df5..a2142a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,14 @@ [package] name = "albatross" -version = "0.1.0" +version = "0.2.0" authors = ["Joey Hines "] edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -clap = "2.33.0" -serde = "1.0.106" -serde_derive = "1.0.104" +structopt = "0.3.20" +serde = { version="1.0.116", features=["derive"] } config = "0.9" log = "0.4.8" chrono = "0.4" @@ -17,3 +16,4 @@ regex = "1.3.9" flate2 = "1.0.14" tar = "0.4.28" reqwest = { version = "0.10", features = ["blocking", "json"] } +discord-hooks-rs = { git = "https://github.com/joeyahines/discord-hooks-rs" } diff --git a/README.md b/README.md index 54e75cb..e3fc79b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,30 @@ Back up what you care about in your Minecraft worlds. Albatross backs up player files and region files within a certain configurable radius. It can also send Discord webhooks. Backups are compressed and stored as `tar.gz` archives. +## Help +``` +albatross 0.2.0 +Backup your Minecraft Server! + +USAGE: + albatross --config-path + +FLAGS: + -h, --help Prints help information + -V, --version Prints version information + +OPTIONS: + -c, --config-path Path to the Albatross config [env: ALBATROSS_CONFIG=] + +SUBCOMMANDS: + backup Backup a server + export Export a backup as a single player world + help Prints this message or the help of the given subcommand(s) + +Process finished with exit code 1 + +``` + ## Config ```toml [backup] diff --git a/src/backup.rs b/src/backup.rs new file mode 100644 index 0000000..92dc9b7 --- /dev/null +++ b/src/backup.rs @@ -0,0 +1,380 @@ +use crate::backup; +use crate::config::{AlbatrossConfig, WorldConfig, WorldType}; +use crate::discord::send_webhook; +use crate::region::Region; +use chrono::{NaiveDateTime, Utc}; +use flate2::read::GzDecoder; +use flate2::write::GzEncoder; +use flate2::Compression; +use std::convert::TryFrom; +use std::fs::{ + copy, create_dir, create_dir_all, remove_dir_all, remove_file, rename, DirEntry, File, +}; +use std::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, + mut world_path: PathBuf, + mut backup_path: PathBuf, +) -> Result { + world_path.push(file_name); + backup_path.push(file_name); + + 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: &PathBuf, + backup_path: &PathBuf, +) -> Result { + let mut src_dir = world_path.clone(); + src_dir.push(dir_name); + let mut backup_dir = backup_path.clone(); + backup_dir.push(dir_name); + create_dir(&backup_dir)?; + + let mut file_count = 0; + for entry in src_dir.read_dir()? { + let entry = entry?; + let mut target = backup_dir.clone(); + 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: &PathBuf, + backup_path: &PathBuf, +) -> Result { + let mut count: u64 = 0; + let mut src_dir = world_path.clone(); + src_dir.push(dir_name); + let mut backup_dir = backup_path.clone(); + backup_dir.push(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) { + if 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: PathBuf, + mut backup_path: PathBuf, + world_config: &WorldConfig, +) -> Result { + let region_count; + backup_path.push(&world_config.world_name); + create_dir(backup_path.as_path())?; + + backup_region("poi", world_config.save_radius, &world_path, &backup_path)?; + 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: PathBuf, + backup_path: PathBuf, + world_config: &WorldConfig, +) -> Result<(u64, u64), std::io::Error> { + backup_dir("data", &world_path, &backup_path)?; + backup_dir("stats", &world_path, &backup_path)?; + + backup_file("level.dat", world_path.clone(), backup_path.clone())?; + backup_file("level.dat_old", world_path.clone(), backup_path.clone())?; + backup_file("session.lock", world_path.clone(), backup_path.clone())?; + backup_file("uid.dat", world_path.clone(), backup_path.clone())?; + + 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: PathBuf, + backup_path: PathBuf, + world_config: &WorldConfig, +) -> Result { + let mut nether_path = world_path; + nether_path.push("DIM-1"); + + 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: PathBuf, + backup_path: PathBuf, + world_config: &WorldConfig, +) -> Result { + let mut end_path = world_path; + end_path.push("DIM1"); + + 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: &PathBuf, output_file: &PathBuf) -> Result<(), std::io::Error> { + 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(()) +} + +/// 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: &PathBuf, + output: &PathBuf, +) -> Result<(), std::io::Error> { + 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)?; + + if let Some(worlds) = &config.world_config { + for world in worlds { + let world_type = match world.world_type.clone() { + Some(world_type) => world_type, + None => 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(()) +} + +/// Get the time of the backup from a file name +/// +/// # Param +/// * `archive_entry`: archive entry +fn get_time_from_file_name( + archive_entry: &DirEntry, +) -> Result, std::io::Error> { + let file_name = archive_entry.file_name().to_str().unwrap().to_string(); + let name: Vec<&str> = file_name.split('_').collect(); + + Ok(chrono::NaiveDateTime::parse_from_str(name[0], "%d-%m-%y_%H.%M.%S").ok()) +} + +/// Removes the old backups from the ouput directory +/// +/// # Params +/// * `output_dir` - output directory containing +/// * `keep` - number of backups to keep +fn remove_old_backups(output_dir: &PathBuf, keep: u64) -> Result { + let mut backups = vec![]; + let mut num_of_removed_backups: usize = 0; + + for entry in output_dir.read_dir()? { + let entry = entry?; + + if let Some(ext) = entry.path().extension() { + if ext == "gz" { + backups.push(entry); + } + } + } + + if backups.len() > keep as usize { + backups.sort_by(|a, b| { + let a_time = get_time_from_file_name(a).unwrap().unwrap(); + let b_time = get_time_from_file_name(b).unwrap().unwrap(); + + b_time.cmp(&a_time) + }); + + num_of_removed_backups = backups.len() - keep as usize; + + for _i in 0..num_of_removed_backups { + let oldest = backups.pop().unwrap(); + remove_file(oldest.path())?; + } + } + + Ok(num_of_removed_backups) +} + +/// Backup the configured worlds from a minecraft server +/// +/// # Params +/// * `cfg` - config file +pub fn do_backup(cfg: AlbatrossConfig, output: Option) -> Result<(), std::io::Error> { + let server_base_dir = cfg.backup.minecraft_dir.clone(); + let worlds = cfg.world_config.clone().expect("No worlds configured"); + let time_str = Utc::now().format("%d-%m-%y_%H.%M.%S").to_string(); + let backup_name = format!("{}_backup.tar.gz", time_str); + let mut output_archive = match output { + Some(out_path) => out_path, + None => cfg.backup.output_dir.clone(), + }; + output_archive.push(backup_name); + let mut tmp_dir = cfg.backup.output_dir.clone(); + tmp_dir.push("tmp"); + remove_dir_all(&tmp_dir).ok(); + + create_dir_all(tmp_dir.clone()).unwrap(); + + send_webhook("**Albatross is swooping in to backup your worlds!**", &cfg); + let timer = Instant::now(); + for world in worlds { + let mut world_dir = server_base_dir.clone(); + let world_name = world.world_name.clone(); + let world_type = match world.world_type.clone() { + Some(world_type) => world_type, + None => 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, + ); + let webhook_msg = match world_type { + WorldType::OVERWORLD => { + let (region_count, player_count) = + backup_overworld(world_dir.clone(), tmp_dir.clone(), &world)?; + format!( + "{} regions and {} player files backed up.", + region_count, player_count + ) + } + WorldType::NETHER => { + let region_count = backup_nether(world_dir, tmp_dir.clone(), &world)?; + format!("{} regions backed up.", region_count) + } + WorldType::END => { + let region_count = backup_end(world_dir, tmp_dir.clone(), &world)?; + format!("{} regions backed up.", region_count) + } + }; + + send_webhook(&webhook_msg, &cfg); + } else { + send_webhook(format!("Error: {} not found.", world_name).as_str(), &cfg); + println!("World \"{}\" not found", world_name.clone()); + } + } + + backup::compress_backup(&tmp_dir, &output_archive)?; + + remove_dir_all(&tmp_dir)?; + + let backups_removed = remove_old_backups(&cfg.backup.output_dir, cfg.backup.backups_to_keep)?; + + if backups_removed > 0 { + let msg = format!( + "Albatross mistook **{}** of your old backups for some french fries and ate them!! SKRAWWWW", + backups_removed + ); + send_webhook(msg.as_str(), &cfg); + } + + let secs = timer.elapsed().as_secs(); + send_webhook( + format!("**Full backup completed in {}s**! *SKREEEEEEEEEE*", secs).as_str(), + &cfg, + ); + Ok(()) +} diff --git a/src/albatross_config.rs b/src/config.rs similarity index 98% rename from src/albatross_config.rs rename to src/config.rs index a792595..fe078a0 100644 --- a/src/albatross_config.rs +++ b/src/config.rs @@ -1,4 +1,5 @@ use config::{Config, ConfigError, File}; +use serde::Deserialize; use std::path::PathBuf; /// World types supported diff --git a/src/discord.rs b/src/discord.rs new file mode 100644 index 0000000..5c1fe17 --- /dev/null +++ b/src/discord.rs @@ -0,0 +1,16 @@ +use crate::config::AlbatrossConfig; +use discord_hooks_rs::DiscordWebhook; + +/// Sends a webhook to Discord if its configured +/// +/// # Params +/// * `msg` - Message to send to discord +/// * `cfg` - Albatross config +pub fn send_webhook(msg: &str, cfg: &AlbatrossConfig) { + if let Some(webhook) = &cfg.backup.discord_webhook { + let json = DiscordWebhook::new().content(msg); + + let client = reqwest::blocking::Client::new(); + client.post(webhook).json(&json).send().ok(); + } +} diff --git a/src/main.rs b/src/main.rs index 0076537..a9dc8b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,391 +1,72 @@ -extern crate serde; - -#[macro_use] -extern crate serde_derive; - -use chrono::{NaiveDateTime, Utc}; -use clap::{App, Arg}; -use flate2::write::GzEncoder; -use flate2::Compression; -use regex::Regex; -use std::fs::{copy, create_dir, create_dir_all, remove_dir_all, remove_file, DirEntry, File}; use std::path::PathBuf; -use std::time::Instant; +use structopt::StructOpt; -mod albatross_config; +mod backup; +mod config; +mod discord; +mod region; -use albatross_config::{AlbatrossConfig, WorldConfig, WorldType}; -use std::collections::HashMap; +use crate::backup::{convert_backup_to_sp, do_backup}; +use crate::config::AlbatrossConfig; -/// Struct to store information about the region -struct Region { - /// x position of the region - x: i64, - /// y position of the region - y: i64, +#[derive(Debug, StructOpt)] +#[structopt(about = "Backup your Minecraft Server!")] +struct Albatross { + /// Path to the Albatross config + #[structopt(short, long, env = "ALBATROSS_CONFIG", parse(from_os_str))] + config_path: PathBuf, + /// Subcommand + #[structopt(subcommand)] + sub_command: SubCommand, } -impl Region { - fn from_string(string: String) -> Option { - let re = Regex::new(r"r\.(?P-?[0-9]*)+\.(?P-?[0-9]*)").unwrap(); - if re.is_match(string.as_str()) { - let captures = re.captures(string.as_str()).unwrap(); +#[derive(Debug, StructOpt)] +enum SubCommand { + /// Backup a server + Backup { + /// Output location override + #[structopt(short = "o", long = "ouptut", parse(from_os_str))] + output: Option, + }, + /// Export a backup as a single player world + Export { + /// Convert backup to singleplayer world + #[structopt(parse(from_os_str))] + input_backup: PathBuf, - return Some(Region { - x: captures["x"].parse::().unwrap(), - y: captures["y"].parse::().unwrap(), - }); - } - - None - } + /// Output location override + #[structopt(parse(from_os_str))] + output: PathBuf, + }, } -/// Backup a directory -/// -/// # Param -/// * `world_path` - path to the world folder -/// * `backup_path` - path to the backup folder -fn backup_dir( - dir_name: &str, - world_path: &PathBuf, - backup_path: &PathBuf, -) -> Result { - let mut src_dir = world_path.clone(); - src_dir.push(dir_name); - let mut backup_dir = backup_path.clone(); - backup_dir.push(dir_name); - create_dir(&backup_dir)?; - - let mut file_count = 0; - for entry in src_dir.read_dir()? { - let entry = entry?; - let mut target = backup_dir.clone(); - 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 -fn backup_region( - dir_name: &str, - save_radius: u64, - world_path: &PathBuf, - backup_path: &PathBuf, -) -> Result { - let mut count: u64 = 0; - let mut src_dir = world_path.clone(); - src_dir.push(dir_name); - let mut backup_dir = backup_path.clone(); - backup_dir.push(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 Some(region) = Region::from_string(file_name) { - if 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 -fn backup_world( - world_path: PathBuf, - backup_path: PathBuf, - world_config: &WorldConfig, -) -> Result { - let mut backup_path = backup_path.clone(); - let region_count; - backup_path.push(&world_config.world_name); - create_dir(backup_path.as_path())?; - - backup_region("poi", world_config.save_radius, &world_path, &backup_path)?; - region_count = backup_region( - "region", - world_config.save_radius, - &world_path, - &backup_path, - )?; - backup_dir("data", &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 -fn backup_overworld( - world_path: PathBuf, - backup_path: PathBuf, - world_config: &WorldConfig, -) -> Result { - backup_world(world_path, backup_path, world_config) -} - -/// Backup the nether -/// -/// # Param -/// * `world_path` - path to the world folder -/// * `backup_path` - path to the backup folder -/// * `world_config` - world config options -fn backup_nether( - world_path: PathBuf, - backup_path: PathBuf, - world_config: &WorldConfig, -) -> Result { - let mut nether_path = world_path.clone(); - nether_path.push("DIM-1"); - - 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 -fn backup_end( - world_path: PathBuf, - backup_path: PathBuf, - world_config: &WorldConfig, -) -> Result { - let mut end_path = world_path.clone(); - end_path.push("DIM1"); - - 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 -fn compress_backup(tmp_dir: &PathBuf, output_file: &PathBuf) -> Result<(), std::io::Error> { - 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(()) -} - -/// Get the time of the backup from a file name -/// -/// # Param -/// * `archive_entry`: archive entry -fn get_time_from_file_name( - archive_entry: &DirEntry, -) -> Result, std::io::Error> { - let file_name = archive_entry.file_name().to_str().unwrap().to_string(); - let name: Vec<&str> = file_name.split("_").collect(); - - Ok(chrono::NaiveDateTime::parse_from_str(name[0], "%d-%m-%y-%H:%M:%S").ok()) -} - -/// Removes the old backups from the ouput directory -/// -/// # Params -/// * `output_dir` - output directory containing -/// * `keep` - number of backups to keep -fn remove_old_backups(output_dir: &PathBuf, keep: u64) -> Result { - let mut backups = vec![]; - let mut num_of_removed_backups: usize = 0; - - for entry in output_dir.read_dir()? { - let entry = entry?; - - if let Some(ext) = entry.path().extension() { - if ext == "gz" { - backups.push(entry); - } - } - } - - if backups.len() > keep as usize { - backups.sort_by(|a, b| { - let a_time = get_time_from_file_name(a).unwrap().unwrap(); - let b_time = get_time_from_file_name(b).unwrap().unwrap(); - - b_time.cmp(&a_time) - }); - - num_of_removed_backups = backups.len() - keep as usize; - - for _i in 0..num_of_removed_backups { - let oldest = backups.pop().unwrap(); - remove_file(oldest.path())?; - } - } - - Ok(num_of_removed_backups) -} - -/// Sends a webhook to Discord if its configured -/// -/// # Params -/// * `msg` - Message to send to discord -/// * `cfg` - Albatross config -fn send_webhook(msg: &str, cfg: &AlbatrossConfig) { - if let Some(webhook) = &cfg.backup.discord_webhook { - let mut map = HashMap::new(); - - map.insert("content".to_string(), msg.to_string()); - - let client = reqwest::blocking::Client::new(); - client.post(webhook).json(&map).send().ok(); - } -} - -/// Backup the configured worlds from a minecraft server -/// -/// # Params -/// * `cfg` - config file -fn do_backup(cfg: AlbatrossConfig) -> Result<(), std::io::Error> { - let server_base_dir = cfg.backup.minecraft_dir.clone(); - let worlds = cfg.world_config.clone().expect("No worlds configured"); - let time_str = Utc::now().format("%d-%m-%y-%H:%M:%S").to_string(); - let backup_name = format!("{}_backup.tar.gz", time_str); - let mut output_archive = cfg.backup.output_dir.clone(); - output_archive.push(backup_name); - let mut tmp_dir = cfg.backup.output_dir.clone(); - tmp_dir.push("tmp"); - remove_dir_all(&tmp_dir).ok(); - - create_dir_all(tmp_dir.clone()).unwrap(); - - send_webhook("**Albatross is swooping in to backup your worlds!**", &cfg); - let timer = Instant::now(); - for world in worlds { - let mut world_dir = server_base_dir.clone(); - let world_name = world.world_name.clone(); - let world_type = match world.world_type.clone() { - Some(world_type) => world_type, - None => 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, - ); - match world_type { - WorldType::OVERWORLD => { - let region_count = - backup_overworld(world_dir.clone(), tmp_dir.clone(), &world)?; - let player_count = backup_dir("playerdata", &world_dir, &tmp_dir)?; - send_webhook( - format!( - "{} regions and {} player files backed up.", - region_count, player_count - ) - .as_str(), - &cfg, - ); - } - WorldType::NETHER => { - let region_count = backup_nether(world_dir, tmp_dir.clone(), &world)?; - send_webhook( - format!("{} regions backed up.", region_count).as_str(), - &cfg, - ); - } - WorldType::END => { - let region_count = backup_end(world_dir, tmp_dir.clone(), &world)?; - send_webhook( - format!("{} regions backed up.", region_count).as_str(), - &cfg, - ); - } - }; - } else { - send_webhook(format!("Error: {} not found.", world_name).as_str(), &cfg); - println!("World \"{}\" not found", world_name.clone()); - } - } - - compress_backup(&tmp_dir, &output_archive)?; - - remove_dir_all(&tmp_dir)?; - - let backups_removed = remove_old_backups(&cfg.backup.output_dir, cfg.backup.backups_to_keep)?; - - if backups_removed > 0 { - let msg = format!( - "Albatross mistook **{}** of your old backups for some french fries and ate them!! SKRAWWWW", - backups_removed - ); - send_webhook(msg.as_str(), &cfg); - } - - let secs = timer.elapsed().as_secs(); - send_webhook( - format!("**Full backup completed in {}s**! *SKREEEEEEEEEE*", secs).as_str(), - &cfg, - ); - Ok(()) -} - -/// Albatross -/// -/// Run backups of a Minecraft world fn main() { - let mut app = App::new("Albatross").about("Backup your worlds").arg( - Arg::with_name("config") - .index(1) - .short("c") - .long("config") - .value_name("CONFIG_PATH") - .help("Config file path"), - ); - // Get arg parser - let matches = app.clone().get_matches(); + let opt = Albatross::from_args(); - if let Some(cfg_path) = matches.value_of("config") { - let cfg = AlbatrossConfig::new(cfg_path).expect("Config not found"); + let cfg = AlbatrossConfig::new(opt.config_path.into_os_string().to_str().unwrap()) + .expect("Config not found"); - if cfg.world_config.is_some() { - println!("Starting backup"); - match do_backup(cfg) { - Err(e) => println!("Error doing backup: {}", e), - _ => {} + if cfg.world_config.is_some() { + match opt.sub_command { + SubCommand::Backup { output } => { + println!("Starting backup"); + match do_backup(cfg, output) { + Ok(_) => println!("Backup complete!"), + Err(e) => println!("Error doing backup: {:?}", e), + }; + } + SubCommand::Export { + input_backup, + output, + } => { + println!("Starting export"); + match convert_backup_to_sp(&cfg, &input_backup, &output) { + Ok(_) => println!("Export complete!"), + Err(e) => println!("Error exporting backup: {:?}", e), + }; } - - println!("Backup complete"); - } else { - println!("No worlds specified to backed up!") } } else { - app.print_help().expect("Unable to print help"); + println!("No worlds specified in config file!") } } diff --git a/src/region.rs b/src/region.rs new file mode 100644 index 0000000..7f9f2bf --- /dev/null +++ b/src/region.rs @@ -0,0 +1,42 @@ +use regex::Regex; +use std::convert::TryFrom; +use std::error::Error; +use std::fmt; + +#[derive(Debug, Clone)] +pub struct RegionParseError; + +impl fmt::Display for RegionParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Unable to parse region file name") + } +} + +impl Error for RegionParseError {} + +/// Struct to store information about the region +pub struct Region { + /// x position of the region + pub x: i64, + /// y position of the region + pub y: i64, +} + +impl TryFrom for Region { + type Error = RegionParseError; + + /// Try from string + fn try_from(value: String) -> Result { + let re = Regex::new(r"r\.(?P-?[0-9]*)+\.(?P-?[0-9]*)").unwrap(); + if re.is_match(&value) { + let captures = re.captures(value.as_str()).unwrap(); + + return Ok(Region { + x: captures["x"].parse::().unwrap(), + y: captures["y"].parse::().unwrap(), + }); + } + + Err(RegionParseError) + } +}