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:
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user