Files

545 lines
17 KiB
PHP

<?php
declare(strict_types=1);
/**
* Shared configuration, storage and abuse-protection helpers.
*
* Secrets are read from process variables or from ../.env. The legacy
* env_vars.php file is loaded when present so existing installations keep
* working during migration.
*/
$appFileEnvironment = [];
function app_load_env_file(string $filename): void
{
global $appFileEnvironment;
if (!is_file($filename)) {
return;
}
if (!is_readable($filename)) {
error_log('Environment file is not readable: ' . $filename);
return;
}
$size = filesize($filename);
if ($size !== false && $size > 65536) {
error_log('Environment file is unexpectedly large: ' . $filename);
return;
}
$lines = file($filename, FILE_IGNORE_NEW_LINES);
if ($lines === false) {
error_log('Environment file could not be read: ' . $filename);
return;
}
foreach ($lines as $lineNumber => $line) {
if ($lineNumber === 0) {
$line = preg_replace('/^\xEF\xBB\xBF/', '', $line) ?? $line;
}
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
if (strncmp($line, 'export ', 7) === 0) {
$line = ltrim(substr($line, 7));
}
$separator = strpos($line, '=');
if ($separator === false) {
error_log('Ignoring invalid environment entry on line ' . ($lineNumber + 1) . '.');
continue;
}
$name = trim(substr($line, 0, $separator));
if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name) !== 1) {
error_log('Ignoring invalid environment variable name on line ' . ($lineNumber + 1) . '.');
continue;
}
// Non-empty process variables and legacy configuration take precedence.
$processValue = getenv($name);
if ($processValue !== false && trim((string)$processValue) !== '') {
continue;
}
$value = trim(substr($line, $separator + 1));
$valueLength = strlen($value);
if ($valueLength >= 2 && $value[0] === '"' && $value[$valueLength - 1] === '"') {
$value = stripcslashes(substr($value, 1, -1));
} elseif ($valueLength >= 2 && $value[0] === "'" && $value[$valueLength - 1] === "'") {
$value = substr($value, 1, -1);
} else {
$value = preg_replace('/\s+#.*$/', '', $value) ?? $value;
$value = rtrim($value);
}
if (strpos($value, "\0") !== false) {
error_log('Ignoring environment value containing a null byte on line ' . ($lineNumber + 1) . '.');
continue;
}
$appFileEnvironment[$name] = $value;
$_ENV[$name] = $value;
}
}
$legacyEnvFile = __DIR__ . '/env_vars.php';
if (is_file($legacyEnvFile)) {
require_once $legacyEnvFile;
}
app_load_env_file(dirname(__DIR__) . DIRECTORY_SEPARATOR . '.env');
function app_env(string $name, string $default = ''): string
{
global $appFileEnvironment;
$value = getenv($name);
if ($value !== false && trim((string)$value) !== '') {
return trim((string)$value);
}
if (array_key_exists($name, $appFileEnvironment)) {
return trim((string)$appFileEnvironment[$name]);
}
return $default;
}
function app_env_int(string $name, int $default, int $min, int $max): int
{
$value = filter_var(app_env($name), FILTER_VALIDATE_INT);
if ($value === false) {
return $default;
}
return max($min, min($max, (int)$value));
}
function app_private_dir(): string
{
$configured = app_env('ZEFIX_PRIVATE_DIR');
return $configured !== ''
? rtrim($configured, '/\\')
: dirname(__DIR__) . DIRECTORY_SEPARATOR . 'zefix-private';
}
function app_ensure_dir(string $directory): void
{
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
throw new RuntimeException('Private storage could not be created.');
}
}
function app_task_dir(): string
{
$directory = app_private_dir() . DIRECTORY_SEPARATOR . 'tasks';
app_ensure_dir($directory);
return $directory;
}
function app_download_dir(): string
{
$directory = app_private_dir() . DIRECTORY_SEPARATOR . 'downloads';
app_ensure_dir($directory);
return $directory;
}
function app_rate_limit_dir(): string
{
$directory = app_private_dir() . DIRECTORY_SEPARATOR . 'rate-limits';
app_ensure_dir($directory);
return $directory;
}
function app_cache_dir(): string
{
$directory = app_private_dir() . DIRECTORY_SEPARATOR . 'cache';
app_ensure_dir($directory);
return $directory;
}
function app_send_security_headers(): void
{
if (headers_sent()) {
return;
}
header("Content-Security-Policy: default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; connect-src 'self' https://challenges.cloudflare.com; upgrade-insecure-requests");
header('Referrer-Policy: strict-origin-when-cross-origin');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()');
header('Cross-Origin-Opener-Policy: same-origin');
}
function app_turnstile_site_key(): string
{
return '0x4AAAAAAD7CJrWpNiK0-A7h';
}
function app_turnstile_secret_key(): string
{
return app_env('TURNSTILE_SECRET');
}
function app_turnstile_is_configured(): bool
{
return app_turnstile_site_key() !== '' && app_turnstile_secret_key() !== '';
}
function app_security_secret(): string
{
$secret = app_env('APP_SECRET');
if ($secret === '') {
$secret = app_turnstile_secret_key();
}
return $secret;
}
function app_base64url_encode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
function app_base64url_decode(string $value): string|false
{
$padding = strlen($value) % 4;
if ($padding !== 0) {
$value .= str_repeat('=', 4 - $padding);
}
return base64_decode(strtr($value, '-_', '+/'), true);
}
function app_create_form_started_token(?int $timestamp = null): string
{
$secret = app_security_secret();
if ($secret === '') {
return '';
}
$timestamp ??= time();
$payload = (string)$timestamp;
$signature = hash_hmac('sha256', $payload, $secret, true);
return app_base64url_encode($payload . '.' . $signature);
}
function app_validate_form_started_token(string $token): bool
{
$secret = app_security_secret();
$decoded = app_base64url_decode($token);
if ($secret === '' || $decoded === false || strlen($decoded) < 34) {
return false;
}
$separator = strpos($decoded, '.');
if ($separator === false) {
return false;
}
$payload = substr($decoded, 0, $separator);
$signature = substr($decoded, $separator + 1);
if (!ctype_digit($payload) || strlen($signature) !== 32) {
return false;
}
$expected = hash_hmac('sha256', $payload, $secret, true);
if (!hash_equals($expected, $signature)) {
return false;
}
$age = time() - (int)$payload;
$minimumSeconds = app_env_int('FORM_MIN_SECONDS', 2, 0, 60);
$maximumSeconds = app_env_int('FORM_MAX_SECONDS', 7200, 60, 86400);
return $age >= $minimumSeconds && $age <= $maximumSeconds;
}
function app_client_ip(): string
{
$remoteAddress = $_SERVER['REMOTE_ADDR'] ?? '';
if (!filter_var($remoteAddress, FILTER_VALIDATE_IP)) {
$remoteAddress = 'unknown';
}
$trustedProxies = array_values(array_filter(array_map(
'trim',
explode(',', app_env('TRUSTED_PROXY_IPS'))
)));
if (!in_array($remoteAddress, $trustedProxies, true)) {
return $remoteAddress;
}
$candidates = [];
if (isset($_SERVER['HTTP_CF_CONNECTING_IP'])) {
$candidates[] = trim((string)$_SERVER['HTTP_CF_CONNECTING_IP']);
}
if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$forwarded = explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']);
$candidates[] = trim($forwarded[0]);
}
foreach ($candidates as $candidate) {
if (filter_var($candidate, FILTER_VALIDATE_IP)) {
return $candidate;
}
}
return $remoteAddress;
}
/** @return array{allowed: bool, retry_after: int} */
function app_rate_limit(string $scope, string $identity, int $limit, int $windowSeconds): array
{
$now = time();
$secret = app_security_secret();
$hash = $secret !== ''
? hash_hmac('sha256', strtolower($identity), $secret)
: hash('sha256', strtolower($identity));
$filename = app_rate_limit_dir() . DIRECTORY_SEPARATOR . preg_replace('/[^a-z0-9_-]/i', '_', $scope) . '-' . $hash . '.json';
$handle = fopen($filename, 'c+');
if ($handle === false) {
throw new RuntimeException('Rate-limit storage could not be opened.');
}
try {
if (!flock($handle, LOCK_EX)) {
throw new RuntimeException('Rate-limit storage could not be locked.');
}
$contents = stream_get_contents($handle);
$state = $contents !== false && $contents !== '' ? json_decode($contents, true) : null;
if (!is_array($state) || !isset($state['started'], $state['count']) || ($now - (int)$state['started']) >= $windowSeconds) {
$state = ['started' => $now, 'count' => 0];
}
$allowed = (int)$state['count'] < $limit;
if ($allowed) {
$state['count'] = (int)$state['count'] + 1;
}
rewind($handle);
ftruncate($handle, 0);
fwrite($handle, json_encode($state, JSON_THROW_ON_ERROR));
fflush($handle);
@chmod($filename, 0600);
flock($handle, LOCK_UN);
return [
'allowed' => $allowed,
'retry_after' => max(1, $windowSeconds - ($now - (int)$state['started'])),
];
} finally {
fclose($handle);
}
}
/** @return array{success: bool, error: string} */
function app_verify_turnstile(string $token, string $remoteIp): array
{
$secret = app_turnstile_secret_key();
if ($secret === '' || $token === '' || strlen($token) > 2048) {
return ['success' => false, 'error' => 'missing-input'];
}
$curl = curl_init('https://challenges.cloudflare.com/turnstile/v0/siteverify');
if ($curl === false) {
return ['success' => false, 'error' => 'internal-error'];
}
$payload = [
'secret' => $secret,
'response' => $token,
];
if ($remoteIp !== 'unknown') {
$payload['remoteip'] = $remoteIp;
}
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);
$response = curl_exec($curl);
$httpCode = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
$curlError = curl_error($curl);
curl_close($curl);
if (!is_string($response) || $httpCode !== 200) {
error_log('Turnstile request failed: HTTP ' . $httpCode . ($curlError !== '' ? ' / ' . $curlError : ''));
return ['success' => false, 'error' => 'verification-unavailable'];
}
$result = json_decode($response, true);
if (!is_array($result) || ($result['success'] ?? false) !== true) {
$errors = is_array($result['error-codes'] ?? null) ? implode(',', $result['error-codes']) : 'invalid-response';
error_log('Turnstile validation rejected: ' . $errors);
return ['success' => false, 'error' => 'challenge-failed'];
}
$officialTestSecrets = [
'1x0000000000000000000000000000000AA',
'2x0000000000000000000000000000000AA',
'3x0000000000000000000000000000000AA',
];
$isLocalDevelopmentTest = app_env('APP_ENV') === 'development'
&& in_array($remoteIp, ['127.0.0.1', '::1'], true)
&& in_array($secret, $officialTestSecrets, true);
// Cloudflare's dummy testing responses do not always contain the widget's
// hostname and action. This exception is restricted to loopback requests,
// development mode and Cloudflare's documented test secrets.
if ($isLocalDevelopmentTest) {
return ['success' => true, 'error' => ''];
}
$allowedHostname = strtolower(app_env('TURNSTILE_ALLOWED_HOSTNAME', 'zefix.silias.ch'));
$hostname = strtolower((string)($result['hostname'] ?? ''));
if ($allowedHostname !== '' && !hash_equals($allowedHostname, $hostname)) {
error_log('Turnstile hostname mismatch.');
return ['success' => false, 'error' => 'hostname-mismatch'];
}
if (($result['action'] ?? '') !== 'turnstile-spin-v2') {
error_log('Turnstile action mismatch.');
return ['success' => false, 'error' => 'action-mismatch'];
}
return ['success' => true, 'error' => ''];
}
/** @return int[]|null */
function app_normalize_positive_id_list(mixed $value, int $maximumItems): ?array
{
if (!is_array($value) || $value === [] || count($value) > $maximumItems) {
return null;
}
$normalized = [];
foreach ($value as $item) {
if (is_array($item) || !is_scalar($item)) {
return null;
}
$item = (string)$item;
if (!ctype_digit($item)) {
return null;
}
$integer = (int)$item;
if ($integer <= 0) {
return null;
}
$normalized[$integer] = $integer;
}
return array_values($normalized);
}
function app_public_base_url(): string
{
return rtrim(app_env('PUBLIC_BASE_URL', 'https://zefix.silias.ch'), '/');
}
function app_download_path(string $token): string
{
if (!preg_match('/^[a-f0-9]{32}$/', $token)) {
throw new InvalidArgumentException('Invalid download token.');
}
return app_download_dir() . DIRECTORY_SEPARATOR . $token . '.csv';
}
function app_cached_string(string $key, int $ttlSeconds, callable $loader): string
{
$safeKey = preg_replace('/[^a-z0-9_-]/i', '_', $key);
$filename = app_cache_dir() . DIRECTORY_SEPARATOR . $safeKey . '.cache';
if (is_file($filename) && filemtime($filename) !== false && filemtime($filename) >= time() - $ttlSeconds) {
$cached = file_get_contents($filename);
if (is_string($cached) && $cached !== '') {
return $cached;
}
}
$lock = fopen($filename . '.lock', 'c+');
if ($lock === false || !flock($lock, LOCK_EX)) {
if (is_resource($lock)) {
fclose($lock);
}
return (string)$loader();
}
try {
clearstatcache(true, $filename);
if (is_file($filename) && filemtime($filename) !== false && filemtime($filename) >= time() - $ttlSeconds) {
return (string)file_get_contents($filename);
}
$fresh = (string)$loader();
if ($fresh !== '') {
$temporary = $filename . '.' . bin2hex(random_bytes(4)) . '.tmp';
file_put_contents($temporary, $fresh, LOCK_EX);
@chmod($temporary, 0600);
if (!@rename($temporary, $filename)) {
// Windows cannot atomically replace an existing file with rename().
@unlink($filename);
if (!@rename($temporary, $filename)) {
@unlink($temporary);
}
}
return $fresh;
}
return is_file($filename) ? (string)file_get_contents($filename) : '';
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
function app_cleanup_directory(string $directory, int $maximumAgeSeconds, string $extension): int
{
if (!is_dir($directory)) {
return 0;
}
$deleted = 0;
$cutoff = time() - $maximumAgeSeconds;
foreach (glob($directory . DIRECTORY_SEPARATOR . '*.' . $extension) ?: [] as $filename) {
$modified = filemtime($filename);
if ($modified !== false && $modified < $cutoff && is_file($filename) && unlink($filename)) {
$deleted++;
}
}
return $deleted;
}
function app_migrate_legacy_tasks(): int
{
$legacyDirectory = __DIR__ . DIRECTORY_SEPARATOR . 'tasks';
if (!is_dir($legacyDirectory)) {
return 0;
}
$migrated = 0;
foreach (glob($legacyDirectory . DIRECTORY_SEPARATOR . '*.json') ?: [] as $source) {
$target = app_task_dir() . DIRECTORY_SEPARATOR . basename($source);
if (is_file($target)) {
continue;
}
if (@rename($source, $target) || (@copy($source, $target) && @unlink($source))) {
@chmod($target, 0600);
$migrated++;
}
}
return $migrated;
}