diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..36324f9d --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# ============================================================================ +# Dungeon Master's Vault — Docker Environment Configuration +# +# Copy this file to .env and update the values: +# cp .env.example .env +# +# Or run the setup script to generate .env with secure random values: +# ./docker-setup.sh +# ============================================================================ + +# --- Application --- +PORT=8890 + +# --- Datomic Database --- +# ADMIN_PASSWORD secures the Datomic admin interface +# DATOMIC_PASSWORD is used by the application to connect to Datomic +# The password in DATOMIC_URL must match DATOMIC_PASSWORD +ADMIN_PASSWORD=change-me-admin +DATOMIC_PASSWORD=change-me-datomic +DATOMIC_URL=datomic:free://datomic:4334/orcpub?password=change-me-datomic + +# --- Security --- +# Secret used to sign JWT tokens (20+ characters recommended) +SIGNATURE=change-me-to-something-unique-and-long + +# --- Email (SMTP) --- +# Leave EMAIL_SERVER_URL empty to disable email functionality +EMAIL_SERVER_URL= +EMAIL_ACCESS_KEY= +EMAIL_SECRET_KEY= +EMAIL_SERVER_PORT=587 +EMAIL_FROM_ADDRESS= +EMAIL_ERRORS_TO= +EMAIL_SSL=FALSE +EMAIL_TLS=FALSE diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml new file mode 100644 index 00000000..be33b3ee --- /dev/null +++ b/.github/workflows/docker-integration.yml @@ -0,0 +1,248 @@ +name: Docker Integration Test + +on: + pull_request: + branches: [develop] + paths: + - 'docker/**' + - 'docker-compose*.yaml' + - 'docker-setup.sh' + - 'docker-user.sh' + - 'deploy/**' + - '.github/workflows/docker-integration.yml' + workflow_dispatch: + +jobs: + docker-test: + name: Docker Setup & User Management + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Lint shell scripts + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck + shellcheck docker-setup.sh docker-user.sh + + - name: Run docker-setup.sh --auto + run: | + ./docker-setup.sh --auto + echo "--- Generated .env (secrets redacted) ---" + sed 's/=.*/=***/' .env + + - name: Validate .env password consistency + run: | + PW=$(grep '^DATOMIC_PASSWORD=' .env | cut -d= -f2) + URL_PW=$(grep '^DATOMIC_URL=' .env | sed 's/.*password=//') + if [ "$PW" != "$URL_PW" ]; then + echo "FAIL: DATOMIC_PASSWORD and DATOMIC_URL password mismatch" + exit 1 + fi + echo "OK: Passwords match" + + - name: Start datomic (no deps) + run: | + docker compose pull + docker compose up -d --no-deps datomic + echo "Datomic container started, waiting for health..." + + - name: Wait for datomic healthy + run: | + for i in $(seq 1 90); do + CID=$(docker compose ps -q datomic 2>/dev/null) || true + if [ -z "$CID" ]; then + echo " [$i/90] datomic container not found yet" + sleep 2 + continue + fi + RUNNING=$(docker inspect --format='{{.State.Running}}' "$CID" 2>/dev/null || echo "false") + STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$CID" 2>/dev/null || echo "starting") + echo " [$i/90] running=$RUNNING health=$STATUS" + if [ "$STATUS" = "healthy" ]; then + echo "Datomic is healthy (after ~$((i * 2))s)" + break + fi + if [ "$RUNNING" = "false" ]; then + echo "WARN: datomic container stopped — dumping logs" + docker compose logs datomic + echo "Container will restart (restart: always), continuing to wait..." + fi + if [ "$i" -eq 90 ]; then + echo "FAIL: Datomic did not become healthy within 180s" + echo "=== container state ===" + docker inspect --format='{{json .State}}' "$CID" | python3 -m json.tool || true + echo "=== datomic logs ===" + docker compose logs datomic + exit 1 + fi + sleep 2 + done + + - name: Start orcpub and web + run: | + docker compose up -d + docker compose ps + + - name: Wait for orcpub healthy + run: | + for i in $(seq 1 90); do + CID=$(docker compose ps -q orcpub 2>/dev/null) || true + if [ -z "$CID" ]; then + echo " [$i/90] orcpub container not found yet" + sleep 2 + continue + fi + STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$CID" 2>/dev/null || echo "starting") + echo " [$i/90] orcpub health=$STATUS" + if [ "$STATUS" = "healthy" ]; then + echo "orcpub is healthy (after ~$((i * 2))s)" + break + fi + if [ "$STATUS" = "unhealthy" ]; then + echo "FAIL: orcpub reported unhealthy" + echo "=== all logs ===" + docker compose logs + exit 1 + fi + if [ "$i" -eq 90 ]; then + echo "FAIL: orcpub did not become healthy within 180s" + docker compose logs + exit 1 + fi + sleep 2 + done + docker compose ps + + - name: Test — create user + run: | + ./docker-user.sh create testadmin admin@test.local SecurePass123 + echo "Exit code: $?" + + - name: Test — check user exists + run: | + OUTPUT=$(./docker-user.sh check testadmin) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "testadmin" + echo "$OUTPUT" | grep -q "admin@test.local" + echo "$OUTPUT" | grep -q "true" # verified + + - name: Test — list includes user + run: | + OUTPUT=$(./docker-user.sh list) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "testadmin" + + - name: Test — duplicate user fails + run: | + if ./docker-user.sh create testadmin admin@test.local SecurePass123 2>&1; then + echo "FAIL: Should have rejected duplicate user" + exit 1 + fi + echo "OK: Duplicate user correctly rejected" + + - name: Test — create second user + run: ./docker-user.sh create player2 player2@test.local AnotherPass456 + + - name: Test — list shows both users + run: | + OUTPUT=$(./docker-user.sh list) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "testadmin" + echo "$OUTPUT" | grep -q "player2" + + - name: Test — verify already-verified user is idempotent + run: | + OUTPUT=$(./docker-user.sh verify testadmin) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "already verified" + + - name: Test — batch create users (with duplicates) + run: | + cat > /tmp/test-users.txt <<'TXT' + # Test batch file + batch1 batch1@test.local BatchPass111 + batch2 batch2@test.local BatchPass222 + # This next line is a duplicate from earlier single-create test + testadmin admin@test.local SecurePass123 + TXT + OUTPUT=$(./docker-user.sh batch /tmp/test-users.txt) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "batch1" + echo "$OUTPUT" | grep -q "batch2" + echo "$OUTPUT" | grep -q "SKIP.*testadmin" + echo "$OUTPUT" | grep -q "2 created" + echo "$OUTPUT" | grep -q "1 skipped (duplicate)" + echo "$OUTPUT" | grep -q "0 failed" + echo "OK: Batch created 2 new, skipped 1 duplicate" + + - name: Test — batch users appear in list + run: | + OUTPUT=$(./docker-user.sh list) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "batch1" + echo "$OUTPUT" | grep -q "batch2" + + - name: Test — check nonexistent user fails + run: | + if ./docker-user.sh check nobody@nowhere.com 2>&1; then + echo "FAIL: Should have reported user not found" + exit 1 + fi + echo "OK: Nonexistent user correctly not found" + + - name: Test — created user can log in via HTTP + run: | + # Use nginx (port 443) since orcpub:8890 is not exposed to host + RESPONSE=$(curl -sk -X POST https://localhost/login \ + -H "Content-Type: application/json" \ + -d '{"username":"testadmin","password":"SecurePass123"}' \ + -w "\n%{http_code}" 2>&1) || true + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') + + echo "HTTP $HTTP_CODE" + echo "$BODY" + + if [ "$HTTP_CODE" = "200" ]; then + echo "OK: Login succeeded" + echo "$BODY" | grep -q "token" + echo "OK: Response contains JWT token" + else + echo "FAIL: Expected HTTP 200, got $HTTP_CODE" + exit 1 + fi + + - name: Test — wrong password is rejected + run: | + HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" \ + -X POST https://localhost/login \ + -H "Content-Type: application/json" \ + -d '{"username":"testadmin","password":"WrongPassword"}' 2>&1) || true + + echo "HTTP $HTTP_CODE" + if [ "$HTTP_CODE" = "401" ]; then + echo "OK: Wrong password correctly rejected" + else + echo "FAIL: Expected HTTP 401, got $HTTP_CODE" + exit 1 + fi + + - name: Collect logs on failure + if: failure() + run: | + echo "=== docker compose ps ===" + docker compose ps + echo "=== datomic logs ===" + docker compose logs datomic + echo "=== orcpub logs ===" + docker compose logs orcpub + echo "=== web logs ===" + docker compose logs web + + - name: Cleanup + if: always() + run: docker compose down -v diff --git a/.gitignore b/.gitignore index 3e3c5553..a49906f5 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ pom.xml orcpub.iml profiles.clj env.sh +.env .repl .nrepl-port *~ diff --git a/docker-compose-build.yaml b/docker-compose-build.yaml index 296e9776..015b6daa 100644 --- a/docker-compose-build.yaml +++ b/docker-compose-build.yaml @@ -1,40 +1,47 @@ --- -version: '3' services: orcpub: build: context: . dockerfile: docker/orcpub/Dockerfile environment: - PORT: 8890 - EMAIL_SERVER_URL: '' - EMAIL_ACCESS_KEY: '' - EMAIL_SECRET_KEY: '' - EMAIL_SERVER_PORT: 587 - # Email address to send from, will default to 'no-reply@orcpub.com' - EMAIL_FROM_ADDRESS: '' - # Email address to send errors to - EMAIL_ERRORS_TO: '' - EMAIL_SSL: 'TRUE' - EMAIL_TLS: 'FALSE' - # Datomic connection string - Make sure the matches the DATOMIC_PASSWORD below - DATOMIC_URL: datomic:free://datomic:4334/orcpub?password= - # The secret used to hash your password in the browser, 20+ characters recommended - SIGNATURE: '' + PORT: ${PORT:-8890} + EMAIL_SERVER_URL: ${EMAIL_SERVER_URL:-} + EMAIL_ACCESS_KEY: ${EMAIL_ACCESS_KEY:-} + EMAIL_SECRET_KEY: ${EMAIL_SECRET_KEY:-} + EMAIL_SERVER_PORT: ${EMAIL_SERVER_PORT:-587} + EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-} + EMAIL_ERRORS_TO: ${EMAIL_ERRORS_TO:-} + EMAIL_SSL: ${EMAIL_SSL:-FALSE} + EMAIL_TLS: ${EMAIL_TLS:-FALSE} + DATOMIC_URL: ${DATOMIC_URL:-datomic:free://datomic:4334/orcpub?password=change-me} + SIGNATURE: ${SIGNATURE:-change-me-to-something-unique} depends_on: - - datomic + datomic: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8890/"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 15s restart: always datomic: build: context: . dockerfile: docker/datomic/Dockerfile environment: - ADMIN_PASSWORD: - # Must match the in the DATOMIC_URL above. - DATOMIC_PASSWORD: + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-admin} + DATOMIC_PASSWORD: ${DATOMIC_PASSWORD:-change-me} volumes: - ./data:/data - ./logs:/logs + healthcheck: + test: ["CMD-SHELL", "grep -q ':10EE ' /proc/net/tcp || grep -q ':10EE ' /proc/net/tcp6"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 40s restart: always web: image: nginx:alpine @@ -46,5 +53,6 @@ services: - ./deploy/snakeoil.crt:/etc/nginx/snakeoil.crt - ./deploy/snakeoil.key:/etc/nginx/snakeoil.key depends_on: - - orcpub + orcpub: + condition: service_healthy restart: always diff --git a/docker-compose.yaml b/docker-compose.yaml index 5ebaa504..08e50897 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,36 +1,43 @@ --- -version: '3' services: orcpub: image: orcpub/orcpub:latest environment: - PORT: 8890 - EMAIL_SERVER_URL: '' - EMAIL_ACCESS_KEY: '' - EMAIL_SECRET_KEY: '' - EMAIL_SERVER_PORT: 587 - # Email address to send from, will default to 'no-reply@orcpub.com' - EMAIL_FROM_ADDRESS: '' - # Email address to send errors to - EMAIL_ERRORS_TO: '' - EMAIL_SSL: 'FALSE' - EMAIL_TLS: 'FALSE' - # Datomic connection string - Make sure the matches the DATOMIC_PASSWORD below - DATOMIC_URL: datomic:free://datomic:4334/orcpub?password= - # The secret used to hash your password in the browser, 20+ characters recommended - SIGNATURE: '' + PORT: ${PORT:-8890} + EMAIL_SERVER_URL: ${EMAIL_SERVER_URL:-} + EMAIL_ACCESS_KEY: ${EMAIL_ACCESS_KEY:-} + EMAIL_SECRET_KEY: ${EMAIL_SECRET_KEY:-} + EMAIL_SERVER_PORT: ${EMAIL_SERVER_PORT:-587} + EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-} + EMAIL_ERRORS_TO: ${EMAIL_ERRORS_TO:-} + EMAIL_SSL: ${EMAIL_SSL:-FALSE} + EMAIL_TLS: ${EMAIL_TLS:-FALSE} + DATOMIC_URL: ${DATOMIC_URL:-datomic:free://datomic:4334/orcpub?password=change-me} + SIGNATURE: ${SIGNATURE:-change-me-to-something-unique} depends_on: - - datomic + datomic: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8890/"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 15s restart: always datomic: image: orcpub/datomic:latest environment: - ADMIN_PASSWORD: - # Must match the in the DATOMIC_URL above. - DATOMIC_PASSWORD: + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-admin} + DATOMIC_PASSWORD: ${DATOMIC_PASSWORD:-change-me} volumes: - ./data:/data - ./logs:/logs + healthcheck: + test: ["CMD-SHELL", "grep -q ':10EE ' /proc/net/tcp || grep -q ':10EE ' /proc/net/tcp6"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 40s restart: always web: image: nginx:alpine @@ -43,5 +50,6 @@ services: - ./deploy/snakeoil.key:/etc/nginx/snakeoil.key - ./deploy/homebrew/:/usr/share/nginx/html/homebrew/ depends_on: - - orcpub + orcpub: + condition: service_healthy restart: always diff --git a/docker-setup.sh b/docker-setup.sh new file mode 100755 index 00000000..05c5d42a --- /dev/null +++ b/docker-setup.sh @@ -0,0 +1,304 @@ +#!/usr/bin/env bash +# +# OrcPub / Dungeon Master's Vault — Docker Setup Script +# +# Prepares everything needed to run the application via Docker Compose: +# 1. Generates secure random passwords and a signing secret +# 2. Creates a .env file (or uses an existing one) +# 3. Generates self-signed SSL certificates (if missing) +# 4. Creates required directories (data, logs, deploy/homebrew) +# +# Usage: +# ./docker-setup.sh # Interactive mode — prompts for optional values +# ./docker-setup.sh --auto # Non-interactive — accepts all defaults +# ./docker-setup.sh --help # Show usage +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${SCRIPT_DIR}/.env" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +color_green='\033[0;32m' +color_yellow='\033[1;33m' +color_red='\033[0;31m' +color_cyan='\033[0;36m' +color_reset='\033[0m' + +info() { printf '%s[INFO]%s %s\n' "$color_green" "$color_reset" "$*"; } +warn() { printf '%s[WARN]%s %s\n' "$color_yellow" "$color_reset" "$*"; } +error() { printf '%s[ERROR]%s %s\n' "$color_red" "$color_reset" "$*" >&2; } +header() { printf '\n%s=== %s ===%s\n\n' "$color_cyan" "$*" "$color_reset"; } + +generate_password() { + # Generate a URL-safe random password (no special chars that break URLs/YAML) + local length="${1:-24}" + if command -v openssl &>/dev/null; then + openssl rand -base64 "$length" | tr -d '/+=' | head -c "$length" + elif [ -r /dev/urandom ]; then + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$length" + else + error "Cannot generate random password: no openssl or /dev/urandom available" + exit 1 + fi +} + +prompt_value() { + local prompt_text="$1" + local default_value="$2" + local result + + if [ "${AUTO_MODE:-false}" = "true" ]; then + echo "$default_value" + return + fi + + if [ -n "$default_value" ]; then + read -rp "${prompt_text} [${default_value}]: " result + echo "${result:-$default_value}" + else + read -rp "${prompt_text}: " result + echo "$result" + fi +} + +usage() { + cat <<'USAGE' +Usage: ./docker-setup.sh [OPTIONS] + +Options: + --auto Non-interactive mode; accept all defaults + --force Overwrite existing .env file + --help Show this help message + +Examples: + ./docker-setup.sh # Interactive setup + ./docker-setup.sh --auto # Quick setup with generated defaults + ./docker-setup.sh --auto --force # Regenerate everything from scratch +USAGE +} + +# --------------------------------------------------------------------------- +# Parse arguments +# --------------------------------------------------------------------------- + +AUTO_MODE=false +FORCE_MODE=false + +for arg in "$@"; do + case "$arg" in + --auto) AUTO_MODE=true ;; + --force) FORCE_MODE=true ;; + --help) usage; exit 0 ;; + *) + error "Unknown option: $arg" + usage + exit 1 + ;; + esac +done + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +header "Dungeon Master's Vault — Docker Setup" + +# ---- Step 1: .env file --------------------------------------------------- + +if [ -f "$ENV_FILE" ] && [ "$FORCE_MODE" = "false" ]; then + info "Existing .env file found. Skipping generation (use --force to overwrite)." +else + header "Database Passwords" + + # Generate defaults but let user override + DEFAULT_ADMIN_PW="$(generate_password 24)" + DEFAULT_DATOMIC_PW="$(generate_password 24)" + DEFAULT_SIGNATURE="$(generate_password 32)" + + ADMIN_PASSWORD=$(prompt_value "Datomic admin password" "$DEFAULT_ADMIN_PW") + DATOMIC_PASSWORD=$(prompt_value "Datomic application password" "$DEFAULT_DATOMIC_PW") + SIGNATURE=$(prompt_value "JWT signing secret (20+ chars)" "$DEFAULT_SIGNATURE") + + header "Application" + + PORT=$(prompt_value "Application port" "8890") + EMAIL_SERVER_URL=$(prompt_value "SMTP server URL (leave empty to skip email)" "") + EMAIL_ACCESS_KEY="" + EMAIL_SECRET_KEY="" + EMAIL_SERVER_PORT="587" + EMAIL_FROM_ADDRESS="" + EMAIL_ERRORS_TO="" + EMAIL_SSL="FALSE" + EMAIL_TLS="FALSE" + + if [ -n "$EMAIL_SERVER_URL" ]; then + EMAIL_ACCESS_KEY=$(prompt_value "SMTP username" "") + EMAIL_SECRET_KEY=$(prompt_value "SMTP password" "") + EMAIL_SERVER_PORT=$(prompt_value "SMTP port" "587") + EMAIL_FROM_ADDRESS=$(prompt_value "From email address" "no-reply@orcpub.com") + EMAIL_ERRORS_TO=$(prompt_value "Error notification email" "") + EMAIL_SSL=$(prompt_value "Use SSL? (TRUE/FALSE)" "FALSE") + EMAIL_TLS=$(prompt_value "Use TLS? (TRUE/FALSE)" "FALSE") + fi + + info "Writing .env file..." + + cat > "$ENV_FILE" </dev/null; then + info "Generating self-signed SSL certificate..." + openssl req \ + -subj "/C=US/ST=State/L=City/O=OrcPub/OU=Dev/CN=localhost" \ + -x509 \ + -nodes \ + -days 365 \ + -newkey rsa:2048 \ + -keyout "$KEY_FILE" \ + -out "$CERT_FILE" \ + 2>/dev/null + info "SSL certificate generated (valid for 365 days)." + else + warn "openssl not found — cannot generate SSL certificates." + warn "Install openssl and run: ./deploy/snakeoil.sh" + fi +fi + +# ---- Step 4: Validation -------------------------------------------------- + +header "Validation" + +ERRORS=0 + +check_file() { + local label="$1" path="$2" + if [ -f "$path" ]; then + info " ${label}: OK" + else + warn " ${label}: MISSING (${path})" + ERRORS=$((ERRORS + 1)) + fi +} + +check_dir() { + local label="$1" path="$2" + if [ -d "$path" ]; then + info " ${label}: OK" + else + warn " ${label}: MISSING (${path})" + ERRORS=$((ERRORS + 1)) + fi +} + +check_file ".env" "$ENV_FILE" +check_file "docker-compose.yaml" "${SCRIPT_DIR}/docker-compose.yaml" +check_file "nginx.conf" "${SCRIPT_DIR}/deploy/nginx.conf" +check_file "SSL certificate" "$CERT_FILE" +check_file "SSL key" "$KEY_FILE" +check_dir "data/" "${SCRIPT_DIR}/data" +check_dir "logs/" "${SCRIPT_DIR}/logs" +check_dir "deploy/homebrew/" "${SCRIPT_DIR}/deploy/homebrew" + +echo "" + +if [ "$ERRORS" -gt 0 ]; then + warn "Setup completed with ${ERRORS} warning(s). Review the items above." +else + info "All checks passed!" +fi + +# ---- Step 5: Next steps --------------------------------------------------- + +header "Next Steps" + +cat <<'NEXT' +1. Review your .env file and adjust values if needed. + +2. Launch the application: + docker-compose up -d + +3. Create your first user (once containers are running): + ./docker-user.sh create + +4. Access the site at: + https://localhost + +5. Manage users later with: + ./docker-user.sh list # List all users + ./docker-user.sh check # Check a user's status + ./docker-user.sh verify # Verify an unverified user + +6. To import homebrew content, place your .orcbrew file at: + deploy/homebrew/homebrew.orcbrew + +7. To build from source instead of pulling images: + docker-compose -f docker-compose-build.yaml build + docker-compose -f docker-compose-build.yaml up -d + +For more details, see README.md. +NEXT diff --git a/docker-user.sh b/docker-user.sh new file mode 100755 index 00000000..9605d214 --- /dev/null +++ b/docker-user.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# +# OrcPub Docker User Management +# +# Injects and verifies users in the Datomic database running inside Docker. +# Works by executing Clojure code inside the orcpub container, using the +# uberjar classpath (which already has datomic.api and buddy.hashers). +# +# Usage: +# ./docker-user.sh create +# ./docker-user.sh verify +# ./docker-user.sh check +# ./docker-user.sh list +# +# The script auto-detects the orcpub container name from docker-compose. +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MANAGE_SCRIPT="${SCRIPT_DIR}/docker/scripts/manage-user.clj" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +color_green='\033[0;32m' +color_red='\033[0;31m' +color_yellow='\033[1;33m' +color_reset='\033[0m' + +info() { printf '%s[OK]%s %s\n' "$color_green" "$color_reset" "$*"; } +error() { printf '%s[ERROR]%s %s\n' "$color_red" "$color_reset" "$*" >&2; } +warn() { printf '%s[WARN]%s %s\n' "$color_yellow" "$color_reset" "$*"; } + +usage() { + cat <<'USAGE' +OrcPub Docker User Management + +Usage: + ./docker-user.sh create + Create a new user (auto-verified, skips email) + + ./docker-user.sh batch + Create multiple users from a file (one JVM startup). + File format: one user per line — username email password + Lines starting with # and blank lines are skipped. + Duplicates are logged and skipped (not treated as errors). + + ./docker-user.sh verify + Verify an existing unverified user + + ./docker-user.sh check + Check if a user exists and show their status + + ./docker-user.sh list + List all users in the database + +Options: + --container Override container name detection + --help Show this help + +Examples: + ./docker-user.sh create admin admin@example.com MySecurePass123 + ./docker-user.sh batch users.txt + ./docker-user.sh check admin + ./docker-user.sh list +USAGE +} + +# --------------------------------------------------------------------------- +# Find the orcpub container +# --------------------------------------------------------------------------- + +find_container() { + local container="" + + # Try docker-compose/docker compose service name first + if command -v docker-compose &>/dev/null; then + container=$(docker-compose ps -q orcpub 2>/dev/null || true) + fi + if [ -z "$container" ] && docker compose version &>/dev/null 2>&1; then + container=$(docker compose ps -q orcpub 2>/dev/null || true) + fi + + # Fallback: search by image name + if [ -z "$container" ]; then + container=$(docker ps -q --filter "ancestor=orcpub/orcpub:latest" 2>/dev/null | head -1 || true) + fi + + # Fallback: search by container name pattern + if [ -z "$container" ]; then + container=$(docker ps -q --filter "name=orcpub" 2>/dev/null | head -1 || true) + fi + + echo "$container" +} + +# --------------------------------------------------------------------------- +# Wait for container and Datomic to be ready +# --------------------------------------------------------------------------- + +wait_for_ready() { + local container="$1" + local max_wait=120 + local waited=0 + + # Check container is running + if ! docker inspect --format='{{.State.Running}}' "$container" 2>/dev/null | grep -q true; then + error "Container $container is not running." + error "Start it first: docker-compose up -d" + exit 1 + fi + + # Wait for Docker's native healthcheck (defined in docker-compose.yaml) + # to report the container as healthy. This avoids spawning a JVM per check. + local health + health=$(docker inspect --format='{{if .State.Health}}yes{{end}}' "$container" 2>/dev/null || true) + + if [ "$health" = "yes" ]; then + printf "Waiting for container health check" + while [ $waited -lt $max_wait ]; do + local status + status=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || true) + if [ "$status" = "healthy" ]; then + echo "" + info "Container is healthy" + return 0 + fi + if [ "$status" = "unhealthy" ]; then + echo "" + error "Container reported unhealthy" + exit 1 + fi + printf "." + sleep 2 + waited=$((waited + 2)) + done + echo "" + error "Timed out waiting for healthy status (${max_wait}s)." + exit 1 + fi + + # Fallback: no healthcheck defined — check HTTP readiness directly + warn "No Docker healthcheck found; polling HTTP on container port..." + printf "Waiting for app" + while [ $waited -lt $max_wait ]; do + if docker exec "$container" wget --no-verbose --tries=1 --spider \ + "http://localhost:${PORT:-8890}/" 2>/dev/null; then + echo "" + return 0 + fi + printf "." + sleep 2 + waited=$((waited + 2)) + done + + echo "" + error "Timed out waiting for app (${max_wait}s). Is the datomic container running?" + exit 1 +} + +# --------------------------------------------------------------------------- +# Run the management script inside the container +# --------------------------------------------------------------------------- + +run_in_container() { + local container="$1" + shift + + # Copy the management script into the container + docker cp "$MANAGE_SCRIPT" "${container}:/tmp/manage-user.clj" + + # Run it with the uberjar classpath + docker exec "$container" \ + java -cp /orcpub.jar clojure.main /tmp/manage-user.clj "$@" +} + +# --------------------------------------------------------------------------- +# Parse args and dispatch +# --------------------------------------------------------------------------- + +CONTAINER_OVERRIDE="" + +# Extract --container flag if present +ARGS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --container) + CONTAINER_OVERRIDE="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + ARGS+=("$1") + shift + ;; + esac +done + +set -- "${ARGS[@]+"${ARGS[@]}"}" + +if [ $# -eq 0 ]; then + usage + exit 1 +fi + +# Verify manage-user.clj exists +if [ ! -f "$MANAGE_SCRIPT" ]; then + error "Management script not found at: $MANAGE_SCRIPT" + exit 1 +fi + +# Find or use specified container +if [ -n "$CONTAINER_OVERRIDE" ]; then + CONTAINER="$CONTAINER_OVERRIDE" +else + CONTAINER=$(find_container) +fi + +if [ -z "$CONTAINER" ]; then + error "Cannot find the orcpub container." + error "Make sure the containers are running: docker-compose up -d" + exit 1 +fi + +# Wait for Datomic to be reachable, then run the command +wait_for_ready "$CONTAINER" + +# For batch: copy the user file into the container and rewrite the path +if [ "${1:-}" = "batch" ]; then + USER_FILE="${2:-}" + if [ -z "$USER_FILE" ]; then + error "Usage: ./docker-user.sh batch " + exit 1 + fi + if [ ! -f "$USER_FILE" ]; then + error "File not found: $USER_FILE" + exit 1 + fi + docker cp "$USER_FILE" "${CONTAINER}:/tmp/batch-users.txt" + run_in_container "$CONTAINER" batch /tmp/batch-users.txt +else + run_in_container "$CONTAINER" "$@" +fi diff --git a/docker/scripts/manage-user.clj b/docker/scripts/manage-user.clj new file mode 100644 index 00000000..b02cac2c --- /dev/null +++ b/docker/scripts/manage-user.clj @@ -0,0 +1,212 @@ +;; OrcPub Docker User Management Script +;; +;; Runs inside the orcpub container using the uberjar classpath: +;; java -cp /orcpub.jar clojure.main /scripts/manage-user.clj [args...] +;; +;; Commands: +;; create — Create and auto-verify a user +;; batch — Create users from a file (one per line) +;; verify — Verify an existing unverified user +;; check — Check if a user exists and their status +;; list — List all users (username + email + verified) + +(ns manage-user + (:require [datomic.api :as d] + [buddy.hashers :as hashers] + [clojure.string :as s])) + +(def datomic-url + (or (System/getenv "DATOMIC_URL") + "datomic:free://datomic:4334/orcpub?password=datomic")) + +(defn get-conn [] + (try + (d/connect datomic-url) + (catch Exception e + (binding [*out* *err*] + (println "ERROR: Cannot connect to Datomic at" datomic-url) + (println " Is the transactor running? Cause:" (.getMessage e))) + (System/exit 1)))) + +(defn find-user [db username-or-email] + (d/q '[:find (pull ?e [:orcpub.user/username + :orcpub.user/email + :orcpub.user/verified? + :orcpub.user/created + :db/id]) . + :in $ ?needle + :where + (or [?e :orcpub.user/username ?needle] + [?e :orcpub.user/email ?needle])] + db + username-or-email)) + +(defn try-create-user! + "Creates a user. Returns {:ok true} on success, {:duplicate \"reason\"} if the + user/email already exists, or {:error \"message\"} on unexpected failure." + [conn username email password] + (let [db (d/db conn) + email (s/lower-case (s/trim email)) + username (s/trim username)] + (cond + (d/q '[:find ?e . :in $ ?email + :where [?e :orcpub.user/email ?email]] db email) + {:duplicate (str "Email already registered: " email)} + + (d/q '[:find ?e . :in $ ?username + :where [?e :orcpub.user/username ?username]] db username) + {:duplicate (str "Username already taken: " username)} + + :else + (do + @(d/transact conn + [{:orcpub.user/email email + :orcpub.user/username username + :orcpub.user/password (hashers/encrypt password) + :orcpub.user/verified? true + :orcpub.user/send-updates? false + :orcpub.user/created (java.util.Date.)}]) + (println "OK: User created and verified —" username "<" email ">") + {:ok true})))) + +(defn batch-create-users! + "Reads a user file (one user per line: username email password) and creates + all users in a single JVM session. Blank lines and #-comments are skipped. + Duplicates are logged and skipped (not counted as failures). + Returns exit code 0 if no hard failures, 1 otherwise." + [conn path] + (let [lines (->> (s/split-lines (slurp path)) + (map s/trim) + (remove #(or (s/blank? %) (s/starts-with? % "#")))) + results (doall + (for [line lines] + (let [parts (s/split line #"\s+")] + (if (< (count parts) 3) + (do (binding [*out* *err*] + (println "SKIP: bad line (need: username email password):" line)) + {:error "bad line"}) + (let [[username email password] parts + result (try + (try-create-user! conn username email password) + (catch Exception e + {:error (.getMessage e)}))] + (when (:duplicate result) + (println "SKIP:" username "—" (:duplicate result))) + (when (:error result) + (binding [*out* *err*] + (println "FAIL:" username "—" (:error result)))) + result))))) + total (count results) + created (count (filter :ok results)) + dupes (count (filter :duplicate results)) + failed (count (filter :error results))] + (println) + (println (format "Batch complete: %d created, %d skipped (duplicate), %d failed, %d total" + created dupes failed total)) + (if (pos? failed) 1 0))) + +(defn verify-user! [conn username-or-email] + (let [db (d/db conn) + user (find-user db username-or-email)] + (if-not user + (do (binding [*out* *err*] + (println "ERROR: User not found:" username-or-email)) + (System/exit 1)) + (if (:orcpub.user/verified? user) + (println "OK: User already verified —" (:orcpub.user/username user)) + (do + @(d/transact conn + [[:db/add (:db/id user) :orcpub.user/verified? true]]) + (println "OK: User verified —" (:orcpub.user/username user))))))) + +(defn check-user [db username-or-email] + (if-let [user (find-user db username-or-email)] + (do + (println "Found user:") + (println " Username:" (:orcpub.user/username user)) + (println " Email: " (:orcpub.user/email user)) + (println " Verified:" (:orcpub.user/verified? user)) + (println " Created: " (:orcpub.user/created user))) + (do + (println "User not found:" username-or-email) + (System/exit 1)))) + +(defn list-users [db] + (let [users (d/q '[:find [(pull ?e [:orcpub.user/username + :orcpub.user/email + :orcpub.user/verified?]) ...] + :where [?e :orcpub.user/username]] + db)] + (if (empty? users) + (println "No users found.") + (do + (println (format "%-20s %-30s %s" "USERNAME" "EMAIL" "VERIFIED")) + (println (apply str (repeat 65 "-"))) + (doseq [u (sort-by :orcpub.user/username users)] + (println (format "%-20s %-30s %s" + (:orcpub.user/username u) + (:orcpub.user/email u) + (:orcpub.user/verified? u)))))))) + +;; --- CLI dispatch --- + +(let [args *command-line-args* + cmd (first args)] + (case cmd + "create" (let [[_ username email password] args] + (when-not (and username email password) + (binding [*out* *err*] + (println "Usage: manage-user.clj create ")) + (System/exit 1)) + (let [conn (get-conn) + result (try-create-user! conn username email password)] + (when-let [msg (or (:duplicate result) (:error result))] + (binding [*out* *err*] + (println "ERROR:" msg)) + (System/exit 1)))) + + "batch" (let [[_ path] args] + (when-not path + (binding [*out* *err*] + (println "Usage: manage-user.clj batch ") + (println " File format: one user per line — username email password") + (println " Lines starting with # and blank lines are skipped")) + (System/exit 1)) + (let [conn (get-conn) + exit (batch-create-users! conn path)] + (System/exit exit))) + + "verify" (let [[_ username-or-email] args] + (when-not username-or-email + (binding [*out* *err*] + (println "Usage: manage-user.clj verify ")) + (System/exit 1)) + (let [conn (get-conn)] + (verify-user! conn username-or-email))) + + "check" (let [[_ username-or-email] args] + (when-not username-or-email + (binding [*out* *err*] + (println "Usage: manage-user.clj check ")) + (System/exit 1)) + (let [conn (get-conn) + db (d/db conn)] + (check-user db username-or-email))) + + "list" (let [conn (get-conn) + db (d/db conn)] + (list-users db)) + + (do + (println "OrcPub User Management") + (println "") + (println "Commands:") + (println " create Create and auto-verify a user") + (println " batch Create users from a file (one per line)") + (println " verify Verify an existing user") + (println " check Check if a user exists") + (println " list List all users") + (when-not cmd + (System/exit 1)))) + ;; Datomic peer threads are non-daemon and keep the JVM alive; force exit. + (System/exit 0))