diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b4d139 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bot-api/ diff --git a/assets/css/admin.css b/assets/css/admin.css index 390c707..3f632e9 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -308,3 +308,60 @@ input:checked + .itk-slider:before { transform: translateX(20px); } .itk-toggle-saving { opacity: .6; pointer-events: none; } .itk-toggle-saved { color: #00a32a; font-size: 11px; } .itk-toggle-error { color: #b32d2e; font-size: 11px; } + +/* ── Central API card ─────────────────────────────────────── */ +.itk-api-card { grid-column: 1 / -1; } +.itk-api-desc { margin: -8px 0 14px; color: #646970; } + +.itk-api-status-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 14px; + padding: 8px 12px; + background: #f6f7f7; + border: 1px solid #e5e5e5; + border-radius: 4px; +} +.itk-api-badge { + display: inline-block; + padding: 3px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 700; + letter-spacing: .5px; + text-transform: uppercase; +} +.itk-api-ok { background: #d7f7e0; color: #1a7a3a; border: 1px solid #9de0b4; } +.itk-api-err { background: #ffecec; color: #b32d2e; border: 1px solid #f7c5c5; } +.itk-api-unknown { background: #f0f0f1; color: #646970; border: 1px solid #c3c4c7; } +.itk-api-time { font-size: 11px; color: #646970; } +.itk-api-err-msg { font-size: 11px; color: #b32d2e; font-style: italic; } + +.itk-api-notice { + padding: 8px 12px; + border-radius: 4px; + font-size: 13px; + margin-bottom: 12px; +} +.itk-api-notice-ok { background: #d7f7e0; border: 1px solid #9de0b4; color: #1a7a3a; } +.itk-api-notice-err { background: #ffecec; border: 1px solid #f7c5c5; color: #b32d2e; } + +.itk-api-table { margin-top: 0 !important; } +.itk-api-table th { width: 130px; } + +.itk-api-form-actions { margin-top: 4px; display: flex; align-items: center; flex-wrap: wrap; gap: 6px; } + +.itk-api-footer { + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid #e5e5e5; + display: flex; + flex-wrap: wrap; + gap: 12px 30px; + align-items: flex-start; +} +.itk-api-queue-row, +.itk-api-history-row { font-size: 13px; display: flex; align-items: center; flex-wrap: wrap; gap: 6px; } +.itk-api-rem { color: #646970; font-size: 12px; } diff --git a/assets/js/admin.js b/assets/js/admin.js index 2baa6dd..b4ca7c8 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -43,6 +43,64 @@ setTimeout(function () { $fb.fadeOut(400, function () { $(this).remove(); }); }, 2000); } + /* ── API: Test connection ─────────────────────────────────── */ + $(document).on('click', '.itk-btn-test-api', function () { + var $btn = $(this); + var api = $btn.data('api'); + var $result = $btn.closest('form').find('.itk-api-ajax-result'); + + $btn.prop('disabled', true).text('Testing…'); + $result.hide(); + + $.post(itkAdmin.ajaxUrl, { + action: 'itk_test_api', + nonce: itkAdmin.nonce, + api: api + }) + .done(function (res) { + var ok = res && res.ok; + var msg = (res && res.message) ? res.message : (ok ? 'Connected.' : 'Test failed.'); + $result.text(msg).css('color', ok ? '#00a32a' : '#b32d2e').show(); + setTimeout(function () { $result.fadeOut(); }, 5000); + }) + .fail(function () { + $result.text('Request failed.').css('color', '#b32d2e').show(); + setTimeout(function () { $result.fadeOut(); }, 4000); + }) + .always(function () { + $btn.prop('disabled', false).text('Test Connection'); + }); + }); + + /* ── API: Flush queue ─────────────────────────────────────── */ + $(document).on('click', '.itk-btn-flush-api', function () { + var $btn = $(this); + var api = $btn.data('api'); + var $result = $btn.siblings('.itk-api-flush-result'); + + $btn.prop('disabled', true).text('Flushing…'); + $result.hide(); + + $.post(itkAdmin.ajaxUrl, { + action: 'itk_flush_api_queue', + nonce: itkAdmin.nonce, + api: api + }) + .done(function (res) { + var ok = res && res.success; + var msg = ok ? 'Queue flushed.' : 'Flush failed.'; + $result.text(msg).css('color', ok ? '#00a32a' : '#b32d2e').show(); + setTimeout(function () { $result.fadeOut(); }, 3000); + }) + .fail(function () { + $result.text('Request failed.').css('color', '#b32d2e').show(); + setTimeout(function () { $result.fadeOut(); }, 3000); + }) + .always(function () { + $btn.prop('disabled', false).text('Flush Now'); + }); + }); + /* ── Config file editor (AJAX) ────────────────────────────── */ $('#itk-save-config').on('click', function (e) { e.preventDefault(); diff --git a/includes/class-itk-admin.php b/includes/class-itk-admin.php index f43bcc4..aa0d861 100644 --- a/includes/class-itk-admin.php +++ b/includes/class-itk-admin.php @@ -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 { + + +
+

Central Bot API

+

Send blocked-bot events to your self-hosted Bot Intelligence Docker stack (port 3001).

+ +
+ + 0): ?> + Last tested ago + + + + +
+ + +
+ + +
+ + +
+ + + + + + + + + + + + + + + +
Enable
API URL + +

Base URL of your Bot API stack (e.g. http://localhost:3001)

+
API Token + +
+
+ + + +
+
+ + +
+ + +
+

Central Honeypot API

+

Send honeypot catch events to your self-hosted Honeypot Intelligence Docker stack (port 3000).

+ +
+ + 0): ?> + Last tested ago + + + + +
+ + +
+ + +
+ + +
+ + + + + + + + + + + + + + + +
Enable
API URL + +

Base URL of your Honeypot API stack (e.g. http://localhost:3000)

+
API Token + +
+
+ + + +
+
+ + +
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), + ]; + } +} diff --git a/includes/class-itk-bot-blocker.php b/includes/class-itk-bot-blocker.php index 765df61..2489c73 100644 --- a/includes/class-itk-bot-blocker.php +++ b/includes/class-itk-bot-blocker.php @@ -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'; diff --git a/includes/class-itk-honeypot.php b/includes/class-itk-honeypot.php index 45fd11a..5a5c876 100644 --- a/includes/class-itk-honeypot.php +++ b/includes/class-itk-honeypot.php @@ -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 { diff --git a/includes/class-itk-hp-api.php b/includes/class-itk-hp-api.php new file mode 100644 index 0000000..7946b1f --- /dev/null +++ b/includes/class-itk-hp-api.php @@ -0,0 +1,243 @@ + 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), + ]; + } +} diff --git a/informatiq-toolkit.php b/informatiq-toolkit.php index 2ecec60..df9d611 100644 --- a/informatiq-toolkit.php +++ b/informatiq-toolkit.php @@ -21,6 +21,8 @@ define('ITK_URL', plugin_dir_url(__FILE__)); define('ITK_BASENAME', plugin_basename(__FILE__)); require_once ITK_PATH . 'includes/class-itk-database.php'; +require_once ITK_PATH . 'includes/class-itk-hp-api.php'; +require_once ITK_PATH . 'includes/class-itk-bot-api.php'; require_once ITK_PATH . 'includes/class-itk-bot-blocker.php'; require_once ITK_PATH . 'includes/class-itk-protection.php'; require_once ITK_PATH . 'includes/class-itk-optimization.php'; @@ -39,6 +41,18 @@ class InformatiQ_Toolkit { } private function __construct() { + // Register custom cron interval (5 minutes) + add_filter('cron_schedules', function ($schedules) { + if (!isset($schedules['itk_5min'])) { + $schedules['itk_5min'] = ['interval' => 300, 'display' => 'Every 5 Minutes']; + } + return $schedules; + }); + + // Boot API cron flushers + ITK_HP_API::register_cron(); + ITK_Bot_API::register_cron(); + new ITK_Bot_Blocker(); new ITK_Protection(); new ITK_Optimization(); @@ -137,6 +151,8 @@ class InformatiQ_Toolkit { } public static function deactivate() { + ITK_HP_API::clear_cron(); + ITK_Bot_API::clear_cron(); flush_rewrite_rules(); } }