forked from fa/breadcrumb-the-shire
342 lines
11 KiB
PHP
342 lines
11 KiB
PHP
<?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) {
|
|
$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"
|
|
);
|
|
}
|
|
|
|
// Validation is on by default; set APP_CONFIG_VALIDATE=false to skip (e.g. in CI without full env).
|
|
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';
|
|
}
|
|
|
|
$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';
|
|
}
|
|
}
|
|
|
|
// Production-only hard requirements: HTTPS, real hostname, crypto key, debug off, strict tenant scope.
|
|
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))));
|
|
}
|
|
|
|
// Accepts either 64-char hex (32 bytes) or base64-encoded 32 bytes — both produce a 256-bit key.
|
|
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;
|
|
}
|
|
}
|