545 lines
17 KiB
PHP
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;
|
|
}
|