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

@@ -17,8 +17,10 @@ class ITK_Admin {
add_action('admin_menu', [$this, 'add_menu']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
add_action('admin_init', [$this, 'handle_actions']);
add_action('wp_ajax_itk_save_setting', [$this, 'ajax_save_setting']);
add_action('wp_ajax_itk_save_config_file',[$this, 'ajax_save_config_file']);
add_action('wp_ajax_itk_save_setting', [$this, 'ajax_save_setting']);
add_action('wp_ajax_itk_save_config_file', [$this, 'ajax_save_config_file']);
add_action('wp_ajax_itk_test_api', [$this, 'ajax_test_api']);
add_action('wp_ajax_itk_flush_api_queue', [$this, 'ajax_flush_api_queue']);
}
public function add_menu(): void {
@@ -65,6 +67,60 @@ class ITK_Admin {
]);
$this->redirect(['tab' => 'bot-blocker', 'saved' => 1]);
break;
case 'save_bot_api':
$this->save_api_settings(ITK_Bot_API::OPT_SETTINGS, 'itk_bot_api_settings');
$this->redirect(['tab' => 'bot-blocker', 'saved' => 1]);
break;
case 'save_hp_api':
$this->save_api_settings(ITK_HP_API::OPT_SETTINGS, 'itk_hp_api_settings');
$this->redirect(['tab' => 'honeypot', 'saved' => 1]);
break;
case 'test_bot_api':
$result = ITK_Bot_API::test_connection();
$s = ITK_Bot_API::settings();
$s['connection_ok'] = $result['ok']; $s['last_verified'] = time();
$s['last_error'] = $result['ok'] ? '' : $result['message'];
update_option(ITK_Bot_API::OPT_SETTINGS, $s);
set_transient('itk_bot_api_test_result', $result, 60);
$this->redirect(['tab' => 'bot-blocker', 'api_tested' => 1]);
break;
case 'test_hp_api':
$result = ITK_HP_API::test_connection();
$s = ITK_HP_API::settings();
$s['connection_ok'] = $result['ok']; $s['last_verified'] = time();
$s['last_error'] = $result['ok'] ? '' : $result['message'];
update_option(ITK_HP_API::OPT_SETTINGS, $s);
set_transient('itk_hp_api_test_result', $result, 60);
$this->redirect(['tab' => 'honeypot', 'api_tested' => 1]);
break;
case 'flush_bot_api':
ITK_Bot_API::flush();
$this->redirect(['tab' => 'bot-blocker', 'api_flushed' => 1]);
break;
case 'flush_hp_api':
ITK_HP_API::flush();
$this->redirect(['tab' => 'honeypot', 'api_flushed' => 1]);
break;
case 'send_bot_history':
$result = ITK_Bot_API::send_history_batch();
set_transient('itk_bot_history_result', $result, 60);
$this->redirect(['tab' => 'bot-blocker', 'history_sent' => 1]);
break;
case 'send_hp_history':
$result = ITK_HP_API::send_history_batch();
set_transient('itk_hp_history_result', $result, 60);
$this->redirect(['tab' => 'honeypot', 'history_sent' => 1]);
break;
case 'reset_bot_history':
delete_option('itk_bot_history_last_id');
delete_option('itk_bot_history_sent');
$this->redirect(['tab' => 'bot-blocker']);
break;
case 'reset_hp_history':
delete_option('itk_hp_history_last_id');
delete_option('itk_hp_history_sent');
$this->redirect(['tab' => 'honeypot']);
break;
case 'save_settings_login':
$this->save_settings_form('itk_security', [
'custom_login_slug',
@@ -102,6 +158,41 @@ class ITK_Admin {
update_option($option, $opts);
}
private function save_api_settings(string $option_key, string $post_key): void {
$cur = get_option($option_key, []);
$posted = $_POST[$post_key] ?? [];
$new_url = esc_url_raw(trim($posted['api_url'] ?? ''));
$changed = $new_url !== ($cur['api_url'] ?? '') || (!empty($posted['api_token']) && $posted['api_token'] !== ($cur['api_token'] ?? ''));
update_option($option_key, array_merge($cur, [
'enabled' => !empty($posted['enabled']),
'api_url' => $new_url,
'api_token' => sanitize_text_field($posted['api_token'] ?? ($cur['api_token'] ?? '')),
'connection_ok' => $changed ? null : ($cur['connection_ok'] ?? null),
'last_verified' => $changed ? 0 : ($cur['last_verified'] ?? 0),
'last_error' => $changed ? '' : ($cur['last_error'] ?? ''),
]));
}
/* ── AJAX: test API connection ─────────────────────────────── */
public function ajax_test_api(): void {
check_ajax_referer(self::NONCE_ACTION, 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error('unauthorized');
$which = sanitize_key($_POST['api'] ?? '');
$result = $which === 'bot' ? ITK_Bot_API::test_connection() : ITK_HP_API::test_connection();
wp_send_json($result);
}
/* ── AJAX: flush API queue ─────────────────────────────────── */
public function ajax_flush_api_queue(): void {
check_ajax_referer(self::NONCE_ACTION, 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error('unauthorized');
$which = sanitize_key($_POST['api'] ?? '');
if ($which === 'bot') ITK_Bot_API::flush(); else ITK_HP_API::flush();
wp_send_json_success('Queue flushed.');
}
private function redirect(array $args): void {
wp_redirect(add_query_arg(array_merge(['page' => self::MENU_SLUG], $args), admin_url('options-general.php')));
exit;
@@ -423,6 +514,98 @@ class ITK_Admin {
<?php submit_button('Save Response Settings'); ?>
</form>
</section>
<?php
/* ── Central Bot API card ─────────────────────── */
$bot_api = ITK_Bot_API::settings();
$bot_queue = count((array) get_option(ITK_Bot_API::OPT_QUEUE, []));
$bot_total = ITK_Database::count_bot_rows();
$bot_sent = (int) get_option('itk_bot_history_sent', 0);
$bot_rem = max(0, $bot_total - $bot_sent);
$bot_ok = $bot_api['connection_ok'];
$bot_cls = is_null($bot_ok) ? 'itk-api-unknown' : ($bot_ok ? 'itk-api-ok' : 'itk-api-err');
$bot_lbl = is_null($bot_ok) ? 'Not tested' : ($bot_ok ? 'Connected' : 'Connection failed');
$bot_test_r = get_transient('itk_bot_api_test_result'); if ($bot_test_r) delete_transient('itk_bot_api_test_result');
$bot_hist_r = get_transient('itk_bot_history_result'); if ($bot_hist_r) delete_transient('itk_bot_history_result');
?>
<section class="itk-card itk-api-card">
<h2>Central Bot API</h2>
<p class="description itk-api-desc">Send blocked-bot events to your self-hosted Bot Intelligence Docker stack (port 3001).</p>
<div class="itk-api-status-bar">
<span class="itk-api-badge <?= esc_attr($bot_cls) ?>"><?= esc_html($bot_lbl) ?></span>
<?php if ($bot_api['last_verified'] > 0): ?>
<span class="itk-api-time">Last tested <?= esc_html(human_time_diff((int)$bot_api['last_verified'])) ?> ago</span>
<?php endif; ?>
<?php if (!$bot_ok && !is_null($bot_ok) && !empty($bot_api['last_error'])): ?>
<span class="itk-api-err-msg"><?= esc_html($bot_api['last_error']) ?></span>
<?php endif; ?>
</div>
<?php if ($bot_test_r): ?>
<div class="itk-api-notice <?= $bot_test_r['ok'] ? 'itk-api-notice-ok' : 'itk-api-notice-err' ?>"><?= esc_html($bot_test_r['message']) ?></div>
<?php endif; ?>
<?php if ($bot_hist_r): ?>
<div class="itk-api-notice <?= $bot_hist_r['ok'] ? 'itk-api-notice-ok' : 'itk-api-notice-err' ?>"><?= esc_html($bot_hist_r['message']) ?></div>
<?php endif; ?>
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=bot-blocker">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="itk_action" value="save_bot_api">
<table class="form-table itk-api-table">
<tr>
<th>Enable</th>
<td><label><input type="checkbox" name="itk_bot_api_settings[enabled]" value="1" <?= checked(!empty($bot_api['enabled'])) ?>> Send events to Central API</label></td>
</tr>
<tr>
<th>API URL</th>
<td>
<input type="url" name="itk_bot_api_settings[api_url]" value="<?= esc_attr($bot_api['api_url'] ?? '') ?>" class="regular-text" placeholder="http://your-server:3001">
<p class="description">Base URL of your Bot API stack (e.g. <code>http://localhost:3001</code>)</p>
</td>
</tr>
<tr>
<th>API Token</th>
<td>
<input type="password" name="itk_bot_api_settings[api_token]" value="" class="regular-text"
placeholder="<?= !empty($bot_api['api_token']) ? '●●●●●●●● (set — leave blank to keep)' : 'Enter bearer token' ?>"
autocomplete="new-password">
</td>
</tr>
</table>
<div class="itk-api-form-actions">
<?php submit_button('Save Bot API Settings', 'primary', 'submit', false); ?>
<button type="button" class="button itk-btn-test-api" data-api="bot" style="margin-left:8px">Test Connection</button>
<span class="itk-api-ajax-result" style="margin-left:10px;display:none"></span>
</div>
</form>
<div class="itk-api-footer">
<div class="itk-api-queue-row">
<strong><?= (int) $bot_queue ?></strong> event(s) pending in queue
<button type="button" class="button button-small itk-btn-flush-api" data-api="bot" style="margin-left:8px">Flush Now</button>
<span class="itk-api-flush-result" style="margin-left:8px;display:none"></span>
</div>
<div class="itk-api-history-row">
<strong>Historical sync:</strong>
<?= number_format($bot_sent) ?> / <?= number_format($bot_total) ?> records sent
<?php if ($bot_rem > 0): ?><em class="itk-api-rem">(<?= number_format($bot_rem) ?> remaining)</em><?php endif; ?>
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=bot-blocker" style="display:inline;margin-left:10px">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="itk_action" value="send_bot_history">
<input type="submit" class="button button-small" value="Send Next 50">
</form>
<?php if ($bot_sent > 0): ?>
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=bot-blocker" style="display:inline;margin-left:6px" onsubmit="return confirm('Reset sync progress? No data is deleted.')">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="itk_action" value="reset_bot_history">
<input type="submit" class="button button-small" value="Reset Progress">
</form>
<?php endif; ?>
</div>
</div>
</section>
</div>
<?php
}
@@ -607,6 +790,98 @@ class ITK_Admin {
<?php submit_button('Save Honeypot Settings'); ?>
</form>
</section>
<?php
/* ── Central Honeypot API card ────────────────── */
$hp_api = ITK_HP_API::settings();
$hp_queue = count((array) get_option(ITK_HP_API::OPT_QUEUE, []));
$hp_total = ITK_Database::count_honeypot_rows();
$hp_sent = (int) get_option('itk_hp_history_sent', 0);
$hp_rem = max(0, $hp_total - $hp_sent);
$hp_ok = $hp_api['connection_ok'];
$hp_cls = is_null($hp_ok) ? 'itk-api-unknown' : ($hp_ok ? 'itk-api-ok' : 'itk-api-err');
$hp_lbl = is_null($hp_ok) ? 'Not tested' : ($hp_ok ? 'Connected' : 'Connection failed');
$hp_test_r = get_transient('itk_hp_api_test_result'); if ($hp_test_r) delete_transient('itk_hp_api_test_result');
$hp_hist_r = get_transient('itk_hp_history_result'); if ($hp_hist_r) delete_transient('itk_hp_history_result');
?>
<section class="itk-card itk-api-card">
<h2>Central Honeypot API</h2>
<p class="description itk-api-desc">Send honeypot catch events to your self-hosted Honeypot Intelligence Docker stack (port 3000).</p>
<div class="itk-api-status-bar">
<span class="itk-api-badge <?= esc_attr($hp_cls) ?>"><?= esc_html($hp_lbl) ?></span>
<?php if ($hp_api['last_verified'] > 0): ?>
<span class="itk-api-time">Last tested <?= esc_html(human_time_diff((int)$hp_api['last_verified'])) ?> ago</span>
<?php endif; ?>
<?php if (!$hp_ok && !is_null($hp_ok) && !empty($hp_api['last_error'])): ?>
<span class="itk-api-err-msg"><?= esc_html($hp_api['last_error']) ?></span>
<?php endif; ?>
</div>
<?php if ($hp_test_r): ?>
<div class="itk-api-notice <?= $hp_test_r['ok'] ? 'itk-api-notice-ok' : 'itk-api-notice-err' ?>"><?= esc_html($hp_test_r['message']) ?></div>
<?php endif; ?>
<?php if ($hp_hist_r): ?>
<div class="itk-api-notice <?= $hp_hist_r['ok'] ? 'itk-api-notice-ok' : 'itk-api-notice-err' ?>"><?= esc_html($hp_hist_r['message']) ?></div>
<?php endif; ?>
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=honeypot">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="itk_action" value="save_hp_api">
<table class="form-table itk-api-table">
<tr>
<th>Enable</th>
<td><label><input type="checkbox" name="itk_hp_api_settings[enabled]" value="1" <?= checked(!empty($hp_api['enabled'])) ?>> Send events to Central API</label></td>
</tr>
<tr>
<th>API URL</th>
<td>
<input type="url" name="itk_hp_api_settings[api_url]" value="<?= esc_attr($hp_api['api_url'] ?? '') ?>" class="regular-text" placeholder="http://your-server:3000">
<p class="description">Base URL of your Honeypot API stack (e.g. <code>http://localhost:3000</code>)</p>
</td>
</tr>
<tr>
<th>API Token</th>
<td>
<input type="password" name="itk_hp_api_settings[api_token]" value="" class="regular-text"
placeholder="<?= !empty($hp_api['api_token']) ? '●●●●●●●● (set — leave blank to keep)' : 'Enter bearer token' ?>"
autocomplete="new-password">
</td>
</tr>
</table>
<div class="itk-api-form-actions">
<?php submit_button('Save Honeypot API Settings', 'primary', 'submit', false); ?>
<button type="button" class="button itk-btn-test-api" data-api="hp" style="margin-left:8px">Test Connection</button>
<span class="itk-api-ajax-result" style="margin-left:10px;display:none"></span>
</div>
</form>
<div class="itk-api-footer">
<div class="itk-api-queue-row">
<strong><?= (int) $hp_queue ?></strong> event(s) pending in queue
<button type="button" class="button button-small itk-btn-flush-api" data-api="hp" style="margin-left:8px">Flush Now</button>
<span class="itk-api-flush-result" style="margin-left:8px;display:none"></span>
</div>
<div class="itk-api-history-row">
<strong>Historical sync:</strong>
<?= number_format($hp_sent) ?> / <?= number_format($hp_total) ?> records sent
<?php if ($hp_rem > 0): ?><em class="itk-api-rem">(<?= number_format($hp_rem) ?> remaining)</em><?php endif; ?>
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=honeypot" style="display:inline;margin-left:10px">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="itk_action" value="send_hp_history">
<input type="submit" class="button button-small" value="Send Next 50">
</form>
<?php if ($hp_sent > 0): ?>
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=honeypot" style="display:inline;margin-left:6px" onsubmit="return confirm('Reset sync progress? No data is deleted.')">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="itk_action" value="reset_hp_history">
<input type="submit" class="button button-small" value="Reset Progress">
</form>
<?php endif; ?>
</div>
</div>
</section>
</div>
<?php
}

View File

@@ -0,0 +1,242 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* ITK Bot Central API Client
*
* Queues bot-block events locally and batch-submits them to the
* central Bot Intelligence API Docker stack.
*
* Mirrors ITK_HP_API but uses the bot-api endpoint and schema:
* { site_hash, bots: [ { ip, bot_type, action, reason, user_agent, request_uri, logged_at } ] }
*/
class ITK_Bot_API {
const OPT_SETTINGS = 'itk_bot_api_settings';
const OPT_QUEUE = 'itk_bot_api_queue';
const CRON_HOOK = 'itk_bot_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_BOT_API_TOKEN') && ITK_BOT_API_TOKEN !== '') {
return (string)ITK_BOT_API_TOKEN;
}
return self::settings()['api_token'] ?? '';
}
/* ── Queue ────────────────────────────────────────────────── */
/**
* Queue one bot event.
* Called from ITK_Bot_Blocker after logging to the local DB.
*/
public static function queue(array $data): 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($data['ip'] ?? ''),
'bot_type' => sanitize_text_field($data['bot_type'] ?? ''),
'action' => sanitize_text_field($data['action'] ?? 'blocked'),
'reason' => sanitize_text_field($data['reason'] ?? ''),
'user_agent' => sanitize_textarea_field($data['ua'] ?? ''),
'request_uri' => sanitize_text_field($data['uri'] ?? ''),
'logged_at' => current_time('mysql'),
];
update_option(self::OPT_QUEUE, $queue);
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);
$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()),
'bots' => $batch,
]),
]
);
if (is_wp_error($response)) {
// Re-queue failed batch
update_option(self::OPT_QUEUE, array_merge($batch, $queue));
return;
}
$s['last_sync'] = time();
$s['sent_total'] = ($s['sent_total'] ?? 0) + count($batch);
update_option(self::OPT_SETTINGS, $s);
}
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;
$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)];
}
// Token check with intentionally bad payload
$auth = wp_remote_post($base . 'api/v1/submit', [
'timeout' => 8,
'headers' => $headers,
'body' => wp_json_encode(['site_hash' => 'connectivity_test', 'bots' => []]),
]);
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::bot_table();
$last_id = (int)get_option('itk_bot_history_last_id', 0);
$total = ITK_Database::count_bot_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.'];
}
$bots = array_map(fn($r) => [
'ip' => $r['ip_address'],
'bot_type' => $r['bot_type'],
'action' => $r['action'],
'reason' => $r['reason'],
'user_agent' => $r['user_agent'],
'request_uri' => $r['request_uri'],
'logged_at' => $r['logged_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()),
'bots' => $bots,
]),
]
);
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_bot_history_sent', 0) + count($rows);
update_option('itk_bot_history_last_id', $new_last);
update_option('itk_bot_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),
];
}
}

View File

@@ -124,7 +124,7 @@ class ITK_Bot_Blocker {
if ($count >= $limit) {
// Over the limit log and send 429.
if (!empty($options['log_blocked_attempts'])) {
ITK_Database::log_bot([
$event = [
'ip' => $ip,
'ua' => $ua,
'referrer' => '',
@@ -132,7 +132,9 @@ class ITK_Bot_Blocker {
'bot_type' => $name,
'reason' => "Rate limited: {$count}/{$limit} req/min",
'action' => 'rate_limited',
]);
];
ITK_Database::log_bot($event);
ITK_Bot_API::queue($event);
}
status_header(429);
header('Retry-After: 60');
@@ -157,7 +159,7 @@ class ITK_Bot_Blocker {
array $options
): void {
if (!empty($options['log_blocked_attempts'])) {
ITK_Database::log_bot([
$event = [
'ip' => $ip,
'ua' => $ua,
'referrer' => $referrer,
@@ -165,7 +167,9 @@ class ITK_Bot_Blocker {
'bot_type' => $bot_type,
'reason' => $reason,
'action' => 'blocked',
]);
];
ITK_Database::log_bot($event);
ITK_Bot_API::queue($event);
}
$code = $options['response_code'] ?? '403';

View File

@@ -174,13 +174,15 @@ class ITK_Honeypot {
}
private function log_block(string $form_type, string $reason): void {
ITK_Database::log_honeypot([
$event = [
'ip' => $this->get_ip(),
'form' => $form_type,
'reason' => $reason,
'uri' => $_SERVER['REQUEST_URI'] ?? '',
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
]);
];
ITK_Database::log_honeypot($event);
ITK_HP_API::queue($event);
}
public function validate_comment(array $comment_data): array {

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),
];
}
}