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

124 lines
4.0 KiB
PHP

<?php
declare(strict_types=1);
/**
* Configuration lives OUTSIDE the web root in a private directory containing a
* `.env` file (see .env.example at the repo root for every key).
*
* The private directory is resolved in this order:
* 1. VISIT_PRIVATE_DIR environment variable (set via `SetEnv` in .htaccess, or cPanel cron env)
* 2. <parent of DOCUMENT_ROOT>/visit-private (e.g. /home/USER/visit-private)
* 3. <repo>/private (local development only)
*/
final class Config
{
/** @var array<string,string> */
private static array $values = [];
private static ?string $privateDir = null;
private static bool $loaded = false;
public static function load(): void
{
if (self::$loaded) {
return;
}
self::$loaded = true;
$dir = self::privateDir();
$env = $dir . '/.env';
if (is_readable($env)) {
foreach (file($env, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) {
continue;
}
[$k, $v] = explode('=', $line, 2);
$k = trim($k);
$v = trim($v);
if (str_starts_with($k, 'export ')) {
$k = trim(substr($k, 7));
}
if ((str_starts_with($v, '"') && str_ends_with($v, '"')) || (str_starts_with($v, "'") && str_ends_with($v, "'"))) {
$v = substr($v, 1, -1);
}
self::$values[$k] = $v;
}
}
}
public static function privateDir(): string
{
if (self::$privateDir !== null) {
return self::$privateDir;
}
$candidates = [];
$fromEnv = getenv('VISIT_PRIVATE_DIR') ?: ($_SERVER['VISIT_PRIVATE_DIR'] ?? ($_SERVER['REDIRECT_VISIT_PRIVATE_DIR'] ?? null));
if ($fromEnv) {
$candidates[] = $fromEnv;
}
if (!empty($_SERVER['DOCUMENT_ROOT'])) {
$candidates[] = dirname(rtrim($_SERVER['DOCUMENT_ROOT'], '/')) . '/visit-private';
}
$candidates[] = dirname(__DIR__, 3) . '/private'; // repo/private (dev)
foreach ($candidates as $c) {
if (is_dir($c)) {
return self::$privateDir = rtrim($c, '/');
}
}
// Fall back to the first candidate so error messages point somewhere useful.
return self::$privateDir = rtrim($candidates[0], '/');
}
public static function get(string $key, ?string $default = null): ?string
{
$env = getenv($key);
if ($env !== false && $env !== '') {
return $env;
}
return (isset(self::$values[$key]) && self::$values[$key] !== '') ? self::$values[$key] : $default;
}
public static function require(string $key): string
{
$v = self::get($key);
if ($v === null) {
throw new RuntimeException("Missing required configuration: $key");
}
return $v;
}
public static function bool(string $key, bool $default = false): bool
{
$v = self::get($key);
if ($v === null) {
return $default;
}
return in_array(strtolower($v), ['1', 'true', 'yes', 'on'], true);
}
public static function int(string $key, int $default): int
{
$v = self::get($key);
return $v === null ? $default : (int)$v;
}
/** "mock" (default when unset), "live". */
public static function mode(): string
{
return strtolower(self::get('INTEGRATION_MODE', 'mock') ?? 'mock') === 'live' ? 'live' : 'mock';
}
public static function isMock(): bool
{
return self::mode() === 'mock';
}
public static function dataDir(): string
{
$d = self::get('DATA_DIR') ?: self::privateDir() . '/data';
if (!is_dir($d)) {
@mkdir($d, 0750, true);
}
return $d;
}
}