179 lines
6.6 KiB
PHP
179 lines
6.6 KiB
PHP
<?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';
|
||
|
|
}
|
||
|
|
}
|