#!/usr/bin/env bash # Update a user's username and/or password in the Docker (production) SQLite DB. # Run this on the prod server next to docker-compose.yml. # # Usage: # ./scripts/xdocker-update-user.sh --username # ./scripts/xdocker-update-user.sh --password # ./scripts/xdocker-update-user.sh --username --password # ./scripts/xdocker-update-user.sh --username --password --yes # # Password is hashed with bcrypt (cost 10), same as the app. # Username rules match registration: 3–32 chars, [a-zA-Z0-9_], unique. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" if [[ -f "$SCRIPT_DIR/../docker-compose.yml" ]]; then ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" elif [[ -f "$SCRIPT_DIR/docker-compose.yml" ]]; then ROOT="$SCRIPT_DIR" else echo "Error: docker-compose.yml not found near $SCRIPT_DIR" >&2 exit 1 fi cd "$ROOT" ENV_FILE="${ENV_FILE:-.env.prod}" COMPOSE=(docker compose) if [[ -f "$ENV_FILE" ]]; then COMPOSE=(docker compose --env-file "$ENV_FILE") fi SERVICE="${SERVICE:-casegambling}" CURRENT_USERNAME="${1:-}" NEW_USERNAME="" NEW_PASSWORD="" ASSUME_YES=0 usage() { echo "Usage: $0 [--username ] [--password ] [--yes]" >&2 echo "Example: $0 oldname --username newname --password 's3cret'" >&2 echo " $0 oldname --password 's3cret' --yes" >&2 exit 1 } if [[ -z "$CURRENT_USERNAME" || "$CURRENT_USERNAME" == "-h" || "$CURRENT_USERNAME" == "--help" ]]; then usage fi shift || true while [[ $# -gt 0 ]]; do case "$1" in --username|-u) [[ $# -ge 2 ]] || { echo "Error: $1 needs a value" >&2; exit 1; } NEW_USERNAME="$2" shift 2 ;; --password|-p) [[ $# -ge 2 ]] || { echo "Error: $1 needs a value" >&2; exit 1; } NEW_PASSWORD="$2" shift 2 ;; --yes|-y) ASSUME_YES=1 shift ;; *) echo "Error: unknown argument: $1" >&2 usage ;; esac done if [[ -z "$NEW_USERNAME" && -z "$NEW_PASSWORD" ]]; then echo "Error: provide at least --username and/or --password" >&2 usage fi if ! command -v docker >/dev/null 2>&1; then echo "Error: docker not found" >&2 exit 1 fi if [[ -z "$("${COMPOSE[@]}" ps -q --status running "$SERVICE" 2>/dev/null || true)" ]]; then echo "Error: compose service '$SERVICE' is not running" >&2 echo "Tip: docker compose --env-file .env.prod up -d" >&2 exit 1 fi run_node() { local mode="$1" "${COMPOSE[@]}" exec -T \ -e UPD_CURRENT="$CURRENT_USERNAME" \ -e UPD_NEW_USERNAME="$NEW_USERNAME" \ -e UPD_NEW_PASSWORD="$NEW_PASSWORD" \ -e UPD_MODE="$mode" \ "$SERVICE" node <<'NODE' const { PrismaClient } = require('@prisma/client'); const bcrypt = require('bcrypt'); const prisma = new PrismaClient(); (async () => { const currentUsername = process.env.UPD_CURRENT; const newUsername = (process.env.UPD_NEW_USERNAME || '').trim(); const newPassword = process.env.UPD_NEW_PASSWORD || ''; const mode = process.env.UPD_MODE; const user = await prisma.user.findUnique({ where: { username: currentUsername }, select: { id: true, username: true, role: true, balance: true, createdAt: true, }, }); if (!user) { console.error(`Error: no user with username '${currentUsername}'`); process.exit(2); } if (mode === 'info') { console.log(`id: ${user.id}`); console.log(`username: ${user.username}`); console.log(`role: ${user.role}`); console.log(`balance: ${user.balance.toString()}`); console.log(`createdAt: ${user.createdAt.toISOString()}`); if (newUsername) console.log(`new user: ${newUsername}`); if (newPassword) console.log(`password: (will be changed)`); process.exit(0); } if (mode !== 'update') { console.error(`Unknown mode: ${mode}`); process.exit(1); } const data = {}; if (newUsername) { if (newUsername.length < 3 || newUsername.length > 32) { console.error('Error: username must be 3–32 characters'); process.exit(1); } if (!/^[a-zA-Z0-9_]+$/.test(newUsername)) { console.error('Error: username may only contain letters, numbers, and underscores'); process.exit(1); } if (newUsername !== user.username) { const taken = await prisma.user.findUnique({ where: { username: newUsername } }); if (taken) { console.error(`Error: username '${newUsername}' already taken`); process.exit(1); } data.username = newUsername; } } if (newPassword) { if (newPassword.length < 6) { console.error('Error: password must be at least 6 characters'); process.exit(1); } data.passwordHash = await bcrypt.hash(newPassword, 10); } if (Object.keys(data).length === 0) { console.log('Nothing to change (same username, no password).'); process.exit(0); } const updated = await prisma.user.update({ where: { id: user.id }, data, select: { id: true, username: true }, }); const parts = []; if (data.username) parts.push(`username → ${updated.username}`); if (data.passwordHash) parts.push('password updated'); console.log(`Updated user id=${updated.id}: ${parts.join(', ')}`); })().catch((e) => { console.error(e); process.exit(1); }).finally(() => prisma.$disconnect()); NODE } echo "==> Looking up user '$CURRENT_USERNAME' …" set +e INFO_OUT="$(run_node info 2>&1)" INFO_RC=$? set -e echo "$INFO_OUT" if [[ "$INFO_RC" -ne 0 ]]; then exit "$INFO_RC" fi if [[ "$ASSUME_YES" -ne 1 ]]; then echo "" printf "Apply changes to '%s'? [y/N] " "$CURRENT_USERNAME" read -r REPLY case "$REPLY" in y|Y|yes|YES) ;; *) echo "Aborted." exit 0 ;; esac fi echo "==> Updating …" run_node update echo "==> Done."