161 lines
6.9 KiB
Bash
161 lines
6.9 KiB
Bash
|
|
#!/usr/bin/env bash
|
||
|
|
# sync-prod-db.sh
|
||
|
|
#
|
||
|
|
# Dumps the production database and restores it to the development database,
|
||
|
|
# then sanitizes the copy to prevent outbound notifications and reduce PII exposure.
|
||
|
|
#
|
||
|
|
# Usage:
|
||
|
|
# PROD_DATABASE_URL="postgres://..." npm run db:sync-prod
|
||
|
|
# # or pass as argument:
|
||
|
|
# bash scripts/sync-prod-db.sh "postgres://..."
|
||
|
|
#
|
||
|
|
# Requirements: pg_dump, pg_restore, psql (PostgreSQL client tools)
|
||
|
|
|
||
|
|
set -euo pipefail
|
||
|
|
|
||
|
|
# ── Colour helpers ────────────────────────────────────────────────────────────
|
||
|
|
RED='\033[0;31m'
|
||
|
|
YELLOW='\033[1;33m'
|
||
|
|
GREEN='\033[0;32m'
|
||
|
|
BOLD='\033[1m'
|
||
|
|
RESET='\033[0m'
|
||
|
|
|
||
|
|
info() { echo -e "${BOLD}$*${RESET}"; }
|
||
|
|
warn() { echo -e "${YELLOW}$*${RESET}"; }
|
||
|
|
success() { echo -e "${GREEN}$*${RESET}"; }
|
||
|
|
error() { echo -e "${RED}ERROR: $*${RESET}" >&2; exit 1; }
|
||
|
|
|
||
|
|
# ── Load .env ─────────────────────────────────────────────────────────────────
|
||
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
|
|
ENV_FILE="$SCRIPT_DIR/../.env"
|
||
|
|
|
||
|
|
if [[ -f "$ENV_FILE" ]]; then
|
||
|
|
# Export only DATABASE_URL from .env (avoid polluting env with unrelated vars)
|
||
|
|
DATABASE_URL_FROM_ENV=$(grep -E '^DATABASE_URL=' "$ENV_FILE" | head -1 | cut -d= -f2- | sed 's/^"//' | sed 's/"$//')
|
||
|
|
if [[ -n "$DATABASE_URL_FROM_ENV" && -z "${DATABASE_URL:-}" ]]; then
|
||
|
|
export DATABASE_URL="$DATABASE_URL_FROM_ENV"
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ── Resolve URLs ──────────────────────────────────────────────────────────────
|
||
|
|
# PROD_DATABASE_URL: env var or first positional argument
|
||
|
|
PROD_DB="${PROD_DATABASE_URL:-${1:-}}"
|
||
|
|
DEV_DB="${DATABASE_URL:-}"
|
||
|
|
|
||
|
|
[[ -z "$PROD_DB" ]] && error "PROD_DATABASE_URL is not set.\nSet it as an environment variable or pass it as the first argument:\n PROD_DATABASE_URL=\"postgres://...\" npm run db:sync-prod"
|
||
|
|
[[ -z "$DEV_DB" ]] && error "DATABASE_URL is not set. Add it to your .env file."
|
||
|
|
|
||
|
|
# Guard against accidentally syncing prod → prod
|
||
|
|
if [[ "$PROD_DB" == "$DEV_DB" ]]; then
|
||
|
|
error "PROD_DATABASE_URL and DATABASE_URL are the same. Aborting to prevent data loss."
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ── Confirmation prompt ───────────────────────────────────────────────────────
|
||
|
|
echo ""
|
||
|
|
warn "⚠️ WARNING: This will OVERWRITE your development database with production data."
|
||
|
|
echo ""
|
||
|
|
echo -e " Source (prod): ${BOLD}${PROD_DB//:*@/:***@}${RESET}" # mask password
|
||
|
|
echo -e " Target (dev): ${BOLD}${DEV_DB//:*@/:***@}${RESET}"
|
||
|
|
echo ""
|
||
|
|
warn "After restore, the script will:"
|
||
|
|
echo " • NULL out all Discord webhook URLs (prevents notification leaks)"
|
||
|
|
echo " • Anonymize email/name/avatar for non-admin users (reduces PII exposure)"
|
||
|
|
echo " • Admin accounts are left untouched so you can still access admin pages"
|
||
|
|
echo ""
|
||
|
|
read -r -p "Type 'yes' to continue: " CONFIRM
|
||
|
|
if [[ "$CONFIRM" != "yes" ]]; then
|
||
|
|
echo "Aborted."
|
||
|
|
exit 0
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ── Dump ──────────────────────────────────────────────────────────────────────
|
||
|
|
DUMP_FILE=$(mktemp /tmp/brackt_prod_dump_XXXXXX.dump)
|
||
|
|
# Ensure the temp file is cleaned up on exit
|
||
|
|
trap 'rm -f "$DUMP_FILE"' EXIT
|
||
|
|
|
||
|
|
info ""
|
||
|
|
info "1/4 Dumping production database…"
|
||
|
|
pg_dump \
|
||
|
|
--no-owner \
|
||
|
|
--no-acl \
|
||
|
|
--format=custom \
|
||
|
|
"$PROD_DB" \
|
||
|
|
--file="$DUMP_FILE"
|
||
|
|
success " Dump complete: $(du -h "$DUMP_FILE" | cut -f1)"
|
||
|
|
|
||
|
|
# ── Drop & recreate dev DB ────────────────────────────────────────────────────
|
||
|
|
info ""
|
||
|
|
info "2/4 Recreating development database…"
|
||
|
|
|
||
|
|
# Extract the database name from the URL (last path component)
|
||
|
|
DEV_DB_NAME="${DEV_DB##*/}"
|
||
|
|
DEV_DB_NAME="${DEV_DB_NAME%%\?*}" # strip any query params
|
||
|
|
|
||
|
|
# Build a connection string to the postgres maintenance DB on the same host
|
||
|
|
# by replacing the DB name with "postgres"
|
||
|
|
ADMIN_DB="${DEV_DB%/$DEV_DB_NAME*}/postgres${DEV_DB#*/$DEV_DB_NAME}"
|
||
|
|
# Simpler: just swap the last path segment
|
||
|
|
ADMIN_DB=$(echo "$DEV_DB" | sed "s|/$DEV_DB_NAME\$|/postgres|;s|/$DEV_DB_NAME?|/postgres?|")
|
||
|
|
|
||
|
|
# Terminate existing connections, then drop and recreate
|
||
|
|
psql "$ADMIN_DB" \
|
||
|
|
-c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DEV_DB_NAME' AND pid <> pg_backend_pid();" \
|
||
|
|
-c "DROP DATABASE IF EXISTS \"$DEV_DB_NAME\";" \
|
||
|
|
-c "CREATE DATABASE \"$DEV_DB_NAME\";" \
|
||
|
|
--quiet
|
||
|
|
success " Database '$DEV_DB_NAME' recreated."
|
||
|
|
|
||
|
|
# ── Restore ───────────────────────────────────────────────────────────────────
|
||
|
|
info ""
|
||
|
|
info "3/4 Restoring production dump to development database…"
|
||
|
|
pg_restore \
|
||
|
|
--no-owner \
|
||
|
|
--no-acl \
|
||
|
|
--dbname="$DEV_DB" \
|
||
|
|
"$DUMP_FILE"
|
||
|
|
success " Restore complete."
|
||
|
|
|
||
|
|
# ── Sanitize ──────────────────────────────────────────────────────────────────
|
||
|
|
info ""
|
||
|
|
info "4/4 Sanitizing development database…"
|
||
|
|
|
||
|
|
SANITIZE_RESULT=$(psql "$DEV_DB" --tuples-only --no-align << 'SQL'
|
||
|
|
BEGIN;
|
||
|
|
|
||
|
|
-- 1. Disable Discord webhook notifications for all leagues
|
||
|
|
UPDATE leagues SET discord_webhook_url = NULL;
|
||
|
|
SELECT 'discord_webhooks_nulled:' || COUNT(*) FROM leagues;
|
||
|
|
|
||
|
|
-- 2. Anonymize PII for non-admin users only
|
||
|
|
-- (admin accounts are untouched so admin pages remain accessible)
|
||
|
|
UPDATE users SET
|
||
|
|
email = 'user_' || id || '@example.com',
|
||
|
|
first_name = 'Test',
|
||
|
|
last_name = 'User',
|
||
|
|
display_name = 'Test User',
|
||
|
|
image_url = NULL
|
||
|
|
WHERE is_admin = FALSE;
|
||
|
|
SELECT 'users_anonymized:' || COUNT(*) FROM users WHERE is_admin = FALSE;
|
||
|
|
|
||
|
|
SELECT 'admin_users_kept:' || COUNT(*) FROM users WHERE is_admin = TRUE;
|
||
|
|
|
||
|
|
COMMIT;
|
||
|
|
SQL
|
||
|
|
)
|
||
|
|
|
||
|
|
LEAGUES_COUNT=$(echo "$SANITIZE_RESULT" | grep 'discord_webhooks_nulled:' | cut -d: -f2 | tr -d ' ')
|
||
|
|
ANON_COUNT=$(echo "$SANITIZE_RESULT" | grep 'users_anonymized:' | cut -d: -f2 | tr -d ' ')
|
||
|
|
ADMIN_COUNT=$(echo "$SANITIZE_RESULT" | grep 'admin_users_kept:' | cut -d: -f2 | tr -d ' ')
|
||
|
|
|
||
|
|
echo ""
|
||
|
|
success "✓ Sync complete. Sanitization summary:"
|
||
|
|
echo " • Discord webhook URLs nulled: ${LEAGUES_COUNT:-?} leagues"
|
||
|
|
echo " • Non-admin users anonymized: ${ANON_COUNT:-?} users"
|
||
|
|
echo " • Admin accounts preserved: ${ADMIN_COUNT:-?} users"
|
||
|
|
echo ""
|
||
|
|
warn "Reminder: ensure your .env does NOT contain:"
|
||
|
|
echo " • RESEND_API_KEY (email sending)"
|
||
|
|
echo " • SENTRY_AUTH_TOKEN (error tracking)"
|
||
|
|
echo " • Production Clerk keys (use dev Clerk instance)"
|
||
|
|
echo ""
|