$ final class SpamDefense
{ public const MIN_RENDER_SECONDS = 3; public const RATE_LIMIT_MAX = 5; public const RATE_LIMIT_WINDOW = 3600; /** @var array<string, list<int>> ip => unix timestamps */ private array $hits = []; /** @var callable(): int */ private $clock; public function __construct(?callable $clock = null) { $this->clock = $clock ?? static fn (): int => time(); } public function checkHoneypot(mixed $value): ?string { if ($value === null || $value === '') return null; return 'honeypot_filled'; } public function checkTimeGate(mixed $ts): ?string { if (!is_int($ts) && !(is_string($ts) && ctype_digit($ts))) { return 'timestamp_missing'; } $delta = ($this->clock)() - (int) $ts; if ($delta < 0) return 'timestamp_in_future'; if ($delta < self::MIN_RENDER_SECONDS) return 'submitted_too_fast'; return null; } public function checkRateLimit(string $ip): ?string { $now = ($this->clock)(); $cutoff = $now - self::RATE_LIMIT_WINDOW; $prior = array_values(array_filter( $this->hits[$ip] ?? [], static fn (int $t): bool => $t >= $cutoff )); if (count($prior) >= self::RATE_LIMIT_MAX) { $this->hits[$ip] = $prior; return 'rate_limited'; } $prior[] = $now; $this->hits[$ip] = $prior; return null; }
}
final class SpamDefense
{ public const MIN_RENDER_SECONDS = 3; public const RATE_LIMIT_MAX = 5; public const RATE_LIMIT_WINDOW = 3600; /** @var array<string, list<int>> ip => unix timestamps */ private array $hits = []; /** @var callable(): int */ private $clock; public function __construct(?callable $clock = null) { $this->clock = $clock ?? static fn (): int => time(); } public function checkHoneypot(mixed $value): ?string { if ($value === null || $value === '') return null; return 'honeypot_filled'; } public function checkTimeGate(mixed $ts): ?string { if (!is_int($ts) && !(is_string($ts) && ctype_digit($ts))) { return 'timestamp_missing'; } $delta = ($this->clock)() - (int) $ts; if ($delta < 0) return 'timestamp_in_future'; if ($delta < self::MIN_RENDER_SECONDS) return 'submitted_too_fast'; return null; } public function checkRateLimit(string $ip): ?string { $now = ($this->clock)(); $cutoff = $now - self::RATE_LIMIT_WINDOW; $prior = array_values(array_filter( $this->hits[$ip] ?? [], static fn (int $t): bool => $t >= $cutoff )); if (count($prior) >= self::RATE_LIMIT_MAX) { $this->hits[$ip] = $prior; return 'rate_limited'; } $prior[] = $now; $this->hits[$ip] = $prior; return null; }
}
final class SpamDefense
{ public const MIN_RENDER_SECONDS = 3; public const RATE_LIMIT_MAX = 5; public const RATE_LIMIT_WINDOW = 3600; /** @var array<string, list<int>> ip => unix timestamps */ private array $hits = []; /** @var callable(): int */ private $clock; public function __construct(?callable $clock = null) { $this->clock = $clock ?? static fn (): int => time(); } public function checkHoneypot(mixed $value): ?string { if ($value === null || $value === '') return null; return 'honeypot_filled'; } public function checkTimeGate(mixed $ts): ?string { if (!is_int($ts) && !(is_string($ts) && ctype_digit($ts))) { return 'timestamp_missing'; } $delta = ($this->clock)() - (int) $ts; if ($delta < 0) return 'timestamp_in_future'; if ($delta < self::MIN_RENDER_SECONDS) return 'submitted_too_fast'; return null; } public function checkRateLimit(string $ip): ?string { $now = ($this->clock)(); $cutoff = $now - self::RATE_LIMIT_WINDOW; $prior = array_values(array_filter( $this->hits[$ip] ?? [], static fn (int $t): bool => $t >= $cutoff )); if (count($prior) >= self::RATE_LIMIT_MAX) { $this->hits[$ip] = $prior; return 'rate_limited'; } $prior[] = $now; $this->hits[$ip] = $prior; return null; }
}
$err = $defense->checkHoneypot($body['_honeypot'] ?? null) ?? $defense->checkTimeGate($body['_ts'] ?? null) ?? Validators::all($body);
if ($err !== null) { /* 422 */ }
$err = $defense->checkHoneypot($body['_honeypot'] ?? null) ?? $defense->checkTimeGate($body['_ts'] ?? null) ?? Validators::all($body);
if ($err !== null) { /* 422 */ }
$err = $defense->checkHoneypot($body['_honeypot'] ?? null) ?? $defense->checkTimeGate($body['_ts'] ?? null) ?? Validators::all($body);
if ($err !== null) { /* 422 */ }
public function append(array $submission): string
{ $row = [ 'id' => self::generateId(), 'received_at' => gmdate('c'), 'name' => (string) ($submission['name'] ?? ''), 'email' => (string) ($submission['email'] ?? ''), 'subject' => (string) ($submission['subject'] ?? ''), 'message' => (string) ($submission['message'] ?? ''), 'client_ip' => (string) ($submission['client_ip'] ?? ''), 'user_agent' => (string) ($submission['user_agent'] ?? ''), ]; $line = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; $fp = fopen($this->path, 'ab'); if ($fp === false) { throw new \RuntimeException('cannot open submissions file'); } try { if (!flock($fp, LOCK_EX)) { throw new \RuntimeException('cannot lock'); } fwrite($fp, $line); fflush($fp); flock($fp, LOCK_UN); } finally { fclose($fp); } return $row['id'];
}
public function append(array $submission): string
{ $row = [ 'id' => self::generateId(), 'received_at' => gmdate('c'), 'name' => (string) ($submission['name'] ?? ''), 'email' => (string) ($submission['email'] ?? ''), 'subject' => (string) ($submission['subject'] ?? ''), 'message' => (string) ($submission['message'] ?? ''), 'client_ip' => (string) ($submission['client_ip'] ?? ''), 'user_agent' => (string) ($submission['user_agent'] ?? ''), ]; $line = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; $fp = fopen($this->path, 'ab'); if ($fp === false) { throw new \RuntimeException('cannot open submissions file'); } try { if (!flock($fp, LOCK_EX)) { throw new \RuntimeException('cannot lock'); } fwrite($fp, $line); fflush($fp); flock($fp, LOCK_UN); } finally { fclose($fp); } return $row['id'];
}
public function append(array $submission): string
{ $row = [ 'id' => self::generateId(), 'received_at' => gmdate('c'), 'name' => (string) ($submission['name'] ?? ''), 'email' => (string) ($submission['email'] ?? ''), 'subject' => (string) ($submission['subject'] ?? ''), 'message' => (string) ($submission['message'] ?? ''), 'client_ip' => (string) ($submission['client_ip'] ?? ''), 'user_agent' => (string) ($submission['user_agent'] ?? ''), ]; $line = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; $fp = fopen($this->path, 'ab'); if ($fp === false) { throw new \RuntimeException('cannot open submissions file'); } try { if (!flock($fp, LOCK_EX)) { throw new \RuntimeException('cannot lock'); } fwrite($fp, $line); fflush($fp); flock($fp, LOCK_UN); } finally { fclose($fp); } return $row['id'];
}
public static function all(array $payload): ?string
{ return self::name($payload['name'] ?? null) ?? self::email($payload['email'] ?? null) ?? self::subject($payload['subject'] ?? null) ?? self::message($payload['message'] ?? null);
} public static function email(mixed $v): ?string { if (!is_string($v) || trim($v) === '') return 'email_required'; if (filter_var(trim($v), FILTER_VALIDATE_EMAIL) === false) { return 'email_invalid'; } if (strlen(trim($v)) > 254) return 'email_too_long'; return null;
}
public static function all(array $payload): ?string
{ return self::name($payload['name'] ?? null) ?? self::email($payload['email'] ?? null) ?? self::subject($payload['subject'] ?? null) ?? self::message($payload['message'] ?? null);
} public static function email(mixed $v): ?string { if (!is_string($v) || trim($v) === '') return 'email_required'; if (filter_var(trim($v), FILTER_VALIDATE_EMAIL) === false) { return 'email_invalid'; } if (strlen(trim($v)) > 254) return 'email_too_long'; return null;
}
public static function all(array $payload): ?string
{ return self::name($payload['name'] ?? null) ?? self::email($payload['email'] ?? null) ?? self::subject($payload['subject'] ?? null) ?? self::message($payload['message'] ?? null);
} public static function email(mixed $v): ?string { if (!is_string($v) || trim($v) === '') return 'email_required'; if (filter_var(trim($v), FILTER_VALIDATE_EMAIL) === false) { return 'email_invalid'; } if (strlen(trim($v)) > 254) return 'email_too_long'; return null;
}
if ($webhookUrl !== '') { contact_form_webhook()->send($webhookUrl, [ 'id' => $id, 'name' => trim((string) $body['name']), 'email' => trim((string) $body['email']), 'subject' => trim((string) $body['subject']), 'message' => (string) $body['message'], 'client_ip' => $ip, 'received_at' => gmdate('c'), ], (int) (getenv('WEBHOOK_TIMEOUT') ?: 3));
}
if ($webhookUrl !== '') { contact_form_webhook()->send($webhookUrl, [ 'id' => $id, 'name' => trim((string) $body['name']), 'email' => trim((string) $body['email']), 'subject' => trim((string) $body['subject']), 'message' => (string) $body['message'], 'client_ip' => $ip, 'received_at' => gmdate('c'), ], (int) (getenv('WEBHOOK_TIMEOUT') ?: 3));
}
if ($webhookUrl !== '') { contact_form_webhook()->send($webhookUrl, [ 'id' => $id, 'name' => trim((string) $body['name']), 'email' => trim((string) $body['email']), 'subject' => trim((string) $body['subject']), 'message' => (string) $body['message'], 'client_ip' => $ip, 'received_at' => gmdate('c'), ], (int) (getenv('WEBHOOK_TIMEOUT') ?: 3));
}
-weight: 500;">docker build -t contact-form https://github.com/sen-ltd/contact-form.-weight: 500;">git
-weight: 500;">docker run --rm -p 8000:8000 -e ADMIN_TOKEN=testsecret contact-form &
sleep 1
TS=$(($(date +%s) - 10))
-weight: 500;">curl -X POST http://localhost:8000/submit \ -H "Content-Type: application/json" \ -d "{\"name\":\"You\",\"email\":\"[email protected]\",\"subject\":\"Hi\",\"message\":\"A longer message here.\",\"_ts\":$TS}"
# {"ok":true,"id":"s_..."}
-weight: 500;">curl http://localhost:8000/submissions -H "Authorization: Bearer testsecret"
-weight: 500;">docker build -t contact-form https://github.com/sen-ltd/contact-form.-weight: 500;">git
-weight: 500;">docker run --rm -p 8000:8000 -e ADMIN_TOKEN=testsecret contact-form &
sleep 1
TS=$(($(date +%s) - 10))
-weight: 500;">curl -X POST http://localhost:8000/submit \ -H "Content-Type: application/json" \ -d "{\"name\":\"You\",\"email\":\"[email protected]\",\"subject\":\"Hi\",\"message\":\"A longer message here.\",\"_ts\":$TS}"
# {"ok":true,"id":"s_..."}
-weight: 500;">curl http://localhost:8000/submissions -H "Authorization: Bearer testsecret"
-weight: 500;">docker build -t contact-form https://github.com/sen-ltd/contact-form.-weight: 500;">git
-weight: 500;">docker run --rm -p 8000:8000 -e ADMIN_TOKEN=testsecret contact-form &
sleep 1
TS=$(($(date +%s) - 10))
-weight: 500;">curl -X POST http://localhost:8000/submit \ -H "Content-Type: application/json" \ -d "{\"name\":\"You\",\"email\":\"[email protected]\",\"subject\":\"Hi\",\"message\":\"A longer message here.\",\"_ts\":$TS}"
# {"ok":true,"id":"s_..."}
-weight: 500;">curl http://localhost:8000/submissions -H "Authorization: Bearer testsecret" - Formspree, Netlify Forms, Basin, Getform â easy, free-tier capped, every submission leaks through a third party.
- reCAPTCHA / hCaptcha / Turnstile â free, but punishes every user who blocks trackers. Also ties you to Google / Intuition / Cloudflare.
- SMTP â "I'll just send myself an email." Brittle, slow, and six months later nobody's reading that inbox.
- Your own tiny HTTP -weight: 500;">service â three files of PHP, zero third parties. - Honeypot field â a hidden _honeypot input the user never sees. If it comes back populated, the sender is a bot.
- Time gate â the browser writes _ts = Date.now()/1000 at form-render time. A real user takes at least a few seconds to type a message; a submission less than 3 seconds after render is almost certainly scripted.
- Rate limit â no matter how clever the sender, you cap each IP at 5 submissions per hour. That's far above any sincere contact rate and well below any spam run. - A third-party script dependency (tracking, privacy, load time).
- A user-experience hit for real visitors, especially on mobile and especially for anyone using a privacy-focused browser.
- An accessibility regression â image captchas are still horrible for screen reader users.
- A fallback path I still have to maintain when Google changes the API. - Zero external dependencies (no MySQL, no Postgres, no SQLite libraries pulled in from Composer).
- Append-only audit log â never rewrite, never delete, never "hmm which row did the schema change on?"
- Crash-safe concurrent writes: if two people submit at the same time, no line may be torn in half. - No CAPTCHA, deliberately. Determined humans get through. Accepted cost.
- JSONL is append-only. You can't "delete" a submission through the API. Edit the file by hand.
- Webhook isn't queued. Delivery failures drop on the floor. JSONL is the source of truth.
- Rate limiter is in-memory, per-process. Multi-worker setups need upstream rate limiting.
- No SMTP. Email delivery is somebody else's problem â point the webhook at an email relay if you need one.