Plan Your Visit landing page for visit.waterschurch.org
Next.js 16 static export + PHP 8 API for cPanel/Apache hosting. - Location-aware campus finder (IP estimate, optional precise location, ZIP search) - Three-step Plan Your Visit form with separate email/SMS consent - PHP API: validation, anti-spam, rate limiting, SQLite lead store, Mailchimp and Sinch Engage adapters, idempotent retries via cron - Attribution capture (utm_*, gclid/gbraid/wbraid, fbclid, ttclid, twclid) and consent-aware analytics hooks - Vitest and PHP test suites, cPanel deployment script and docs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Deploy visit.waterschurch.org to the cPanel host over SSH.
|
||||
#
|
||||
# deploy/deploy.sh # build + upload + configure (goes live)
|
||||
# deploy/deploy.sh --no-build # upload the existing ./out
|
||||
# deploy/deploy.sh --keep-redirect # deploy but keep sending visitors to
|
||||
# # the Church Center form (pre-launch)
|
||||
#
|
||||
# Requires: the SSH alias "waterschurch" from ~/.ssh/config, rsync, node, npm.
|
||||
# Idempotent: safe to re-run for every content update.
|
||||
#
|
||||
# On the server (as the cPanel user) it:
|
||||
# 1. ensures the subdomain exists with document root ~/visit.waterschurch.org
|
||||
# 2. creates ~/visit-private/{data,logs} and a .env from .env.example if missing
|
||||
# 3. rsyncs ./out → ~/visit.waterschurch.org, preserving cPanel's own files
|
||||
# (.user.ini, php.ini, .well-known/) and rebuilding .htaccess from:
|
||||
# cPanel-generated PHP blocks + [pre-launch redirect] + deploy/htaccess.root
|
||||
# 4. installs the 5-minute retry cron job if absent
|
||||
# 5. requests an AutoSSL check and prints the health endpoint
|
||||
# It never touches DNS, the main site, or an existing .env.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SSH_HOST="${SSH_HOST:-waterschurch}"
|
||||
SUBDOMAIN="visit"
|
||||
ROOTDOMAIN="waterschurch.org"
|
||||
FQDN="$SUBDOMAIN.$ROOTDOMAIN"
|
||||
REMOTE_DOCROOT="visit.waterschurch.org" # relative to $HOME on the server
|
||||
REMOTE_PRIVATE="visit-private" # relative to $HOME on the server
|
||||
|
||||
BUILD=1
|
||||
KEEP_REDIRECT=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-build) BUILD=0 ;;
|
||||
--keep-redirect) KEEP_REDIRECT=1 ;;
|
||||
*) echo "unknown option: $arg"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [[ $BUILD -eq 1 ]]; then
|
||||
echo "▶ building"
|
||||
npm run build
|
||||
fi
|
||||
[[ -f out/index.html && -f out/.htaccess && -f out/api/plan-visit.php ]] || { echo "out/ is incomplete — run npm run build"; exit 1; }
|
||||
|
||||
echo "▶ preparing server"
|
||||
ssh "$SSH_HOST" bash -s -- "$SUBDOMAIN" "$ROOTDOMAIN" "$REMOTE_DOCROOT" "$REMOTE_PRIVATE" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
SUB="$1"; ROOT="$2"; DOCROOT="$HOME/$3"; PRIV="$HOME/$4"; FQDN="$SUB.$ROOT"
|
||||
|
||||
if uapi --output=json DomainInfo list_domains 2>/dev/null | grep -q "\"$FQDN\""; then
|
||||
CUR=$(uapi --output=json DomainInfo single_domain_data domain="$FQDN" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["data"].get("documentroot",""))' 2>/dev/null || true)
|
||||
echo " subdomain $FQDN exists (docroot: ${CUR:-unknown})"
|
||||
if [[ -n "$CUR" && "$CUR" != "$DOCROOT" ]]; then
|
||||
echo " ⚠ document root differs — pointing it at $DOCROOT"
|
||||
uapi --output=json SubDomain changedocroot subdomain="$SUB" rootdomain="$ROOT" dir="$DOCROOT" >/dev/null
|
||||
fi
|
||||
else
|
||||
echo " creating subdomain $FQDN → $DOCROOT"
|
||||
uapi --output=json SubDomain addsubdomain domain="$SUB" rootdomain="$ROOT" dir="$DOCROOT" | python3 -c 'import json,sys; r=json.load(sys.stdin)["result"]; print(" ", "ok" if r["status"] else r["errors"])'
|
||||
fi
|
||||
mkdir -p "$DOCROOT"
|
||||
|
||||
# Preserve cPanel-generated .htaccess blocks (PHP ini directives + PHP handler) before we replace the file.
|
||||
if [[ -f "$DOCROOT/.htaccess" ]]; then
|
||||
awk '/^# BEGIN cPanel-generated/,/^# END cPanel-generated/ {print} /^# php -- BEGIN cPanel-generated/,/^# php -- END cPanel-generated/ {print}' "$DOCROOT/.htaccess" > "$DOCROOT/.htaccess.cpanel-blocks"
|
||||
echo " preserved $(grep -c . "$DOCROOT/.htaccess.cpanel-blocks") lines of cPanel-generated .htaccess directives"
|
||||
fi
|
||||
|
||||
mkdir -p "$PRIV/data" "$PRIV/logs"
|
||||
chmod 750 "$PRIV" "$PRIV/data" "$PRIV/logs"
|
||||
echo " private dir: $PRIV"
|
||||
REMOTE
|
||||
|
||||
echo "▶ uploading build"
|
||||
rsync -az --delete \
|
||||
--exclude '/.htaccess' --exclude '/.htaccess.*' \
|
||||
--exclude '/.user.ini' --exclude '/php.ini' --exclude '/.well-known/' \
|
||||
--exclude 'api/data/' \
|
||||
--chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r \
|
||||
out/ "$SSH_HOST:$REMOTE_DOCROOT/"
|
||||
scp -q out/.htaccess "$SSH_HOST:$REMOTE_DOCROOT/.htaccess.visit"
|
||||
scp -q deploy/htaccess.prelaunch-redirect "$SSH_HOST:$REMOTE_DOCROOT/.htaccess.prelaunch"
|
||||
scp -q .env.example "$SSH_HOST:$REMOTE_PRIVATE/.env.example"
|
||||
|
||||
echo "▶ configuring"
|
||||
ssh "$SSH_HOST" bash -s -- "$REMOTE_DOCROOT" "$REMOTE_PRIVATE" "$FQDN" "$KEEP_REDIRECT" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
DOCROOT="$HOME/$1"; PRIV="$HOME/$2"; FQDN="$3"; KEEP="$4"
|
||||
|
||||
# --- .env (never overwritten)
|
||||
if [[ ! -f "$PRIV/.env" ]]; then
|
||||
cp "$PRIV/.env.example" "$PRIV/.env"
|
||||
SALT=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
||||
sed -i "s|^HASH_SALT=.*|HASH_SALT=$SALT|; s|^ALLOWED_ORIGINS=.*|ALLOWED_ORIGINS=|; s|^DEBUG_GEO=.*|DEBUG_GEO=false|" "$PRIV/.env"
|
||||
echo " created $PRIV/.env (INTEGRATION_MODE=mock — add API keys, then set live)"
|
||||
else
|
||||
echo " kept existing $PRIV/.env"
|
||||
fi
|
||||
chmod 640 "$PRIV/.env"
|
||||
|
||||
# --- assemble .htaccess: cPanel blocks + [pre-launch redirect] + site config
|
||||
{
|
||||
[[ -f "$DOCROOT/.htaccess.cpanel-blocks" ]] && cat "$DOCROOT/.htaccess.cpanel-blocks" && echo
|
||||
if [[ "$KEEP" == "1" ]]; then cat "$DOCROOT/.htaccess.prelaunch"; fi
|
||||
sed "s|^# SetEnv VISIT_PRIVATE_DIR .*|SetEnv VISIT_PRIVATE_DIR $PRIV|" "$DOCROOT/.htaccess.visit"
|
||||
} > "$DOCROOT/.htaccess.new"
|
||||
mv -f "$DOCROOT/.htaccess.new" "$DOCROOT/.htaccess"
|
||||
rm -f "$DOCROOT/.htaccess.visit" "$DOCROOT/.htaccess.prelaunch" "$DOCROOT/.htaccess.cpanel-blocks"
|
||||
chmod 644 "$DOCROOT/.htaccess"
|
||||
if [[ "$KEEP" == "1" ]]; then echo " .htaccess: PRE-LAUNCH redirect to Church Center is ACTIVE"; else echo " .htaccess: site is LIVE (no redirect)"; fi
|
||||
|
||||
# --- PHP binary for cron
|
||||
PHP_BIN=$(command -v php || true)
|
||||
for c in /usr/local/bin/php /opt/cpanel/ea-php83/root/usr/bin/php /opt/cpanel/ea-php82/root/usr/bin/php; do
|
||||
[[ -z "$PHP_BIN" && -x "$c" ]] && PHP_BIN="$c"
|
||||
done
|
||||
echo " php: ${PHP_BIN:-NOT FOUND} $($PHP_BIN -r 'echo PHP_VERSION;' 2>/dev/null || true)"
|
||||
|
||||
# --- cron (idempotent)
|
||||
CRON="*/5 * * * * VISIT_PRIVATE_DIR=$PRIV $PHP_BIN $DOCROOT/api/cron/retry-integrations.php >> $PRIV/logs/cron.log 2>&1"
|
||||
if ! crontab -l 2>/dev/null | grep -qF "retry-integrations.php"; then
|
||||
( crontab -l 2>/dev/null || true; echo "$CRON" ) | crontab -
|
||||
echo " cron installed (every 5 minutes)"
|
||||
else
|
||||
echo " cron already present"
|
||||
fi
|
||||
|
||||
# --- SSL
|
||||
if uapi --output=json SSL start_autossl_check >/dev/null 2>&1; then echo " AutoSSL check requested"; else echo " (AutoSSL check not available via UAPI; use cPanel → SSL/TLS Status if needed)"; fi
|
||||
|
||||
echo " build: $(head -1 "$DOCROOT/BUILD.txt")"
|
||||
REMOTE
|
||||
|
||||
echo "▶ verifying"
|
||||
sleep 2
|
||||
printf " https://%s/ → " "$FQDN"; curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" --max-time 20 "https://$FQDN/" || true
|
||||
printf " /api/health.php → "; curl -s --max-time 20 "https://$FQDN/api/health.php" | head -c 600 || true
|
||||
echo
|
||||
if [[ $KEEP_REDIRECT -eq 1 ]]; then
|
||||
echo "Deployed behind the pre-launch redirect. To go live: deploy/deploy.sh --no-build"
|
||||
else
|
||||
echo "Live. Next: fill in $REMOTE_PRIVATE/.env on the server, set INTEGRATION_MODE=live, and run docs/LAUNCH-CHECKLIST.md."
|
||||
fi
|
||||
@@ -0,0 +1,15 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# PRE-LAUNCH: keep sending visitors to the Church Center form until Waters
|
||||
# Church flips the switch. The /api/ endpoints are exempt so the deployment
|
||||
# can be verified (they are rate-limited and same-origin protected). To go live, delete this block (or run deploy/deploy.sh
|
||||
# without --keep-redirect).
|
||||
# ---------------------------------------------------------------------------
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_URI} !^/api/
|
||||
RewriteCond %{REQUEST_URI} !^/404\.html$
|
||||
RewriteCond %{REQUEST_URI} !^/\.well-known/
|
||||
RewriteRule ^(.*)$ https://waterschurch.churchcenter.com/people/forms/21186 [R=302,L]
|
||||
</IfModule>
|
||||
# --------------------------------------------------------------- end prelaunch
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# ============================================================================
|
||||
# visit.waterschurch.org — Apache configuration for the subdomain document root
|
||||
# Copied to out/.htaccess by `npm run build` (scripts/postbuild.mjs).
|
||||
# ============================================================================
|
||||
|
||||
Options -Indexes -MultiViews
|
||||
DirectoryIndex index.html
|
||||
ErrorDocument 404 /404.html
|
||||
# cPanel's default 403 page does not exist in this document root; show our branded page instead (status stays 403).
|
||||
ErrorDocument 403 /404.html
|
||||
|
||||
# --- Private configuration directory for the PHP API -----------------------
|
||||
# Uncomment and set to the absolute path of the folder holding your .env file
|
||||
# (OUTSIDE the document root). If omitted, the API looks for
|
||||
# <parent of document root>/visit-private, e.g. /home/USER/visit-private.
|
||||
# SetEnv VISIT_PRIVATE_DIR /home/USER/visit-private
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# Force HTTPS and the canonical non-www host
|
||||
RewriteCond %{HTTPS} !=on [OR]
|
||||
RewriteCond %{HTTP_HOST} ^www\.visit\.waterschurch\.org$ [NC]
|
||||
RewriteRule ^(.*)$ https://visit.waterschurch.org/$1 [R=301,L]
|
||||
|
||||
# Never serve dot-files (except the ACME challenge folder used for SSL issuance)
|
||||
RewriteCond %{REQUEST_URI} !^/\.well-known/
|
||||
RewriteRule (^|/)\.(?!well-known) - [F,L]
|
||||
|
||||
# Next.js static export: serve /path as /path.html when the file exists
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/$1.html -f
|
||||
RewriteRule ^(.+?)/?$ /$1.html [L]
|
||||
|
||||
# Strip trailing slash on non-directories
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(.+)/$ /$1 [R=301,L]
|
||||
</IfModule>
|
||||
|
||||
# --- Security headers -------------------------------------------------------
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set Permissions-Policy "geolocation=(self), camera=(), microphone=(), payment=()"
|
||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" "expr=%{HTTPS} == 'on'"
|
||||
# CSP is report-only until Waters Church confirms its tag/pixel list. See docs/DEPLOY-CPANEL.md.
|
||||
Header always set Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com https://connect.facebook.net https://analytics.tiktok.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://www.google-analytics.com https://analytics.google.com https://www.facebook.com https://analytics.tiktok.com https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; base-uri 'self'; form-action 'self' sms:; frame-ancestors 'self'"
|
||||
|
||||
# Immutable hashed assets
|
||||
<FilesMatch "\.(js|css|woff2|avif|webp|jpg|jpeg|png|svg|ico)$">
|
||||
Header set Cache-Control "public, max-age=31536000, immutable"
|
||||
</FilesMatch>
|
||||
# HTML and text files: always revalidate
|
||||
<FilesMatch "\.(html|xml|txt|json)$">
|
||||
Header set Cache-Control "public, max-age=0, must-revalidate"
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
||||
# --- Compression -----------------------------------------------------------
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/html text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml font/woff2
|
||||
</IfModule>
|
||||
<IfModule mod_brotli.c>
|
||||
AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml
|
||||
</IfModule>
|
||||
|
||||
# --- MIME types some cPanel builds miss -------------------------------------
|
||||
AddType image/avif .avif
|
||||
AddType image/webp .webp
|
||||
AddType font/woff2 .woff2
|
||||
|
||||
# --- Expires fallback (when mod_headers is unavailable) ---------------------
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive On
|
||||
ExpiresByType image/avif "access plus 1 year"
|
||||
ExpiresByType image/webp "access plus 1 year"
|
||||
ExpiresByType image/jpeg "access plus 1 year"
|
||||
ExpiresByType image/png "access plus 1 year"
|
||||
ExpiresByType image/svg+xml "access plus 1 year"
|
||||
ExpiresByType font/woff2 "access plus 1 year"
|
||||
ExpiresByType text/css "access plus 1 year"
|
||||
ExpiresByType application/javascript "access plus 1 year"
|
||||
ExpiresByType text/html "access plus 0 seconds"
|
||||
</IfModule>
|
||||
Reference in New Issue
Block a user