#!/usr/bin/env bash
#
# 2025-09-06 Marcel Herrguth
# As shown on https://youtu.be/P-Pr7Dy6mP8
# Attribution back to this GIST is required!
#
# Parts of this script are oriented from https://github.com/zammad/zammad/tree/develop/contrib/backup
# Only PostGreSQL is supported; No symlinks are supported
#
# Restoration only works on a pre-installed Zammad installation of the same version (or higher).
# During Restore, this script will remove the existing (local) Zammad database without any warning; if it's an external one, ensure it's empty.

#----- CONFIG -------------------------------------------------------------------------------#
# Use either a directory or a supported repository type (like s3:https//domain.tld/bucket)
BACKUP_REPOSITORY='s3:https://s3.domain.tld/resticsample'

# Zammad Installation directory
ZAMMAD_DIR='/opt/zammad'

# Backup either the whole Zammad directory (yes) or just the storage folder (attachments; no)
# Storage folder only is best practice as per documentation
# https://docs.zammad.org/en/latest/appendix/backup-and-restore/index.html
FULL_FS_DUMP='no'

# How many daily and hourly snapshot should stay available?
HOLD_DAYS=14
HOLD_HOURLY=1

# Do you want to cause downtime during backup? (This is the safest backup way)
STOP_DURING_BACKUP='yes'
#----- CONFIG END ---------------------------------------------------------------------------#

# ---- Magic --------------------------------------------------------------------------------#
function pre_flight () {
  # Verify that we have all we need.
  if [[ ""$BACKUP_REPOSITORY"" == s3:* ]]; then
    if [[ -z "${AWS_ACCESS_KEY_ID}" || -z "${AWS_SECRET_ACCESS_KEY}" ]]; then
      echo "You have chosen to backup to S3 (it seems), but either AWS_ACCESS_KEY_ID and/or AWS_SECRET_ACCESS_KEY."
      exit 1
    fi
  else
    echo "This script has been tested with S3 only. This should be fine."
    echo "Ensure that the required environment information for restic are available as per their documentation:"
    echo "https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html"
  fi

  if [[ -z "${RESTIC_PASSWORD}" ]]; then
    echo "You forgot to set RESTIC_PASSWORD which is mandatory for your backup repository!"
    exit 1
  fi
}

function start_zammad () {
  echo "# Starting Zammad"
  systemctl start zammad
}

function stop_zammad () {
  echo "# Stopping Zammad"
  systemctl stop zammad
}

function get_db_credentials () {
  DB_HOST="$(grep -m 1 '^[[:space:]]*host:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*host:[[:space:]]*//g')"
  DB_PORT="$(grep -m 1 '^[[:space:]]*port:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*port:[[:space:]]*//g')"
  DB_NAME="$(grep -m 1 '^[[:space:]]*database:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*database:[[:space:]]* //g')"
  DB_USER="$(grep -m 1 '^[[:space:]]*username:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*username:[[:space:]]*//g')"
  DB_PASS="$(grep -m 1 '^[[:space:]]*password:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*password:[[:space:]]*//g')"

  # Ensure that HOST and PORT are not empty, provide defaults if needed.
  if [ "${DB_HOST}x" == "x" ]; then
    DB_HOST="localhost"
  fi
  if [ "${DB_PORT}x" == "x" ]; then
    DB_PORT="5432"
  fi
}

function create_pgpassfile() {
  PGPASSFILE=$(mktemp)
  export PGPASSFILE
    chmod 600 "$PGPASSFILE"
    cat <<EOT > "$PGPASSFILE"
*:*:${DB_NAME}:${DB_USER}:${DB_PASS}
EOT
  trap 'rm "$PGPASSFILE"' EXIT
}

function kind_exit () {
  # We're nice to our admin and bring Zammad back up before exiting
  start_zammad
  exit 1
}

function db_helper_alter_user () {
  # Get DB credentials
  get_db_credentials

  if [ "${DB_PASS}x" == "x" ]; then
    echo "# Found an empty password - I'll be fixing this for you ..."

    DB_PASS="$(tr -dc A-Za-z0-9 < /dev/urandom | head -c10)"

    sed -e "s/.*username:.*/  username: ${DB_USER}/" \
    -e "s/.*password:.*/  password: ${DB_PASS}/" \
    -e "s/.*database:.*/  database: ${DB_NAME}/" < ${ZAMMAD_DIR}/contrib/packager.io/database.yml.pkgr > ${ZAMMAD_DIR}/config/database.yml

    echo "# ... Fixing permission database.yml"
    chown zammad:zammad ${ZAMMAD_DIR}/config/database.yml
  fi

  if [ "${DB_USER}x" == "x" ]; then

    echo "ERROR - Your configuration file does not seem to contain a username."
    echo "Aborting the script - double check your installation."

    kind_exit
  fi

  if [[ ("${DB_HOST}" == "localhost") || "${DB_HOST}" == "127.0.0.1" || "${DB_HOST}" == "::1" ]]; then
    # Looks like a local pgsql installation - let's continue
    su -c "psql -c \"ALTER USER ${DB_USER} WITH PASSWORD '${DB_PASS}';\"" postgres
    state=$?

  else
    echo "You don't seem to be using a local PostgreSQL installation."
    echo "This script does not support your installation. No changes were done."
    echo "Please ensure that your database.yml contains a database user password and that the users password fits."

    kind_exit
  fi

  if [ "${state}" != "0" ]; then
    echo "ERROR - Our previous command returned an unhandled error code."
    kind_exit
  fi
}

function check_for_empty_password() {
  if [ "${DB_PASS}x" == "x" ]; then
    echo "# ERROR - Found an empty database password ..."
    db_helper_alter_user
    exit 2
  fi
}

function check_database_config_exists () {
  if [ -f ${ZAMMAD_DIR}/config/database.yml ]; then
    get_db_credentials
  else
    echo -e "${ZAMMAD_DIR}/config/database.yml is missing. is Zammad installed and configured yet? \nAborting..."
    exit 1
  fi
}

function delete_old_backups () {
  echo "# Invoking cleanup as per snapshot rules"
  restic -r "$BACKUP_REPOSITORY" forget --group-by '' --keep-hourly $HOLD_HOURLY --keep-daily $HOLD_DAYS --prune
}

function write_backup () {
  stop_zammad if "${STOP_DURING_BACKUP}x" == 'yesx'
  echo "# Creating postgresql backup..."

  create_pgpassfile

  pg_dump --dbname "${DB_NAME}" \
      --username "${DB_USER}" \
      ${DB_HOST:+--host $DB_HOST} \
      ${DB_PORT:+--port $DB_PORT} \
      --no-privileges --no-owner > /tmp/zammad_db.psql

  state=$?

  if [ "${state}" == "1" ]; then
    echo -e "\n\n # ERROR(${state}) - Database credentials are wrong or database server configuration is invalid."
    echo -e " #-> BACKUP WAS NOT SUCCESSFUL"
    exit 2
  fi

  if [ "${FULL_FS_DUMP}" == 'no' ]; then
    BACKUP_DIR="${ZAMMAD_DIR}/storage/"

    if [ ! -d "${ZAMMAD_DIR}/storage/" ]; then
      # Admin has requested an attachment backup only, however, there is no storage
      # directory. We'll warn Mr.Admin and create the directory as workaround.
      echo " ... WARNING: You don't seem to have any attachments in the file system!"
      echo " ... This is FINE if you store your attachments either in database or S3!"
      echo " ... Creating empty storage directory so the backup can continue ..."

      mkdir -p ${ZAMMAD_DIR}/storage/
      chown -R zammad:zammad ${ZAMMAD_DIR}/storage/
    fi
  else
    BACKUP_DIR="${ZAMMAD_DIR}"
  fi

  restic -r "$BACKUP_REPOSITORY" migrate
  restic --no-scan --read-concurrency=25 --pack-size=128 --compression off -r "$BACKUP_REPOSITORY" backup \
    --exclude "${BACKUP_DIR}/tmp/*" --exclude "${BACKUP_DIR}/node_modules/" \
    --exclude "${BACKUP_DIR}/.git/" --exclude "${BACKUP_DIR}/.yarn/cache/" \
    ${BACKUP_DIR} /tmp/zammad_db.psql

  state=$?

  # clean up temporary database dump
  rm -f /tmp/zammad_db.psql

  start_zammad if "${STOP_DURING_BACKUP}x" == 'yesx'

  if [ $state == '1' ]; then
    echo "# FATAL - Restic could not create the snapshot."
  elif [ $state == '3' ]; then
    echo "# WARNING - Files changed during snapshot creation; Snapshot potentially incomplete!"
  fi

  if [ $state -gt 0 ]; then
    echo "# BACKUP WAS NOT SUCCESSFUL"
    exit $state
  fi
}

function list_available_snapshots () {
  pre_flight
  echo "# Here's your currently available snapshots in your backup repository:"
  restic -r "$BACKUP_REPOSITORY" snapshots
}

function verify_backup_repository_health () {
  pre_flight

  VERIFY_PERCENTAGE="${1:-100}"

  echo "# Checking ${VERIFY_PERCENTAGE} of your repository ..."
  restic -r "$BACKUP_REPOSITORY" check --read-data-subset="${VERIFY_PERCENTAGE}%"
}

function get_snapshot_id () {
  if [ -n "${1}" ]; then
    RESTORE_SNAPSHOT_ID="${1}"
  else
    # User did not provide snapshot so we'll hard guess 'latest'
    RESTORE_SNAPSHOT_ID='latest'

    echo "... No Snapshot provided, guessing you want to restore 'latest'!"
    for i in {15..1}; do
      echo -ne "... You have $i seconds to abort.\r"; sleep 1
    done
  fi
}

function restore_backup () {
  echo "# Checking requirements"

  if ! id "zammad" &> /dev/null; then
    echo "# ERROR: User 'zammad' is not present, but should be by default! #"
    echo "# Restoration was NOT successful, exiting ..."
    exit 1
  fi

  stop_zammad

  echo "# ... Dropping current database ${DB_NAME}"

  # This step is only needed for the majority of pgsql dumps, as they don't provide a drop table statement which will cause
  # relation errors during restoration (as on package installation there's always an initialized database)
  if command -v zammad > /dev/null; then
    zammad config:set DISABLE_DATABASE_ENVIRONMENT_CHECK=1
    zammad run rake db:drop
    zammad config:set DISABLE_DATABASE_ENVIRONMENT_CHECK=0
  else
    DISABLE_DATABASE_ENVIRONMENT_CHECK=1 ${ZAMMAD_DIR}/bin/rake db:drop
  fi

  echo "# ... Creating database ${DB_NAME} for owner ${DB_USER}"
  if command -v zammad > /dev/null; then
    # We'll skip this part for docker installations
    su -c "psql -c \"CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};\"" postgres
  else
    ${ZAMMAD_DIR}/bin/rake db:create
  fi

  echo "# Restoring PostgreSQL DB"

  create_pgpassfile

  # We're removing uncritical dump information that caused "ugly" error
  # messages on older script versions. These could safely be ignored.
  restic -r "$BACKUP_REPOSITORY" dump latest '/tmp/zammad_db.psql' | \
  sed '/^CREATE EXTENSION IF NOT EXISTS plpgsql/d'| \
  sed '/^COMMENT ON EXTENSION plpgsql/d'| \
  psql -q -b -o /dev/null \
  ${DB_HOST:+--host $DB_HOST} ${DB_PORT:+--port $DB_PORT} ${DB_USER:+--username $DB_USER} --dbname ${DB_NAME}

  state=$?

  if [[ ("${state}" == "1") || ( "${state}" == "2") || ( "${state}" == "3") ]]; then
    # We're checking for critical restoration errors
    # It may not cover all possible errors which is out of scope of this script
    echo -e "\n\n # ERROR(${state}) - Database credentials are wrong or database server configuration is invalid."
    echo -e " #-> RESTORE WAS NOT SUCCESSFUL"
    exit 2
  fi

  if [ "${FULL_FS_DUMP}" == 'no' ]; then
    BACKUP_DIR="${ZAMMAD_DIR}/storage/"
  else
    BACKUP_DIR="${ZAMMAD_DIR}"
  fi

  echo "# Restoring Files"
  restic -r "$BACKUP_REPOSITORY" restore $RESTORE_SNAPSHOT_ID --include "$BACKUP_DIR" --target /

  state=$?

  if [[ ($state == '1') || ($state == '2') ]]; then
    echo "# ERROR(${state}) - File restore reported an error."
    echo "- Check file permissions, and ensure Zammad IS NOT running, and try again."
    echo -e " \n# RESTORE WAS NOT SUCCESSFUL"
    exit 1
  fi

  echo "# Ensuring correct file permissions ..."
  chown -R zammad:zammad ${ZAMMAD_DIR}

  # Ensure all data is loaded from the restored database and not the cache of the previous system state
  echo "# Clearing Cache ..."
  if command -v zammad > /dev/null; then
    zammad run rails r "Rails.cache.clear"
  else
    ${ZAMMAD_DIR}/bin/rails r "Rails.cache.clear"
  fi

  echo "# Attempting to execute database migrations"
  if command -v zammad > /dev/null; then
    zammad run rake db:migrate
  else
    ${ZAMMAD_DIR}/bin/rake db:migrate
  fi

  start_zammad
}

function start_backup_message () {
    echo -e "\n# Backup script started - $(date)!\n"
}

function start_restore_message () {
    echo -e "\n# Restore script started - $(date)!\n"
}

function finished_backup_message () {
    echo -e "\n# Backup script finished; Check output! - $(date)!\n"
}

function finished_restore_message () {
    echo -e "\n# Restore script finished; Check output! - $(date)!\n"
}

function execute_backup () {
  pre_flight
  start_backup_message
  check_database_config_exists
  check_for_empty_password
  write_backup
  delete_old_backups
  finished_backup_message
}

function execute_restoration () {
  pre_flight
  check_database_config_exists
  check_for_empty_password
  start_restore_message
  get_snapshot_id $1
  restore_backup
  finished_restore_message
}

function command_reference () {
  echo "COMMAND REFERENCE"
  echo "- backup: creates a new snapshot and backs up database and storage."
  echo "- restore <snapshot>: Restore a given snapshot from your backup. If you omit the snapshot ID, latest will be used."
  echo "- list_snapshots: List all available snapshot IDs."
  echo "- verify_health <percentage>: Verify the health of your backup repository. If you omit percentage, 100% will be assumed."
}

# ---- Option part and control --------------------------------------------------------------#

case "$1" in
  backup)
    execute_backup
    ;;

  restore)
    execute_restoration $2
    ;;

  list_snapshots)
    list_available_snapshots
    ;;

  verify_health)
    verify_backup_repository_health "$2"
    ;;

  *)
    command_reference
    ;;
esac