Files
4WDCSA.co.za/scripts/backup.sh
T
twotalesanimation 81efab9cb0 Security hardening, DB connection fix, and handover documentation
Prepares the site for handover to the incoming committee. Three groups of
change: security fixes, a production stability fix, and documentation.

SECURITY

- upload_blog_image.php accepted unauthenticated uploads with no extension
  or MIME validation, into a web-served directory. Anyone could upload and
  execute a .php file. Now requires a session, checks blog ownership, and
  runs through validateFileUpload() with a new blog_image type.
- Added uploads/.htaccess and assets/uploads/.htaccess disabling the PHP
  engine, so an upload bypass still cannot execute. Force-added: .gitignore
  contains a bare .htaccess rule.
- blog/autosave.php updated posts by blog_id alone, letting any logged-in
  member overwrite anyone's post. Now scoped to the author, admin override
  retained. It also silently reassigned authorship to the editing admin;
  the existing author is now preserved.
- add_to_cart.php took item_price and user_id from POST, so drinks could be
  charged at any price to any member's tab, unauthenticated. Price and name
  are now looked up server-side from bar_items. Its require paths were also
  relative and broken.
- Session cookies now httponly/secure/samesite with strict mode, set in both
  .htaccess and .user.ini since php_flag and .user.ini apply under different
  SAPIs. The 30 minute idle timeout that login set but never checked is now
  enforced. Google login now regenerates the session id.
- Forced HTTPS. Required, because Secure cookies are never sent over plain
  HTTP and login would fail silently. LetsEncrypt acme-challenge is exempted
  above the redirect so certificate renewal keeps working.
- display_errors off; .htaccess now blocks *.log, error_log, .git and
  dotfiles. Debug logs containing member IDs, raw webhook bodies and payment
  signatures were publicly fetchable.
- Removed the IKHOKHA_BYPASS_SIGNATURE switch and the logging of raw webhook
  bodies, app IDs and signatures. Replaced a hardcoded personal Gmail
  recipient with the optional DEVELOPER_EMAIL variable.
- Added auth to fetch_users, fetch_bar_tabs, fetch_drinks, create_bar_tab and
  submit_order. Converted get_tab_total to a prepared statement.
- Tightened 11 mkdir(0777) calls to 0755.
- Fixed the validate_login rewrite, which pointed at a nonexistent path and
  only worked by falling through to the generic .php rule.

DATABASE STABILITY

openDatabaseConnection() opened a new connection on every call across 73 call
sites, and the server was hitting "Too many connections" in production. It now
returns one shared handle per request, reusing the connection connection.php
already opened. The 40 conn->close() calls in functions.php are removed, since
closing the shared handle breaks every later caller; PHP releases it at end of
request. A liveness check reconnects transparently if anything closes it.

Verified no function was lost (99 before and after), every removed close was
inside a braced block, and no transaction spans a helper that would open a
nested one.

DOCUMENTATION AND TOOLING

- docs/HANDOVER.md: operator guide covering architecture, routing, roles, the
  membership lifecycle, payments, backups, POPIA obligations, environment
  variables, known issues and a handover checklist.
- docs/FUNCTIONS.md: reference for the helpers in functions.php.
- scripts/backup.sh: nightly database and uploads backup, written outside the
  web root, with rotation and verification. Dumps are written to a temp file
  and only promoted after passing gzip, CREATE TABLE and completion-marker
  checks, so a failed run can never overwrite a good backup.
- .deployignore and .gitattributes added; .gitattributes pins LF on shell
  scripts and server config because core.autocrlf is true on Windows.
- Removed leftover debris: src/api/test_log.php and .htaccess1.

All 22 modified PHP files pass php -l. The backup script was tested against a
stub mysqldump for healthy, truncated and failing dumps, and for rotation.
Nothing was run against a live database or web server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 13:56:32 +02:00

219 lines
8.3 KiB
Bash
Executable File

#!/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: <site-parent>/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" <<EOF
[client]
host="$DB_HOST"
user="$DB_USER"
password="$DB_PASS"
EOF
DB_FILE="$DB_DIR/${DB_NAME}-${STAMP}.sql.gz"
# Write to a temporary file first and only promote it to the real filename
# once every verification below has passed. This means a failed run can never
# overwrite or corrupt yesterday's good backup.
DB_PART="${DB_FILE}.part"
DUMP_ERR="$(mktemp)"
log "Dumping database '$DB_NAME'..."
# --single-transaction gives a consistent snapshot without locking the site.
# --quick streams rows instead of buffering the whole table in memory.
mysqldump --defaults-extra-file="$MYSQL_CNF" \
--single-transaction \
--quick \
--routines \
--triggers \
--events \
--default-character-set=utf8mb4 \
"$DB_NAME" 2>"$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