diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh index f1951d9..7470383 100755 --- a/api/docker-entrypoint.sh +++ b/api/docker-entrypoint.sh @@ -7,6 +7,36 @@ if [ "$NODE_ENV" = "production" ] && [ "$NODE_TLS_REJECT_UNAUTHORIZED" = "0" ]; exit 1 fi +# Validate critical environment variables +ENV_ERRORS=0 +check_env() { + if [ -z "$2" ] || echo "$2" | grep -q "REQUIRED_STRONG_PASSWORD\|GENERATE_WITH_openssl"; then + echo "FATAL: $1 is not set or contains a placeholder value" + ENV_ERRORS=$((ENV_ERRORS + 1)) + fi +} + +check_env "DATABASE_URL" "$DATABASE_URL" +check_env "REDIS_URL" "$REDIS_URL" +check_env "JWT_ACCESS_SECRET" "$JWT_ACCESS_SECRET" +check_env "JWT_REFRESH_SECRET" "$JWT_REFRESH_SECRET" +check_env "ENCRYPTION_KEY" "$ENCRYPTION_KEY" +check_env "INITIAL_ADMIN_PASSWORD" "$INITIAL_ADMIN_PASSWORD" + +if [ "$ENV_ERRORS" -gt 0 ]; then + echo "" + echo "FATAL: $ENV_ERRORS required environment variable(s) missing or invalid." + echo "Run the configuration wizard: bash config.sh" + echo "Or set them manually in .env and restart." + exit 1 +fi + +# Fix permissions for mounted volumes (host dirs may be root-owned on first run) +if [ "$(id -u)" = "0" ]; then + mkdir -p /app/logs /data/geoip /app/uploads 2>/dev/null || true + chown -R node:node /app/logs /data/geoip /app/uploads 2>/dev/null || true +fi + # Wait for PostgreSQL to be ready before running migrations echo "Waiting for database..." MAX_WAIT=30 @@ -43,6 +73,7 @@ if [ -n "$MAXMIND_ACCOUNT_ID" ] && [ -n "$MAXMIND_LICENSE_KEY" ]; then if node -e " const https = require('https'); const fs = require('fs'); + setTimeout(() => { console.error('GeoIP download timed out after 60s'); process.exit(1); }, 60000).unref(); const auth = Buffer.from('$MAXMIND_ACCOUNT_ID:$MAXMIND_LICENSE_KEY').toString('base64'); const get = (url, cb) => https.get(url, { headers: url.includes('maxmind.com') ? { Authorization: 'Basic ' + auth } : {} }, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) return get(res.headers.location, cb); @@ -51,7 +82,7 @@ if [ -n "$MAXMIND_ACCOUNT_ID" ] && [ -n "$MAXMIND_LICENSE_KEY" ]; then res.pipe(out); out.on('close', () => cb(null)); }).on('error', cb); - get('$DOWNLOAD_URL', (err) => { if (err) { console.error(err.message); process.exit(1); } }); + get('$DOWNLOAD_URL', (err) => { if (err) { console.error(err.message); process.exit(1); } else { process.exit(0); } }); "; then tar -xzf /tmp/geolite2.tar.gz -C /tmp/ 2>/dev/null MMDB_FILE=$(find /tmp -name 'GeoLite2-City.mmdb' -type f 2>/dev/null | head -1) @@ -82,4 +113,9 @@ if [ -f "src/server.ts" ] && echo "$@" | grep -q "npm.*start\|node.*dist"; then fi echo "Starting server..." -exec "$@" +# Drop to node user if running as root (production image uses su-exec) +if [ "$(id -u)" = "0" ] && command -v su-exec >/dev/null 2>&1; then + exec su-exec node "$@" +else + exec "$@" +fi diff --git a/api/prisma/migrations/20260407200000_add_petition_cover_video/migration.sql b/api/prisma/migrations/20260407200000_add_petition_cover_video/migration.sql new file mode 100644 index 0000000..14e8f26 --- /dev/null +++ b/api/prisma/migrations/20260407200000_add_petition_cover_video/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable: Add missing coverVideoId column to petitions table +-- This column was present in the Prisma schema but omitted from the original +-- 20260402200000_add_petitions migration. +ALTER TABLE "petitions" ADD COLUMN "coverVideoId" INTEGER; diff --git a/config.sh b/config.sh index 8532052..ccf4f9b 100755 --- a/config.sh +++ b/config.sh @@ -12,6 +12,42 @@ ENV_EXAMPLE="$SCRIPT_DIR/.env.example" MKDOCS_YML="$SCRIPT_DIR/mkdocs/mkdocs.yml" SERVICES_YAML="$SCRIPT_DIR/configs/homepage/services.yaml" +# --- Non-interactive mode flags --- +NON_INTERACTIVE=false +NI_DOMAIN="" +NI_ADMIN_EMAIL="" +NI_ADMIN_PASSWORD="" +NI_PRODUCTION=true +NI_ENABLE_ALL=false + +# --- Arg parser --- +while [[ $# -gt 0 ]]; do + case "$1" in + --non-interactive|-y) NON_INTERACTIVE=true; shift ;; + --domain) NI_DOMAIN="$2"; shift 2 ;; + --admin-email) NI_ADMIN_EMAIL="$2"; shift 2 ;; + --admin-password) NI_ADMIN_PASSWORD="$2"; shift 2 ;; + --development) NI_PRODUCTION=false; shift ;; + --enable-all) NI_ENABLE_ALL=true; shift ;; + --help|-h) + echo "Usage: bash config.sh [OPTIONS]" + echo "" + echo "Options:" + echo " --non-interactive, -y Skip all prompts (generate secrets, use defaults)" + echo " --domain DOMAIN Set domain (default: cmlite.org)" + echo " --admin-email EMAIL Set admin email (default: admin@DOMAIN)" + echo " --admin-password PASS Set admin password (must meet policy: 12+ chars, upper+lower+digit)" + echo " --development Set NODE_ENV=development (default: production)" + echo " --enable-all Enable all optional features" + echo " --help, -h Show this help" + echo "" + echo "Example:" + echo " bash config.sh --non-interactive --domain example.org --admin-password MyStr0ngPass123" + exit 0 ;; + *) shift ;; + esac +done + # --- Detect install mode --- # Release mode: installed from tarball (has VERSION file, no .git directory) # Source mode: cloned from git repository @@ -35,13 +71,15 @@ fi # ============================================================================= # Ensure stdin is connected to the terminal (handles curl | bash case) # ============================================================================= -if [[ ! -t 0 ]]; then - if [[ -e /dev/tty ]]; then - exec 0&2 - echo " Download and run manually: bash config.sh" >&2 - exit 1 +if [[ "$NON_INTERACTIVE" == "false" ]]; then + if [[ ! -t 0 ]]; then + if [[ -e /dev/tty ]]; then + exec 0&2 + echo " Use --non-interactive for headless mode, or run manually: bash config.sh" >&2 + exit 1 + fi fi fi @@ -110,6 +148,13 @@ validate_password() { prompt_yes_no() { local prompt=$1 default=${2:-n} + # In non-interactive mode, use default (or yes-for-all if --enable-all) + if [[ "$NON_INTERACTIVE" == "true" ]]; then + if [[ "$NI_ENABLE_ALL" == "true" ]]; then + return 0 # yes to everything + fi + [[ "$default" == "y" ]] && return 0 || return 1 + fi local yn if [[ "$default" == "y" ]]; then read -rp "$prompt [Y/n]: " yn @@ -196,14 +241,21 @@ initialize_env() { RECONFIGURE_MODE=false if [[ -f "$ENV_FILE" ]]; then - warn "Existing .env file found at $ENV_FILE" - if prompt_yes_no "Back up existing .env and create a fresh one?"; then + if [[ "$NON_INTERACTIVE" == "true" ]]; then + # Non-interactive: back up and create fresh backup_env_file cp "$ENV_EXAMPLE" "$ENV_FILE" - success "Created fresh .env from .env.example" + success "Created fresh .env from .env.example (backed up existing)" else - info "Keeping existing .env. Existing secrets will be preserved." - RECONFIGURE_MODE=true + warn "Existing .env file found at $ENV_FILE" + if prompt_yes_no "Back up existing .env and create a fresh one?"; then + backup_env_file + cp "$ENV_EXAMPLE" "$ENV_FILE" + success "Created fresh .env from .env.example" + else + info "Keeping existing .env. Existing secrets will be preserved." + RECONFIGURE_MODE=true + fi fi else cp "$ENV_EXAMPLE" "$ENV_FILE" @@ -218,12 +270,15 @@ initialize_env() { configure_domain() { header "Domain Configuration" - info "Root domain serves MkDocs documentation." - info "All application routes are at app.." - echo "" - - read -rp "Enter your domain (e.g., example.org) [default: cmlite.org]: " domain - domain=${domain:-cmlite.org} + if [[ "$NON_INTERACTIVE" == "true" ]]; then + domain="${NI_DOMAIN:-cmlite.org}" + else + info "Root domain serves MkDocs documentation." + info "All application routes are at app.." + echo "" + read -rp "Enter your domain (e.g., example.org) [default: cmlite.org]: " domain + domain=${domain:-cmlite.org} + fi update_env_var "DOMAIN" "$domain" update_env_var "BASE_DOMAIN" "https://$domain" @@ -252,14 +307,23 @@ configure_domain() { success "Domain set to: $domain" - echo "" - if prompt_yes_no "Is this a production deployment?"; then - update_env_var "NODE_ENV" "production" - success "NODE_ENV set to production" - IS_PRODUCTION="yes" + if [[ "$NON_INTERACTIVE" == "true" ]]; then + if [[ "$NI_PRODUCTION" == "true" ]]; then + update_env_var "NODE_ENV" "production" + IS_PRODUCTION="yes" + else + IS_PRODUCTION="no" + fi else - info "NODE_ENV stays as development" - IS_PRODUCTION="no" + echo "" + if prompt_yes_no "Is this a production deployment?"; then + update_env_var "NODE_ENV" "production" + success "NODE_ENV set to production" + IS_PRODUCTION="yes" + else + info "NODE_ENV stays as development" + IS_PRODUCTION="no" + fi fi # Store for later use @@ -269,34 +333,55 @@ configure_domain() { configure_admin() { header "Admin Credentials" - local default_email="admin@${CONFIGURED_DOMAIN:-cmlite.org}" - read -rp "Admin email [default: $default_email]: " admin_email - admin_email=${admin_email:-$default_email} + if [[ "$NON_INTERACTIVE" == "true" ]]; then + local admin_email="${NI_ADMIN_EMAIL:-admin@${CONFIGURED_DOMAIN:-cmlite.org}}" + local admin_password="${NI_ADMIN_PASSWORD:-}" - local admin_password="" - while true; do - echo "" - read -rsp "Admin password (min 12 chars, uppercase + lowercase + digit): " admin_password - echo "" - - if validate_password "$admin_password"; then - read -rsp "Confirm password: " confirm_password - echo "" - if [[ "$admin_password" == "$confirm_password" ]]; then - break - else - warn "Passwords do not match. Try again." - fi - else - warn "Password must be 12+ characters with uppercase, lowercase, and a digit." + # Generate a password if not provided + if [[ -z "$admin_password" ]]; then + admin_password="CmLite$(openssl rand -base64 12 | tr -dc 'a-zA-Z0-9' | head -c 12)1" + info "Generated admin password: $admin_password" + info "SAVE THIS — it will not be shown again." fi - done - update_env_var "INITIAL_ADMIN_EMAIL" "$admin_email" - update_env_var "INITIAL_ADMIN_PASSWORD" "$admin_password" - update_env_var "N8N_USER_EMAIL" "$admin_email" + if ! validate_password "$admin_password"; then + error "Admin password does not meet policy (12+ chars, uppercase + lowercase + digit)" + exit 1 + fi - success "Admin credentials configured" + update_env_var "INITIAL_ADMIN_EMAIL" "$admin_email" + update_env_var "INITIAL_ADMIN_PASSWORD" "$admin_password" + update_env_var "N8N_USER_EMAIL" "$admin_email" + success "Admin credentials configured ($admin_email)" + else + local default_email="admin@${CONFIGURED_DOMAIN:-cmlite.org}" + read -rp "Admin email [default: $default_email]: " admin_email + admin_email=${admin_email:-$default_email} + + local admin_password="" + while true; do + echo "" + read -rsp "Admin password (min 12 chars, uppercase + lowercase + digit): " admin_password + echo "" + + if validate_password "$admin_password"; then + read -rsp "Confirm password: " confirm_password + echo "" + if [[ "$admin_password" == "$confirm_password" ]]; then + break + else + warn "Passwords do not match. Try again." + fi + else + warn "Password must be 12+ characters with uppercase, lowercase, and a digit." + fi + done + + update_env_var "INITIAL_ADMIN_EMAIL" "$admin_email" + update_env_var "INITIAL_ADMIN_PASSWORD" "$admin_password" + update_env_var "N8N_USER_EMAIL" "$admin_email" + success "Admin credentials configured" + fi } # Check if a key already has a real value in .env (non-empty, not a placeholder) @@ -537,6 +622,14 @@ generate_all_secrets() { configure_smtp() { header "Email Configuration" + if [[ "$NON_INTERACTIVE" == "true" ]]; then + # Non-interactive: use MailHog defaults (production SMTP can be configured later) + update_env_var "VAULTWARDEN_SMTP_SECURITY" "off" + info "Using MailHog for email (configure SMTP later via .env)" + SMTP_MODE="mailhog" + return + fi + info "By default, emails are captured by MailHog (test mode)." info "You can configure a production SMTP server now or later." echo "" @@ -623,15 +716,17 @@ configure_features() { success "Jitsi Meet enabled" MEET_ENABLED="yes" - echo "" - info "Jitsi requires your server's public IP for media traffic (NAT traversal)." - info "Firewall must allow UDP port 10000 for video/audio." - read -rp " Server public IP [leave blank to set later]: " jvb_ip - if [[ -n "$jvb_ip" ]]; then - update_env_var "JVB_ADVERTISE_IP" "$jvb_ip" - success "JVB advertise IP set to $jvb_ip" - else - warn "Set JVB_ADVERTISE_IP in .env before starting Jitsi containers." + if [[ "$NON_INTERACTIVE" == "false" ]]; then + echo "" + info "Jitsi requires your server's public IP for media traffic (NAT traversal)." + info "Firewall must allow UDP port 10000 for video/audio." + read -rp " Server public IP [leave blank to set later]: " jvb_ip + if [[ -n "$jvb_ip" ]]; then + update_env_var "JVB_ADVERTISE_IP" "$jvb_ip" + success "JVB advertise IP set to $jvb_ip" + else + warn "Set JVB_ADVERTISE_IP in .env before starting Jitsi containers." + fi fi else MEET_ENABLED="no" @@ -642,38 +737,63 @@ configure_features() { success "SMS Campaigns enabled" SMS_ENABLED="yes" - echo "" - info "SMS uses a Termux-based Android phone as the sending device." - read -rp " Termux API URL [default: http://10.0.0.193:5001]: " termux_url - termux_url=${termux_url:-http://10.0.0.193:5001} - update_env_var "TERMUX_API_URL" "$termux_url" + if [[ "$NON_INTERACTIVE" == "false" ]]; then + echo "" + info "SMS uses a Termux-based Android phone as the sending device." + read -rp " Termux API URL [default: http://10.0.0.193:5001]: " termux_url + termux_url=${termux_url:-http://10.0.0.193:5001} + update_env_var "TERMUX_API_URL" "$termux_url" - read -rp " Termux API Key [leave blank to set later]: " termux_key - if [[ -n "$termux_key" ]]; then - update_env_var "TERMUX_API_KEY" "$termux_key" + read -rp " Termux API Key [leave blank to set later]: " termux_key + if [[ -n "$termux_key" ]]; then + update_env_var "TERMUX_API_KEY" "$termux_key" + fi fi else SMS_ENABLED="no" fi + if prompt_yes_no "Enable Social Connections (friendships, challenges, spotlights)?"; then + update_env_var "ENABLE_SOCIAL" "true" + success "Social Connections enabled" + else + update_env_var "ENABLE_SOCIAL" "false" + fi + + if prompt_yes_no "Enable People CRM (unified contact management)?"; then + update_env_var "ENABLE_PEOPLE" "true" + success "People CRM enabled" + else + update_env_var "ENABLE_PEOPLE" "false" + fi + + if prompt_yes_no "Enable Analytics & GeoIP (visitor tracking, geo dashboard)?"; then + update_env_var "ENABLE_ANALYTICS" "true" + success "Analytics enabled" + else + update_env_var "ENABLE_ANALYTICS" "false" + fi + if prompt_yes_no "Enable Docs Comments & Version History (Gitea-backed)?"; then update_env_var "GITEA_COMMENTS_ENABLED" "true" success "Docs Comments & Version History enabled" DOCS_COMMENTS_ENABLED="yes" - echo "" - info "Gitea auto-setup will create the API token, repos, and OAuth app automatically." - info "You need to provide the Gitea admin password (set during Gitea's first-run install)." - echo "" + if [[ "$NON_INTERACTIVE" == "false" ]]; then + echo "" + info "Gitea auto-setup will create the API token, repos, and OAuth app automatically." + info "You need to provide the Gitea admin password (set during Gitea's first-run install)." + echo "" - read -srp " Gitea admin password [leave blank to set up later via admin GUI]: " gitea_admin_pw - echo "" - if [[ -n "$gitea_admin_pw" ]]; then - update_env_var "GITEA_ADMIN_PASSWORD" "$gitea_admin_pw" - update_env_var "GITEA_COMMENTS_REPO_OWNER" "admin" - success "Gitea admin password saved — auto-setup will run on next start" - else - info "No password provided. Run Gitea Setup from the admin GUI after first start." + read -srp " Gitea admin password [leave blank to set up later via admin GUI]: " gitea_admin_pw + echo "" + if [[ -n "$gitea_admin_pw" ]]; then + update_env_var "GITEA_ADMIN_PASSWORD" "$gitea_admin_pw" + update_env_var "GITEA_COMMENTS_REPO_OWNER" "admin" + success "Gitea admin password saved — auto-setup will run on next start" + else + info "No password provided. Run Gitea Setup from the admin GUI after first start." + fi fi else DOCS_COMMENTS_ENABLED="no" @@ -692,15 +812,17 @@ configure_features() { success "Bunker Ops enabled" BUNKER_OPS_ENABLED="yes" - echo "" - read -rp " Instance label [default: domain name]: " instance_label - if [[ -n "$instance_label" ]]; then - update_env_var "INSTANCE_LABEL" "$instance_label" - fi + if [[ "$NON_INTERACTIVE" == "false" ]]; then + echo "" + read -rp " Instance label [default: domain name]: " instance_label + if [[ -n "$instance_label" ]]; then + update_env_var "INSTANCE_LABEL" "$instance_label" + fi - read -rp " Remote write URL (VictoriaMetrics endpoint): " remote_write_url - if [[ -n "$remote_write_url" ]]; then - update_env_var "BUNKER_OPS_REMOTE_WRITE_URL" "$remote_write_url" + read -rp " Remote write URL (VictoriaMetrics endpoint): " remote_write_url + if [[ -n "$remote_write_url" ]]; then + update_env_var "BUNKER_OPS_REMOTE_WRITE_URL" "$remote_write_url" + fi fi else BUNKER_OPS_ENABLED="no" @@ -711,19 +833,21 @@ configure_features() { update_env_var "ENABLE_ANALYTICS" "true" success "Analytics enabled" - echo "" - info "GeoIP tracking requires a free MaxMind account." - info "Sign up at: https://www.maxmind.com/en/geolite2/signup" - read -rp " MaxMind Account ID [leave blank to set later]: " maxmind_id - if [[ -n "$maxmind_id" ]]; then - update_env_var "MAXMIND_ACCOUNT_ID" "$maxmind_id" - read -rp " MaxMind License Key: " maxmind_key - if [[ -n "$maxmind_key" ]]; then - update_env_var "MAXMIND_LICENSE_KEY" "$maxmind_key" - success "MaxMind GeoIP credentials configured" + if [[ "$NON_INTERACTIVE" == "false" ]]; then + echo "" + info "GeoIP tracking requires a free MaxMind account." + info "Sign up at: https://www.maxmind.com/en/geolite2/signup" + read -rp " MaxMind Account ID [leave blank to set later]: " maxmind_id + if [[ -n "$maxmind_id" ]]; then + update_env_var "MAXMIND_ACCOUNT_ID" "$maxmind_id" + read -rp " MaxMind License Key: " maxmind_key + if [[ -n "$maxmind_key" ]]; then + update_env_var "MAXMIND_LICENSE_KEY" "$maxmind_key" + success "MaxMind GeoIP credentials configured" + fi + else + info "Set MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY in .env to enable geo tracking." fi - else - info "Set MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY in .env to enable geo tracking." fi else info "Analytics disabled (can enable later in admin Settings)" @@ -733,25 +857,245 @@ configure_features() { configure_pangolin() { header "Tunnel Configuration (Pangolin)" + if [[ "$NON_INTERACTIVE" == "true" ]]; then + info "Skipping Pangolin setup (configure later via admin GUI or .env)" + PANGOLIN_CONFIGURED="no" + return + fi + info "Pangolin provides secure public access to your services." info "Skip this if you'll configure tunneling later." echo "" - if prompt_yes_no "Configure Pangolin tunnel now?"; then - read -rp " Pangolin API URL [default: https://api.bnkserve.org/v1]: " pang_url - pang_url=${pang_url:-https://api.bnkserve.org/v1} - read -rp " Pangolin API key: " pang_key - read -rp " Pangolin Organization ID: " pang_org + if ! prompt_yes_no "Configure Pangolin tunnel now?"; then + PANGOLIN_CONFIGURED="no" + return + fi - update_env_var "PANGOLIN_API_URL" "$pang_url" - update_env_var "PANGOLIN_API_KEY" "$pang_key" - update_env_var "PANGOLIN_ORG_ID" "$pang_org" + read -rp " Pangolin API URL [default: https://api.bnkserve.org/v1]: " pang_url + pang_url=${pang_url:-https://api.bnkserve.org/v1} + read -rp " Pangolin API key: " pang_key + read -rp " Pangolin Organization ID: " pang_org - success "Pangolin configured" + update_env_var "PANGOLIN_API_URL" "$pang_url" + update_env_var "PANGOLIN_API_KEY" "$pang_key" + update_env_var "PANGOLIN_ORG_ID" "$pang_org" + + success "Pangolin API credentials saved" + + # Check if curl and jq are available for site setup + if ! command -v curl &>/dev/null || ! command -v jq &>/dev/null; then + warn "curl or jq not found — skipping site setup." info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services." PANGOLIN_CONFIGURED="yes" + return + fi + + # Verify API connectivity + info "Verifying Pangolin API connectivity..." + local health_status + health_status=$(curl -s -o /dev/null -w "%{http_code}" -m 10 \ + -H "Authorization: Bearer $pang_key" \ + "$pang_url/" 2>/dev/null) || true + + if [[ "$health_status" != "200" ]]; then + warn "Could not reach Pangolin API (HTTP $health_status)." + info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services." + PANGOLIN_CONFIGURED="yes" + return + fi + + success "Pangolin API reachable" + echo "" + + # Offer site setup options + info "Set up a tunnel site now?" + echo -e " ${BOLD}1)${NC} Create a new site" + echo -e " ${BOLD}2)${NC} Connect to an existing site" + echo -e " ${BOLD}3)${NC} Skip (configure later in admin GUI)" + echo "" + local site_choice + read -rp " Choose [1-3, default: 3]: " site_choice + site_choice=${site_choice:-3} + + case "$site_choice" in + 1) pangolin_create_site "$pang_url" "$pang_key" "$pang_org" ;; + 2) pangolin_connect_site "$pang_url" "$pang_key" "$pang_org" ;; + *) + info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services." + ;; + esac + + PANGOLIN_CONFIGURED="yes" +} + +# Helper: Call Pangolin API and extract data from response envelope +pangolin_api() { + local method=$1 url=$2 api_key=$3 + shift 3 + local body_args=() + if [[ $# -gt 0 ]]; then + body_args=(-d "$1") + fi + curl -s -m 15 -X "$method" "$url" \ + -H "Authorization: Bearer $api_key" \ + -H "Content-Type: application/json" \ + "${body_args[@]}" 2>/dev/null +} + +pangolin_create_site() { + local api_url=$1 api_key=$2 org_id=$3 + local domain="${CONFIGURED_DOMAIN:-cmlite.org}" + local site_name + + read -rp " Site name [default: changemaker-$domain]: " site_name + site_name=${site_name:-changemaker-$domain} + + info "Fetching Newt credentials..." + local defaults_resp + defaults_resp=$(pangolin_api GET "$api_url/org/$org_id/pick-site-defaults" "$api_key") + + local newt_id newt_secret client_address + newt_id=$(echo "$defaults_resp" | jq -r '.data.newtId // .newtId // empty' 2>/dev/null) + newt_secret=$(echo "$defaults_resp" | jq -r '.data.newtSecret // .newtSecret // .data.secret // .secret // empty' 2>/dev/null) + client_address=$(echo "$defaults_resp" | jq -r '.data.clientAddress // .clientAddress // .data.address // .address // empty' 2>/dev/null) + + if [[ -z "$newt_id" || -z "$newt_secret" ]]; then + warn "Could not fetch Newt credentials from pickSiteDefaults." + info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services." + return + fi + + success "Got Newt credentials (newtId: $newt_id)" + + info "Creating site \"$site_name\"..." + local create_payload + create_payload=$(jq -n \ + --arg name "$site_name" \ + --arg newtId "$newt_id" \ + --arg secret "$newt_secret" \ + --arg address "$client_address" \ + '{name: $name, type: "newt", newtId: $newtId, secret: $secret, address: $address}') + + local site_resp + site_resp=$(pangolin_api PUT "$api_url/org/$org_id/site" "$api_key" "$create_payload") + + local site_id + site_id=$(echo "$site_resp" | jq -r '.data.siteId // .siteId // empty' 2>/dev/null) + + if [[ -z "$site_id" ]]; then + local err_msg + err_msg=$(echo "$site_resp" | jq -r '.message // .error // "Unknown error"' 2>/dev/null) + warn "Site creation failed: $err_msg" + info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services." + return + fi + + success "Site created: $site_id ($site_name)" + + # Derive endpoint from API URL (strip /v1 path) + local endpoint + endpoint=$(echo "$api_url" | sed 's|/v1/*$||') + + # Write credentials to .env + update_env_var "PANGOLIN_SITE_ID" "$site_id" + update_env_var "PANGOLIN_NEWT_ID" "$newt_id" + update_env_var "PANGOLIN_NEWT_SECRET" "$newt_secret" + update_env_var "PANGOLIN_ENDPOINT" "$endpoint" + + success "Tunnel credentials written to .env" + info "Resources will be created automatically via the admin GUI or sync endpoint." +} + +pangolin_connect_site() { + local api_url=$1 api_key=$2 org_id=$3 + + info "Fetching sites from organization..." + local sites_resp + sites_resp=$(pangolin_api GET "$api_url/org/$org_id/sites" "$api_key") + + # Extract sites array (handle Pangolin response envelope) + local sites_json + sites_json=$(echo "$sites_resp" | jq '.data.sites // .data // .sites // []' 2>/dev/null) + + local site_count + site_count=$(echo "$sites_json" | jq 'length' 2>/dev/null) + + if [[ -z "$site_count" || "$site_count" -eq 0 ]]; then + warn "No sites found in this organization." + info "Use option 1 to create a new site, or set up via the admin GUI." + return + fi + + echo "" + echo -e " ${BOLD}Available sites:${NC}" + echo "" + + local i=1 + while IFS= read -r line; do + local name site_id online last_seen + name=$(echo "$line" | jq -r '.name') + site_id=$(echo "$line" | jq -r '.siteId') + online=$(echo "$line" | jq -r '.online // false') + last_seen=$(echo "$line" | jq -r '.lastSeen // "never"') + + local status_tag + if [[ "$online" == "true" ]]; then + status_tag="${GREEN}online${NC}" + else + status_tag="${DIM}offline${NC}" + fi + + echo -e " ${BOLD}$i)${NC} $name ${DIM}(ID: $site_id)${NC} — $status_tag" + i=$((i + 1)) + done < <(echo "$sites_json" | jq -c '.[]') + + echo "" + local choice + read -rp " Select site [1-$site_count]: " choice + + if [[ -z "$choice" || "$choice" -lt 1 || "$choice" -gt "$site_count" ]] 2>/dev/null; then + warn "Invalid selection." + return + fi + + local selected + selected=$(echo "$sites_json" | jq -c ".[$((choice - 1))]") + + local sel_id sel_name + sel_id=$(echo "$selected" | jq -r '.siteId') + sel_name=$(echo "$selected" | jq -r '.name') + + # Derive endpoint from API URL + local endpoint + endpoint=$(echo "$api_url" | sed 's|/v1/*$||') + + # Write site ID to .env + update_env_var "PANGOLIN_SITE_ID" "$sel_id" + update_env_var "PANGOLIN_ENDPOINT" "$endpoint" + + success "Connected to site: $sel_name (ID: $sel_id)" + + # Check if we also need Newt credentials + local existing_newt_id + existing_newt_id=$(grep "^PANGOLIN_NEWT_ID=" "$ENV_FILE" 2>/dev/null | cut -d= -f2-) + + if [[ -z "$existing_newt_id" ]]; then + info "Newt credentials not yet set." + info "If you have the Newt ID and Secret for this site, enter them now." + info "Otherwise, set them later via the admin GUI." + echo "" + read -rp " Newt ID (leave blank to skip): " newt_id_input + if [[ -n "$newt_id_input" ]]; then + read -rp " Newt Secret: " newt_secret_input + if [[ -n "$newt_secret_input" ]]; then + update_env_var "PANGOLIN_NEWT_ID" "$newt_id_input" + update_env_var "PANGOLIN_NEWT_SECRET" "$newt_secret_input" + success "Newt credentials written to .env" + fi + fi else - PANGOLIN_CONFIGURED="no" + info "Existing Newt credentials found in .env (newtId: $existing_newt_id)" fi } @@ -1182,6 +1526,11 @@ YAML configure_docs_reset() { header "Documentation Site" + if [[ "$NON_INTERACTIVE" == "true" ]]; then + info "Keeping existing documentation content" + return + fi + if prompt_yes_no "Reset documentation site to baseline? (keeps header & tracking)"; then touch "$SCRIPT_DIR/mkdocs/.reset-docs-on-startup" success "Docs reset scheduled for first startup" @@ -1248,6 +1597,13 @@ fix_container_permissions() { install_upgrade_watcher() { header "System Upgrade Watcher" + if [[ "$NON_INTERACTIVE" == "true" ]]; then + mkdir -p "$SCRIPT_DIR/data/upgrade" + info "Skipping systemd watcher install (run manually later)" + UPGRADE_WATCHER="skipped" + return + fi + info "The upgrade watcher lets you trigger upgrades from the admin Settings page." info "It installs a systemd path watcher that monitors for trigger files." echo "" @@ -1304,6 +1660,12 @@ install_upgrade_watcher() { install_backup_timer() { header "Automated Backups" + if [[ "$NON_INTERACTIVE" == "true" ]]; then + info "Skipping backup timer install (run manually later)" + BACKUP_TIMER="skipped" + return + fi + info "Daily automated backups protect against data loss." info "Backs up PostgreSQL databases + uploads to ./backups/ with 30-day retention." echo "" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1edca97..c98d7ae 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -102,6 +102,10 @@ services: - ALERTMANAGER_EMBED_PORT=${ALERTMANAGER_EMBED_PORT:-8895} # SMS Campaigns (Termux Android Bridge) - ENABLE_SMS=${ENABLE_SMS:-false} + # Social, People, Analytics (initial defaults; DB authoritative once admin saves) + - ENABLE_SOCIAL=${ENABLE_SOCIAL:-false} + - ENABLE_PEOPLE=${ENABLE_PEOPLE:-false} + - ENABLE_ANALYTICS=${ENABLE_ANALYTICS:-false} - TERMUX_API_URL=${TERMUX_API_URL:-http://10.0.0.193:5001} - TERMUX_API_KEY=${TERMUX_API_KEY:-} - SMS_DELAY_BETWEEN_MS=${SMS_DELAY_BETWEEN_MS:-3000} @@ -128,6 +132,7 @@ services: - ./data/upgrade:/app/upgrade:rw - ./configs:/app/configs:ro - ./logs/api:/app/logs + - ./.env:/.env:rw # Pangolin setup auto-writes credentials deploy: resources: limits: @@ -167,6 +172,7 @@ services: - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET} - JWT_INVITE_SECRET=${JWT_INVITE_SECRET} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100} + - ENCRYPTION_KEY=${ENCRYPTION_KEY} - ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true} - MEDIA_ROOT=/media/local - MEDIA_UPLOADS=/media/uploads @@ -362,7 +368,7 @@ services: redis: image: ${GITEA_REGISTRY:-gitea.bnkops.com/admin}/redis:7-alpine container_name: redis-changemaker - command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass "${REDIS_PASSWORD}" + command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy noeviction --requirepass "${REDIS_PASSWORD}" ports: - "127.0.0.1:6379:6379" volumes: diff --git a/docker-compose.yml b/docker-compose.yml index e4ac8d8..a3a1eda 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -103,6 +103,10 @@ services: - ALERTMANAGER_EMBED_PORT=${ALERTMANAGER_EMBED_PORT:-8895} # SMS Campaigns (Termux Android Bridge) - ENABLE_SMS=${ENABLE_SMS:-false} + # Social, People, Analytics (initial defaults; DB authoritative once admin saves) + - ENABLE_SOCIAL=${ENABLE_SOCIAL:-false} + - ENABLE_PEOPLE=${ENABLE_PEOPLE:-false} + - ENABLE_ANALYTICS=${ENABLE_ANALYTICS:-false} - TERMUX_API_URL=${TERMUX_API_URL:-http://10.0.0.193:5001} - TERMUX_API_KEY=${TERMUX_API_KEY:-} - SMS_DELAY_BETWEEN_MS=${SMS_DELAY_BETWEEN_MS:-3000} @@ -138,6 +142,7 @@ services: - ./data/upgrade:/app/upgrade:rw - ./configs:/app/configs:ro - ./logs/api:/app/logs + - ./.env:/.env:rw # Pangolin setup auto-writes credentials deploy: resources: limits: @@ -181,6 +186,7 @@ services: - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET} - JWT_INVITE_SECRET=${JWT_INVITE_SECRET} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100} + - ENCRYPTION_KEY=${ENCRYPTION_KEY} - ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true} - MEDIA_ROOT=/media/local - MEDIA_UPLOADS=/media/uploads @@ -386,7 +392,7 @@ services: redis: image: redis:7-alpine container_name: redis-changemaker - command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass "${REDIS_PASSWORD}" + command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy noeviction --requirepass "${REDIS_PASSWORD}" ports: - "127.0.0.1:6379:6379" volumes: diff --git a/scripts/install.sh b/scripts/install.sh index dc63d2f..49f3958 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -26,7 +26,6 @@ HEALTH_TIMEOUT=180 HEALTH_INTERVAL=5 # --- State flags for cleanup --- -EXTRACT_DIR="" TARBALL_PATH="" CONFIG_COMPLETE=false @@ -47,10 +46,6 @@ error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } cleanup() { local exit_code=$? if [[ $exit_code -ne 0 ]]; then - # Clean up temp extraction directory - if [[ -n "$EXTRACT_DIR" ]] && [[ -d "$EXTRACT_DIR" ]]; then - rm -rf "$EXTRACT_DIR" - fi # Clean up downloaded tarball (but not user-provided ones) if [[ -z "$LOCAL_TARBALL" ]] && [[ -n "$TARBALL_PATH" ]] && [[ -f "$TARBALL_PATH" ]]; then rm -f "$TARBALL_PATH" @@ -192,22 +187,32 @@ fi # ============================================================================= info "Extracting to ${INSTALL_DIR}..." -mkdir -p "$(dirname "$INSTALL_DIR")" -# Extract to temp, then move (handles tarball root directory naming) -EXTRACT_DIR=$(mktemp -d) -tar xzf "$TARBALL_PATH" -C "$EXTRACT_DIR" - -# Find the extracted directory (tarball might have any root name) -EXTRACTED=$(find "$EXTRACT_DIR" -maxdepth 1 -mindepth 1 -type d | head -1) -if [[ -z "$EXTRACTED" ]]; then - error "Tarball extraction failed — no directory found" - exit 1 +# Ensure the install directory exists and is empty +if [[ -d "$INSTALL_DIR" ]]; then + # Remove any leftover files (may need sudo for root-owned Docker artifacts) + rm -rf "${INSTALL_DIR:?}"/* "${INSTALL_DIR}"/.[!.]* 2>/dev/null || true + if [[ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]]; then + if command -v sudo &>/dev/null; then + warn "Removing root-owned leftovers from previous install..." + sudo rm -rf "${INSTALL_DIR:?}"/* "${INSTALL_DIR}"/.[!.]* 2>/dev/null || true + fi + if [[ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]]; then + error "Cannot clean install directory ${INSTALL_DIR} — remove manually (may need sudo)" + exit 1 + fi + fi +else + mkdir -p "$INSTALL_DIR" fi -mv "$EXTRACTED" "$INSTALL_DIR" -rm -rf "$EXTRACT_DIR" -EXTRACT_DIR="" # Clear so cleanup doesn't try to remove it +# Extract directly into INSTALL_DIR, stripping the tarball's root directory +tar xzf "$TARBALL_PATH" --strip-components=1 -C "$INSTALL_DIR" + +if [[ ! -f "$INSTALL_DIR/docker-compose.yml" ]]; then + error "Tarball extraction failed — docker-compose.yml not found" + exit 1 +fi # Clean up downloaded tarball if [[ -z "$LOCAL_TARBALL" ]] && [[ -f "$TARBALL_PATH" ]]; then diff --git a/scripts/test-deployment.sh b/scripts/test-deployment.sh new file mode 100755 index 0000000..cab347f --- /dev/null +++ b/scripts/test-deployment.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# ============================================================================= +# Changemaker Lite — Deployment Test Suite +# +# Verifies that all core services are running and responding correctly. +# Run after install or upgrade to validate the deployment. +# +# Usage: +# bash scripts/test-deployment.sh [OPTIONS] +# +# Options: +# --wait SECS Wait for services before testing (default: 0) +# --domain DOM Test tunnel connectivity for this domain +# --quiet Only show failures and summary +# --help Show this help +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WAIT_SECS=0 +TEST_DOMAIN="" +QUIET=false + +# Colors +if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then + RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' + BOLD='\033[1m' NC='\033[0m' +else + RED='' GREEN='' YELLOW='' BOLD='' NC='' +fi + +while [[ $# -gt 0 ]]; do + case "$1" in + --wait) WAIT_SECS="$2"; shift 2 ;; + --domain) TEST_DOMAIN="$2"; shift 2 ;; + --quiet) QUIET=true; shift ;; + --help|-h) + sed -n '2,16p' "$0" | grep '^#' | sed 's/^# \?//' + exit 0 ;; + *) shift ;; + esac +done + +PASS=0 FAIL=0 WARN=0 SKIP=0 + +pass() { PASS=$((PASS+1)); [[ "$QUIET" == "false" ]] && echo -e " ${GREEN}PASS${NC} $1"; } +fail() { FAIL=$((FAIL+1)); echo -e " ${RED}FAIL${NC} $1"; } +warn() { WARN=$((WARN+1)); [[ "$QUIET" == "false" ]] && echo -e " ${YELLOW}WARN${NC} $1"; } +skip() { SKIP=$((SKIP+1)); [[ "$QUIET" == "false" ]] && echo -e " ${YELLOW}SKIP${NC} $1"; } + +# Load .env +if [[ -f "$SCRIPT_DIR/.env" ]]; then + source <(grep -E '^(API_PORT|ADMIN_PORT|MEDIA_API_PORT|DOMAIN|ENABLE_|INITIAL_ADMIN_EMAIL)=' "$SCRIPT_DIR/.env" 2>/dev/null || true) +fi +API_PORT="${API_PORT:-4000}" +ADMIN_PORT="${ADMIN_PORT:-3000}" +MEDIA_API_PORT="${MEDIA_API_PORT:-4100}" +DOMAIN="${DOMAIN:-cmlite.org}" +[[ -n "$TEST_DOMAIN" ]] && DOMAIN="$TEST_DOMAIN" + +cd "$SCRIPT_DIR" + +echo -e "${BOLD}Changemaker Lite — Deployment Test${NC}" +echo "" + +# Wait if requested +if [[ "$WAIT_SECS" -gt 0 ]]; then + echo "Waiting ${WAIT_SECS}s for services to start..." + sleep "$WAIT_SECS" +fi + +# ─── Core Health ───────────────────────────────────────────────────────── +echo -e "${BOLD}Core Health${NC}" + +HEALTH=$(curl -sf "http://localhost:${API_PORT}/api/health" 2>/dev/null || echo "") +if echo "$HEALTH" | grep -q '"healthy"'; then + pass "API health" +else + fail "API health (${HEALTH:-no response})" +fi + +MEDIA=$(curl -sf "http://localhost:${MEDIA_API_PORT}/health" 2>/dev/null || echo "") +if echo "$MEDIA" | grep -q '"ok"'; then + pass "Media API health" +else + fail "Media API health" +fi + +ADMIN_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "http://localhost:${ADMIN_PORT}/" 2>/dev/null || echo "0") +if [[ "$ADMIN_CODE" == "200" ]]; then + pass "Admin GUI (HTTP $ADMIN_CODE)" +else + fail "Admin GUI (HTTP $ADMIN_CODE)" +fi + +# ─── Authentication ────────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}Authentication${NC}" + +# Get admin email from .env +ADMIN_EMAIL="${INITIAL_ADMIN_EMAIL:-admin@${DOMAIN}}" +# Try to login — write payload to file to avoid ! escaping +LOGIN_FILE=$(mktemp) +# Read password from .env directly (avoid bash expansion issues with !) +ADMIN_PW=$(grep "^INITIAL_ADMIN_PASSWORD=" "$SCRIPT_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2-) +printf '{"email":"%s","password":"%s"}' "$ADMIN_EMAIL" "$ADMIN_PW" > "$LOGIN_FILE" + +TOKEN=$(curl -sf -X POST "http://localhost:${API_PORT}/api/auth/login" \ + -H "Content-Type: application/json" -d "@$LOGIN_FILE" 2>/dev/null \ + | python3 -c "import sys,json; print(json.load(sys.stdin).get('accessToken',''))" 2>/dev/null || echo "") +rm -f "$LOGIN_FILE" + +if [[ -n "$TOKEN" ]]; then + pass "Admin login" +else + fail "Admin login ($ADMIN_EMAIL)" +fi + +# ─── Authenticated Endpoints ───────────────────────────────────────────── +echo "" +echo -e "${BOLD}Authenticated API${NC}" + +if [[ -n "$TOKEN" ]]; then + AUTH="Authorization: Bearer $TOKEN" + for ep in "/api/settings" "/api/users" "/api/dashboard/summary" "/api/services/status"; do + CODE=$(curl -sf -o /dev/null -w "%{http_code}" "http://localhost:${API_PORT}${ep}" -H "$AUTH" 2>/dev/null || echo "0") + if [[ "$CODE" == "200" ]]; then + pass "GET $ep" + else + fail "GET $ep (HTTP $CODE)" + fi + done +else + skip "Authenticated endpoints (no token)" +fi + +# ─── Public Endpoints ──────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}Public API${NC}" + +for ep in "/api/campaigns/public" "/api/map/shifts/public" "/api/pages/listed" \ + "/api/donation-pages" "/api/homepage" "/api/qr?text=test" \ + "/api/petitions/public" "/api/payments/plans" "/api/payments/products" \ + "/api/map/cuts/public"; do + CODE=$(curl -sf -o /dev/null -w "%{http_code}" "http://localhost:${API_PORT}${ep}" 2>/dev/null || echo "0") + if [[ "$CODE" == "200" ]]; then + pass "GET $ep" + else + fail "GET $ep (HTTP $CODE)" + fi +done + +# ─── Supporting Services ───────────────────────────────────────────────── +echo "" +echo -e "${BOLD}Supporting Services${NC}" + +declare -A SERVICES=( + [gitea]=3030 [nocodb]=8091 [listmonk]=9001 [n8n]=5678 + [homepage]=3010 [excalidraw]=8090 [vaultwarden]=8445 + [mailhog]=8025 [mkdocs-dev]=4003 [mkdocs-static]=4004 [mini-qr]=8089 +) + +for svc in "${!SERVICES[@]}"; do + port=${SERVICES[$svc]} + CODE=$(curl -sf -o /dev/null -w "%{http_code}" --max-time 5 "http://localhost:${port}/" 2>/dev/null || echo "0") + if [[ "$CODE" == "200" || "$CODE" == "302" ]]; then + pass "$svc (:$port)" + else + fail "$svc (:$port) (HTTP $CODE)" + fi +done + +# ─── Nginx Proxy ───────────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}Nginx Proxy${NC}" + +NGINX_HEALTH=$(docker compose ps nginx --format '{{.Health}}' 2>/dev/null || echo "unknown") +if [[ "$NGINX_HEALTH" == "healthy" ]]; then + pass "Nginx container healthy" + + API_PROXY=$(curl -sf -H "Host: api.${DOMAIN}" "http://localhost:80/api/health" 2>/dev/null || echo "") + if echo "$API_PROXY" | grep -q '"healthy"'; then + pass "Nginx -> API proxy" + else + fail "Nginx -> API proxy" + fi + + ADMIN_PROXY=$(curl -sf -o /dev/null -w "%{http_code}" -H "Host: app.${DOMAIN}" "http://localhost:80/" 2>/dev/null || echo "0") + if [[ "$ADMIN_PROXY" == "200" ]]; then + pass "Nginx -> Admin proxy" + else + fail "Nginx -> Admin proxy (HTTP $ADMIN_PROXY)" + fi +else + fail "Nginx container ($NGINX_HEALTH)" +fi + +# ─── Tunnel (optional) ────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}Tunnel${NC}" + +NEWT_STATUS=$(docker compose ps newt --format '{{.Status}}' 2>/dev/null || echo "") +if echo "$NEWT_STATUS" | grep -q "Up"; then + pass "Newt container running" + + TUNNEL_API=$(curl -sf -o /dev/null -w "%{http_code}" --max-time 10 "https://api.${DOMAIN}/api/health" 2>/dev/null || echo "0") + if [[ "$TUNNEL_API" == "200" ]]; then + pass "Tunnel -> api.${DOMAIN}" + else + warn "Tunnel -> api.${DOMAIN} (HTTP $TUNNEL_API)" + fi + + TUNNEL_APP=$(curl -sf -o /dev/null -w "%{http_code}" --max-time 10 "https://app.${DOMAIN}/" 2>/dev/null || echo "0") + if [[ "$TUNNEL_APP" == "200" ]]; then + pass "Tunnel -> app.${DOMAIN}" + else + warn "Tunnel -> app.${DOMAIN} (HTTP $TUNNEL_APP — check Pangolin resource auth)" + fi +else + skip "Tunnel (Newt not running)" +fi + +# ─── Database ──────────────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}Database${NC}" + +USER_COUNT=$(docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 -t -c "SELECT COUNT(*) FROM users" 2>/dev/null | tr -d ' ' || echo "0") +if [[ "$USER_COUNT" -gt "0" ]] 2>/dev/null; then + pass "Users in DB: $USER_COUNT" +else + fail "No users found in database" +fi + +TABLE_COUNT=$(docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 -t -c "SELECT COUNT(*) FROM pg_tables WHERE schemaname='public'" 2>/dev/null | tr -d ' ' || echo "0") +if [[ "$TABLE_COUNT" -gt "100" ]] 2>/dev/null; then + pass "Tables in DB: $TABLE_COUNT" +else + warn "Table count: $TABLE_COUNT (expected 180+)" +fi + +# ─── Summary ───────────────────────────────────────────────────────────── +TOTAL=$((PASS + FAIL + WARN + SKIP)) +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +if [[ "$FAIL" -eq 0 ]]; then + echo -e " ${GREEN}${BOLD}ALL TESTS PASSED${NC} ($PASS passed, $WARN warnings, $SKIP skipped)" +else + echo -e " ${RED}${BOLD}$FAIL FAILURES${NC} ($PASS passed, $FAIL failed, $WARN warnings, $SKIP skipped)" +fi +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +exit "$FAIL" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..cbda490 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# ============================================================================= +# Changemaker Lite — Uninstall Script +# +# Safely stops and removes all Changemaker containers, volumes, and files. +# Handles root-owned files created by Docker containers. +# +# Usage: +# bash scripts/uninstall.sh [OPTIONS] +# +# Options: +# --keep-data Keep database volumes (PostgreSQL, Redis, etc.) +# --keep-media Keep the media/ directory (uploaded files) +# --yes, -y Skip confirmation prompt +# --help Show this help +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +KEEP_DATA=false +KEEP_MEDIA=false +SKIP_CONFIRM=false + +# Colors +if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then + RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' + BLUE='\033[0;34m' BOLD='\033[1m' NC='\033[0m' +else + RED='' GREEN='' YELLOW='' BLUE='' BOLD='' NC='' +fi + +info() { echo -e "${BLUE}[INFO]${NC} $*"; } +success() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --keep-data) KEEP_DATA=true; shift ;; + --keep-media) KEEP_MEDIA=true; shift ;; + --yes|-y) SKIP_CONFIRM=true; shift ;; + --help|-h) + sed -n '2,16p' "$0" | grep '^#' | sed 's/^# \?//' + exit 0 ;; + *) shift ;; + esac +done + +echo -e "${BOLD}Changemaker Lite — Uninstall${NC}" +echo "" + +if [[ ! -f "$SCRIPT_DIR/docker-compose.yml" ]]; then + error "Not a Changemaker Lite installation: $SCRIPT_DIR" + exit 1 +fi + +info "Install directory: $SCRIPT_DIR" +[[ "$KEEP_DATA" == "true" ]] && info "Database volumes will be preserved" +[[ "$KEEP_MEDIA" == "true" ]] && info "Media files will be preserved" +echo "" + +if [[ "$SKIP_CONFIRM" == "false" ]]; then + echo -e "${RED}${BOLD}WARNING: This will permanently remove Changemaker Lite.${NC}" + read -rp "Type 'uninstall' to confirm: " confirm + if [[ "$confirm" != "uninstall" ]]; then + echo "Cancelled." + exit 0 + fi +fi + +# Step 1: Stop and remove containers +echo "" +info "Stopping containers..." +cd "$SCRIPT_DIR" +if [[ "$KEEP_DATA" == "true" ]]; then + docker compose down --remove-orphans 2>/dev/null || true +else + docker compose down -v --remove-orphans 2>/dev/null || true +fi +success "Containers stopped" + +# Step 2: Remove systemd units (if installed) +if command -v systemctl &>/dev/null; then + for unit in changemaker-upgrade.path changemaker-upgrade.service changemaker-backup.timer changemaker-backup.service; do + if systemctl is-enabled "$unit" &>/dev/null 2>&1; then + info "Removing systemd unit: $unit" + sudo systemctl disable --now "$unit" 2>/dev/null || true + sudo rm -f "/etc/systemd/system/$unit" 2>/dev/null || true + fi + done + sudo systemctl daemon-reload 2>/dev/null || true +fi + +# Step 3: Remove files +info "Removing installation files..." + +# Try normal rm first +cd "$HOME" +if [[ "$KEEP_MEDIA" == "true" ]]; then + # Move media out, remove everything, move media back + MEDIA_TMP=$(mktemp -d) + mv "$SCRIPT_DIR/media" "$MEDIA_TMP/" 2>/dev/null || true +fi + +rm -rf "$SCRIPT_DIR" 2>/dev/null || true + +# Handle root-owned files from Docker +if [[ -d "$SCRIPT_DIR" ]]; then + warn "Some files are root-owned (from Docker). Using sudo to clean up..." + sudo rm -rf "$SCRIPT_DIR" 2>/dev/null || { + error "Could not remove $SCRIPT_DIR — manual cleanup needed:" + echo " sudo rm -rf $SCRIPT_DIR" + } +fi + +# Restore media if requested +if [[ "$KEEP_MEDIA" == "true" ]] && [[ -d "${MEDIA_TMP:-}/media" ]]; then + mkdir -p "$SCRIPT_DIR" + mv "$MEDIA_TMP/media" "$SCRIPT_DIR/" + rm -rf "$MEDIA_TMP" + success "Media files preserved at $SCRIPT_DIR/media/" +fi + +echo "" +success "Changemaker Lite has been uninstalled." +[[ "$KEEP_DATA" == "true" ]] && info "Database volumes were preserved (use 'docker volume ls' to manage)" +echo ""