#!/bin/bash # # 4WDCSA - Nightly backup # ============================================================================= # Dumps the MySQL database and archives member-uploaded files. # # WHY THIS EXISTS # Before this script the only backup was a manual .sql dump left sitting in # the web root. If the host lost a disk, the club lost its entire membership # register, payment history and indemnity signatures. # # WHAT IT BACKS UP # 1. The full MySQL database (schema + data) # 2. uploads/ - proofs of payment, blog images, indemnity signatures # 3. assets/uploads/ - campsite and gallery images # # WHERE BACKUPS GO # OUTSIDE the web root, so they can never be downloaded by a visitor. # Default: /backups (i.e. a sibling of the web root) # # SETUP # 1. chmod +x scripts/backup.sh # 2. Run it once by hand and confirm it reports SUCCESS: # ./scripts/backup.sh # 3. Add a nightly cron job (SiteWorx > Cron Jobs), 02:30 daily: # 30 2 * * * /home/YOURUSER/scripts/backup.sh >> /home/YOURUSER/backups/cron.log 2>&1 # 4. IMPORTANT: copy the backups off this server periodically. A backup that # lives only on the machine it is backing up is not a backup. # # RESTORING (see docs/HANDOVER.md for the full procedure) # gunzip < backups/db/4wdcsa-YYYY-MM-DD.sql.gz | mysql -u USER -p DBNAME # # ============================================================================= set -uo pipefail # --- Configuration ---------------------------------------------------------- # Web root = the directory containing .env (this script lives in scripts/) SITE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # Backups are written outside the web root by default. Override with BACKUP_DIR. BACKUP_DIR="${BACKUP_DIR:-$(dirname "$SITE_ROOT")/backups}" # How many daily backups to keep before deleting the oldest. KEEP_DAYS="${KEEP_DAYS:-14}" STAMP="$(date +%Y-%m-%d_%H%M)" DATE_ONLY="$(date +%Y-%m-%d)" DB_DIR="$BACKUP_DIR/db" FILES_DIR="$BACKUP_DIR/files" # --- Helpers ---------------------------------------------------------------- log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } fail() { log "ERROR: $*"; cleanup; exit 1; } MYSQL_CNF="" DUMP_ERR="" DB_PART="" cleanup() { # Always remove the temporary credentials file, even on failure [ -n "$MYSQL_CNF" ] && [ -f "$MYSQL_CNF" ] && rm -f "$MYSQL_CNF" [ -n "$DUMP_ERR" ] && [ -f "$DUMP_ERR" ] && rm -f "$DUMP_ERR" # Never leave a half-written or unverified dump lying around - someone # could later restore from it believing it to be good. [ -n "$DB_PART" ] && [ -f "$DB_PART" ] && rm -f "$DB_PART" } trap cleanup EXIT INT TERM # Read a single key out of the .env file. # Handles optional quotes and ignores comments. # NOTE: this runs inside $( ), i.e. a subshell - it must not call fail(), # because exiting a subshell would not stop the script. The caller validates # the returned value instead. read_env() { local key="$1" file="$SITE_ROOT/.env" value [ -f "$file" ] || return 1 value="$(grep -E "^[[:space:]]*${key}[[:space:]]*=" "$file" | head -n1 | cut -d= -f2-)" # strip surrounding whitespace, then surrounding quotes value="$(echo "$value" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" value="$(echo "$value" | sed -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'$/\1/")" echo "$value" } # --- Preflight -------------------------------------------------------------- log "=== 4WDCSA backup starting ===" log "Site root: $SITE_ROOT" log "Backup dir: $BACKUP_DIR" [ -f "$SITE_ROOT/.env" ] || fail ".env not found at $SITE_ROOT/.env - cannot read database credentials" command -v mysqldump >/dev/null 2>&1 || fail "mysqldump not found in PATH. On shared hosting it is sometimes at /usr/bin/mysqldump - adjust PATH in the cron job." DB_HOST="$(read_env DB_HOST)" DB_USER="$(read_env DB_USER)" DB_PASS="$(read_env DB_PASS)" DB_NAME="$(read_env DB_NAME)" [ -n "$DB_NAME" ] || fail "DB_NAME is empty - could not parse .env" [ -n "$DB_USER" ] || fail "DB_USER is empty - could not parse .env" mkdir -p "$DB_DIR" "$FILES_DIR" || fail "Cannot create $BACKUP_DIR" # Backups contain the full member register. Keep them private. chmod 700 "$BACKUP_DIR" "$DB_DIR" "$FILES_DIR" 2>/dev/null # --- Database --------------------------------------------------------------- # The password is passed via a temporary defaults-file rather than on the # command line, because command lines are visible to every user on the box # via `ps`. MYSQL_CNF="$(mktemp)" chmod 600 "$MYSQL_CNF" cat > "$MYSQL_CNF" <"$DUMP_ERR" | gzip -9 > "$DB_PART" DUMP_STATUS="${PIPESTATUS[0]}" if [ "$DUMP_STATUS" -ne 0 ]; then log "mysqldump stderr: $(cat "$DUMP_ERR" 2>/dev/null)" fail "mysqldump failed with status $DUMP_STATUS" fi # --- Verify the dump is actually usable ------------------------------------- # A truncated dump is worse than no dump, because it looks like a backup # succeeded. Verify by content, not by file size: a well-compressing dump can # legitimately be small, so byte count tells us very little. # # Three checks, in order of strength: # 1. gzip integrity - the archive is not corrupt # 2. CREATE TABLE - it actually contains schema # 3. "Dump completed" - mysqldump reached the end and was not killed midway gzip -t "$DB_PART" 2>/dev/null || fail "Dump failed gzip integrity check" # Single decompression pass, reused for both content checks DUMP_TEXT="$(gunzip -c "$DB_PART")" || fail "Could not decompress the dump" TABLE_COUNT="$(printf '%s' "$DUMP_TEXT" | grep -c '^CREATE TABLE')" [ "$TABLE_COUNT" -ge 1 ] || fail "Dump contains no CREATE TABLE statements - the database may be empty or the dump failed" printf '%s' "$DUMP_TEXT" | tail -5 | grep -q "Dump completed" \ || fail "Dump is missing the 'Dump completed' marker - it is truncated" unset DUMP_TEXT # Verified - promote it to the real filename mv -f "$DB_PART" "$DB_FILE" || fail "Could not move verified dump into place" DB_PART="" # so the cleanup trap does not try to delete it log "Database OK: $(du -h "$DB_FILE" | cut -f1), ${TABLE_COUNT} tables" # --- Uploaded files --------------------------------------------------------- # Member uploads are not in git, so if they are lost they are gone for good. FILES_FILE="$FILES_DIR/uploads-${STAMP}.tar.gz" TAR_TARGETS="" [ -d "$SITE_ROOT/uploads" ] && TAR_TARGETS="$TAR_TARGETS uploads" [ -d "$SITE_ROOT/assets/uploads" ] && TAR_TARGETS="$TAR_TARGETS assets/uploads" if [ -n "$TAR_TARGETS" ]; then log "Archiving uploads:$TAR_TARGETS" # shellcheck disable=SC2086 if tar -czf "$FILES_FILE" -C "$SITE_ROOT" $TAR_TARGETS 2>/dev/null; then gzip -t "$FILES_FILE" 2>/dev/null || fail "Uploads archive failed integrity check" log "Uploads OK: $(du -h "$FILES_FILE" | cut -f1)" else fail "Failed to archive uploads" fi else log "WARNING: no upload directories found - skipping file backup" fi # --- Rotation --------------------------------------------------------------- log "Removing backups older than ${KEEP_DAYS} days..." DELETED_DB="$(find "$DB_DIR" -name '*.sql.gz' -type f -mtime +"$KEEP_DAYS" -print -delete 2>/dev/null | wc -l | tr -d ' ')" DELETED_FL="$(find "$FILES_DIR" -name '*.tar.gz' -type f -mtime +"$KEEP_DAYS" -print -delete 2>/dev/null | wc -l | tr -d ' ')" log "Rotated out ${DELETED_DB} database and ${DELETED_FL} upload backups" # --- Summary ---------------------------------------------------------------- CURRENT_COUNT="$(find "$DB_DIR" -name '*.sql.gz' -type f | wc -l | tr -d ' ')" TOTAL_SIZE="$(du -sh "$BACKUP_DIR" 2>/dev/null | cut -f1)" log "Backups on disk: ${CURRENT_COUNT}, total size ${TOTAL_SIZE}" log "=== SUCCESS - backup completed for ${DATE_ONLY} ===" exit 0