#!/bin/sh -eu
#
# https://serverfault.com/questions/834994/rsync-only-keep-10-backup-folders
export PATH=/bin:/usr/bin

################################################################################
# Variables
################################################################################

# Credit variables
MY_NAME="timemachine"
MY_DESC="OSX-like timemachine cli script for Linux and BSD (and even OSX)"
MY_PROJ="https://github.com/cytopia/linux-timemachine"
MY_AUTH="cytopia"
MY_MAIL="cytopia@everythingcli.org"
MY_VERS="1.3.2"
MY_DATE="2022-07-12"

# Default command line arguments
VERBOSE=
PORT=
KEY=

# Default variables
SSH_ARGS="-oStrictHostKeyChecking=no -oLogLevel=QUIET -q"



################################################################################
# Info Functions
################################################################################

print_usage() {
	echo "Usage: ${MY_NAME} [-vd]   <source> <dest> -- [rsync opts]"
	echo

	echo "       ${MY_NAME} [-vdpi] <source> <host>:<dest>        -- [rsync opts]"
	echo "       ${MY_NAME} [-vdpi] <source> <user>@<host>:<dest> -- [rsync opts]"
	echo "       ${MY_NAME} [-vdpi] <source> <ssh-alias>:<dest>   -- [rsync opts]"
	echo

	echo "       ${MY_NAME} [-vdpi] <host>:<source>        <dest> -- [rsync opts]"
	echo "       ${MY_NAME} [-vdpi] <user>@<host>:<source> <dest> -- [rsync opts]"
	echo "       ${MY_NAME} [-vdpi] <ssh-alias>:<source>   <dest> -- [rsync opts]"
	echo

	echo "       ${MY_NAME} -V, --version"
	echo "       ${MY_NAME} -h, --help"
	echo

	echo "This shell script mimics the behavior of OSX's timemachine."
	echo "It uses rsync to incrementally back up your data to a different directory or remote server via SSH."
	echo "All operations are incremental, atomic and automatically resumable."
	echo

	echo "By default it uses --recursive --perms --owner --group --times --links."
	echo "In case your target filesystem does not support any of those options, you can explicitly"
	echo "disable those options via --no-perms --no-owner --no-group --no-times and --copy-links."
	echo

	echo "Required arguments:"
	echo "  <source>              Local source directory"
	echo "  <dest>                Local destination directory."
	echo "  <host>:<dest>         SSH host and source/destination directory on server"
	echo "  <user>@<host>:<dest>  SSH user, SSH host and source/destination directory on server"
	echo "  <ssh-alias>:<dest>    SSH alias (defined in ~/.ssh/config) and source/destination directory on server"
	echo

	echo "Options:"
	echo "  -p, --port            Specify alternative SSH port for remote backups if it is not 22."
	echo "  -i, --identity        Specify path to SSH key."
	echo "  -v, --verbose         Be verbose."
	echo "  -d, --debug           Be even more verbose."
	echo

	echo "Misc Options:"
	echo "  -V, --version         Print version information and exit"
	echo "  -h, --help            Show this help screen"
	echo

	echo "Examples:"
	echo "  Simply back up one directory recursively"
	echo "      timemachine /home/user /data"
	echo "  Do the same, but be verbose"
	echo "      timemachine -v /home/user /data"
	echo "  Append rsync options and be very verbose"
	echo "      timemachine -d /home/user /data -- --progress --verbose"
	echo "  Log to file"
	echo "      timemachine -v /home/user /data > /var/log/timemachine.log 2> /var/log/timemachine.err"
	echo

	echo "Documentation:"
	echo "  View more examples at: ${MY_PROJ}"
}

print_version() {
	echo "${MY_NAME} v${MY_VERS} (${MY_DATE})"
	echo "${MY_DESC}"
	echo
	echo "Copyright (c) 2017 ${MY_AUTH} <${MY_MAIL}>"
	echo "${MY_PROJ}"
}



################################################################################
# Logging Functions
################################################################################

logdebug() {
	# Only log to stdout when verbose is turned on
	if [ "${VERBOSE}" = "debug" ]; then
		echo "$(date +'%Y-%m-%d %H:%M:%S') ${MY_NAME}: [DEBUG] ${*}"
	fi
}

logmsg() {
	# Only log to stdout when verbose/debug is turned on
	if [ "${VERBOSE}" = "verbose" ] || [ "${VERBOSE}" = "debug" ]; then
		echo "$(date +'%Y-%m-%d %H:%M:%S') ${MY_NAME}: [INFO]  ${*}"
	fi
}

logerr() {
	echo "$(date +'%Y-%m-%d %H:%M:%S') ${MY_NAME}: [ERROR] ${*}" >&2
}



################################################################################
# Backup Functions
################################################################################

###
### POSIX compliant path escape (like printf "%q" in bash)
###
escape_path() {
	# POSIX version
	# https://mullikine.github.io/posts/missing-posix-shell-functions-cmd-and-myeval/
	for var in "${@}"; do
		#printf "%s" "$(printf %s "${var}" | sed "s/'/'\\\\''/g")";
		printf "'%s' " "$(printf %s "${var}" | sed "s/'/'\\\\''/g")";
	done | sed 's/ $//'
}

###
### Check if the destination is a remote server
###
is_remote() {
	logdebug "Checking if dir is remote (SSH) or local"
	if echo "${1}" | grep -E '.+:.+' >/dev/null; then
		logdebug "Dir is remote: ${1}"
		return 0
	else
		logdebug "Dir is local: ${1}"
		return 1
	fi
}

###
### Check if a directory exists locally or remotely
###
dir_exists() {
	directory="${1}"

	if is_remote "${directory}"; then
		ssh_part="$( echo "${directory}" | awk -F':' '{print $1}' )"
		dir_part="$( echo "${directory}" | awk -F':' '{print $2}' )"
		dir_part="$( escape_path "${dir_part}" )"
		ssh_cmd="test -d ${dir_part}"
		cmd="ssh ${SSH_ARGS} ${ssh_part} ${ssh_cmd}"
		logdebug "Checking if remote dir exists: ${dir_part}"
		logdebug "\$ ${cmd}"
		if ! eval "${cmd}"; then
			logerr "Remote directory does not exist: ${dir_part}"
			return 1
		fi
	else
		directory="$( escape_path "${directory}" )"
		cmd="test -d ${directory}"
		logdebug "Checking if local dir exists: ${directory}"
		logdebug "\$ ${cmd}"
		if ! eval "${cmd}" >/dev/null; then
			logerr "Local directory does not exist: ${directory}"
			return 1
		fi
	fi
}

###
### Check if a symlink exists locally or remotely
###
link_exists() {
	directory="${1}"

	if is_remote "${directory}"; then
		ssh_part="$( echo "${directory}" | awk -F':' '{print $1}' )"
		dir_part="$( echo "${directory}" | awk -F':' '{print $2}' )"
		dir_part="$( escape_path "${dir_part}" )"
		ssh_cmd="test -L ${dir_part}"
		cmd="ssh ${SSH_ARGS} ${ssh_part} ${ssh_cmd}"
		logdebug "Checking if remote symlink exists: ${dir_part}"
		logdebug "\$ ${cmd}"
		if ! eval "${cmd}" >/dev/null; then
			logdebug "Remote symlink does not exist: ${dir_part}"
			return 1
		fi
	else
		directory="$( escape_path "${directory}" )"
		cmd="test -L ${directory}"
		logdebug "Checking if local symlink exists: ${directory}"
		logdebug "\$ ${cmd}"
		if ! eval "${cmd}" >/dev/null; then
			logdebug "Local symlink does not exist: ${directory}"
			return 1
		fi
	fi
}

###
### Rename local or remote directory
###
rename_directory() {
	from="${1}"
	to="${2}"

	if is_remote "${from}"; then
		ssh_part="$( echo "${from}" | awk -F':' '{print $1}' )"
		dir_from_part="$( echo "${from}" | awk -F':' '{print $2}' )"
		dir_to_part="$( echo "${to}" | awk -F':' '{print $2}' )"
		dir_from_part="$( escape_path "${dir_from_part}" )"
		dir_to_part="$( escape_path "${dir_to_part}" )"
		ssh_cmd="mv ${dir_from_part} ${dir_to_part}"
		cmd="ssh ${SSH_ARGS} ${ssh_part} ${ssh_cmd}"
		logdebug "Renaming remote dir: ${dir_from_part} -> ${dir_to_part}"
		logdebug "\$ ${cmd}"
		if ! eval "${cmd}" >/dev/null; then
			logerr "Failed to rename remote dir"
			return 1
		fi
	else
		from="$( escape_path "${from}" )"
		to="$( escape_path "${to}" )"
		cmd="mv ${from} ${to}"
		logdebug "Renaming local dir: ${from} -> ${to}"
		logdebug "\$ ${cmd}"
		if ! eval "${cmd}" >/dev/null; then
			logerr "Failed to rename local dir"
			return 1
		fi
	fi
}

###
### Symlink local or remote directory
###
link_directory() {
	dir="${1}"
	lnk="${2}"

	if is_remote "${lnk}"; then
		ssh_part="$( echo "${lnk}" | awk -F':' '{print $1}' )"
		lnk_part="$( echo "${lnk}" | awk -F':' '{print $2}' )"
		dir="$( escape_path "${dir}" )"
		lnk_part="$( escape_path "${lnk_part}" )"
		ssh_cmd="ln -sfn ${dir} ${lnk_part}"
		cmd="ssh ${SSH_ARGS} ${ssh_part} ${ssh_cmd}"
		logdebug "Creating remote symlink: ${lnk}"
		logdebug "\$ ${cmd}"
		if ! eval "${cmd}" >/dev/null; then
			logerr "Failed to created remote symlink: ${lnk}"
			return 1
		fi
	else
		dir="$( escape_path "${dir}" )"
		lnk="$( escape_path "${lnk}" )"
		cmd="ln -sfn ${dir} ${lnk}"
		logdebug "Creating local symlink: ${lnk}"
		logdebug "\$ ${cmd}"
		if ! eval "${cmd}" >/dev/null; then
			logerr "Failed to created local symlink: ${lnk}"
			return 1
		fi
	fi
}



################################################################################
# Entrypoint: Parse cmd args
################################################################################

# Parse input args with getopts
while test "${#}" -gt 0; do
	case "${1}" in
		# ---------- Help / version ----------
		-V | --version)
			print_version
			exit
			;;
		-h | --help)
			print_usage
			exit
			;;
		# ---------- Verbosity ----------
		-v | --verbose)
			if [ "${VERBOSE}" != "debug" ]; then
				VERBOSE="verbose"
			fi
			shift
			;;
		-d | --debug)
			VERBOSE="debug"
			shift
			;;
		# ---------- Options ----------
		-p | --port)
			if [ -z "${2:-}" ]; then
				logerr "${1} requires an argument, see -h for help."
				exit 2
			fi
			shift
			PORT="${1}"
			SSH_ARGS="${SSH_ARGS} -p ${PORT}"
			shift
			;;
		-i | --identity)
			if [ -z "${2:-}" ]; then
				logerr "${1} requires an argument, see -h for help."
				exit 2
			fi
			shift
			KEY="${1}"
			SSH_ARGS="${SSH_ARGS} -i ${KEY}"
			shift
			;;
		-*)
			if [ "${1}" != "--" ]; then
				logerr "Unknown option ${1}, see -h for help."
				exit 2
			fi
			shift
			;;
		*)
			# No more options available, so break and evaluate
			# positional arguments.
			break
	esac
done



################################################################################
# Entrypoint: Validate cmd args
################################################################################

if [ "${#}" -lt "2" ]; then
	logerr "<source> and <destination> are required."
	logerr "See -h for help."
	exit 1
fi

if is_remote "${1}"; then
	if is_remote "${2}"; then
		logerr "Source and Target cannot both be remote locations."
		logerr "See -h for help."
		exit 1
	fi
fi

if ! dir_exists "${1}"; then
	logerr "Source directory does not exist: ${1}"
	logerr "See -h for help."
	exit 1
fi
if ! dir_exists "${2}"; then
	logerr "Target directory does not exist: ${2}"
	logerr "See -h for help."
	exit 1
fi

if ! command -v rsync >/dev/null 2>&1; then
	logerr "rsync binary not found but required."
	exit 1
fi



################################################################################
# Main Entrypoint
################################################################################

# Get arguments and remove them afterwards to have ${@} contain
# all additional rsync options
SRC="${1}"
DEST="${2}"
shift 2
[ "${#}" -ge 1 ] && [ "${1}" = "--" ] && shift

# Name of the backup directory
BACKUP="$( date '+%Y-%m-%d__%H-%M-%S' )"

# Name of the backup directory which is currently in progress (incomplete)
# Used for atomic backups
BACKUP_INPROGRESS=".inprogress"

# Name of the symlink pointing to the latest successful backup
BACKUP_LATEST="current"

# Rsync partial directory to store partially transferred files
# in order to speed up a possible resume for the next run
RSYNC_PARTIAL=".partial"


###
### 1/3 Incremental, resumable and atomic rsync backup
###

# [incremental] --link-dest:          Used to hardlink files which are equal (instead of re-copying them)
# [resume]      --partial-dir:        Where to store unfinished files for resume
# [atomic]      ${BACKUP_INPROGRESS}: Tmp dest dir for atomic operations

BTYPE=

# Only link destination if it already exists
if link_exists "${DEST}/${BACKUP_LATEST}"; then
	BTYPE="incremental"

	logmsg "Starting incremental backup"
	logmsg "\$ rsync $* $( escape_path "${SRC}" ) $( escape_path "${DEST}/${BACKUP_INPROGRESS}" )"

	cmd="rsync \
		-e \"ssh ${SSH_ARGS}\" \
		--recursive \
		--perms \
		--owner \
		--group \
		--times \
		--links \
		--delete \
		--delete-excluded \
		--partial-dir=${RSYNC_PARTIAL} \
		--link-dest=../${BACKUP_LATEST} \
		$* \
		$( escape_path "${SRC}" ) $( escape_path "${DEST}/${BACKUP_INPROGRESS}" )"
else
	BTYPE="full"

	logmsg "Starting full backup"
	logmsg "\$ rsync $* $( escape_path "${SRC}" ) $( escape_path "${DEST}/${BACKUP_INPROGRESS}" )"

	cmd="rsync \
		-e \"ssh ${SSH_ARGS}\" \
		--recursive \
		--perms \
		--owner \
		--group \
		--times \
		--links \
		--delete \
		--delete-excluded \
		--partial-dir=${RSYNC_PARTIAL} \
		$* \
		$( escape_path "${SRC}" ) $( escape_path "${DEST}/${BACKUP_INPROGRESS}" )"
fi

if ! eval "${cmd}"; then
	logerr "${MY_NAME} Backup has failed"
	exit 1
fi


###
### 2/3 Finish atomic operation
###
# Move temporary atomic directory to chosen dest directory
rename_directory "${DEST}/${BACKUP_INPROGRESS}" "${DEST}/${BACKUP}"


###
### 3/3 Latest symlink
###
link_directory "${BACKUP}" "${DEST}/${BACKUP_LATEST}"
link_exists "${DEST}/${BACKUP_LATEST}"


###
### Finished
###
logmsg "Finished ${BTYPE} backup"
