[], 'locked_until' => 0]; $lockedUntil = (int)($entry['locked_until'] ?? 0); if ($lockedUntil > $now) { return [false, $lockedUntil - $now]; } $attempts = array_values(array_filter( (array)($entry['attempts'] ?? []), static fn($t) => is_int($t) && $t > $now - $window )); if (count($attempts) >= $max) { $entry['locked_until'] = $now + $lockout; $entry['attempts'] = $attempts; $data[$key] = $entry; ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE)); fflush($fp); return [false, $lockout]; } return [true, 0]; } finally { flock($fp, LOCK_UN); fclose($fp); } } function qdb_login_rate_limit_record_failure(string $username, ?array $settings = null): int { $settings ??= qdb_settings_get(); $max = (int)$settings['login_max_attempts']; $window = (int)$settings['login_window_seconds']; $lockout = (int)$settings['login_lockout_seconds']; $now = time(); $key = qdb_login_rate_limit_key($username); $path = qdb_login_rate_limit_path(); $dir = dirname($path); if (!is_dir($dir)) { @mkdir($dir, 0755, true); } $fp = @fopen($path, 'c+'); if ($fp === false) { return 0; } try { if (!flock($fp, LOCK_EX)) { return 0; } $raw = stream_get_contents($fp); $data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : []; if (!is_array($data)) { $data = []; } $entry = $data[$key] ?? ['attempts' => [], 'locked_until' => 0]; $attempts = array_values(array_filter( (array)($entry['attempts'] ?? []), static fn($t) => is_int($t) && $t > $now - $window )); $attempts[] = $now; $retry = 0; if (count($attempts) >= $max) { $entry['locked_until'] = $now + $lockout; $retry = $lockout; } $entry['attempts'] = $attempts; $data[$key] = $entry; ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE)); fflush($fp); return $retry; } finally { flock($fp, LOCK_UN); fclose($fp); } } function qdb_login_rate_limit_clear(string $username): void { $key = qdb_login_rate_limit_key($username); $path = qdb_login_rate_limit_path(); if (!is_file($path)) { return; } $fp = @fopen($path, 'c+'); if ($fp === false) { return; } try { if (!flock($fp, LOCK_EX)) { return; } $raw = stream_get_contents($fp); $data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : []; if (!is_array($data) || !isset($data[$key])) { return; } unset($data[$key]); ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE)); fflush($fp); } finally { flock($fp, LOCK_UN); fclose($fp); } } function qdb_login_rate_limit_deny(int $retryAfterSeconds): never { if ($retryAfterSeconds > 0) { header('Retry-After: ' . (string)$retryAfterSeconds); } json_error( 'RATE_LIMITED', 'Too many login attempts. Please wait before trying again.', 429 ); }