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>
50 lines
2.0 KiB
PHP
50 lines
2.0 KiB
PHP
<?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);
|
|
}
|
|
}
|