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 $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 */ 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'; } }