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,49 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user