Files
Waters ChurchandClaude Fable 5.1 78cd0cc550 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>
2026-09-22 13:22:26 -04:00

87 lines
3.3 KiB
PHP

<?php
declare(strict_types=1);
/**
* Minimal structured logger. Writes JSON lines to a private log file and
* redacts anything that looks like personal data or a secret.
*/
final class Log
{
private static string $file = '';
private static int $level = 1;
private const LEVELS = ['debug' => 0, 'info' => 1, 'warn' => 2, 'error' => 3];
private const REDACT_KEYS = ['email', 'phone', 'firstname', 'first_name', 'lastname', 'last_name', 'authorization', 'api_key', 'apikey', 'secret', 'token', 'password', 'questions', 'destination_number', 'email_address'];
public static function init(string $file, ?string $level = 'info'): void
{
self::$file = $file;
self::$level = self::LEVELS[strtolower($level ?? 'info')] ?? 1;
$dir = dirname($file);
if (!is_dir($dir)) {
@mkdir($dir, 0750, true);
}
}
/** @param array<string,mixed> $ctx */
public static function debug(string $msg, array $ctx = []): void { self::write('debug', $msg, $ctx); }
/** @param array<string,mixed> $ctx */
public static function info(string $msg, array $ctx = []): void { self::write('info', $msg, $ctx); }
/** @param array<string,mixed> $ctx */
public static function warn(string $msg, array $ctx = []): void { self::write('warn', $msg, $ctx); }
/** @param array<string,mixed> $ctx */
public static function error(string $msg, array $ctx = []): void { self::write('error', $msg, $ctx); }
/** @param array<string,mixed> $ctx */
private static function write(string $level, string $msg, array $ctx): void
{
if ((self::LEVELS[$level] ?? 1) < self::$level) {
return;
}
$line = json_encode([
'ts' => gmdate('c'),
'level' => $level,
'msg' => $msg,
'ctx' => self::redact($ctx),
], JSON_UNESCAPED_SLASHES) . "\n";
if (self::$file !== '' && (is_writable(dirname(self::$file)) || is_writable(self::$file))) {
@file_put_contents(self::$file, $line, FILE_APPEND | LOCK_EX);
} else {
error_log(rtrim($line));
}
}
/** @param mixed $value */
public static function redact($value, string $key = ''): mixed
{
if (is_array($value)) {
$out = [];
foreach ($value as $k => $v) {
$out[$k] = self::redact($v, (string)$k);
}
return $out;
}
if (is_string($value)) {
if (in_array(strtolower($key), self::REDACT_KEYS, true)) {
return self::mask($value);
}
// Inline redaction of emails, phone numbers and bearer-like tokens inside free text
$value = preg_replace('/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i', '[email]', $value) ?? $value;
$value = preg_replace('/\+?\d[\d\s().-]{8,}\d/', '[phone]', $value) ?? $value;
return preg_replace('/(Basic|Bearer)\s+[A-Za-z0-9+\/=._-]{8,}/', '$1 [redacted]', $value) ?? $value;
}
return $value;
}
private static function mask(string $v): string
{
$len = strlen($v);
if ($len === 0) {
return '';
}
if ($len <= 4) {
return str_repeat('*', $len);
}
return substr($v, 0, 2) . str_repeat('*', max(1, $len - 4)) . substr($v, -2);
}
}