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:
Waters Church
2026-09-22 13:22:26 -04:00
co-authored by Claude Fable 5.1
commit 78cd0cc550
109 changed files with 16861 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# /api — PHP endpoints. Everything under lib/, cron/, config/ and data/ is private.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^(lib|cron|config|data)(/|$) - [F,L]
</IfModule>
# Belt and braces for servers without mod_rewrite
<FilesMatch "^(bootstrap|Config|Log|Db|Http|Campuses|Phone|Validator|RateLimit|Turnstile|GeoIp|Geocode|Mailchimp|Sinch|LeadProcessor)\.php$">
Require all denied
</FilesMatch>
<FilesMatch "\.(json|sqlite|sqlite-wal|sqlite-shm|log|lock|env)$">
Require all denied
</FilesMatch>
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
</IfModule>
Options -Indexes
+1
View File
@@ -0,0 +1 @@
Require all denied
+40
View File
@@ -0,0 +1,40 @@
<?php
/**
* CLI: export leads as CSV for staff follow-up.
*
* php api/cron/export-leads.php [--since=YYYY-MM-DD] > leads.csv
*
* Contains personal data — store the output securely and delete when done.
*/
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
require_once dirname(__DIR__) . '/lib/bootstrap.php';
$since = null;
foreach ($argv as $arg) {
if (str_starts_with($arg, '--since=')) {
$since = substr($arg, 8);
}
}
$sql = 'SELECT submission_id, created_at, first_name, last_name, email, phone_e164, campus_id, visit_date, service_time, adults, children, child_age_ranges, questions, email_consent, sms_consent, consent_version, consent_at, variant, attribution, mailchimp_status, sms_status FROM leads';
$params = [];
if ($since) {
$sql .= ' WHERE created_at >= ?';
$params[] = $since;
}
$sql .= ' ORDER BY created_at ASC';
$st = Db::pdo()->prepare($sql);
$st->execute($params);
$out = fopen('php://stdout', 'w');
$first = true;
while ($row = $st->fetch()) {
if ($first) {
fputcsv($out, array_keys($row));
$first = false;
}
fputcsv($out, $row);
}
+29
View File
@@ -0,0 +1,29 @@
<?php
/**
* Cron: retry failed Mailchimp / Sinch deliveries and purge expired leads.
*
* cPanel → Cron Jobs → every 5 minutes:
* VISIT_PRIVATE_DIR=/home/USER/visit-private /usr/local/bin/php /home/USER/visit.waterschurch.org/api/cron/retry-integrations.php >> /home/USER/visit-private/logs/cron.log 2>&1
*
* CLI only — web requests are refused by the .htaccess in this folder AND by the guard below.
*/
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
require_once dirname(__DIR__) . '/lib/bootstrap.php';
require_once dirname(__DIR__) . '/lib/LeadProcessor.php';
$lock = fopen(Config::dataDir() . '/retry.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
fwrite(STDOUT, gmdate('c') . " retry: another run is in progress\n");
exit(0);
}
$result = LeadProcessor::retryDue(Config::int('RETRY_BATCH_SIZE', 25));
$purged = LeadProcessor::purgeExpired();
fwrite(STDOUT, sprintf("%s retry: checked=%d processed=%d purged=%d mode=%s\n", gmdate('c'), $result['checked'], $result['processed'], $purged, Config::mode()));
flock($lock, LOCK_UN);
+28
View File
@@ -0,0 +1,28 @@
<?php
/** GET /api/geocode.php?zip=02763 — US ZIP centroid lookup for the manual campus search. */
declare(strict_types=1);
require_once __DIR__ . '/lib/bootstrap.php';
require_once __DIR__ . '/lib/Validator.php';
require_once __DIR__ . '/lib/RateLimit.php';
require_once __DIR__ . '/lib/Geocode.php';
Api::requireMethod('GET');
$ip = Api::clientIp();
$rl = RateLimit::hit(RateLimit::ipBucket('geocode', $ip), 40, 600);
if (!$rl['allowed']) {
Api::json(['ok' => false, 'error' => 'Too many searches. Please wait a moment.'], 429, ['Retry-After' => (string)$rl['retryAfter']]);
}
$zip = isset($_GET['zip']) ? preg_replace('/\D/', '', (string)$_GET['zip']) : '';
$zip = substr((string)$zip, 0, 5);
if (strlen($zip) !== 5) {
Api::json(['ok' => false, 'error' => 'Enter a 5-digit ZIP code.'], 400);
}
$loc = Geocode::zip($zip);
if (!$loc) {
Api::json(['ok' => false, 'error' => "We couldn't find that ZIP. Try another or pick a campus below."], 200);
}
Api::json(['ok' => true, 'location' => $loc], 200, ['Cache-Control' => 'public, max-age=86400']);
+48
View File
@@ -0,0 +1,48 @@
<?php
/**
* GET /api/health.php — deployment self-check (no secrets or PII in the output).
* Safe to leave enabled; reveals only booleans and version strings.
*/
declare(strict_types=1);
require_once __DIR__ . '/lib/bootstrap.php';
Api::requireMethod('GET');
$checks = [];
$checks['php'] = PHP_VERSION;
$checks['extensions'] = [
'curl' => extension_loaded('curl'),
'pdo_sqlite' => extension_loaded('pdo_sqlite'),
'pdo_mysql' => extension_loaded('pdo_mysql'),
'json' => extension_loaded('json'),
'mbstring' => extension_loaded('mbstring'),
'openssl' => extension_loaded('openssl'),
];
$checks['private_dir_found'] = is_dir(Config::privateDir());
$checks['env_file_found'] = is_readable(Config::privateDir() . '/.env');
$checks['data_dir_writable'] = is_writable(Config::dataDir());
$checks['logs_dir_writable'] = is_writable(Config::privateDir() . '/logs') || is_writable(Config::privateDir());
$checks['integration_mode'] = Config::mode();
$checks['mailchimp_configured'] = Config::get('MAILCHIMP_API_KEY') !== null && Config::get('MAILCHIMP_AUDIENCE_ID') !== null;
$checks['sinch_configured'] = Config::get('SINCH_API_KEY') !== null && Config::get('SINCH_API_SECRET') !== null;
$checks['geoip_provider'] = Config::get('GEOIP_PROVIDER', 'ipapi_co');
$checks['geocode_provider'] = Config::get('GEOCODE_PROVIDER', 'zippopotam');
$checks['turnstile_enabled'] = Config::get('TURNSTILE_SECRET_KEY') !== null;
$checks['https'] = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
try {
Db::pdo();
$checks['database'] = 'ok (' . Db::pdo()->getAttribute(PDO::ATTR_DRIVER_NAME) . ')';
} catch (Throwable $e) {
$checks['database'] = 'error';
Log::error('Health: database failed', ['error' => $e->getMessage()]);
}
try {
$checks['campuses_loaded'] = count(Campuses::all());
} catch (Throwable $e) {
$checks['campuses_loaded'] = 0;
}
$ok = $checks['private_dir_found'] && $checks['data_dir_writable'] && $checks['database'] !== 'error' && $checks['campuses_loaded'] > 0 && $checks['extensions']['curl'];
Api::json(['ok' => $ok, 'checks' => $checks], $ok ? 200 : 500);
+1
View File
@@ -0,0 +1 @@
Require all denied
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
/**
* Reads the shared campus configuration (config/campuses.json). The build
* copies it to api/config/campuses.json; in local development we fall back to
* the repo copy.
*/
final class Campuses
{
/** @var array<string,array<string,mixed>>|null */
private static ?array $byId = null;
/** @return array<string,array<string,mixed>> */
public static function all(): array
{
if (self::$byId !== null) {
return self::$byId;
}
$candidates = [
dirname(__DIR__) . '/config/campuses.json',
dirname(__DIR__, 3) . '/config/campuses.json',
];
foreach ($candidates as $file) {
if (is_readable($file)) {
$data = json_decode((string)file_get_contents($file), true);
if (is_array($data) && isset($data['campuses'])) {
$out = [];
foreach ($data['campuses'] as $c) {
$out[$c['id']] = $c;
}
return self::$byId = $out;
}
}
}
throw new RuntimeException('campuses.json not found');
}
/** @return array<string,mixed>|null */
public static function get(string $id): ?array
{
return self::all()[$id] ?? null;
}
/** @return string[] */
public static function openIds(): array
{
return array_keys(array_filter(self::all(), static fn($c) => ($c['status'] ?? 'open') === 'open'));
}
/** "Sunday 9:30 AM" style summary of the first service day. */
public static function timesSummary(array $campus): string
{
$parts = [];
foreach ($campus['serviceTimes'] ?? [] as $st) {
$parts[] = $st['day'] . ' ' . implode(', ', $st['times']);
}
return implode(' · ', $parts);
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
/**
* Configuration lives OUTSIDE the web root in a private directory containing a
* `.env` file (see .env.example at the repo root for every key).
*
* The private directory is resolved in this order:
* 1. VISIT_PRIVATE_DIR environment variable (set via `SetEnv` in .htaccess, or cPanel cron env)
* 2. <parent of DOCUMENT_ROOT>/visit-private (e.g. /home/USER/visit-private)
* 3. <repo>/private (local development only)
*/
final class Config
{
/** @var array<string,string> */
private static array $values = [];
private static ?string $privateDir = null;
private static bool $loaded = false;
public static function load(): void
{
if (self::$loaded) {
return;
}
self::$loaded = true;
$dir = self::privateDir();
$env = $dir . '/.env';
if (is_readable($env)) {
foreach (file($env, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) {
continue;
}
[$k, $v] = explode('=', $line, 2);
$k = trim($k);
$v = trim($v);
if (str_starts_with($k, 'export ')) {
$k = trim(substr($k, 7));
}
if ((str_starts_with($v, '"') && str_ends_with($v, '"')) || (str_starts_with($v, "'") && str_ends_with($v, "'"))) {
$v = substr($v, 1, -1);
}
self::$values[$k] = $v;
}
}
}
public static function privateDir(): string
{
if (self::$privateDir !== null) {
return self::$privateDir;
}
$candidates = [];
$fromEnv = getenv('VISIT_PRIVATE_DIR') ?: ($_SERVER['VISIT_PRIVATE_DIR'] ?? ($_SERVER['REDIRECT_VISIT_PRIVATE_DIR'] ?? null));
if ($fromEnv) {
$candidates[] = $fromEnv;
}
if (!empty($_SERVER['DOCUMENT_ROOT'])) {
$candidates[] = dirname(rtrim($_SERVER['DOCUMENT_ROOT'], '/')) . '/visit-private';
}
$candidates[] = dirname(__DIR__, 3) . '/private'; // repo/private (dev)
foreach ($candidates as $c) {
if (is_dir($c)) {
return self::$privateDir = rtrim($c, '/');
}
}
// Fall back to the first candidate so error messages point somewhere useful.
return self::$privateDir = rtrim($candidates[0], '/');
}
public static function get(string $key, ?string $default = null): ?string
{
$env = getenv($key);
if ($env !== false && $env !== '') {
return $env;
}
return (isset(self::$values[$key]) && self::$values[$key] !== '') ? self::$values[$key] : $default;
}
public static function require(string $key): string
{
$v = self::get($key);
if ($v === null) {
throw new RuntimeException("Missing required configuration: $key");
}
return $v;
}
public static function bool(string $key, bool $default = false): bool
{
$v = self::get($key);
if ($v === null) {
return $default;
}
return in_array(strtolower($v), ['1', 'true', 'yes', 'on'], true);
}
public static function int(string $key, int $default): int
{
$v = self::get($key);
return $v === null ? $default : (int)$v;
}
/** "mock" (default when unset), "live". */
public static function mode(): string
{
return strtolower(self::get('INTEGRATION_MODE', 'mock') ?? 'mock') === 'live' ? 'live' : 'mock';
}
public static function isMock(): bool
{
return self::mode() === 'mock';
}
public static function dataDir(): string
{
$d = self::get('DATA_DIR') ?: self::privateDir() . '/data';
if (!is_dir($d)) {
@mkdir($d, 0750, true);
}
return $d;
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
/**
* Lead persistence. SQLite by default (available on virtually every cPanel PHP
* build via pdo_sqlite), stored in the private data directory. Set
* DATABASE_DSN / DATABASE_USER / DATABASE_PASSWORD to use cPanel MySQL instead.
*
* Leads are ALWAYS written here before any third-party call is made.
*/
final class Db
{
private static ?PDO $pdo = null;
public static function pdo(): PDO
{
if (self::$pdo instanceof PDO) {
return self::$pdo;
}
$dsn = Config::get('DATABASE_DSN');
if ($dsn) {
$pdo = new PDO($dsn, Config::get('DATABASE_USER', ''), Config::get('DATABASE_PASSWORD', ''), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_TIMEOUT => 5,
]);
} else {
$file = Config::dataDir() . '/leads.sqlite';
$isNew = !file_exists($file);
$pdo = new PDO('sqlite:' . $file, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_TIMEOUT => 5,
]);
$pdo->exec('PRAGMA journal_mode=WAL');
$pdo->exec('PRAGMA busy_timeout=5000');
$pdo->exec('PRAGMA foreign_keys=ON');
if ($isNew) {
@chmod($file, 0640);
}
}
self::$pdo = $pdo;
self::migrate($pdo);
return $pdo;
}
public static function isSqlite(): bool
{
return self::pdo()->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
}
private static function migrate(PDO $pdo): void
{
$sqlite = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
$text = $sqlite ? 'TEXT' : 'TEXT';
$json = $sqlite ? 'TEXT' : 'JSON';
$pk = $sqlite ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'BIGINT AUTO_INCREMENT PRIMARY KEY';
$ts = $sqlite ? "TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))" : 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP';
$pdo->exec("CREATE TABLE IF NOT EXISTS leads (
id $pk,
submission_id VARCHAR(36) NOT NULL UNIQUE,
created_at $ts,
updated_at $ts,
first_name VARCHAR(80) NOT NULL,
last_name VARCHAR(80) NULL,
email VARCHAR(254) NOT NULL,
phone_e164 VARCHAR(20) NULL,
campus_id VARCHAR(40) NOT NULL,
visit_date VARCHAR(10) NULL,
service_time VARCHAR(80) NULL,
adults SMALLINT NULL,
children SMALLINT NULL,
child_age_ranges VARCHAR(80) NULL,
questions $text NULL,
email_consent TINYINT NOT NULL DEFAULT 0,
sms_consent TINYINT NOT NULL DEFAULT 0,
consent_version VARCHAR(40) NOT NULL,
consent_at VARCHAR(30) NOT NULL,
consent_ip_hash VARCHAR(64) NULL,
attribution $json NULL,
variant VARCHAR(40) NULL,
referrer $text NULL,
landing_url $text NULL,
user_agent VARCHAR(300) NULL,
mailchimp_status VARCHAR(20) NOT NULL DEFAULT 'pending',
mailchimp_detail $text NULL,
mailchimp_attempts SMALLINT NOT NULL DEFAULT 0,
sms_status VARCHAR(20) NOT NULL DEFAULT 'pending',
sms_detail $text NULL,
sms_message_id VARCHAR(80) NULL,
sms_attempts SMALLINT NOT NULL DEFAULT 0,
next_retry_at VARCHAR(30) NULL
)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_leads_retry ON leads (next_retry_at)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_leads_created ON leads (created_at)");
$pdo->exec("CREATE TABLE IF NOT EXISTS rate_limits (
bucket VARCHAR(120) NOT NULL PRIMARY KEY,
hits INT NOT NULL DEFAULT 0,
window_start INT NOT NULL
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS cache (
cache_key VARCHAR(120) NOT NULL PRIMARY KEY,
value $text NOT NULL,
expires_at INT NOT NULL
)");
}
/** Simple cache helpers (used for geolocation lookups). */
public static function cacheGet(string $key): ?string
{
$st = self::pdo()->prepare('SELECT value FROM cache WHERE cache_key = ? AND expires_at > ?');
$st->execute([$key, time()]);
$v = $st->fetchColumn();
return $v === false ? null : (string)$v;
}
public static function cacheSet(string $key, string $value, int $ttlSeconds): void
{
$pdo = self::pdo();
if (self::isSqlite()) {
$st = $pdo->prepare('INSERT OR REPLACE INTO cache (cache_key, value, expires_at) VALUES (?, ?, ?)');
} else {
$st = $pdo->prepare('REPLACE INTO cache (cache_key, value, expires_at) VALUES (?, ?, ?)');
}
$st->execute([$key, $value, time() + $ttlSeconds]);
if (random_int(1, 50) === 1) {
$pdo->prepare('DELETE FROM cache WHERE expires_at < ?')->execute([time()]);
}
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
/**
* Privacy-conscious approximate IP geolocation for the "closest campus"
* recommendation. Results are city-level, cached briefly by hashed IP, and
* never stored with a lead.
*
* GEOIP_PROVIDER:
* none – disabled (visitor always chooses manually)
* header – trust a CDN header (Cloudflare: CF-IPCountry/CF-IPLatitude/CF-IPLongitude)
* ipapi_co – https://ipapi.co (no key needed for low volume; GEOIP_API_KEY optional)
* ipinfo – https://ipinfo.io (GEOIP_API_KEY required)
* ip_api_com – http(s)://ip-api.com (GEOIP_API_KEY required for HTTPS/commercial use)
*/
final class GeoIp
{
/** @return array{lat:float,lng:float,city:?string,region:?string}|null */
public static function locate(string $ip): ?array
{
$provider = strtolower(Config::get('GEOIP_PROVIDER', 'ipapi_co') ?? 'none');
if ($provider === 'none' || self::isPrivate($ip)) {
return null;
}
if ($provider === 'header') {
return self::fromHeaders();
}
$cacheKey = 'geoip:' . substr(hash('sha256', $ip . (Config::get('HASH_SALT') ?? '')), 0, 40);
$cached = Db::cacheGet($cacheKey);
if ($cached !== null) {
$c = json_decode($cached, true);
return is_array($c) && $c ? $c : null;
}
$result = null;
try {
$result = match ($provider) {
'ipapi_co' => self::ipapiCo($ip),
'ipinfo' => self::ipinfo($ip),
'ip_api_com' => self::ipApiCom($ip),
default => null,
};
} catch (Throwable $e) {
Log::warn('GeoIP provider failed', ['provider' => $provider, 'error' => $e->getMessage()]);
}
Db::cacheSet($cacheKey, json_encode($result ?? []), Config::int('GEOIP_CACHE_TTL', 3600));
return $result;
}
private static function fromHeaders(): ?array
{
$lat = $_SERVER['HTTP_CF_IPLATITUDE'] ?? null;
$lng = $_SERVER['HTTP_CF_IPLONGITUDE'] ?? null;
if ($lat === null || $lng === null) {
return null;
}
return self::result((float)$lat, (float)$lng, $_SERVER['HTTP_CF_IPCITY'] ?? null, $_SERVER['HTTP_CF_REGION_CODE'] ?? null);
}
private static function ipapiCo(string $ip): ?array
{
$key = Config::get('GEOIP_API_KEY');
$base = rtrim(Config::get('GEOIP_BASE_URL_OVERRIDE') ?: 'https://ipapi.co', '/'); // override is for the test-suite only
$url = "$base/" . rawurlencode($ip) . "/json/" . ($key ? "?key=" . rawurlencode($key) : '');
$r = Http::request('GET', $url, null, ['Accept' => 'application/json'], 3);
$j = $r['json'];
if ($r['status'] !== 200 || !is_array($j) || !isset($j['latitude'], $j['longitude'])) {
return null;
}
return self::result((float)$j['latitude'], (float)$j['longitude'], $j['city'] ?? null, $j['region_code'] ?? null);
}
private static function ipinfo(string $ip): ?array
{
$key = Config::require('GEOIP_API_KEY');
$r = Http::request('GET', "https://ipinfo.io/" . rawurlencode($ip) . "/json?token=" . rawurlencode($key), null, ['Accept' => 'application/json'], 3);
$j = $r['json'];
if ($r['status'] !== 200 || !is_array($j) || empty($j['loc'])) {
return null;
}
[$lat, $lng] = array_map('floatval', explode(',', (string)$j['loc']) + [0, 0]);
return self::result($lat, $lng, $j['city'] ?? null, $j['region'] ?? null);
}
private static function ipApiCom(string $ip): ?array
{
$key = Config::get('GEOIP_API_KEY');
$base = $key ? "https://pro.ip-api.com/json/" : "http://ip-api.com/json/";
$r = Http::request('GET', $base . rawurlencode($ip) . "?fields=status,lat,lon,city,region" . ($key ? "&key=" . rawurlencode($key) : ''), null, [], 3);
$j = $r['json'];
if ($r['status'] !== 200 || !is_array($j) || ($j['status'] ?? '') !== 'success') {
return null;
}
return self::result((float)$j['lat'], (float)$j['lon'], $j['city'] ?? null, $j['region'] ?? null);
}
private static function result(float $lat, float $lng, ?string $city, ?string $region): ?array
{
if ($lat === 0.0 && $lng === 0.0 || abs($lat) > 90 || abs($lng) > 180) {
return null;
}
// Round to ~1 km so we never hold anything close to a precise location.
return ['lat' => round($lat, 2), 'lng' => round($lng, 2), 'city' => $city ? Validator::clean($city, 60) : null, 'region' => $region ? Validator::clean($region, 20) : null];
}
public static function isPrivate(string $ip): bool
{
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/**
* US ZIP → coordinates for the manual campus search.
*
* GEOCODE_PROVIDER:
* zippopotam – https://api.zippopotam.us (free, no key) [default]
* nominatim – OpenStreetMap Nominatim (free, 1 req/s, requires identifying UA)
* google – Google Geocoding API (GEOCODE_API_KEY required)
*/
final class Geocode
{
/** @return array{lat:float,lng:float,city:?string,state:?string}|null */
public static function zip(string $zip): ?array
{
if (!preg_match('/^\d{5}$/', $zip)) {
return null;
}
$cacheKey = 'zip:' . $zip;
$cached = Db::cacheGet($cacheKey);
if ($cached !== null) {
$c = json_decode($cached, true);
return is_array($c) && $c ? $c : null;
}
$provider = strtolower(Config::get('GEOCODE_PROVIDER', 'zippopotam') ?? 'zippopotam');
$result = null;
try {
$result = match ($provider) {
'nominatim' => self::nominatim($zip),
'google' => self::google($zip),
default => self::zippopotam($zip),
};
} catch (Throwable $e) {
Log::warn('Geocode provider failed', ['provider' => $provider, 'error' => $e->getMessage()]);
}
// ZIP centroids do not change: cache for 30 days (failures for 10 minutes).
Db::cacheSet($cacheKey, json_encode($result ?? []), $result ? 30 * 86400 : 600);
return $result;
}
private static function zippopotam(string $zip): ?array
{
$base = rtrim(Config::get('GEOCODE_BASE_URL_OVERRIDE') ?: 'https://api.zippopotam.us', '/'); // override is for the test-suite only
$r = Http::request('GET', "$base/us/$zip", null, ['Accept' => 'application/json'], 6);
$j = $r['json'];
if ($r['status'] !== 200 || !is_array($j) || empty($j['places'][0])) {
return null;
}
$p = $j['places'][0];
return self::result((float)$p['latitude'], (float)$p['longitude'], $p['place name'] ?? null, $p['state abbreviation'] ?? null);
}
private static function nominatim(string $zip): ?array
{
$r = Http::request('GET', "https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&countrycodes=us&postalcode=$zip", null, ['Accept' => 'application/json'], 6);
$j = $r['json'];
if ($r['status'] !== 200 || !is_array($j) || empty($j[0]['lat'])) {
return null;
}
return self::result((float)$j[0]['lat'], (float)$j[0]['lon'], null, null);
}
private static function google(string $zip): ?array
{
$key = Config::require('GEOCODE_API_KEY');
$r = Http::request('GET', "https://maps.googleapis.com/maps/api/geocode/json?components=postal_code:$zip|country:US&key=" . rawurlencode($key), null, [], 6);
$j = $r['json'];
if ($r['status'] !== 200 || !is_array($j) || ($j['status'] ?? '') !== 'OK') {
return null;
}
$loc = $j['results'][0]['geometry']['location'] ?? null;
if (!$loc) {
return null;
}
$city = null;
$state = null;
foreach ($j['results'][0]['address_components'] ?? [] as $c) {
if (in_array('locality', $c['types'] ?? [], true)) {
$city = $c['long_name'];
}
if (in_array('administrative_area_level_1', $c['types'] ?? [], true)) {
$state = $c['short_name'];
}
}
return self::result((float)$loc['lat'], (float)$loc['lng'], $city, $state);
}
private static function result(float $lat, float $lng, ?string $city, ?string $state): ?array
{
if (($lat === 0.0 && $lng === 0.0) || abs($lat) > 90 || abs($lng) > 180) {
return null;
}
return ['lat' => round($lat, 4), 'lng' => round($lng, 4), 'city' => $city ? Validator::clean($city, 60) : null, 'state' => $state ? Validator::clean($state, 20) : null];
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
/** Small cURL wrapper with hard timeouts, used by every provider adapter. */
final class Http
{
/**
* @param array<string,string> $headers
* @return array{status:int, body:string, json:mixed, error:?string}
*/
public static function request(string $method, string $url, ?string $body = null, array $headers = [], int $timeout = 8): array
{
$ch = curl_init($url);
$hdrs = [];
foreach ($headers as $k => $v) {
$hdrs[] = "$k: $v";
}
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => min(5, $timeout),
CURLOPT_TIMEOUT => $timeout,
CURLOPT_HTTPHEADER => $hdrs,
CURLOPT_USERAGENT => 'visit.waterschurch.org/1.0 (+https://visit.waterschurch.org)',
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$out = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_error($ch) ?: null;
$text = is_string($out) ? $out : '';
$json = null;
if ($text !== '') {
$decoded = json_decode($text, true);
$json = json_last_error() === JSON_ERROR_NONE ? $decoded : null;
}
return ['status' => $status, 'body' => $text, 'json' => $json, 'error' => $err];
}
/**
* @param array<string,mixed> $payload
* @param array<string,string> $headers
* @return array{status:int, body:string, json:mixed, error:?string}
*/
public static function json(string $method, string $url, ?array $payload, array $headers = [], int $timeout = 8): array
{
$headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json'] + $headers;
return self::request($method, $url, $payload === null ? null : json_encode($payload, JSON_UNESCAPED_SLASHES), $headers, $timeout);
}
/** True for statuses/transport errors that are worth retrying later. */
public static function isTransient(int $status, ?string $error): bool
{
return $error !== null || $status === 0 || $status === 408 || $status === 429 || $status >= 500;
}
}
+186
View File
@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/Mailchimp.php';
require_once __DIR__ . '/Sinch.php';
/**
* Persists a lead FIRST, then runs integrations, recording each outcome so a
* partial failure never loses the lead and can be retried by cron.
*/
final class LeadProcessor
{
public const MAX_ATTEMPTS = 5;
/**
* @param array<string,mixed> $d Validated data from Validator::planVisit
* @return array{lead:array<string,mixed>, duplicate:bool}
*/
public static function persist(array $d, string $ip, string $userAgent): array
{
$pdo = Db::pdo();
$existing = self::find($d['submission_id']);
if ($existing) {
return ['lead' => $existing, 'duplicate' => true];
}
$now = gmdate('Y-m-d\TH:i:s\Z');
$st = $pdo->prepare('INSERT INTO leads (
submission_id, created_at, updated_at, first_name, last_name, email, phone_e164, campus_id, visit_date, service_time,
adults, children, child_age_ranges, questions, email_consent, sms_consent, consent_version, consent_at, consent_ip_hash,
attribution, variant, referrer, landing_url, user_agent, mailchimp_status, sms_status
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)');
$attr = $d['attribution'];
try {
$st->execute([
$d['submission_id'], $now, $now, $d['first_name'], $d['last_name'], $d['email'], $d['phone_e164'], $d['campus_id'], $d['visit_date'], $d['service_time'],
$d['adults'], $d['children'], $d['child_age_ranges'], $d['questions'], (int)$d['email_consent'], (int)$d['sms_consent'], $d['consent_version'], $now,
hash('sha256', $ip . '|' . (Config::get('HASH_SALT') ?? 'visit')),
json_encode($attr, JSON_UNESCAPED_SLASHES), $attr['variant'] ?? null, $attr['referrer'] ?? null, $attr['landing_url'] ?? null, mb_substr($userAgent, 0, 300),
$d['email_consent'] ? 'pending' : 'skipped', $d['sms_consent'] ? 'pending' : 'skipped',
]);
} catch (PDOException $e) {
// Unique constraint race: another request with the same id got there first.
if (str_contains($e->getMessage(), 'UNIQUE') || str_contains($e->getMessage(), 'Duplicate')) {
$row = self::find($d['submission_id']);
if ($row) {
return ['lead' => $row, 'duplicate' => true];
}
}
throw $e;
}
$lead = self::find($d['submission_id']);
if (!$lead) {
throw new RuntimeException('Lead insert failed');
}
return ['lead' => $lead, 'duplicate' => false];
}
/** @return array<string,mixed>|null */
public static function find(string $submissionId): ?array
{
$st = Db::pdo()->prepare('SELECT * FROM leads WHERE submission_id = ?');
$st->execute([$submissionId]);
$row = $st->fetch();
return $row ?: null;
}
/**
* Runs Mailchimp then Sinch for a stored lead. Each step is guarded by a
* status transition so concurrent requests/cron cannot double-send.
*
* @param array<string,mixed> $lead
* @return array{email:string, sms:string}
*/
public static function runIntegrations(array $lead): array
{
$campus = Campuses::get($lead['campus_id']) ?? ['id' => $lead['campus_id'], 'name' => $lead['campus_id'], 'address' => ['line1' => '', 'city' => ''], 'serviceTimes' => []];
$out = ['email' => $lead['mailchimp_status'], 'sms' => $lead['sms_status']];
if (self::claim($lead['id'], 'mailchimp')) {
try {
$r = Mailchimp::upsertVisitor($lead, $campus);
} catch (Throwable $e) {
Log::error('Mailchimp adapter threw', ['error' => $e->getMessage()]);
$r = ['status' => 'failed', 'detail' => 'exception: ' . $e->getMessage(), 'transient' => true];
}
$out['email'] = self::record($lead['id'], 'mailchimp', $r['status'], $r['detail'] ?? null, $r['transient'] ?? false);
}
if (self::claim($lead['id'], 'sms')) {
try {
$r = Sinch::sendVisitConfirmation($lead, $campus);
} catch (Throwable $e) {
Log::error('Sinch adapter threw', ['error' => $e->getMessage()]);
$r = ['status' => 'failed', 'detail' => 'exception: ' . $e->getMessage(), 'message_id' => null, 'transient' => true];
}
$out['sms'] = self::record($lead['id'], 'sms', $r['status'], $r['detail'] ?? null, $r['transient'] ?? false, $r['message_id'] ?? null);
if (in_array($r['status'], ['sent', 'mock'], true)) {
try {
Sinch::sendInternalLeadNotification($lead, $campus);
} catch (Throwable $e) {
Log::warn('Internal notify failed', ['error' => $e->getMessage()]);
}
}
}
return $out;
}
/** Atomically move pending/retry → sending. Returns false if someone else owns it. */
private static function claim(int $id, string $which): bool
{
$col = $which === 'sms' ? 'sms_status' : 'mailchimp_status';
$att = $which === 'sms' ? 'sms_attempts' : 'mailchimp_attempts';
$st = Db::pdo()->prepare("UPDATE leads SET $col = 'sending', $att = $att + 1, updated_at = ? WHERE id = ? AND $col IN ('pending','retry')");
$st->execute([gmdate('Y-m-d\TH:i:s\Z'), $id]);
return $st->rowCount() === 1;
}
/** Persists the outcome and returns the final stored status ('sent', 'mock', 'skipped', 'failed' or 'retry'). */
private static function record(int $id, string $which, string $status, ?string $detail, bool $transient, ?string $messageId = null): string
{
$col = $which === 'sms' ? 'sms_status' : 'mailchimp_status';
$det = $which === 'sms' ? 'sms_detail' : 'mailchimp_detail';
$att = $which === 'sms' ? 'sms_attempts' : 'mailchimp_attempts';
$pdo = Db::pdo();
$attempts = (int)$pdo->query("SELECT $att FROM leads WHERE id = $id")->fetchColumn();
$final = $status;
$nextRetry = null;
if ($status === 'failed' && $transient && $attempts < self::MAX_ATTEMPTS) {
$final = 'retry';
$nextRetry = gmdate('Y-m-d\TH:i:s\Z', time() + self::backoffSeconds($attempts));
}
$sql = "UPDATE leads SET $col = ?, $det = ?, updated_at = ?" . ($which === 'sms' ? ', sms_message_id = COALESCE(?, sms_message_id)' : '') . ', next_retry_at = ? WHERE id = ?';
$params = [$final, $detail ? mb_substr($detail, 0, 500) : null, gmdate('Y-m-d\TH:i:s\Z')];
if ($which === 'sms') {
$params[] = $messageId;
}
$params[] = $nextRetry;
$params[] = $id;
$pdo->prepare($sql)->execute($params);
// Keep the earliest pending retry across both integrations
$pdo->prepare("UPDATE leads SET next_retry_at = (SELECT MIN(x) FROM (SELECT next_retry_at AS x FROM leads WHERE id = ?)) WHERE id = ?")->execute([$id, $id]);
Log::info("Integration $which → $final", ['lead' => $id, 'attempts' => $attempts]);
return $final;
}
public static function backoffSeconds(int $attempt): int
{
return min(6 * 3600, (int)(60 * (2 ** max(0, $attempt - 1)))); // 1m, 2m, 4m, 8m … capped at 6h
}
/**
* Cron entry point: retry rows whose integrations are in 'retry' (or stuck in 'sending' for >15 min).
* @return array{checked:int, processed:int}
*/
public static function retryDue(int $limit = 25): array
{
$pdo = Db::pdo();
$now = gmdate('Y-m-d\TH:i:s\Z');
$stale = gmdate('Y-m-d\TH:i:s\Z', time() - 900);
// Unstick crashed "sending" rows
$pdo->prepare("UPDATE leads SET mailchimp_status = 'retry' WHERE mailchimp_status = 'sending' AND updated_at < ?")->execute([$stale]);
$pdo->prepare("UPDATE leads SET sms_status = 'retry' WHERE sms_status = 'sending' AND updated_at < ?")->execute([$stale]);
$st = $pdo->prepare("SELECT * FROM leads WHERE (mailchimp_status = 'retry' OR sms_status = 'retry') AND (next_retry_at IS NULL OR next_retry_at <= ?) ORDER BY created_at ASC LIMIT $limit");
$st->execute([$now]);
$rows = $st->fetchAll();
$processed = 0;
foreach ($rows as $row) {
self::runIntegrations($row);
$processed++;
}
return ['checked' => count($rows), 'processed' => $processed];
}
/** Data minimisation: delete leads older than LEAD_RETENTION_DAYS (0 disables). */
public static function purgeExpired(): int
{
$days = Config::int('LEAD_RETENTION_DAYS', 0);
if ($days <= 0) {
return 0;
}
$cutoff = gmdate('Y-m-d\TH:i:s\Z', time() - $days * 86400);
$st = Db::pdo()->prepare("DELETE FROM leads WHERE created_at < ? AND mailchimp_status NOT IN ('pending','retry','sending') AND sms_status NOT IN ('pending','retry','sending')");
$st->execute([$cutoff]);
return $st->rowCount();
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/**
* Minimal structured logger. Writes JSON lines to a private log file and
* redacts anything that looks like personal data or a secret.
*/
final class Log
{
private static string $file = '';
private static int $level = 1;
private const LEVELS = ['debug' => 0, 'info' => 1, 'warn' => 2, 'error' => 3];
private const REDACT_KEYS = ['email', 'phone', 'firstname', 'first_name', 'lastname', 'last_name', 'authorization', 'api_key', 'apikey', 'secret', 'token', 'password', 'questions', 'destination_number', 'email_address'];
public static function init(string $file, ?string $level = 'info'): void
{
self::$file = $file;
self::$level = self::LEVELS[strtolower($level ?? 'info')] ?? 1;
$dir = dirname($file);
if (!is_dir($dir)) {
@mkdir($dir, 0750, true);
}
}
/** @param array<string,mixed> $ctx */
public static function debug(string $msg, array $ctx = []): void { self::write('debug', $msg, $ctx); }
/** @param array<string,mixed> $ctx */
public static function info(string $msg, array $ctx = []): void { self::write('info', $msg, $ctx); }
/** @param array<string,mixed> $ctx */
public static function warn(string $msg, array $ctx = []): void { self::write('warn', $msg, $ctx); }
/** @param array<string,mixed> $ctx */
public static function error(string $msg, array $ctx = []): void { self::write('error', $msg, $ctx); }
/** @param array<string,mixed> $ctx */
private static function write(string $level, string $msg, array $ctx): void
{
if ((self::LEVELS[$level] ?? 1) < self::$level) {
return;
}
$line = json_encode([
'ts' => gmdate('c'),
'level' => $level,
'msg' => $msg,
'ctx' => self::redact($ctx),
], JSON_UNESCAPED_SLASHES) . "\n";
if (self::$file !== '' && (is_writable(dirname(self::$file)) || is_writable(self::$file))) {
@file_put_contents(self::$file, $line, FILE_APPEND | LOCK_EX);
} else {
error_log(rtrim($line));
}
}
/** @param mixed $value */
public static function redact($value, string $key = ''): mixed
{
if (is_array($value)) {
$out = [];
foreach ($value as $k => $v) {
$out[$k] = self::redact($v, (string)$k);
}
return $out;
}
if (is_string($value)) {
if (in_array(strtolower($key), self::REDACT_KEYS, true)) {
return self::mask($value);
}
// Inline redaction of emails, phone numbers and bearer-like tokens inside free text
$value = preg_replace('/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i', '[email]', $value) ?? $value;
$value = preg_replace('/\+?\d[\d\s().-]{8,}\d/', '[phone]', $value) ?? $value;
return preg_replace('/(Basic|Bearer)\s+[A-Za-z0-9+\/=._-]{8,}/', '$1 [redacted]', $value) ?? $value;
}
return $value;
}
private static function mask(string $v): string
{
$len = strlen($v);
if ($len === 0) {
return '';
}
if ($len <= 4) {
return str_repeat('*', $len);
}
return substr($v, 0, 2) . str_repeat('*', max(1, $len - 4)) . substr($v, -2);
}
}
+172
View File
@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
/**
* Mailchimp Marketing API v3 adapter (server-only).
*
* Required config: MAILCHIMP_API_KEY, MAILCHIMP_SERVER_PREFIX (e.g. us21), MAILCHIMP_AUDIENCE_ID
* Optional: MAILCHIMP_JOURNEY_ID + MAILCHIMP_JOURNEY_STEP_ID (Customer Journey API trigger),
* MAILCHIMP_TAG_PREFIX (default "Visit:" → tags like "Visit:Norwood", matching the
* church's existing convention), MAILCHIMP_DOUBLE_OPTIN (default false)
*
* Idempotency: contacts are keyed by the MD5 of the lower-cased email (Mailchimp's
* subscriber hash) and upserted with PUT, so retries never create duplicates.
*/
final class Mailchimp
{
/**
* @param array<string,mixed> $lead Row from the leads table
* @param array<string,mixed> $campus
* @return array{status:string, detail:?string, transient:bool}
*/
public static function upsertVisitor(array $lead, array $campus): array
{
if (!(int)$lead['email_consent']) {
return ['status' => 'skipped', 'detail' => 'no email consent', 'transient' => false];
}
$payload = self::buildMemberPayload($lead, $campus);
$tags = self::buildTags($lead, $campus);
if (Config::isMock()) {
Log::info('[mock] Mailchimp upsert', ['tags' => $tags, 'merge_fields' => array_keys($payload['merge_fields'])]);
return ['status' => 'mock', 'detail' => 'mock mode: ' . implode(',', $tags), 'transient' => false];
}
$apiKey = Config::require('MAILCHIMP_API_KEY');
$dc = Config::get('MAILCHIMP_SERVER_PREFIX') ?: (str_contains($apiKey, '-') ? substr($apiKey, strrpos($apiKey, '-') + 1) : null);
if (!$dc) {
throw new RuntimeException('MAILCHIMP_SERVER_PREFIX missing');
}
$list = Config::require('MAILCHIMP_AUDIENCE_ID');
$hash = md5(strtolower($lead['email']));
$base = Config::get('MAILCHIMP_BASE_URL_OVERRIDE') ?: "https://$dc.api.mailchimp.com/3.0"; // override is for the test-suite only
$auth = ['Authorization' => 'Basic ' . base64_encode("anystring:$apiKey")];
// 1) Upsert the member (idempotent)
$r = Http::json('PUT', "$base/lists/$list/members/$hash?skip_merge_validation=true", $payload, $auth, 10);
if ($r['status'] < 200 || $r['status'] >= 300) {
$detail = is_array($r['json']) ? ($r['json']['title'] ?? '') . ': ' . ($r['json']['detail'] ?? '') : ($r['error'] ?? "HTTP {$r['status']}");
$transient = Http::isTransient($r['status'], $r['error']);
Log::warn('Mailchimp upsert failed', ['status' => $r['status'], 'detail' => $detail]);
return ['status' => 'failed', 'detail' => substr($detail, 0, 500), 'transient' => $transient];
}
// 2) Apply tags (separate endpoint; PUT on members accepts tags only on create)
$tr = Http::json('POST', "$base/lists/$list/members/$hash/tags", [
'tags' => array_map(static fn($t) => ['name' => $t, 'status' => 'active'], $tags),
], $auth, 10);
if ($tr['status'] < 200 || $tr['status'] >= 300) {
Log::warn('Mailchimp tag update failed (member saved)', ['status' => $tr['status']]);
}
// 3) Optionally trigger a Customer Journey step (API-triggered journeys)
$journey = Config::get('MAILCHIMP_JOURNEY_ID');
$step = Config::get('MAILCHIMP_JOURNEY_STEP_ID');
$journeyNote = '';
if ($journey && $step) {
$jr = Http::json('POST', "$base/customer-journeys/journeys/$journey/steps/$step/actions/trigger", ['email_address' => $lead['email']], $auth, 10);
$journeyNote = ($jr['status'] >= 200 && $jr['status'] < 300) ? '; journey triggered' : "; journey trigger HTTP {$jr['status']}";
if ($jr['status'] < 200 || $jr['status'] >= 300) {
Log::warn('Mailchimp journey trigger failed', ['status' => $jr['status'], 'body' => substr($jr['body'], 0, 300)]);
}
}
$status = is_array($r['json']) ? ($r['json']['status'] ?? 'unknown') : 'unknown';
return ['status' => 'sent', 'detail' => "member status: $status" . $journeyNote, 'transient' => false];
}
/**
* @param array<string,mixed> $lead
* @param array<string,mixed> $campus
* @return array<string,mixed>
*/
public static function buildMemberPayload(array $lead, array $campus): array
{
$attr = is_string($lead['attribution'] ?? null) ? (json_decode($lead['attribution'], true) ?: []) : ($lead['attribution'] ?? []);
$doubleOptIn = Config::bool('MAILCHIMP_DOUBLE_OPTIN', false);
$merge = [
'FNAME' => $lead['first_name'],
'LNAME' => $lead['last_name'] ?? '',
'CAMPUS' => $campus['name'],
'VISITDATE' => $lead['visit_date'] ?? '',
'SERVICE' => $lead['service_time'] ?? '',
'ADULTS' => $lead['adults'] ?? '',
'KIDS' => $lead['children'] ?? '',
'KIDAGES' => $lead['child_age_ranges'] ?? '',
'QUESTIONS' => mb_substr((string)($lead['questions'] ?? ''), 0, 255),
'UTMSOURCE' => $attr['utm_source'] ?? '',
'UTMCAMP' => $attr['utm_campaign'] ?? '',
'LPVARIANT' => $attr['variant'] ?? '',
'SUBID' => $lead['submission_id'],
];
if (!empty($lead['phone_e164'])) {
$merge['PHONE'] = $lead['phone_e164'];
}
// Remove empties so we never overwrite existing values with blanks.
$merge = array_filter($merge, static fn($v) => $v !== '' && $v !== null);
return [
'email_address' => $lead['email'],
'status_if_new' => $doubleOptIn ? 'pending' : 'subscribed',
'email_type' => 'html',
'merge_fields' => $merge,
'marketing_permissions' => [],
];
}
/**
* @param array<string,mixed> $lead
* @param array<string,mixed> $campus
* @return string[]
*/
public static function buildTags(array $lead, array $campus): array
{
$prefix = Config::get('MAILCHIMP_TAG_PREFIX', 'Visit:') ?? 'Visit:';
$attr = is_string($lead['attribution'] ?? null) ? (json_decode($lead['attribution'], true) ?: []) : ($lead['attribution'] ?? []);
// Campus tag first: "Visit:Norwood" is what the church's Mailchimp journeys trigger on.
$tags = [
$prefix . $campus['name'],
$prefix . 'First-Time Visitor',
$prefix . 'Source visit.waterschurch.org',
];
$platform = self::platform($attr);
$tags[] = $prefix . 'Platform ' . $platform;
if (!empty($attr['utm_campaign'])) {
$tags[] = $prefix . 'Campaign ' . mb_substr($attr['utm_campaign'], 0, 60);
}
if (!empty($lead['visit_date'])) {
$tags[] = $prefix . 'Visit ' . $lead['visit_date'];
} elseif (!empty($lead['service_time'])) {
$tags[] = $prefix . 'Service ' . mb_substr($lead['service_time'], 0, 40);
}
if ((int)($lead['children'] ?? 0) > 0) {
$tags[] = $prefix . 'Has Kids';
}
if ((int)$lead['sms_consent']) {
$tags[] = $prefix . 'SMS Opt-In';
}
return array_values(array_unique(array_map(static fn($t) => mb_substr($t, 0, 100), $tags)));
}
/** @param array<string,mixed> $attr */
public static function platform(array $attr): string
{
$src = strtolower((string)($attr['utm_source'] ?? ''));
if (!empty($attr['gclid']) || !empty($attr['gbraid']) || !empty($attr['wbraid']) || preg_match('/google|adwords/', $src)) {
return 'Google';
}
if (preg_match('/instagram|^ig$/', $src)) {
return 'Instagram';
}
if (!empty($attr['fbclid']) || preg_match('/facebook|^fb$|meta/', $src)) {
return 'Facebook';
}
if (!empty($attr['ttclid']) || str_contains($src, 'tiktok')) {
return 'TikTok';
}
if (!empty($attr['twclid']) || preg_match('/twitter|^x$|x\.com/', $src)) {
return 'X';
}
return $src !== '' ? ucfirst($src) : 'Direct';
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/** North American Numbering Plan normalization to E.164 (+1NXXNXXXXXX). */
final class Phone
{
public static function normalizeNanp(string $input): ?string
{
$digits = preg_replace('/\D+/', '', $input) ?? '';
if (strlen($digits) === 11 && $digits[0] === '1') {
$digits = substr($digits, 1);
}
if (strlen($digits) !== 10) {
return null;
}
$area = substr($digits, 0, 3);
$exchange = substr($digits, 3, 3);
if (in_array($area[0], ['0', '1'], true) || in_array($exchange[0], ['0', '1'], true)) {
return null;
}
if ($area[1] === '1' && $area[2] === '1') {
return null; // N11 service codes
}
return '+1' . $digits;
}
public static function isE164(string $v): bool
{
return (bool)preg_match('/^\+[1-9]\d{6,14}$/', $v);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/** Fixed-window rate limiter backed by the database (works on shared hosting without Redis). */
final class RateLimit
{
/**
* @return array{allowed:bool, retryAfter:int}
*/
public static function hit(string $bucket, int $limit, int $windowSeconds): array
{
$pdo = Db::pdo();
$now = time();
$pdo->beginTransaction();
try {
$st = $pdo->prepare('SELECT hits, window_start FROM rate_limits WHERE bucket = ?');
$st->execute([$bucket]);
$row = $st->fetch();
if (!$row || ($now - (int)$row['window_start']) >= $windowSeconds) {
if (Db::isSqlite()) {
$pdo->prepare('INSERT OR REPLACE INTO rate_limits (bucket, hits, window_start) VALUES (?, 1, ?)')->execute([$bucket, $now]);
} else {
$pdo->prepare('REPLACE INTO rate_limits (bucket, hits, window_start) VALUES (?, 1, ?)')->execute([$bucket, $now]);
}
$pdo->commit();
return ['allowed' => true, 'retryAfter' => 0];
}
$hits = (int)$row['hits'] + 1;
$pdo->prepare('UPDATE rate_limits SET hits = ? WHERE bucket = ?')->execute([$hits, $bucket]);
$pdo->commit();
if ($hits > $limit) {
return ['allowed' => false, 'retryAfter' => max(1, $windowSeconds - ($now - (int)$row['window_start']))];
}
return ['allowed' => true, 'retryAfter' => 0];
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
Log::warn('Rate limit storage failed; allowing request', ['error' => $e->getMessage()]);
return ['allowed' => true, 'retryAfter' => 0];
}
}
/** Stable, non-reversible bucket key for an IP. */
public static function ipBucket(string $prefix, string $ip): string
{
return $prefix . ':' . substr(hash('sha256', $ip . '|' . (Config::get('HASH_SALT') ?? 'visit')), 0, 32);
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
/**
* Sinch Engage (formerly MessageMedia) Messages API adapter (server-only).
*
* Verified against the Sinch Engage OpenAPI spec (github.com/messagemedia/sinchengage-developer):
* POST {SINCH_API_BASE}/v1/messages
* Authorization: Basic base64(api_key:api_secret)
* Account: <sub-account id> (optional; lets a parent key send as a campus sub-account)
* { "messages": [ { "content", "destination_number", "source_number", "format": "SMS",
* "delivery_report": true, "metadata": {...}, "message_expiry_timestamp" } ] }
*
* Regional hosts: https://us.app.api.sinch.com (US), https://eu.app.api.sinch.com, https://au.app.api.sinch.com
*
* Config: SINCH_API_BASE, SINCH_API_KEY, SINCH_API_SECRET, SINCH_DEFAULT_SOURCE_NUMBER,
* SINCH_USE_CAMPUS_SUBACCOUNTS (default true), SINCH_INTERNAL_NOTIFY_NUMBER (optional)
*
* Idempotency: a lead is only sent once. The DB row is marked "sending" before the
* request and updated with the provider message_id after; retries skip rows that are
* not in a retryable state.
*/
final class Sinch
{
public const TEMPLATE_DEFAULT = "Hi {{first_name}} — we're excited that you're considering Waters Church {{campus_name}}. Your visit details: {{service_or_date}}. Have a question? Reply to this message. Reply STOP to opt out.";
/**
* @param array<string,mixed> $lead
* @param array<string,mixed> $campus
* @return array{status:string, detail:?string, message_id:?string, transient:bool}
*/
public static function sendVisitConfirmation(array $lead, array $campus): array
{
if (!(int)$lead['sms_consent']) {
return ['status' => 'skipped', 'detail' => 'no sms consent', 'message_id' => null, 'transient' => false];
}
$to = $lead['phone_e164'] ?? null;
if (!$to || !Phone::isE164($to)) {
return ['status' => 'failed', 'detail' => 'no valid phone', 'message_id' => null, 'transient' => false];
}
$content = self::buildConfirmation($lead, $campus);
$from = self::sourceNumberFor($campus);
$account = self::accountFor($campus);
if (Config::isMock()) {
Log::info('[mock] Sinch send', ['account' => $account, 'source' => $from, 'chars' => mb_strlen($content)]);
return ['status' => 'mock', 'detail' => 'mock mode; would send from ' . ($from ?? 'default') . ($account ? " as $account" : ''), 'message_id' => 'mock-' . substr($lead['submission_id'], 0, 8), 'transient' => false];
}
$message = [
'content' => $content,
'destination_number' => $to,
'format' => 'SMS',
'delivery_report' => true,
'metadata' => ['submission_id' => $lead['submission_id'], 'campus' => $campus['id'], 'source' => 'visit.waterschurch.org'],
'message_expiry_timestamp' => gmdate('Y-m-d\TH:i:s\Z', time() + 6 * 3600),
];
if ($from) {
$message['source_number'] = $from;
}
$r = self::post('/v1/messages', ['messages' => [$message]], $account);
if ($r['status'] === 202 || $r['status'] === 200) {
$msg = $r['json']['messages'][0] ?? [];
$id = is_array($msg) ? ($msg['message_id'] ?? null) : null;
$status = is_array($msg) ? ($msg['status'] ?? 'queued') : 'queued';
// Only claim success when the provider actually accepted the message.
if (!$id) {
return ['status' => 'failed', 'detail' => 'provider response missing message_id', 'message_id' => null, 'transient' => true];
}
return ['status' => 'sent', 'detail' => "provider status: $status", 'message_id' => $id, 'transient' => false];
}
$detail = is_array($r['json']) ? json_encode($r['json']) : ($r['error'] ?? "HTTP {$r['status']}");
Log::warn('Sinch send failed', ['status' => $r['status'], 'detail' => substr((string)$detail, 0, 300)]);
return ['status' => 'failed', 'detail' => substr((string)$detail, 0, 500), 'message_id' => null, 'transient' => Http::isTransient($r['status'], $r['error'])];
}
/**
* Optional internal heads-up to campus staff. Enabled only when
* SINCH_INTERNAL_NOTIFY_NUMBER is set. Contains no visitor PII beyond first name.
*
* @param array<string,mixed> $lead
* @param array<string,mixed> $campus
*/
public static function sendInternalLeadNotification(array $lead, array $campus): ?array
{
$to = Config::get('SINCH_INTERNAL_NOTIFY_NUMBER');
if (!$to) {
return null;
}
$content = sprintf('New first-time visitor plan: %s → %s%s. View in the leads export.', $lead['first_name'], $campus['name'], $lead['visit_date'] ? ' on ' . $lead['visit_date'] : '');
if (Config::isMock()) {
Log::info('[mock] Sinch internal notify', ['to' => $to]);
return ['status' => 'mock'];
}
$r = self::post('/v1/messages', ['messages' => [['content' => $content, 'destination_number' => $to, 'format' => 'SMS', 'source_number' => Config::get('SINCH_DEFAULT_SOURCE_NUMBER')]]], null);
return ['status' => ($r['status'] === 202 || $r['status'] === 200) ? 'sent' : 'failed'];
}
public static function normalizePhoneNumber(string $raw): ?string
{
return Phone::normalizeNanp($raw);
}
/**
* @param array<string,mixed> $lead
* @param array<string,mixed> $campus
*/
public static function buildConfirmation(array $lead, array $campus): string
{
$template = Config::get('SINCH_CONFIRMATION_TEMPLATE') ?: self::TEMPLATE_DEFAULT;
$when = self::serviceOrDate($lead, $campus);
$vars = [
'{{first_name}}' => $lead['first_name'],
'{{campus_name}}' => $campus['name'],
'{{service_or_date}}' => $when,
'{{address}}' => $campus['address']['line1'] . ', ' . $campus['address']['city'],
];
$text = strtr($template, $vars);
$text = preg_replace('/\s+/', ' ', trim($text)) ?? $text;
// Keep to a sane length; carriers concatenate but cost rises. 320 chars = 2 segments (GSM) or ~4 (UCS-2).
return mb_substr($text, 0, 320);
}
/**
* @param array<string,mixed> $lead
* @param array<string,mixed> $campus
*/
public static function serviceOrDate(array $lead, array $campus): string
{
$parts = [];
if (!empty($lead['visit_date'])) {
$ts = strtotime($lead['visit_date']);
$parts[] = $ts ? date('D, M j', $ts) : $lead['visit_date'];
}
if (!empty($lead['service_time'])) {
$parts[] = $lead['service_time'];
}
if (!$parts) {
$parts[] = Campuses::timesSummary($campus);
}
return implode(' · ', $parts);
}
/** @param array<string,mixed> $campus */
public static function sourceNumberFor(array $campus): ?string
{
if (Config::bool('SINCH_USE_CAMPUS_NUMBERS', true) && !empty($campus['smsNumber'])) {
return $campus['smsNumber'];
}
return Config::get('SINCH_DEFAULT_SOURCE_NUMBER');
}
/** @param array<string,mixed> $campus */
public static function accountFor(array $campus): ?string
{
if (Config::bool('SINCH_USE_CAMPUS_SUBACCOUNTS', true) && !empty($campus['sinchAccountId'])) {
return $campus['sinchAccountId'];
}
return Config::get('SINCH_ACCOUNT_ID');
}
/**
* @param array<string,mixed> $payload
* @return array{status:int, body:string, json:mixed, error:?string}
*/
private static function post(string $path, array $payload, ?string $account): array
{
$base = rtrim(Config::get('SINCH_API_BASE', 'https://us.app.api.sinch.com') ?? '', '/');
$key = Config::require('SINCH_API_KEY');
$secret = Config::require('SINCH_API_SECRET');
$headers = ['Authorization' => 'Basic ' . base64_encode("$key:$secret")];
if ($account) {
$headers['Account'] = $account;
}
return Http::json('POST', $base . $path, $payload, $headers, 10);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/** Optional Cloudflare Turnstile verification. Only enforced when TURNSTILE_SECRET_KEY is set. */
final class Turnstile
{
public static function enabled(): bool
{
return Config::get('TURNSTILE_SECRET_KEY') !== null;
}
public static function verify(?string $token, string $ip): bool
{
if (!self::enabled()) {
return true;
}
if (!$token) {
return false;
}
$res = Http::request('POST', 'https://challenges.cloudflare.com/turnstile/v0/siteverify', http_build_query([
'secret' => Config::require('TURNSTILE_SECRET_KEY'),
'response' => $token,
'remoteip' => $ip,
]), ['Content-Type' => 'application/x-www-form-urlencoded'], 6);
if ($res['error'] !== null || $res['status'] !== 200) {
// Fail open on provider outage so real visitors are never blocked; honeypot + rate limit still apply.
Log::warn('Turnstile verification unavailable', ['status' => $res['status'], 'error' => $res['error']]);
return true;
}
return (bool)($res['json']['success'] ?? false);
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
/**
* Server-side validation and sanitization of the Plan Your Visit payload.
* Mirrors src/lib/schema.ts. The server is authoritative.
*/
final class Validator
{
public const CHILD_AGE_RANGES = ['0-2', '3-5', '6-10', '11-13', '14-18'];
private const ATTR_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'gclid', 'gbraid', 'wbraid', 'fbclid', 'ttclid', 'twclid', 'variant', 'landing_url', 'referrer', 'first_seen_at'];
/**
* @param array<string,mixed> $in
* @return array{data:array<string,mixed>, errors:array<string,string>}
*/
public static function planVisit(array $in): array
{
$errors = [];
$d = [];
$d['submission_id'] = self::str($in, 'submissionId', 36);
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $d['submission_id'])) {
$errors['submissionId'] = 'Invalid submission id.';
}
$d['first_name'] = self::str($in, 'firstName', 60);
if ($d['first_name'] === '') {
$errors['firstName'] = 'Please tell us your first name.';
}
$d['last_name'] = self::str($in, 'lastName', 60) ?: null;
$email = strtolower(self::str($in, 'email', 254));
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !str_contains($email, '.')) {
$errors['email'] = "That email doesn't look right.";
}
$d['email'] = $email;
$d['email_consent'] = (bool)($in['emailConsent'] ?? false);
$d['sms_consent'] = (bool)($in['smsConsent'] ?? false);
$phoneRaw = self::str($in, 'phone', 30);
$d['phone_e164'] = null;
if ($phoneRaw !== '') {
$e164 = Phone::normalizeNanp($phoneRaw);
if ($e164 === null) {
$errors['phone'] = "That phone number doesn't look right.";
} else {
$d['phone_e164'] = $e164;
}
}
if ($d['sms_consent'] && $d['phone_e164'] === null && !isset($errors['phone'])) {
$errors['phone'] = 'Add a mobile number so we can text you, or uncheck texting.';
}
$d['campus_id'] = self::str($in, 'campusId', 40);
if (!in_array($d['campus_id'], Campuses::openIds(), true)) {
$errors['campusId'] = 'Please choose a campus.';
}
$visitDate = self::str($in, 'visitDate', 10);
$d['visit_date'] = null;
if ($visitDate !== '') {
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $visitDate) || !strtotime($visitDate)) {
$errors['visitDate'] = 'Use the date picker.';
} elseif ($visitDate < gmdate('Y-m-d', time() - 86400)) { // allow one day of timezone slack
$errors['visitDate'] = 'Pick today or a future date.';
} else {
$d['visit_date'] = $visitDate;
}
}
$d['service_time'] = self::str($in, 'serviceTime', 80) ?: null;
$d['adults'] = self::intOrNull($in, 'adults', 1, 20);
$d['children'] = self::intOrNull($in, 'children', 0, 10);
$ranges = $in['childAgeRanges'] ?? [];
$clean = [];
if (is_array($ranges)) {
foreach ($ranges as $r) {
if (is_string($r) && in_array($r, self::CHILD_AGE_RANGES, true) && !in_array($r, $clean, true)) {
$clean[] = $r;
}
}
}
$d['child_age_ranges'] = $clean ? implode(',', $clean) : null;
$d['questions'] = self::str($in, 'questions', 1000) ?: null;
$d['consent_version'] = self::str($in, 'consentVersion', 40);
if ($d['consent_version'] === '') {
$errors['consentVersion'] = 'Missing consent version.';
}
// Anti-spam: honeypot + minimum time on form
$d['honeypot'] = self::str($in, 'website', 200);
$startedAt = (int)($in['startedAt'] ?? 0);
$d['elapsed_ms'] = $startedAt > 0 ? max(0, (int)(microtime(true) * 1000) - $startedAt) : 0;
// Attribution: whitelist keys, cap lengths, strip control chars
$attr = [];
if (isset($in['attribution']) && is_array($in['attribution'])) {
foreach ($in['attribution'] as $k => $v) {
if (is_string($k) && is_string($v) && in_array($k, self::ATTR_KEYS, true)) {
$max = in_array($k, ['landing_url', 'referrer'], true) ? 2000 : 200;
$attr[$k] = self::clean($v, $max);
}
}
}
$d['attribution'] = $attr;
$d['turnstile_token'] = is_string($in['turnstileToken'] ?? null) ? substr($in['turnstileToken'], 0, 4000) : null;
return ['data' => $d, 'errors' => $errors];
}
/** @param array<string,mixed> $in */
private static function str(array $in, string $key, int $max): string
{
$v = $in[$key] ?? '';
if (!is_string($v)) {
return '';
}
return self::clean($v, $max);
}
public static function clean(string $v, int $max): string
{
$v = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $v) ?? '';
$v = trim($v);
if (!mb_check_encoding($v, 'UTF-8')) {
$v = mb_convert_encoding($v, 'UTF-8', 'UTF-8');
}
return mb_substr($v, 0, $max);
}
/** @param array<string,mixed> $in */
private static function intOrNull(array $in, string $key, int $min, int $max): ?int
{
if (!isset($in[$key]) || $in[$key] === '' || $in[$key] === null) {
return null;
}
if (!is_numeric($in[$key])) {
return null;
}
$n = (int)$in[$key];
return ($n < $min || $n > $max) ? null : $n;
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
/**
* Shared bootstrap for every API endpoint.
*
* - Loads configuration from a private .env OUTSIDE the web root (see Config.php)
* - Hardens error output (never leak stack traces or secrets to the browser)
* - Provides JSON response helpers and same-origin protection
*/
declare(strict_types=1);
if (PHP_VERSION_ID < 80100) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => 'Server misconfiguration (PHP 8.1+ required).']);
exit;
}
ini_set('display_errors', '0');
ini_set('log_errors', '1');
error_reporting(E_ALL);
date_default_timezone_set('UTC');
require_once __DIR__ . '/Config.php';
require_once __DIR__ . '/Log.php';
require_once __DIR__ . '/Db.php';
require_once __DIR__ . '/Http.php';
require_once __DIR__ . '/Campuses.php';
require_once __DIR__ . '/Phone.php';
Config::load();
Log::init(Config::privateDir() . '/logs/app.log', Config::get('LOG_LEVEL', 'info'));
set_exception_handler(static function (Throwable $e): void {
Log::error('Unhandled exception', ['type' => get_class($e), 'message' => $e->getMessage(), 'file' => basename($e->getFile()), 'line' => $e->getLine()]);
Api::json(['ok' => false, 'error' => 'Something went wrong on our end. Please try again in a moment.'], 500);
});
set_error_handler(static function (int $no, string $str, string $file, int $line): bool {
Log::warn('PHP notice', ['no' => $no, 'message' => $str, 'file' => basename($file), 'line' => $line]);
return true;
});
final class Api
{
/** @param array<string,mixed> $payload */
public static function json(array $payload, int $status = 200, array $headers = []): never
{
if (!headers_sent()) {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: same-origin');
foreach ($headers as $k => $v) {
header("$k: $v");
}
}
echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
public static function requireMethod(string ...$methods): void
{
$m = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($m === 'OPTIONS') {
self::corsHeaders();
http_response_code(204);
exit;
}
if (!in_array($m, $methods, true)) {
self::json(['ok' => false, 'error' => 'Method not allowed'], 405, ['Allow' => implode(', ', $methods)]);
}
self::corsHeaders();
}
/**
* Same-origin protection for a static site: the browser always sends Origin
* (or at least Referer) on cross-site fetches. Allowed origins are the
* production URL plus anything in ALLOWED_ORIGINS (comma separated, for
* local development).
*/
public static function requireSameOrigin(): void
{
$allowed = self::allowedOrigins();
$origin = $_SERVER['HTTP_ORIGIN'] ?? null;
$referer = $_SERVER['HTTP_REFERER'] ?? null;
$candidate = $origin ?: ($referer ? self::originOf($referer) : null);
$custom = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
if ($candidate === null) {
// No Origin/Referer at all: only accept if our custom header is present (fetch sets it; simple forms cannot).
if ($custom !== 'visit-page') {
self::json(['ok' => false, 'error' => 'Request blocked.'], 403);
}
return;
}
if (!in_array(rtrim($candidate, '/'), $allowed, true)) {
Log::warn('Cross-origin request rejected', ['origin' => $candidate]);
self::json(['ok' => false, 'error' => 'Request blocked.'], 403);
}
}
/** @return string[] */
public static function allowedOrigins(): array
{
$list = [rtrim(Config::get('PUBLIC_SITE_URL', 'https://visit.waterschurch.org'), '/')];
foreach (explode(',', Config::get('ALLOWED_ORIGINS', '')) as $o) {
$o = rtrim(trim($o), '/');
if ($o !== '') {
$list[] = $o;
}
}
// Same host as the request (covers preview subdomains on the same server).
$host = $_SERVER['HTTP_HOST'] ?? '';
if ($host !== '') {
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$list[] = "$scheme://$host";
}
return array_values(array_unique($list));
}
private static function corsHeaders(): void
{
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '' && in_array(rtrim($origin, '/'), self::allowedOrigins(), true)) {
header("Access-Control-Allow-Origin: $origin");
header('Vary: Origin');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Requested-With');
header('Access-Control-Max-Age: 600');
}
}
private static function originOf(string $url): ?string
{
$p = parse_url($url);
if (!$p || empty($p['host']) || empty($p['scheme'])) {
return null;
}
$port = isset($p['port']) ? ':' . $p['port'] : '';
return strtolower($p['scheme'] . '://' . $p['host'] . $port);
}
/** @return array<string,mixed> */
public static function readJsonBody(int $maxBytes = 32768): array
{
$len = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($len > $maxBytes) {
self::json(['ok' => false, 'error' => 'Request too large.'], 413);
}
$raw = file_get_contents('php://input', false, null, 0, $maxBytes + 1) ?: '';
if (strlen($raw) > $maxBytes) {
self::json(['ok' => false, 'error' => 'Request too large.'], 413);
}
$data = json_decode($raw, true);
if (!is_array($data)) {
self::json(['ok' => false, 'error' => 'Invalid request body.'], 400);
}
return $data;
}
/** Best-effort client IP (respects cPanel/Cloudflare proxies only when configured). */
public static function clientIp(): string
{
$trust = Config::bool('TRUST_PROXY_HEADERS', false);
if ($trust) {
foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR'] as $h) {
if (!empty($_SERVER[$h])) {
$ip = trim(explode(',', (string)$_SERVER[$h])[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
}
}
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
/**
* GET /api/locate.php — approximate, non-invasive location for the campus recommendation.
* Returns city-level coordinates only. Nothing is stored beyond a short hashed cache.
*/
declare(strict_types=1);
require_once __DIR__ . '/lib/bootstrap.php';
require_once __DIR__ . '/lib/Validator.php';
require_once __DIR__ . '/lib/RateLimit.php';
require_once __DIR__ . '/lib/GeoIp.php';
Api::requireMethod('GET');
$ip = Api::clientIp();
$rl = RateLimit::hit(RateLimit::ipBucket('locate', $ip), 30, 600);
if (!$rl['allowed']) {
Api::json(['ok' => false, 'reason' => 'rate_limited'], 429, ['Retry-After' => (string)$rl['retryAfter']]);
}
// Local development helper: ?debug_ip=8.8.8.8 (only when DEBUG_GEO=true)
if (Config::bool('DEBUG_GEO', false) && !empty($_GET['debug_ip']) && filter_var($_GET['debug_ip'], FILTER_VALIDATE_IP)) {
$ip = (string)$_GET['debug_ip'];
}
if (Config::bool('DEBUG_GEO', false) && isset($_GET['debug_lat'], $_GET['debug_lng'])) {
Api::json(['ok' => true, 'approximate' => ['lat' => (float)$_GET['debug_lat'], 'lng' => (float)$_GET['debug_lng'], 'city' => 'Debug', 'confidence' => 'low']]);
}
$loc = GeoIp::locate($ip);
if (!$loc) {
Api::json(['ok' => false, 'reason' => 'unavailable'], 200, ['Cache-Control' => 'private, max-age=300']);
}
Api::json([
'ok' => true,
'approximate' => ['lat' => $loc['lat'], 'lng' => $loc['lng'], 'city' => $loc['city'], 'region' => $loc['region'], 'confidence' => 'low'],
], 200, ['Cache-Control' => 'private, max-age=3600']);
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* POST /api/plan-visit.php
*
* 1. Same-origin + method checks
* 2. Rate limit (per IP) and bot checks (honeypot, minimum time, optional Turnstile)
* 3. Validate + normalize
* 4. Persist the lead (idempotent on submissionId)
* 5. Run Mailchimp / Sinch, recording each outcome for retry
* 6. Return a friendly summary — never provider internals
*/
declare(strict_types=1);
require_once __DIR__ . '/lib/bootstrap.php';
require_once __DIR__ . '/lib/Validator.php';
require_once __DIR__ . '/lib/RateLimit.php';
require_once __DIR__ . '/lib/Turnstile.php';
require_once __DIR__ . '/lib/LeadProcessor.php';
Api::requireMethod('POST');
Api::requireSameOrigin();
$ip = Api::clientIp();
$ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
// --- Rate limiting: 5 submissions / 10 minutes per IP, 60 / day per IP
$rl = RateLimit::hit(RateLimit::ipBucket('submit:10m', $ip), Config::int('RATE_LIMIT_SUBMIT_PER_10MIN', 5), 600);
if (!$rl['allowed']) {
Api::json(['ok' => false, 'error' => 'Too many attempts. Please wait a moment and try again.', 'retryAfter' => $rl['retryAfter']], 429, ['Retry-After' => (string)$rl['retryAfter']]);
}
$rlDay = RateLimit::hit(RateLimit::ipBucket('submit:1d', $ip), Config::int('RATE_LIMIT_SUBMIT_PER_DAY', 60), 86400);
if (!$rlDay['allowed']) {
Api::json(['ok' => false, 'error' => 'Too many attempts today. Text us and we will help directly.', 'retryAfter' => $rlDay['retryAfter']], 429, ['Retry-After' => (string)$rlDay['retryAfter']]);
}
$input = Api::readJsonBody();
['data' => $d, 'errors' => $errors] = Validator::planVisit($input);
// --- Bot checks. Respond with a fake success so bots learn nothing.
if ($d['honeypot'] !== '' || ($d['elapsed_ms'] > 0 && $d['elapsed_ms'] < Config::int('MIN_FORM_SECONDS', 3) * 1000)) {
Log::warn('Bot signal on submit', ['honeypot' => $d['honeypot'] !== '', 'elapsed_ms' => $d['elapsed_ms']]);
Api::json(['ok' => true, 'submissionId' => $d['submission_id'], 'email' => ['status' => 'skipped'], 'sms' => ['status' => 'skipped']]);
}
if (!Turnstile::verify($d['turnstile_token'], $ip)) {
Api::json(['ok' => false, 'error' => 'Security check failed. Please try again.'], 400);
}
if ($errors) {
Api::json(['ok' => false, 'error' => 'Please check the highlighted fields.', 'fieldErrors' => $errors], 422);
}
// --- Persist first
['lead' => $lead, 'duplicate' => $duplicate] = LeadProcessor::persist($d, $ip, $ua);
Log::info($duplicate ? 'Duplicate submission (idempotent replay)' : 'Lead stored', ['lead' => $lead['id'], 'campus' => $lead['campus_id'], 'email_consent' => (bool)$lead['email_consent'], 'sms_consent' => (bool)$lead['sms_consent']]);
// --- Integrations (each guarded; failures are recorded for cron retry)
$statuses = LeadProcessor::runIntegrations($lead);
$public = static function (string $s): string {
return match ($s) {
'sent' => 'sent',
'mock' => 'mock',
'skipped' => 'skipped',
'failed' => 'failed',
default => 'queued', // pending / retry / sending
};
};
Api::json([
'ok' => true,
'submissionId' => $lead['submission_id'],
'duplicate' => $duplicate,
'email' => ['status' => $public($statuses['email'])],
'sms' => ['status' => $public($statuses['sms'])],
]);