#!/bin/bash
#
# kbfs-backup-revs
# John Simpson <jms1@jms1.net> 2019-02-20
#
# Back up all accessible revisions of all accessible KBFS directories
#
# Requires 'jq'
#
# Last update: 2019-03-07 jms1 - fixed PATH

PATH="/usr/bin:/bin:/usr/local/bin"

########################################

function redline {
    if [[ -t 1 ]]
    then
        printf "\e[0;1;37;41m%s\e[0K\e[0m\n" "$*"
    else
        echo "***** $* *****"
    fi
}

function greenline {
    if [[ -t 1 ]]
    then
        printf "\e[0;1;37;42m%s\e[0K\e[0m\n" "$*"
    else
        echo -- "----- $* -----"
    fi
}

function blueline {
    if [[ -t 1 ]]
    then
        printf "\e[0;1;37;44m%s\e[0K\e[0m\n" "$*"
    else
        echo "===== $* ====="
    fi
}

function die {
    redline "$*"
    exit 1
}

function failed {
    redline "$*"
    if $DIE_ON_FAIL
    then
        exit 1
    fi
}

function run {
    greenline ">>> $@"
    "$@"
}

function usage {
    MSG="${*:-}"
    cat <<EOF
$0 [options] DIR [...]

Back up KBFS, by rev, to a given target direcory.

Each DIR is inspected and used as follows:

- Any TLF (top-level folder) will be considered a SOURCE to be backed up. TLFs
  are "/keybase/private/xxx", "keybase/public/xxx", or "/keybase/team/xxx".
  Each TLF must exist and be readable.

- Any directory not starting with "/keybase" will be treated as the TARGET,
  i.e. where you want the backups to be stored. This directory must already
  exist and be writable.

- Any other directory (i.e. "/keybase/xxx", "/keybase/private/xxx/yyy", etc.)
  will be considered an error.

You MUST specify EXACTLY ONE target directory.

If no SOURCEs are named, the script will back up all TLFs it can see.

Backups are done using 'rsync'. The '--link-prev' option is normally used so
that files which are unchanged from one rev to the next are created as hard
links to the previous backup's copy of the same file, which saves disk space
on the TARGET directory's filesystem.

-h      Show this help message.

-a      Process all available revs, including those which have already been
        backed up.

-L      Do not try to use the '--link-prev' option. Note: if you do this, the
        backups will contain everything in the TLF, for every rev. This will
        end up using a lot more disk space on the TARGET filesystem.

-v      Send '-v' to each 'rsync' command.

-n      Only back up the newest rev from each TLF.

-f      Continue on fail. Normally if an 'rsync' command fails, the script
        stops immediately. With this option, the script will keep going.

Note: if you use the '-L' option, your backups will contain a full copy of
everything in the TLF, for every rev. For example, if you have a TLF which
contains 1 GB of data, and there are 15 revs available, you will end up using
15 GB on the backup filesystem.

EOF

    if [[ -n "${MSG}" ]]
    then
        die "${MSG}"
    fi

    exit 0
}

###############################################################################

function newest_backup {
    TLF="$1"

    TLF_NAME="${TLF#/keybase/}"
    NBAK=0

    shopt -s nullglob
    for DIR in "${BACKUP_ROOT}/${TLF_NAME}"/*
    do
        N="${DIR##*/}"
        if [[ $N =~ ^[0-9]+$ ]]
        then
            if (( N > NBAK ))
            then
                NBAK=$N
            fi
        fi
    done

    echo $NBAK
}

function backup_tlf {
    TLF="$1"

    TLF_NAME="${TLF#/keybase/}"

    ########################################
    # Make sure the TLF's target exists

    if [[ ! -d "${BACKUP_ROOT}/${TLF_NAME}" ]]
    then
        run mkdir -p "${BACKUP_ROOT}/${TLF_NAME}" \
            || die "${TLF} mkdir '${BACKUP_ROOT}/${TLF_NAME}' failed"
    fi

    ########################################
    # Figure out oldest/newest revs and newest existing backup

    NEWEST_BAK=$( newest_backup "$TLF" )

    REV_NEWEST=$( jq .Revision "${TLF}/.kbfs_status" )
    if [[ -z "$REV_NEWEST" ]]
    then
        redline "${TLF} unable to find newest rev, skipping"
        continue
    fi

    REV_OLDEST=$( jq .LastGCRevision "${TLF}/.kbfs_status" )
    if [[ -z "$REV_OLDEST" ]]
    then
        redline "${TLF} unable to find oldest rev, skipping"
        continue
    fi

    ########################################
    # Figure out which rev we should start with,
    # and if we're doing anything at all

    if $ONLY_NEWEST
    then
        REV_FIRST=$REV_NEWEST
    elif (( $NEWEST_BAK >= $REV_OLDEST ))
    then
        REV_FIRST=$((NEWEST_BAK+1))
    else
        REV_FIRST=$REV_OLDEST
    fi

    if (( $NEWEST_BAK >= $REV_NEWEST))
    then
        blueline "${TLF} avail=(${REV_OLDEST}..${REV_NEWEST}) nb=${NEWEST_BAK} process=none"
        continue
    fi

    blueline "${TLF} avail=(${REV_OLDEST}..${REV_NEWEST}) nb=${NEWEST_BAK} process=(${REV_FIRST}..${REV_NEWEST})"

    ########################################
    # Cycle through all revs

    for (( REV=REV_FIRST ; REV<=REV_NEWEST ; REV++ ))
    do
        SRC="${TLF}/.kbfs_archived_rev=${REV}"
        DST="${BACKUP_ROOT}/${TLF_NAME}/${REV}"

        ########################################
        # Read this rev's '.kbfs_status' file into memory

        KBFS_STATUS_TEXT=$( < "${SRC}/.kbfs_status" )

        ########################################
        # If the rev doesn't exist in KBFS (i.e. if garbage collection
        # happened between then and now), skip it

        if [[ ! -f "${SRC}/.kbfs_status" ]]
        then
            blueline "${TLF} rev ${REV} - rev is missing, skipping"
            continue
        fi

        ########################################
        # If the rev already exists in the backup, skip it

        if $SKIP_DONE
        then
            if [[ -d "${DST}" ]]
            then
                blueline "${TLF} rev ${REV} - already backed up, skipping"
                continue
            fi
        fi

        ########################################
        # Figure out the appropriate '--link-dest=' option
        # - highest rev which exists and is lower than "this" rev

        LINKDEST=""

        if $USE_LINKS
        then
            NEWEST_BAK=$( newest_backup "$TLF" )
            if (( $NEWEST_BAK > 0 ))
            then
                LINKDEST="--link-dest=${BACKUP_ROOT}/${TLF_NAME}/${NEWEST_BAK}"
            fi
        fi

        ########################################
        # Do the deed

        OPT="-a --chmod=u+w,go= ${SHOW}"

        if [[ -n "$LINKDEST" ]]
        then
            blueline "${TLF} rev ${REV} previous ${NEWEST_BAK}"
            run rsync $OPT "$LINKDEST" "${SRC}/" "${DST}/" || failed "rsync failed"
        else
            blueline "${TLF} rev ${REV}"
            run rsync $OPT             "${SRC}/" "${DST}/" || failed "rsync failed"
        fi

        echo "$KBFS_STATUS_TEXT" > "${DST}/.kbfs_status"

    done # REV
}

###############################################################################
###############################################################################
###############################################################################

SHOW="-q"
USE_LINKS=true
SKIP_DONE=true
ONLY_NEWEST=false
DIE_ON_FAIL=true

while getopts ":havLnf" OPT
do
    case $OPT in
        h)  usage
            ;;
        a)  SKIP_DONE=false
            ;;
        v)  SHOW="-v"
            ;;
        L)  USE_LINKS=false
            ;;
        n)  ONLY_NEWEST=true
            ;;
        f)  DIE_ON_FAIL=false
            ;;
        *)  usage "ERROR: unknown option '$OPTARG'"
            ;;
    esac
done

shift $((OPTIND-1))

############################################################
#
# The rest of the command line should contain zero or more source directories
# (starting with '/keybase/') and exactly one target directory (NOT starting
# with '/keybase').

declare -a SOURCES

while [[ -n "${1:-}" ]]
do
    ARG="${1:-}"
    shift

    if [[ $ARG =~ ^/keybase/(private|public|team)/[^/]+$ ]]
    then
        SOURCES+=( $ARG )
    elif [[ $ARG =~ ^/keybase ]]
    then
        die "ERROR: invalid source '${ARG}'"
    elif [[ -n "${BACKUP_ROOT:-}" ]]
    then
        die "ERROR: only one backup target allowed"
    elif [[ ! -d "${ARG}" ]]
    then
        die "ERROR: backup target '${ARG}' does not exist"
    else
        BACKUP_ROOT=$( realpath "${ARG}" )
    fi        
done

########################################
# If no sources were specified, use all of them

if (( ${#SOURCES[@]} < 1 ))
then
    XIFS="$IFS"

    IFS=$'\n'
    for TLF in /keybase/{private,public,team}/*
    do
        SOURCES+=( $TLF )
    done

    IFS="$XIFS"
fi

if (( ${#SOURCES[@]} < 1 ))
then
    die "ERROR: '/keybase/{private,public,team}/*' does not exist"
fi

########################################
# Make sure the sources all exist

ERR=false
for TLF in "${SOURCES[@]}"
do
    if [[ ! -d "${TLF}" ]]
    then
        redline "ERROR: source '${TLF}' does not exist"
        ERR=true
    fi
done

if $ERR
then
    exit 1
fi

############################################################
# Make sure that
# - a backup target was specified
# - the backup target exists
# - the backup target is not under KBFS

if [[ -z "${BACKUP_ROOT}" ]]
then
    usage "ERROR: no backup target directory specified"
fi

if [[ ! -d "${BACKUP_ROOT}" ]]
then
    die "ERROR: backup target '${BACKUP_ROOT}' does not exist"
fi

KBFS_REAL=$( realpath /keybase/private/.. )
if [[ "${BACKUP_ROOT}" =~ ^${KBFS_REAL} ]]
then
    die "ERROR: backup root '${BACKUP_ROOT}' cannot be within KBFS"
fi

############################################################
#
# Process each TLF (top-level folder)

for TLF in "${SOURCES[@]}"
do
    backup_tlf "$TLF"
done
