Files
Waters ChurchandClaude Fable 5.1 78cd0cc550 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>
2026-09-22 13:22:26 -04:00

150 lines
5.6 KiB
PHP

<?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;
}
}