#!/usr/bin/php
<?php
namespace nazarpc;
use
	RuntimeException,
	SQLite3;

/**
 * @package   Just backup btrfs
 * @author    Nazar Mokrynskyi <nazar@mokrynskyi.com>
 * @copyright Copyright (c) 2014-2023, Nazar Mokrynskyi
 * @license   http://opensource.org/licenses/MIT
 * @version   0.10.1
 */
class Just_backup_btrfs {
	/**
	 * @var array
	 */
	protected $config;
	/**
	 * @var string
	 */
	protected $binary;
	protected $btrfs_cmd;
	protected $btrfs_send_cmd;
	protected $btrfs_receive_cmd;
	protected $action_snapshot_post;
	protected $action_send_pre;
	protected $action_receive_post;
	protected $skip_auto_mounting;
	/**
	 * @param array $config
	 *
	 * @throws RuntimeException
	 */
	function __construct ($config) {
		if (!$config) {
			echo "Incorrect configuration, aborted\n";
			exit(1);
		}
		/* Run trap_exit() on any exit from do_backup()
		 * to guarantee thet post-snapshot action is performed,
		 * e.g. volume is unmounted */
		// TODO: works as expected only if one mount point is snapshotted
		// TODO: make an array of action_snapshot_post and run all of them
		register_shutdown_function(array($this, 'trap_exit'));
		// TODO: if $this->btrfs_cmd is set, this is not needed
		$this->config = $config;
		if (file_exists('/usr/sbin/btrfs')) {
			$this->binary = '/usr/sbin/btrfs';
		} elseif (file_exists('/sbin/btrfs')) {
			$this->binary = '/sbin/btrfs';
		} elseif (file_exists('/bin/btrfs')) {
			$this->binary = '/bin/btrfs';
		} else {
			echo "Can't find btrfs binary\n";
			exit(1);
		}
	}
	function trap_exit () {
		if ($this->action_snapshot_post) {
			$this->exec("$this->action_snapshot_post", "Running post-snapshot action");
		}
	}
	function backup () {
		$default_config = [
			'optimize_mounts'      => true,
			'minimum_delete_count' => 1
		];
		foreach ($this->config as $local_config) {
			$this->do_backup($local_config + $default_config);
			$this->trap_exit();
		}
	}
	/**
	 * @param array $config
	 */
	protected function do_backup ($config) {
		$source               = $config['source_mounted_volume'];
		$destination          = $config['destination_within_partition'];
		$minimum_delete_count = $config['minimum_delete_count'];
		$snapshot_name_prefix = @$config['snapshot_name_prefix'] ?: null;
		/* custom btrfs command for local operations */
		$this->btrfs_cmd      = @$config['btrfs_cmd'] ?: $this->binary;
		/* custom commands in `btrfs send | btrfs receive` pipe,
		 * allow to use ssh on both sides of the pipe,
		 * example: "btrfs_send_cmd" : "ssh username@host btrfs" */
		$this->btrfs_send_cmd  = @$config['btrfs_send_cmd'] ?: $this->binary;
		$this->btrfs_receive_cmd = @$config['btrfs_receive_cmd'] ?: $this->binary;
		// TODO: check that commands are really available
		/* shell command to execute before snapshotting,
		 * e.g. mount btrfs volume (useful when only subvolumes
		 * are mounted, but the root is not) */
		$action_snapshot_pre  = @$config['action_snapshot_pre'] ?: null;
		/* shell command to execute on any exit,
		 * e.g. umount btrfs volume */
		$this->action_snapshot_post = @$config['action_snapshot_post'] ?: null;
		/* shell command to execute before `[ssh] btrfs send | [ssh] btrfs receive`,
		 * useful e.g. to mount sth on external server via SSH */
		$this->action_send_pre      = @$config['action_send_pre'] ?: null;
		/* shell command to execure after `[ssh] btrfs send | [ssh] btrfs receive`,
		 * e.g. umount sth on external server via SSH */
		$this->action_receive_post  = @$config['action_receive_post'] ?: null;
		/* skip attempts to mount subvol=/ (root subvolume) before
		 * `btrfs send | btrfs receive`, e.g. if propper mounting was
		 * defined in $action* variables;
		 * possible values: "yes", "true", "1";
		 * other values and being not set are equal to false */
		$this->skip_auto_mounting = @$config['skip_auto_mounting'] ?: null;
		if ($this->skip_auto_mounting and (strcasecmp($this->skip_auto_mounting, "yes") or
		                             strcasecmp($this->skip_auto_mounting, "true") or
		                             strcasecmp($this->skip_auto_mounting, "1"))) {
			$this->skip_auto_mounting = 1;
		} else {
			$this->skip_auto_mounting = 0;
		}

		/* execute it here early enough so that $history_db is not created outside
		 * of target mountpoint if sth is mounted in $action_snapshot_pre */
		if ($action_snapshot_pre) {
			$this->exec("$action_snapshot_pre", "Running pre-snapshot action");
		}

		$history_db = $this->init_database($destination, $snapshot_name_prefix);
		if (!$history_db) {
			return;
		}

		list($keep_year, $keep_month, $keep_day, $keep_hour) = $this->how_long_to_keep($history_db, $config['keep_snapshots']);
		if (!$keep_hour) {
			return;
		}
		$date             = time();
		if ($snapshot_name_prefix) {
			$snapshot = $snapshot_name_prefix . "__" . date($config['date_format'], $date);
		} else {
			$snapshot = date($config['date_format'], $date);
		}
		$snapshot_escaped = $history_db::escapeString($snapshot);
		$comment          = ''; //TODO Comments support
		if (!$history_db->exec(
			"INSERT INTO `history` (
				`snapshot_name`,
				`comment`,
				`date`,
				`keep_hour`,
				`keep_day`,
				`keep_month`,
				`keep_year`
			) VALUES (
				'$snapshot_escaped',
				'$comment',
				'$date',
				'$keep_hour',
				'$keep_day',
				'$keep_month',
				'$keep_year'
			)"
		)
		) {
			echo "Snapshot $snapshot for $source failed because of database error\n";
			return;
		}

		$this->exec($this->btrfs_cmd . ' subvolume snapshot -r "' . $source . '" "' . $destination . '/' . $snapshot . '"');
		$this->exec("sync"); // To actually write snapshot to disk
		if (!file_exists("$destination/$snapshot")) {
			echo "Snapshot creation for $source failed\n";
			return;
		}

		echo "Snapshot $snapshot for $source created successfully\n";

		$this->do_backup_external($config, $history_db, $snapshot, $comment, $date);

		$this->cleanup($history_db, $destination, $minimum_delete_count);
		$history_db->close();
	}
	/**
	 * @param string $cmd     Command to execute
	 * @param string $comment Optional comment
	 *
	 * @return string
	 */
	protected function exec ($cmd, $comment = '') {
		if ($comment) {
			echo "$comment\n";
		}
		echo "CMD: $cmd\n";
		return shell_exec($cmd);
	}
	/**
	 * @param string $destination
	 *
	 * @return false|SQLite3
	 */
	protected function init_database ($destination, $prefix) {
		if (!file_exists($destination) && !@mkdir($destination, 0755, true)) {
			echo "Creating backup destination $destination failed, check permissions\n";
			return false;
		}

		if ($prefix) {
			$db_file_name = $destination . "/" . $prefix . "__" . "history.db";
		} else {
			$db_file_name = $destination . "/" . "history.db";
		}
		$history_db = new SQLite3($db_file_name);
		if (!$history_db) {
			echo "Opening database $db_file_name failed, check permissions\n";
			return false;
		} else {
			echo "Openned database " . $db_file_name . PHP_EOL;
		}

		$history_db->exec(
			"CREATE TABLE IF NOT EXISTS `history` (
				`snapshot_name` TEXT,
				`comment` TEXT,
				`date` INTEGER,
				`keep_hour` INTEGER,
				`keep_day` INTEGER,
				`keep_month` INTEGER,
				`keep_year` INTEGER
			)"
		);
		return $history_db;
	}
	/**
	 * @param array   $config
	 * @param SQLite3 $history_db
	 * @param string  $snapshot
	 * @param string  $comment
	 * @param int     $date
	 */
	protected function do_backup_external ($config, $history_db, $snapshot, $comment, $date) {
		if (!isset($config['destination_other_partition']) || !$config['destination_other_partition']) {
			return;
		}
		$source               = $config['source_mounted_volume'];
		$destination          = $config['destination_within_partition'];
		$destination_external = $config['destination_other_partition'];
		$minimum_delete_count = @$config['minimum_delete_count_other'] ?: $config['minimum_delete_count'];
		$history_db_external  = $this->init_database($destination_external, $snapshot_name_prefix);
		if (!$history_db_external) {
			return;
		}

		$common_snapshot = $this->get_last_common_snapshot($history_db, $history_db_external, $destination, $destination_external);
		list($keep_year, $keep_month, $keep_day, $keep_hour) = $this->how_long_to_keep(
			$history_db_external,
			@$config['keep_other_snapshots'] ?: $config['keep_snapshots']
		);
		if (!$keep_hour) {
			return;
		}
		$snapshot_escaped = $history_db::escapeString($snapshot);
		if (!$history_db_external->exec(
			"INSERT INTO `history` (
					`snapshot_name`,
					`comment`,
					`date`,
					`keep_hour`,
					`keep_day`,
					`keep_month`,
					`keep_year`
				) VALUES (
					'$snapshot_escaped',
					'$comment',
					'$date',
					'$keep_hour',
					'$keep_day',
					'$keep_month',
					'$keep_year'
				)"
		)
		) {
			echo "Creating backup $snapshot of $source to $destination_external failed because of database error\n";
			return;
		}
		/**
		 * Next block is because of BTRFS limitations - we can't receive incremental snapshots diff into path which is mounted as subvolume, not root of the partition.
		 * To overcome this we determine partition which was mounted, subvolume path inside partition, mount partition root to temporary path and determine full path of our destination inside this new mount point.
		 * This new mount point will be stored as $destination_external_fixed, mount point will be stored in $target_mount_point variable
		 */
		$mount_point_options = $this->determine_mount_point($destination_external);
		if (!$mount_point_options) {
			echo "Can't find where and how $destination_external is mounted, probably it is not on BTRFS partition?\n";
			echo "Creating backup $snapshot of $source to $destination_external failed\n";
			return;
		}
		list($partition, $mount_point, $mount_options) = $mount_point_options;

		/**
		 * Remove subvolume options, we'll be mounting root instead
		 */
		$mount_options = implode(
			",",
			array_filter(explode(",", $mount_options), function ($option) {
				if (strpos($option, "subvolid=") === 0) {
					return false;
				}
				if (strpos($option, "subvol=") === 0) {
					return false;
				}

				return true;
			})
		);

		/**
		 * Set fixed destination as path inside subvolume, just remove mount point from the whole destination path
		 */
		$destination_external_fixed = str_replace($mount_point, '', $destination_external);
		/**
		 * Now detect whether partition subvolume was mounted, $m[1] will contain subvolume path inside partition
		 */
		if (preg_match("#$partition\[(.+)\]#", exec("findmnt $mount_point"), $m)) {
			$destination_external_fixed = $m[1].$destination_external_fixed;
		}

		$target_mount_point         = "/tmp/just_backup_btrfs_".md5($partition);
		$destination_external_fixed = $target_mount_point.$destination_external_fixed;
		if (!$this->skip_auto_mounting and (!is_dir($target_mount_point))) {
			mkdir($target_mount_point);
			$this->exec(
				"mount -o subvol=/,$mount_options $partition $target_mount_point",
				'Mounting root subvolume'
			);
		} else {
			echo "Mounting root subvolume skipped, since already mounted\n";
		}
		unset($mount_point_options, $partition, $mount_point, $mount_options);

		if (!$this->skip_auto_mounting and (!isset($destination_external_fixed, $target_mount_point))) {
			echo "Can't find where and how $destination_external is mounted, probably it is not on BTRFS partition?\n";
			echo "Creating backup $snapshot of $source to $destination_external failed\n";
			return;
		}
		if ($this->action_send_pre) {
			$this->exec("$action_snapshot_pre", "Running pre-send action");
		}
		if ($common_snapshot) {
			$this->exec(
				"$this->btrfs_send_cmd send -p \"$destination/$common_snapshot\" \"$destination/$snapshot\" | $this->btrfs_receive_cmd receive $destination_external_fixed",
				'Making incremental backup'
			);
			$type = 'incremental backup';
		} else {
			$this->exec(
				"$this->btrfs_send_cmd send  \"$destination/$snapshot\" | $this->btrfs_receive_cmd receive $destination_external_fixed",
				'Making full backup'
			);
			$type = 'backup';
		}
		if (!file_exists("$destination_external/$snapshot")) {
			echo "Creating $type $snapshot of $source to $destination_external failed\n";
		} else {
			$this->cleanup($history_db_external, $destination_external, $minimum_delete_count);
			echo "Creating $type $snapshot of $source to $destination_external finished successfully\n";
			if ($this->action_receive_post) {
				$this->exec("$this->action_receive_post", "Running post-receive action");
			}
		}
		if (!$this->skip_auto_mounting) {
			if (!$config['optimize_mounts']) {
				$this->exec(
					"umount $target_mount_point",
					'Unmounting root subvolume'
				);
				rmdir($target_mount_point);
			} else {
				echo "Unmounting root subvolume skipped\n";
			}
		}
	}
	protected function determine_mount_point ($destination_external) {
		$mount_point_options = [];
		$mount               = $this->exec('mount');
		foreach (explode("\n", $mount) as $mount_string) {
			/**
			 * Choose only BTRFS filesystems
			 */
			preg_match("#^(.+) on (.+) type btrfs \((.+)\)$#", $mount_string, $m);
			/**
			 * If our destination is inside current mount point - this is what we need
			 */
			if (isset($m[2]) && strpos($destination_external, $m[2]) === 0) {
				/**
				 * Partition in form of /dev/sdXY
				 */
				$partition = $m[1];
				/**
				 * Mount point
				 */
				$mount_point = $m[2];
				/**
				 * Mount options
				 */
				$mount_options = $m[3];
				if (!isset($mount_point_options[1]) || strlen($mount_point_options[1]) < strlen($mount_point)) {
					$mount_point_options = [$partition, $mount_point, $mount_options];
				}
			}
		}
		return $mount_point_options ?: false;
	}
	/**
	 * @param SQLite3 $history_db
	 * @param SQLite3 $history_db_external
	 * @param string  $destination
	 * @param string  $destination_external
	 *
	 * @return bool|string
	 */
	protected function get_last_common_snapshot ($history_db, $history_db_external, $destination, $destination_external) {
		$snapshots = $history_db_external->query(
			"SELECT `snapshot_name`
			FROM `history`
			ORDER BY `date` DESC"
		);
		while ($snapshot = $snapshots->fetchArray(SQLITE3_ASSOC)) {
            $snapshot_name = $snapshot['snapshot_name'];
			$snapshot_escaped = $history_db::escapeString($snapshot_name);
			$snapshot_found   = $history_db
				->query(
					"SELECT `snapshot_name`
					FROM `history`
					WHERE `snapshot_name` = '$snapshot_escaped'"
				)
				->fetchArray();
			if (
				$snapshot_found &&
				file_exists("$destination/$snapshot_name") &&
				file_exists("$destination_external/$snapshot_name")
			) {
				return $snapshot_name;
			}
		}
		return false;
	}
	/**
	 * @param SQLite3 $history_db
	 * @param array   $keep_snapshots
	 *
	 * @return array
	 */
	protected function how_long_to_keep ($history_db, $keep_snapshots) {
		return [
			$keep_year = $this->keep_or_not($history_db, $keep_snapshots['year'], 'year'),
			$keep_month = $keep_year ? 1 : $this->keep_or_not($history_db, $keep_snapshots['month'], 'month'),
			$keep_day = $keep_month ? 1 : $this->keep_or_not($history_db, $keep_snapshots['day'], 'day'),
			$keep_hour = $keep_day ? 1 : $this->keep_or_not($history_db, $keep_snapshots['hour'], 'hour')
		];
	}
	/**
	 * @param SQLite3 $history_db
	 * @param int     $keep
	 * @param string  $interval
	 *
	 * @return int
	 */
	protected function keep_or_not ($history_db, $keep, $interval) {
		if ($keep == -1) {
			return 1;
		} elseif ($keep == 0) {
			return 0;
		}
		$offset    = 3600 / $keep;
		$condition = [
			'`keep_year` = 1',
			'`keep_month` = 1',
			'`keep_day` = 1',
			'`keep_hour` = 1'
		];
		/**
		 * Not an error it should go through all further cases
		 */
		switch ($interval) {
			case 'year':
				$offset *= 365 / 30; // We divide by 30 because of next condition which represents days as well
				array_pop($condition);
			case 'month':
				$offset *= 30;
				array_pop($condition);
			case 'day':
				$offset *= 24;
				array_pop($condition);
		}
		$condition = implode(' OR ', $condition);
		return $history_db->querySingle(
			"SELECT `snapshot_name`
			FROM `history`
			WHERE
				($condition) AND
				`date`	> ".(time() - $offset)
		) ? 0 : 1;
	}
	/**
	 * @param SQLite3 $history_db
	 * @param string  $destination
	 * @param int     $minimum_delete_count
	 */
	protected function cleanup ($history_db, $destination, $minimum_delete_count) {
		foreach ($this->snapshots_for_removal($history_db, $minimum_delete_count) as $snapshot_for_removal) {
			$this->exec("$this->btrfs_cmd subvolume delete \"$destination/$snapshot_for_removal\"");
			if (file_exists("$destination/$snapshot_for_removal")) {
				echo "Removing old snapshot $snapshot_for_removal from $destination failed\n";
				continue;
			}
			$snapshot_for_removal_escaped = $history_db::escapeString($snapshot_for_removal);
			if (!$history_db->exec(
				"DELETE FROM `history`
				WHERE `snapshot_name` = '$snapshot_for_removal_escaped'"
			)
			) {
				echo "Old snapshot $snapshot_for_removal removed successfully from $destination, but not removed from history because of database error\n";
				continue;
			}
			echo "Old snapshot $snapshot_for_removal removed successfully from $destination\n";
		}
	}
	/**
	 * @param SQLite3 $history_db
	 * @param int     $minimum_delete_count
	 *
	 * @return string[]
	 */
	protected function snapshots_for_removal ($history_db, $minimum_delete_count) {
		$snapshots_for_removal = [];
		$date                  = time();
		$hour_ago              = 3600;
		$day_ago               = $hour_ago * 24;
		$month_ago             = $day_ago * 30;
		$snapshots             = $history_db->query(
			"SELECT `snapshot_name`
			FROM `history`
			WHERE
				(
					`keep_day`	= 0 AND
					`date`		< ($date - $hour_ago)
				) OR
				(
					`keep_month`	= 0 AND
					`date`			< ($date - $day_ago)
				) OR
				(
					`keep_year`	= 0 AND
					`date`		< ($date - $month_ago)
				)"
		);
		while ($entry = $snapshots->fetchArray(SQLITE3_ASSOC)) {
            $snapshots_for_removal[] = $entry['snapshot_name'];
		}
		$snapshots = array_filter(array_unique($snapshots_for_removal));
		return count($snapshots) < $minimum_delete_count ? [] : $snapshots;
	}
}

echo "Just backup btrfs started...\n";

$Just_backup_btrfs = new Just_backup_btrfs(
	json_decode(file_get_contents(isset($argv[1]) ? $argv[1] : '/etc/just-backup-btrfs.json'), true)
);
$Just_backup_btrfs->backup();

echo "Just backup btrfs finished!\n";
