feat: add Central API clients, bot rate limiting, and admin API UI

- Add ITK_HP_API and ITK_Bot_API static classes with queue/flush/cron
- Add WP-Cron (5 min) + shutdown flush for both API queues
- Bot Blocker and Honeypot now queue events to their respective APIs
- Admin: Bot Blocker tab gains Central Bot API settings panel
  (enable, URL, token, test connection, flush queue, historical sync)
- Admin: Honeypot tab gains Central Honeypot API settings panel
- Admin JS: AJAX handlers for Test Connection and Flush Now buttons
- Admin CSS: API card styles (status badge, notices, footer controls)
- Add .gitignore (excludes bot-api/ which lives in CloudHost/bot-api)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 18:32:27 +02:00
parent 6d4349ff7b
commit a8d7972ad7
9 changed files with 906 additions and 8 deletions

View File

@@ -0,0 +1,243 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* ITK Honeypot Central API Client
*
* Queues honeypot block events locally and batch-submits them to the
* central Honeypot API (HoneypotFields Docker stack).
*
* Ported from SmartHoneypotAPIClient (HoneypotFields v2.4.0).
*/
class ITK_HP_API {
const OPT_SETTINGS = 'itk_hp_api_settings';
const OPT_QUEUE = 'itk_hp_api_queue';
const CRON_HOOK = 'itk_hp_api_flush';
const QUEUE_MAX = 500;
const BATCH_SIZE = 50;
/* ── Bootstrap ────────────────────────────────────────────── */
public static function register_cron(): void {
if (!wp_next_scheduled(self::CRON_HOOK)) {
wp_schedule_event(time(), 'itk_5min', self::CRON_HOOK);
}
add_action(self::CRON_HOOK, [self::class, 'flush']);
add_action('shutdown', [self::class, 'flush_shutdown']);
}
public static function clear_cron(): void {
wp_clear_scheduled_hook(self::CRON_HOOK);
}
/* ── Settings ─────────────────────────────────────────────── */
public static function defaults(): array {
return [
'enabled' => false,
'api_url' => '',
'api_token' => '',
'last_sync' => 0,
'sent_total' => 0,
'connection_ok' => null,
'last_verified' => 0,
'last_error' => '',
];
}
public static function settings(): array {
return array_merge(self::defaults(), (array)get_option(self::OPT_SETTINGS, []));
}
private static function token(): string {
if (defined('ITK_HP_API_TOKEN') && ITK_HP_API_TOKEN !== '') {
return (string)ITK_HP_API_TOKEN;
}
return self::settings()['api_token'] ?? '';
}
/* ── Queue ────────────────────────────────────────────────── */
/**
* Queue one honeypot block event.
* Called from ITK_Honeypot::log_block() when API is enabled.
*/
public static function queue(array $event): void {
$s = self::settings();
if (empty($s['enabled']) || empty($s['api_url'])) return;
$queue = (array)get_option(self::OPT_QUEUE, []);
if (count($queue) >= self::QUEUE_MAX) array_shift($queue);
$queue[] = [
'ip' => sanitize_text_field($event['ip'] ?? ''),
'form_type' => sanitize_text_field($event['form'] ?? 'Unknown'),
'reason' => sanitize_text_field($event['reason'] ?? ''),
'user_agent' => sanitize_textarea_field($event['ua'] ?? ''),
'blocked_at' => current_time('mysql'),
];
update_option(self::OPT_QUEUE, $queue);
// Auto-flush when batch is ready
if (count($queue) >= self::BATCH_SIZE) {
self::flush();
}
}
/* ── Flush ────────────────────────────────────────────────── */
public static function flush(): void {
$s = self::settings();
if (empty($s['enabled']) || empty($s['api_url'])) return;
$queue = (array)get_option(self::OPT_QUEUE, []);
if (empty($queue)) return;
$batch = array_splice($queue, 0, self::BATCH_SIZE);
update_option(self::OPT_QUEUE, $queue); // save remainder
$headers = ['Content-Type' => 'application/json'];
$token = self::token();
if ($token !== '') $headers['Authorization'] = 'Bearer ' . $token;
$response = wp_remote_post(
trailingslashit(esc_url_raw($s['api_url'])) . 'api/v1/submit',
[
'timeout' => 15,
'headers' => $headers,
'body' => wp_json_encode([
'site_hash' => hash('sha256', home_url()),
'blocks' => $batch,
]),
]
);
if (is_wp_error($response)) {
// Re-queue failed batch at the front
$queue = array_merge($batch, $queue);
update_option(self::OPT_QUEUE, $queue);
return;
}
$s['last_sync'] = time();
$s['sent_total'] = ($s['sent_total'] ?? 0) + count($batch);
update_option(self::OPT_SETTINGS, $s);
}
/**
* Emergency flush on PHP shutdown (small queue only).
*/
public static function flush_shutdown(): void {
$queue = (array)get_option(self::OPT_QUEUE, []);
if (count($queue) >= 5) self::flush();
}
/* ── Test connection ──────────────────────────────────────── */
public static function test_connection(): array {
$s = self::settings();
if (empty($s['api_url'])) {
return ['ok' => false, 'message' => 'No API URL configured.'];
}
$base = trailingslashit(esc_url_raw($s['api_url']));
$headers = ['Content-Type' => 'application/json'];
$token = self::token();
if ($token !== '') $headers['Authorization'] = 'Bearer ' . $token;
// Step 1: reachability
$health = wp_remote_get($base . 'api/v1/health', ['timeout' => 8]);
if (is_wp_error($health)) {
return ['ok' => false, 'message' => 'Cannot reach API: ' . $health->get_error_message()];
}
if (wp_remote_retrieve_response_code($health) !== 200) {
return ['ok' => false, 'message' => 'API returned HTTP ' . wp_remote_retrieve_response_code($health)];
}
// Step 2: token check (intentionally bad payload → 400 = auth OK, 403 = wrong token)
$auth = wp_remote_post($base . 'api/v1/submit', [
'timeout' => 8,
'headers' => $headers,
'body' => wp_json_encode(['site_hash' => 'connectivity_test', 'blocks' => []]),
]);
if (is_wp_error($auth)) {
return ['ok' => false, 'message' => 'Token check failed: ' . $auth->get_error_message()];
}
if (wp_remote_retrieve_response_code($auth) === 403) {
return ['ok' => false, 'message' => 'API reachable but token rejected (HTTP 403).'];
}
return ['ok' => true, 'message' => 'Connection verified. API is reachable and token accepted.'];
}
/* ── Send history batch ───────────────────────────────────── */
public static function send_history_batch(int $batch_size = 50): array {
$s = self::settings();
if (empty($s['api_url'])) {
return ['ok' => false, 'message' => 'No API URL configured.'];
}
global $wpdb;
$table = ITK_Database::honeypot_table();
$last_id = (int)get_option('itk_hp_history_last_id', 0);
$total = ITK_Database::count_honeypot_rows();
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$table} WHERE id > %d ORDER BY id ASC LIMIT %d",
$last_id, $batch_size
), ARRAY_A);
if (empty($rows)) {
return ['ok' => true, 'sent' => 0, 'remaining' => 0, 'has_more' => false,
'message' => 'All records have already been sent.'];
}
$blocks = array_map(fn($r) => [
'ip' => $r['ip_address'],
'form_type' => $r['form_type'],
'reason' => $r['reason'],
'user_agent' => $r['user_agent'],
'blocked_at' => $r['blocked_at'],
], $rows);
$headers = ['Content-Type' => 'application/json'];
$token = self::token();
if ($token !== '') $headers['Authorization'] = 'Bearer ' . $token;
$response = wp_remote_post(
trailingslashit(esc_url_raw($s['api_url'])) . 'api/v1/submit',
[
'timeout' => 30,
'headers' => $headers,
'body' => wp_json_encode([
'site_hash' => hash('sha256', home_url()),
'blocks' => $blocks,
]),
]
);
if (is_wp_error($response)) {
return ['ok' => false, 'message' => 'Request failed: ' . $response->get_error_message()];
}
if (wp_remote_retrieve_response_code($response) !== 200) {
return ['ok' => false, 'message' => 'API returned HTTP ' . wp_remote_retrieve_response_code($response)];
}
$new_last = (int)end($rows)['id'];
$sent_total = (int)get_option('itk_hp_history_sent', 0) + count($rows);
update_option('itk_hp_history_last_id', $new_last);
update_option('itk_hp_history_sent', $sent_total);
$remaining = max(0, $total - $sent_total);
return [
'ok' => true,
'sent' => count($rows),
'remaining' => $remaining,
'has_more' => $remaining > 0,
'message' => sprintf('Sent %d records. %d remaining.', count($rows), $remaining),
];
}
}