prepare( "SELECT SUM(attempted_at > DATE_SUB(NOW(), INTERVAL 1 MINUTE)) AS minute, SUM(attempted_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)) AS hour FROM auth_attempts WHERE ip = ? AND scope = ? AND attempted_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)" ); $stmt->execute([$key ?? _throttle_ip(), $scope]); $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: ['minute' => 0, 'hour' => 0]; } catch (PDOException $e) { // Fail open if the table is missing (migration not applied yet during a // deploy). Log loudly so an operator notices. error_log('[throttle] check failed (run migration_20260913_local_auth.sql?): ' . $e->getMessage()); return true; } if ((int) $row['minute'] >= $maxPerMinute) return false; if ((int) $row['hour'] >= $maxPerHour) return false; return true; } function throttle_record(PDO $pdo, string $scope, ?string $key = null): void { try { $pdo->prepare("INSERT INTO auth_attempts (ip, scope) VALUES (?, ?)") ->execute([$key ?? _throttle_ip(), $scope]); // Probabilistic cleanup so the table doesn't grow forever — cheap on a // 1% sample of writes, and saves wiring up a cron for a small install. if (random_int(0, 99) === 0) { @$pdo->exec("DELETE FROM auth_attempts WHERE attempted_at < DATE_SUB(NOW(), INTERVAL 1 DAY)"); } } catch (PDOException $e) { error_log('[throttle] record failed (run migration_20260913_local_auth.sql?): ' . $e->getMessage()); } } // Convenience: 429 + generic message, then exit. Callers that would rather keep // the visitor on the form can use throttle_check() and render their own error. function throttle_block(string $reason = ''): void { http_response_code(429); header('Retry-After: 60'); header('Content-Type: text/html; charset=utf-8'); echo '
Aguarde alguns minutos antes de tentar novamente.
' . '