From 62f906d6f0b3f85cd3146b9641242580d08144e0 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 2 Mar 2026 11:12:25 -0700 Subject: [PATCH 1/2] Fix upgrade script for Gancio config loss and LSIO volume shadowing Two issues occurred during upgrades: 1. Gancio config.json lost when Docker volume name prefix changes (e.g., changemakerlite_ vs changemaker-lite_). Gancio finds existing DB but no config and enters restart loop. Fix: verify_gancio_config() checks the volume and regenerates config.json from .env if missing. 2. mkdocs-site-server (LSIO nginx) returns 403 after upgrade because the anonymous /config volume shadows the ./mkdocs/site bind mount. Fix: docker compose rm -sf the LSIO container before up -d so the anonymous volume is recreated fresh. Also adds Gancio and MkDocs site health checks to Phase 6 verification. Co-Authored-By: Claude Opus 4.6 --- scripts/upgrade.sh | 67 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 7570eed6..3913eaf7 100755 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -26,6 +26,9 @@ CONDITIONAL_CONTAINERS="nginx code-server" APP_CONTAINERS="api admin media-api nginx" # Infrastructure containers (must stay up) INFRA_CONTAINERS="v2-postgres redis" +# LSIO containers with anonymous /config volumes (must be force-recreated on upgrade +# to prevent stale anonymous volumes from shadowing bind mounts underneath /config) +LSIO_VOLUME_CONTAINERS="mkdocs-site-server" # User-modifiable paths (auto-resolve keeps user version on conflict) USER_PATHS=( @@ -106,6 +109,36 @@ restore_user_paths() { fi } +# --- Verify Gancio config.json in its data volume --- +# Gancio uses a named Docker volume for /home/node/data. If the volume loses +# config.json (e.g., volume name prefix mismatch after compose project rename), +# Gancio detects an existing DB but no config and refuses to start with: +# "Non empty db! Please move your current db elsewhere than retry." +# This regenerates config.json from .env vars when missing. +verify_gancio_config() { + local gancio_volume + gancio_volume="$(docker volume ls --format '{{.Name}}' | grep 'gancio-data' | head -1 || true)" + if [[ -z "$gancio_volume" ]]; then + return # No gancio volume exists yet; first run will handle it + fi + + # Check if config.json exists and is non-empty + if docker run --rm -v "${gancio_volume}:/data" alpine test -s /data/config.json 2>/dev/null; then + success "Gancio config.json present in $gancio_volume" + return + fi + + warn "Gancio config.json missing in volume $gancio_volume — regenerating from .env" + local base_url="${GANCIO_BASE_URL:-https://events.cmlite.org}" + local pg_user="${V2_POSTGRES_USER:-changemaker}" + local pg_pass="${V2_POSTGRES_PASSWORD:-changemaker}" + local config_json="{\"baseurl\":\"${base_url}\",\"server\":{\"host\":\"0.0.0.0\",\"port\":13120},\"db\":{\"dialect\":\"postgres\",\"host\":\"changemaker-v2-postgres\",\"port\":5432,\"database\":\"gancio\",\"username\":\"${pg_user}\",\"password\":\"${pg_pass}\"}}" + + docker run --rm -v "${gancio_volume}:/data" alpine sh -c \ + "echo '${config_json}' > /data/config.json && chown 1000:1000 /data/config.json" + success "Gancio config.json regenerated" +} + # --- Lockfile --- acquire_lock() { if [[ -f "$LOCK_FILE" ]]; then @@ -656,6 +689,18 @@ info "Stopping application containers..." docker compose stop $APP_CONTAINERS 2>/dev/null || true success "Application containers stopped" +# Force-recreate LSIO containers to prevent anonymous volume shadowing bind mounts. +# LSIO images define a VOLUME at /config in their Dockerfile. When a container is +# merely restarted, Docker reuses the old anonymous volume whose /config/www is empty, +# which shadows the bind mount (e.g., ./mkdocs/site:/config/www → 403 Forbidden). +# Removing the container first ensures a fresh anonymous volume that respects bind mounts. +info "Removing LSIO containers (clearing anonymous volumes)..." +docker compose rm -sf $LSIO_VOLUME_CONTAINERS 2>/dev/null || true +success "LSIO containers cleared for fresh recreation" + +# Verify Gancio config.json exists before starting services +verify_gancio_config + # Ensure infrastructure is running and healthy info "Ensuring infrastructure is up..." docker compose up -d $INFRA_CONTAINERS @@ -757,6 +802,28 @@ if docker ps --format '{{.Names}}' | grep -q 'changemaker-media-api'; then fi fi +# Gancio health (optional) +if docker ps --format '{{.Names}}' | grep -q 'gancio-changemaker'; then + if docker compose ps gancio --format '{{.Status}}' 2>/dev/null | grep -q "healthy"; then + success "Gancio: healthy" + elif docker compose ps gancio --format '{{.Status}}' 2>/dev/null | grep -qi "restarting"; then + warn "Gancio: restart loop detected (check config.json in gancio-data volume)" + VERIFY_FAILED=true + else + info "Gancio: starting (may take up to 60s)" + fi +fi + +# MkDocs static site health +if docker ps --format '{{.Names}}' | grep -q 'mkdocs-site-server'; then + if curl -sf http://localhost:${MKDOCS_SITE_SERVER_PORT:-4004}/ -o /dev/null 2>/dev/null; then + success "MkDocs site (port ${MKDOCS_SITE_SERVER_PORT:-4004}): healthy" + else + warn "MkDocs site (port ${MKDOCS_SITE_SERVER_PORT:-4004}): not responding" + VERIFY_FAILED=true + fi +fi + # Check for containers in restart loop RESTARTING="$(docker compose ps 2>/dev/null | grep -i "restarting" || true)" if [[ -n "$RESTARTING" ]]; then From f57a6d07f59826c22e93a893ce21769e2457695a Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 2 Mar 2026 14:15:26 -0700 Subject: [PATCH 2/2] Fix poll vote submission failure and add pridecorner.ca nginx routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users could not submit scheduling poll votes when an invalid or partial email was entered — Zod rejected empty strings and non-email text with a generic validation error. Added client-side email validation in both SchedulingPollPage and SchedulingPollWidget, plus z.preprocess() on the backend to coerce empty strings to undefined. Also added pridecorner.ca to all nginx server blocks and added generate_nginx_configs() to config.sh so template-based configs are generated during setup. Co-Authored-By: Claude Opus 4.6 --- .../scheduling/SchedulingPollWidget.tsx | 7 ++++- admin/src/pages/public/SchedulingPollPage.tsx | 7 ++++- .../meeting-planner.schemas.ts | 5 +++- config.sh | 28 +++++++++++++++++++ nginx/conf.d/api.conf | 2 +- nginx/conf.d/services.conf | 16 +++++------ 6 files changed, 53 insertions(+), 12 deletions(-) diff --git a/admin/src/components/scheduling/SchedulingPollWidget.tsx b/admin/src/components/scheduling/SchedulingPollWidget.tsx index 62f9fd78..1db293a1 100644 --- a/admin/src/components/scheduling/SchedulingPollWidget.tsx +++ b/admin/src/components/scheduling/SchedulingPollWidget.tsx @@ -184,6 +184,11 @@ export function SchedulingPollWidget({ pollSlug, showComments = true, title }: S setSubmitMsg({ type: 'error', text: 'Please vote on at least one option' }); return; } + const trimmedEmail = voterEmail.trim(); + if (trimmedEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) { + setSubmitMsg({ type: 'error', text: 'Please enter a valid email address, or leave the email field blank' }); + return; + } setSubmitting(true); setSubmitMsg(null); @@ -191,7 +196,7 @@ export function SchedulingPollWidget({ pollSlug, showComments = true, title }: S const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + pollSlug); const { data } = await axios.post(`${apiBase}/meeting-planner/public/${pollSlug}/vote`, { voterName: voterName.trim(), - voterEmail: voterEmail.trim() || undefined, + voterEmail: trimmedEmail || undefined, voterToken: storedToken || undefined, votes: Object.entries(votes).map(([optionId, value]) => ({ optionId, value })), }); diff --git a/admin/src/pages/public/SchedulingPollPage.tsx b/admin/src/pages/public/SchedulingPollPage.tsx index 7c35e5d2..b674beb9 100644 --- a/admin/src/pages/public/SchedulingPollPage.tsx +++ b/admin/src/pages/public/SchedulingPollPage.tsx @@ -110,13 +110,18 @@ export default function SchedulingPollPage() { message.warning('Please vote on at least one option'); return; } + const trimmedEmail = voterEmail.trim(); + if (trimmedEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) { + message.warning('Please enter a valid email address, or leave the email field blank'); + return; + } setSubmitting(true); try { const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + slug); const { data } = await axios.post(`/api/meeting-planner/public/${slug}/vote`, { voterName: voterName.trim(), - voterEmail: voterEmail.trim() || undefined, + voterEmail: trimmedEmail || undefined, voterToken: storedToken || undefined, votes: Object.entries(votes).map(([optionId, value]) => ({ optionId, value })), }); diff --git a/api/src/modules/meeting-planner/meeting-planner.schemas.ts b/api/src/modules/meeting-planner/meeting-planner.schemas.ts index 45146593..8f00cab7 100644 --- a/api/src/modules/meeting-planner/meeting-planner.schemas.ts +++ b/api/src/modules/meeting-planner/meeting-planner.schemas.ts @@ -37,7 +37,10 @@ export const addOptionsSchema = z.object({ export const submitVotesSchema = z.object({ voterName: z.string().min(1, 'Name is required').max(100), - voterEmail: z.string().email().max(200).optional(), + voterEmail: z.preprocess( + (val) => (typeof val === 'string' && val.trim() === '' ? undefined : val), + z.string().email('Please enter a valid email address').max(200).optional(), + ), voterToken: z.string().optional(), votes: z.array(z.object({ optionId: z.string().min(1), diff --git a/config.sh b/config.sh index e7ddf824..8cc7326b 100755 --- a/config.sh +++ b/config.sh @@ -559,6 +559,33 @@ configure_cors() { success "CORS origins set for $domain" } +generate_nginx_configs() { + header "Nginx Configuration" + + local domain="${CONFIGURED_DOMAIN:-cmlite.org}" + local template_dir="$SCRIPT_DIR/nginx/conf.d" + local templates_found=0 + + for template in "$template_dir"/*.conf.template; do + [[ -f "$template" ]] || continue + templates_found=$((templates_found + 1)) + local conf_file="${template%.template}" + local conf_name + conf_name=$(basename "$conf_file") + + # Substitute ${DOMAIN} while preserving nginx variables ($host, $scheme, etc.) + sed "s/\${DOMAIN}/$domain/g" "$template" > "$conf_file" + success "Generated $conf_name" + done + + if [[ $templates_found -eq 0 ]]; then + warn "No nginx .conf.template files found — skipping" + else + success "Generated $templates_found nginx configs for domain: $domain" + info "Restart nginx to apply: docker compose restart nginx" + fi +} + # ============================================================================= # Homepage services.yaml # ============================================================================= @@ -1053,6 +1080,7 @@ main() { configure_features configure_pangolin configure_cors + generate_nginx_configs generate_services_yaml fix_container_permissions diff --git a/nginx/conf.d/api.conf b/nginx/conf.d/api.conf index f312f3df..360fc6c0 100644 --- a/nginx/conf.d/api.conf +++ b/nginx/conf.d/api.conf @@ -1,6 +1,6 @@ server { listen 80; - server_name api.cmlite.org api.betteredmonton.org; + server_name api.cmlite.org api.betteredmonton.org api.pridecorner.ca; add_header X-Frame-Options "SAMEORIGIN" always; # Media API endpoints (must come BEFORE / for longest prefix match) diff --git a/nginx/conf.d/services.conf b/nginx/conf.d/services.conf index 0bab3390..9f1959f8 100644 --- a/nginx/conf.d/services.conf +++ b/nginx/conf.d/services.conf @@ -40,7 +40,7 @@ server { # Grafana — allows iframe embedding from admin (app.cmlite.org) server { listen 80; - server_name grafana.cmlite.org grafana.betteredmonton.org; + server_name grafana.cmlite.org grafana.betteredmonton.org grafana.pridecorner.ca; add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always; location / { @@ -59,7 +59,7 @@ server { # NocoDB (data browser) — allows iframe embedding from admin server { listen 80; - server_name db.cmlite.org db.betteredmonton.org; + server_name db.cmlite.org db.betteredmonton.org db.pridecorner.ca; add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always; location / { @@ -76,7 +76,7 @@ server { # Listmonk server { listen 80; - server_name listmonk.cmlite.org listmonk.betteredmonton.org; + server_name listmonk.cmlite.org listmonk.betteredmonton.org listmonk.pridecorner.ca; add_header X-Frame-Options "SAMEORIGIN" always; location / { @@ -92,7 +92,7 @@ server { # MkDocs — allows iframe embedding from admin server { listen 80; - server_name docs.cmlite.org docs.betteredmonton.org; + server_name docs.cmlite.org docs.betteredmonton.org docs.pridecorner.ca; add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always; location / { @@ -207,7 +207,7 @@ server { # Rocket.Chat (team chat) — allows iframe embedding from admin server { listen 80; - server_name chat.cmlite.org chat.betteredmonton.org; + server_name chat.cmlite.org chat.betteredmonton.org chat.pridecorner.ca; add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always; location / { @@ -246,7 +246,7 @@ server { # Jitsi Meet (video conferencing) — allows iframe embedding from admin (app.cmlite.org) server { listen 80; - server_name meet.cmlite.org meet.betteredmonton.org; + server_name meet.cmlite.org meet.betteredmonton.org meet.pridecorner.ca; add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always; location / { @@ -366,7 +366,7 @@ server { # Admin GUI — app subdomain server { listen 80; - server_name app.cmlite.org app.betteredmonton.org; + server_name app.cmlite.org app.betteredmonton.org app.pridecorner.ca; add_header X-Frame-Options "SAMEORIGIN" always; # Social media bot detection for OG meta tags @@ -513,7 +513,7 @@ server { # Root domain — routes to admin GUI (supports custom DOMAIN env var) server { listen 80; - server_name betteredmonton.org; + server_name betteredmonton.org pridecorner.ca; add_header X-Frame-Options "SAMEORIGIN" always; location / {