Files
breadcrumb-the-shire/lib/App/Bootstrap/EnvValidator.php

342 lines
11 KiB
PHP
Raw Normal View History

2026-03-04 15:56:58 +01:00
<?php
namespace MintyPHP\App\Bootstrap;
final class EnvValidator
{
/** @var list<string> */
private const REQUIRED_KEYS = [
'APP_NAME',
'APP_ENV',
'APP_DEBUG',
'APP_TIMEZONE',
'APP_STORAGE_PATH',
'APP_LOCALE',
'APP_LOCALES',
'APP_I18N_DOMAIN',
'SESSION_NAME',
'TENANT_SCOPE_STRICT',
'DB_HOST',
'DB_PORT',
'DB_NAME',
'DB_USER',
'DB_PASS',
'CACHE_SERVERS',
'FIREWALL_CONCURRENCY',
'FIREWALL_SPINLOCK_SECONDS',
'FIREWALL_INTERVAL_SECONDS',
'FIREWALL_CACHE_PREFIX',
'FIREWALL_REVERSE_PROXY',
];
public static function validate(): void
{
if (!self::isValidationEnabled()) {
return;
}
$env = [];
foreach (array_merge(self::REQUIRED_KEYS, ['APP_URL', 'APP_CRYPTO_KEY', 'APP_VENDOR_PHP_ISSUES_MODE']) as $key) {
2026-03-04 15:56:58 +01:00
$env[$key] = getenv($key);
}
$errors = self::validateArray($env);
if ($errors === []) {
return;
}
throw new \RuntimeException(
"Configuration validation failed:\n - "
. implode("\n - ", $errors)
. "\nPlease verify your .env based on .env.example"
);
}
2026-03-06 00:44:52 +01:00
// Validation is on by default; set APP_CONFIG_VALIDATE=false to skip (e.g. in CI without full env).
2026-03-04 15:56:58 +01:00
public static function isValidationEnabled(): bool
{
$raw = getenv('APP_CONFIG_VALIDATE');
if ($raw === false || trim((string) $raw) === '') {
return true;
}
return self::parseBool((string) $raw) ?? true;
}
/**
* @param array<string, mixed> $env
* @return list<string>
*/
public static function validateArray(array $env): array
{
$errors = [];
foreach (self::REQUIRED_KEYS as $key) {
if (!self::hasEnvKey($env, $key)) {
$errors[] = "Missing required env key '{$key}'";
continue;
}
if (self::value($env, $key) === '') {
$errors[] = "Env key '{$key}' must not be empty";
}
}
$appEnv = strtolower(self::value($env, 'APP_ENV'));
if ($appEnv !== '' && !in_array($appEnv, ['dev', 'prod', 'test'], true)) {
$errors[] = "APP_ENV must be one of: dev, prod, test";
}
$appDebug = self::value($env, 'APP_DEBUG');
if ($appDebug !== '' && self::parseBool($appDebug) === null) {
$errors[] = "APP_DEBUG must be a boolean value (true/false/1/0)";
}
$tenantScopeStrict = self::value($env, 'TENANT_SCOPE_STRICT');
if ($tenantScopeStrict !== '' && self::parseBool($tenantScopeStrict) === null) {
$errors[] = "TENANT_SCOPE_STRICT must be a boolean value (true/false/1/0)";
}
$vendorPhpIssuesMode = strtolower(self::value($env, 'APP_VENDOR_PHP_ISSUES_MODE'));
if (
$vendorPhpIssuesMode !== ''
&& !in_array($vendorPhpIssuesMode, ['strict', 'suppress_deprecations', 'suppress_deprecations_warnings'], true)
) {
$errors[] = 'APP_VENDOR_PHP_ISSUES_MODE must be one of: strict, suppress_deprecations, suppress_deprecations_warnings';
}
2026-03-04 15:56:58 +01:00
$reverseProxy = self::value($env, 'FIREWALL_REVERSE_PROXY');
if ($reverseProxy !== '' && self::parseBool($reverseProxy) === null) {
$errors[] = "FIREWALL_REVERSE_PROXY must be a boolean value (true/false/1/0)";
}
$timezone = self::value($env, 'APP_TIMEZONE');
if ($timezone !== '' && !in_array($timezone, \DateTimeZone::listIdentifiers(), true)) {
$errors[] = "APP_TIMEZONE is invalid: '{$timezone}'";
}
$locales = self::parseCsv(self::value($env, 'APP_LOCALES'));
if ($locales === []) {
$errors[] = 'APP_LOCALES must contain at least one locale';
}
$locale = self::value($env, 'APP_LOCALE');
if ($locale !== '' && $locales !== [] && !in_array($locale, $locales, true)) {
$errors[] = "APP_LOCALE '{$locale}' is not part of APP_LOCALES";
}
$dbPort = self::parseInt(self::value($env, 'DB_PORT'));
if ($dbPort === null || $dbPort < 1 || $dbPort > 65535) {
$errors[] = 'DB_PORT must be an integer between 1 and 65535';
}
$firewallConcurrency = self::parseInt(self::value($env, 'FIREWALL_CONCURRENCY'));
if ($firewallConcurrency === null || $firewallConcurrency < 1) {
$errors[] = 'FIREWALL_CONCURRENCY must be an integer >= 1';
}
$firewallInterval = self::parseInt(self::value($env, 'FIREWALL_INTERVAL_SECONDS'));
if ($firewallInterval === null || $firewallInterval < 1) {
$errors[] = 'FIREWALL_INTERVAL_SECONDS must be an integer >= 1';
}
$firewallSpinLock = self::parseFloat(self::value($env, 'FIREWALL_SPINLOCK_SECONDS'));
if ($firewallSpinLock === null || $firewallSpinLock <= 0) {
$errors[] = 'FIREWALL_SPINLOCK_SECONDS must be a float > 0';
}
$cacheServers = self::parseCsv(self::value($env, 'CACHE_SERVERS'));
if ($cacheServers === []) {
$errors[] = 'CACHE_SERVERS must contain at least one host[:port] entry';
} else {
foreach ($cacheServers as $server) {
if (!preg_match('/^[a-z0-9.-]+(?::\d{1,5})?$/i', $server)) {
$errors[] = "CACHE_SERVERS contains invalid entry '{$server}'";
continue;
}
$parts = explode(':', $server, 2);
if (count($parts) !== 2) {
continue;
}
$port = self::parseInt($parts[1]);
if ($port === null || $port < 1 || $port > 65535) {
$errors[] = "CACHE_SERVERS entry '{$server}' has invalid port";
}
}
}
$storagePath = self::value($env, 'APP_STORAGE_PATH');
if ($storagePath !== '') {
$absoluteStoragePath = self::absolutePath($storagePath);
if (!is_dir($absoluteStoragePath)) {
$errors[] = "APP_STORAGE_PATH does not exist: '{$storagePath}'";
} elseif (!is_writable($absoluteStoragePath)) {
$errors[] = "APP_STORAGE_PATH is not writable: '{$storagePath}'";
}
}
$cryptoKey = self::value($env, 'APP_CRYPTO_KEY');
if ($cryptoKey !== '' && !self::isValidCryptoKey($cryptoKey)) {
$errors[] = 'APP_CRYPTO_KEY must be 64-char hex or base64 encoded 32-byte key';
}
$appUrl = self::value($env, 'APP_URL');
$appUrlIsValid = true;
if ($appUrl !== '' && !self::isValidHttpUrl($appUrl)) {
$errors[] = "APP_URL is invalid: '{$appUrl}'";
$appUrlIsValid = false;
}
if (in_array($appEnv, ['dev', 'prod'], true) && $appUrl === '') {
if ($appEnv === 'prod') {
$errors[] = 'APP_URL must be set in production';
} else {
$errors[] = 'APP_URL must be set in development';
}
}
2026-03-06 00:44:52 +01:00
// Production-only hard requirements: HTTPS, real hostname, crypto key, debug off, strict tenant scope.
2026-03-04 15:56:58 +01:00
if ($appEnv === 'prod') {
if ($appUrl !== '' && $appUrlIsValid) {
$appUrlScheme = strtolower((string) parse_url($appUrl, PHP_URL_SCHEME));
if ($appUrlScheme !== 'https') {
$errors[] = 'APP_URL must use https in production';
}
$appUrlHost = strtolower((string) parse_url($appUrl, PHP_URL_HOST));
if (self::isLocalOrIpHost($appUrlHost)) {
$errors[] = 'APP_URL must not use localhost or an IP host in production';
}
}
if ($cryptoKey === '') {
$errors[] = 'APP_CRYPTO_KEY must be set in production';
}
if (self::parseBool($appDebug) !== false) {
$errors[] = 'APP_DEBUG must be false in production';
}
if (self::parseBool($tenantScopeStrict) !== true) {
$errors[] = 'TENANT_SCOPE_STRICT must be true in production';
}
}
return $errors;
}
/**
* @param array<string, mixed> $env
*/
private static function hasEnvKey(array $env, string $key): bool
{
return array_key_exists($key, $env) && $env[$key] !== false && $env[$key] !== null;
}
/**
* @param array<string, mixed> $env
*/
private static function value(array $env, string $key): string
{
if (!self::hasEnvKey($env, $key)) {
return '';
}
return trim((string) $env[$key]);
}
private static function parseBool(string $value): ?bool
{
$parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
return $parsed;
}
private static function parseInt(string $value): ?int
{
if ($value === '') {
return null;
}
$parsed = filter_var($value, FILTER_VALIDATE_INT);
return $parsed !== false ? (int) $parsed : null;
}
private static function parseFloat(string $value): ?float
{
if ($value === '') {
return null;
}
$parsed = filter_var($value, FILTER_VALIDATE_FLOAT);
return $parsed !== false ? (float) $parsed : null;
}
/**
* @return list<string>
*/
private static function parseCsv(string $value): array
{
if ($value === '') {
return [];
}
return array_values(array_filter(array_map('trim', explode(',', $value))));
}
2026-03-06 00:44:52 +01:00
// Accepts either 64-char hex (32 bytes) or base64-encoded 32 bytes — both produce a 256-bit key.
2026-03-04 15:56:58 +01:00
private static function isValidCryptoKey(string $value): bool
{
if (preg_match('/^[a-f0-9]{64}$/i', $value) === 1) {
return true;
}
$decoded = base64_decode($value, true);
return is_string($decoded) && strlen($decoded) === 32;
}
private static function isValidHttpUrl(string $value): bool
{
if (filter_var($value, FILTER_VALIDATE_URL) === false) {
return false;
}
$scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
return in_array($scheme, ['http', 'https'], true);
}
private static function isLocalOrIpHost(string $host): bool
{
$host = strtolower(trim($host));
if ($host === '') {
return true;
}
if ($host === 'localhost' || str_ends_with($host, '.localhost')) {
return true;
}
$host = trim($host, '[]');
return filter_var($host, FILTER_VALIDATE_IP) !== false;
}
private static function absolutePath(string $path): string
{
if (self::isAbsolutePath($path)) {
return $path;
}
$projectRoot = dirname(__DIR__, 3);
return $projectRoot . '/' . ltrim($path, '/');
}
private static function isAbsolutePath(string $path): bool
{
if ($path === '') {
return false;
}
if ($path[0] === '/' || $path[0] === '\\') {
return true;
}
return preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1;
}
}