feat: centralized API dashboard + Docker container

API server (api/):
- Node.js + Express + SQLite (better-sqlite3, WAL mode)
- POST /api/v1/submit — receive blocks from WP sites (rate limited 30/min/IP)
- GET  /api/v1/stats  — public aggregated stats with 30s cache
- GET  /api/v1/stream — SSE live feed, pushed every 2s
- GET  /api/v1/health — health check
- IP masking: only first 2 octets stored (192.168.x.x)
- UA family detection: curl, Python, Go, bots, Chrome, etc.
- docker-compose.yml with named volume for SQLite persistence

Dashboard (api/public/index.html):
- Hacker/terminal aesthetic: black + matrix green, CRT scanlines
- Live stat cards: total blocked, today, 7d, 30d, sites reporting
- Canvas 24h activity trend chart with gradient bars
- CSS bar charts: form types, bot toolkit, block reasons
- Live SSE threat feed with countUp animation and auto-scroll
- Top 10 attackers table with frequency bars
- Polls /api/v1/stats every 6s, SSE for instant feed updates

WordPress plugin (honeypot-fields.php):
- SmartHoneypotAPIClient: queue (WP option) + WP-cron batch flush every 5min
- log_spam() now enqueues to central API after local DB write
- Admin 'Central API' tab: enable toggle, endpoint URL, sync stats, manual flush
- Cron properly registered/deregistered on activate/deactivate
This commit is contained in:
2026-03-09 19:21:41 +01:00
parent a3e38faffa
commit 6740180981
7 changed files with 1328 additions and 306 deletions

View File

@@ -3,7 +3,7 @@
* Plugin Name: Honeypot Fields
* Plugin URI: https://informatiq.services
* Description: Adds invisible honeypot fields to all forms to block spam bots. Works with WordPress core forms, Elementor, Gravity Forms, Contact Form 7, WooCommerce, and more.
* Version: 2.1.0
* Version: 2.2.0
* Author: Malin
* Author URI: https://malin.ro
* License: GPL v2 or later
@@ -19,7 +19,7 @@ if (!defined('ABSPATH')) {
* ====================================================================*/
class SmartHoneypotDB {
const TABLE_VERSION = 1;
const TABLE_VERSION = 1;
const TABLE_VERSION_OPTION = 'hp_db_version';
public static function table(): string {
@@ -29,7 +29,7 @@ class SmartHoneypotDB {
public static function install() {
global $wpdb;
$table = self::table();
$table = self::table();
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
@@ -58,8 +58,8 @@ class SmartHoneypotDB {
self::table(),
[
'blocked_at' => current_time('mysql'),
'ip_address' => sanitize_text_field($data['ip'] ?? ''),
'form_type' => sanitize_text_field($data['form'] ?? 'Unknown'),
'ip_address' => sanitize_text_field($data['ip'] ?? ''),
'form_type' => sanitize_text_field($data['form'] ?? 'Unknown'),
'reason' => sanitize_text_field($data['reason'] ?? ''),
'request_uri' => esc_url_raw(substr($data['uri'] ?? '', 0, 1000)),
'user_agent' => sanitize_textarea_field($data['ua'] ?? ''),
@@ -72,7 +72,7 @@ class SmartHoneypotDB {
global $wpdb;
$table = self::table();
$limit = max(1, intval($args['per_page'] ?? 25));
$offset = max(0, intval($args['offset'] ?? 0));
$offset = max(0, intval($args['offset'] ?? 0));
$where = '1=1';
$params = [];
@@ -97,12 +97,7 @@ class SmartHoneypotDB {
$params[] = $offset;
$sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY blocked_at DESC LIMIT %d OFFSET %d";
if ($params) {
$sql = $wpdb->prepare($sql, $params);
}
return $wpdb->get_results($sql) ?: [];
return $wpdb->get_results($wpdb->prepare($sql, $params)) ?: [];
}
public static function count(array $args = []): int {
@@ -128,10 +123,7 @@ class SmartHoneypotDB {
}
$sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
if ($params) {
$sql = $wpdb->prepare($sql, $params);
}
return (int) $wpdb->get_var($sql);
return (int) $wpdb->get_var($params ? $wpdb->prepare($sql, $params) : $sql);
}
public static function get_form_types(): array {
@@ -139,12 +131,12 @@ class SmartHoneypotDB {
return $wpdb->get_col("SELECT DISTINCT form_type FROM " . self::table() . " ORDER BY form_type ASC") ?: [];
}
public static function clear(): int {
public static function clear(): void {
global $wpdb;
return (int) $wpdb->query("TRUNCATE TABLE " . self::table());
$wpdb->query("TRUNCATE TABLE " . self::table());
}
public static function delete_older_than_days(int $days) {
public static function delete_older_than_days(int $days): void {
global $wpdb;
$wpdb->query(
$wpdb->prepare(
@@ -155,6 +147,85 @@ class SmartHoneypotDB {
}
}
/* ======================================================================
* CENTRAL API CLIENT
* Queues blocked submissions and batch-sends to a central dashboard.
* ====================================================================*/
class SmartHoneypotAPIClient {
const OPT_SETTINGS = 'hp_api_settings';
const OPT_QUEUE = 'hp_api_queue';
const QUEUE_MAX = 500;
const BATCH_SIZE = 50;
public static function defaults(): array {
return [
'enabled' => false,
'api_url' => '',
'last_sync' => 0,
'sent_total' => 0,
];
}
public static function settings(): array {
return wp_parse_args(get_option(self::OPT_SETTINGS, []), self::defaults());
}
/** Called from log_spam() — very fast, just appends to option. */
public static function enqueue(array $data): void {
$s = self::settings();
if (!$s['enabled'] || empty($s['api_url'])) {
return;
}
$queue = (array) get_option(self::OPT_QUEUE, []);
if (count($queue) >= self::QUEUE_MAX) {
array_shift($queue); // drop oldest when full
}
$queue[] = $data;
update_option(self::OPT_QUEUE, $queue, false); // no autoload
}
/** Called by WP-cron every 5 minutes. Sends pending batch to the API. */
public static function flush(): void {
$s = self::settings();
if (!$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);
$site_hash = hash('sha256', home_url());
$response = wp_remote_post(
trailingslashit(esc_url_raw($s['api_url'])) . 'api/v1/submit',
[
'timeout' => 15,
'blocking' => true,
'headers' => ['Content-Type' => 'application/json'],
'body' => wp_json_encode([
'site_hash' => $site_hash,
'blocks' => $batch,
]),
]
);
if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
update_option(self::OPT_QUEUE, $queue, false);
$s['last_sync'] = time();
$s['sent_total'] = ($s['sent_total'] ?? 0) + count($batch);
update_option(self::OPT_SETTINGS, $s);
}
}
/** Number of items currently waiting to be sent. */
public static function queue_size(): int {
return count((array) get_option(self::OPT_QUEUE, []));
}
}
/* ======================================================================
* ADMIN PAGE
* ====================================================================*/
@@ -165,16 +236,15 @@ class SmartHoneypotAdmin {
const PER_PAGE = 25;
public static function register() {
add_action('admin_menu', [self::class, 'add_menu']);
add_action('admin_init', [self::class, 'handle_actions']);
add_action('admin_enqueue_scripts', [self::class, 'enqueue_styles']);
add_action('admin_menu', [self::class, 'add_menu']);
add_action('admin_init', [self::class, 'handle_actions']);
add_action('admin_enqueue_scripts', [self::class, 'enqueue_styles']);
add_filter('plugin_action_links_' . plugin_basename(HP_PLUGIN_FILE), [self::class, 'plugin_links']);
}
public static function plugin_links($links) {
$log_link = '<a href="' . admin_url('admin.php?page=' . self::MENU_SLUG) . '">View Logs</a>';
array_unshift($links, $log_link);
array_push($links, '<a href="https://informatiq.services" target="_blank">Documentation</a>');
array_unshift($links, '<a href="' . admin_url('admin.php?page=' . self::MENU_SLUG) . '">View Logs</a>');
$links[] = '<a href="https://informatiq.services" target="_blank">Documentation</a>';
return $links;
}
@@ -194,39 +264,30 @@ class SmartHoneypotAdmin {
if ($hook !== 'toplevel_page_' . self::MENU_SLUG) {
return;
}
// Inline styles — no external file needed
$css = '
#hp-log-wrap { max-width: 1400px; }
#hp-log-wrap .hp-stats { display:flex; gap:16px; margin:16px 0; flex-wrap:wrap; }
#hp-log-wrap .hp-stat-card {
background:#fff; border:1px solid #c3c4c7; border-radius:4px;
padding:16px 24px; min-width:140px; text-align:center;
}
#hp-log-wrap .hp-stat-card .hp-stat-num { font-size:2em; font-weight:700; color:#2271b1; line-height:1.2; }
#hp-log-wrap .hp-stat-card .hp-stat-lbl { color:#646970; font-size:12px; }
#hp-log-wrap .hp-filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
#hp-log-wrap .hp-filters input, #hp-log-wrap .hp-filters select { height:32px; }
#hp-log-wrap table.hp-log-table { width:100%; border-collapse:collapse; background:#fff; }
#hp-log-wrap table.hp-log-table th {
background:#f0f0f1; padding:8px 12px; text-align:left;
border-bottom:2px solid #c3c4c7; white-space:nowrap;
}
#hp-log-wrap table.hp-log-table td { padding:8px 12px; border-bottom:1px solid #f0f0f1; vertical-align:top; }
#hp-log-wrap table.hp-log-table tr:hover td { background:#f6f7f7; }
#hp-log-wrap .hp-ua { font-size:11px; color:#646970; max-width:300px; word-break:break-all; }
#hp-log-wrap .hp-badge {
display:inline-block; padding:2px 8px; border-radius:3px; font-size:11px; font-weight:600;
background:#ffecec; color:#b32d2e; border:1px solid #f7c5c5;
}
#hp-log-wrap .hp-pagination { margin:12px 0; display:flex; align-items:center; gap:8px; }
#hp-log-wrap .hp-pagination a, #hp-log-wrap .hp-pagination span {
display:inline-block; padding:4px 10px; border:1px solid #c3c4c7;
border-radius:3px; background:#fff; text-decoration:none; color:#2271b1;
}
#hp-log-wrap .hp-pagination span.current { background:#2271b1; color:#fff; border-color:#2271b1; }
#hp-log-wrap .hp-clear-btn { color:#b32d2e; }
';
wp_add_inline_style('common', $css);
wp_add_inline_style('common', '
#hp-wrap { max-width:1400px; }
#hp-wrap .hp-tabs { margin:16px 0 0; }
#hp-wrap .hp-stats { display:flex; gap:14px; margin:16px 0; flex-wrap:wrap; }
#hp-wrap .hp-stat-card { background:#fff; border:1px solid #c3c4c7; border-radius:4px; padding:14px 22px; min-width:130px; text-align:center; }
#hp-wrap .hp-stat-num { font-size:2em; font-weight:700; color:#2271b1; line-height:1.2; }
#hp-wrap .hp-stat-lbl { color:#646970; font-size:12px; }
#hp-wrap .hp-filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
#hp-wrap .hp-filters input, #hp-wrap .hp-filters select { height:32px; }
#hp-wrap table.hp-log { width:100%; border-collapse:collapse; background:#fff; }
#hp-wrap table.hp-log th { background:#f0f0f1; padding:8px 12px; text-align:left; border-bottom:2px solid #c3c4c7; white-space:nowrap; }
#hp-wrap table.hp-log td { padding:8px 12px; border-bottom:1px solid #f0f0f1; vertical-align:top; }
#hp-wrap table.hp-log tr:hover td { background:#f6f7f7; }
#hp-wrap .hp-ua { font-size:11px; color:#646970; max-width:300px; word-break:break-all; }
#hp-wrap .hp-badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:11px; font-weight:600; background:#ffecec; color:#b32d2e; border:1px solid #f7c5c5; }
#hp-wrap .hp-pager { margin:12px 0; display:flex; align-items:center; gap:8px; }
#hp-wrap .hp-pager a, #hp-wrap .hp-pager span { display:inline-block; padding:4px 10px; border:1px solid #c3c4c7; border-radius:3px; background:#fff; text-decoration:none; color:#2271b1; }
#hp-wrap .hp-pager span.current { background:#2271b1; color:#fff; border-color:#2271b1; }
#hp-wrap .hp-red { color:#b32d2e; }
#hp-wrap .hp-api-status { display:inline-flex; align-items:center; gap:6px; font-weight:600; }
#hp-wrap .hp-api-status .dot { width:10px; height:10px; border-radius:50%; display:inline-block; }
#hp-wrap .dot-on { background:#00a32a; }
#hp-wrap .dot-off { background:#646970; }
');
}
public static function handle_actions() {
@@ -236,9 +297,29 @@ class SmartHoneypotAdmin {
if (!current_user_can('manage_options')) {
wp_die('Unauthorized');
}
if ($_POST['hp_action'] === 'clear_logs') {
SmartHoneypotDB::clear();
wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'cleared' => 1], admin_url('admin.php')));
wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'logs', 'cleared' => 1], admin_url('admin.php')));
exit;
}
if ($_POST['hp_action'] === 'save_api_settings') {
$current = SmartHoneypotAPIClient::settings();
$new = [
'enabled' => !empty($_POST['hp_api_enabled']),
'api_url' => esc_url_raw(trim($_POST['hp_api_url'] ?? '')),
'last_sync' => $current['last_sync'],
'sent_total' => $current['sent_total'],
];
update_option(SmartHoneypotAPIClient::OPT_SETTINGS, $new);
wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'saved' => 1], admin_url('admin.php')));
exit;
}
if ($_POST['hp_action'] === 'flush_queue') {
SmartHoneypotAPIClient::flush();
wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'flushed' => 1], admin_url('admin.php')));
exit;
}
}
@@ -248,165 +329,217 @@ class SmartHoneypotAdmin {
return;
}
// Filters from query string
$search = sanitize_text_field($_GET['hp_search'] ?? '');
$filter_ip = sanitize_text_field($_GET['hp_ip'] ?? '');
$filter_form = sanitize_text_field($_GET['hp_form'] ?? '');
$paged = max(1, intval($_GET['paged'] ?? 1));
$per_page = self::PER_PAGE;
$offset = ($paged - 1) * $per_page;
$query_args = array_filter([
'ip' => $filter_ip,
'form' => $filter_form,
'search' => $search,
'per_page' => $per_page,
'offset' => $offset,
]);
$rows = SmartHoneypotDB::get_rows($query_args);
$total = SmartHoneypotDB::count($query_args);
$total_ever = SmartHoneypotDB::count();
$form_types = SmartHoneypotDB::get_form_types();
$total_pages = max(1, ceil($total / $per_page));
// Unique IPs total
global $wpdb;
$unique_ips = (int) $wpdb->get_var("SELECT COUNT(DISTINCT ip_address) FROM " . SmartHoneypotDB::table());
$today = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM " . SmartHoneypotDB::table() . " WHERE blocked_at >= CURDATE()"
);
$base_url = admin_url('admin.php?page=' . self::MENU_SLUG);
$tab = sanitize_key($_GET['tab'] ?? 'logs');
?>
<div class="wrap" id="hp-log-wrap">
<h1 class="wp-heading-inline">Honeypot Logs</h1>
<div class="wrap" id="hp-wrap">
<h1 class="wp-heading-inline">Honeypot Fields</h1>
<?php if (!empty($_GET['cleared'])): ?>
<div class="notice notice-success is-dismissible"><p>All logs have been cleared.</p></div>
<div class="notice notice-success is-dismissible"><p>Logs cleared.</p></div>
<?php endif; ?>
<?php if (!empty($_GET['saved'])): ?>
<div class="notice notice-success is-dismissible"><p>API settings saved.</p></div>
<?php endif; ?>
<?php if (!empty($_GET['flushed'])): ?>
<div class="notice notice-success is-dismissible"><p>Queue flushed to central API.</p></div>
<?php endif; ?>
<!-- Stats cards -->
<div class="hp-stats">
<div class="hp-stat-card">
<div class="hp-stat-num"><?= number_format($total_ever) ?></div>
<div class="hp-stat-lbl">Total Blocked</div>
</div>
<div class="hp-stat-card">
<div class="hp-stat-num"><?= number_format($today) ?></div>
<div class="hp-stat-lbl">Blocked Today</div>
</div>
<div class="hp-stat-card">
<div class="hp-stat-num"><?= number_format($unique_ips) ?></div>
<div class="hp-stat-lbl">Unique IPs</div>
</div>
<div class="hp-stat-card">
<div class="hp-stat-num"><?= count($form_types) ?></div>
<div class="hp-stat-lbl">Form Types Hit</div>
</div>
</div>
<nav class="nav-tab-wrapper hp-tabs">
<a href="<?= esc_url(admin_url('admin.php?page=' . self::MENU_SLUG . '&tab=logs')) ?>"
class="nav-tab <?= $tab === 'logs' ? 'nav-tab-active' : '' ?>">
Blocked Logs
</a>
<a href="<?= esc_url(admin_url('admin.php?page=' . self::MENU_SLUG . '&tab=settings')) ?>"
class="nav-tab <?= $tab === 'settings' ? 'nav-tab-active' : '' ?>">
Central API
</a>
</nav>
<!-- Filters -->
<form method="get" action="">
<input type="hidden" name="page" value="<?= esc_attr(self::MENU_SLUG) ?>">
<div class="hp-filters">
<input type="text" name="hp_search" placeholder="Search IP, UA, reason…"
value="<?= esc_attr($search) ?>" size="30">
<input type="text" name="hp_ip" placeholder="Filter by IP"
value="<?= esc_attr($filter_ip) ?>">
<select name="hp_form">
<option value="">All form types</option>
<?php foreach ($form_types as $ft): ?>
<option value="<?= esc_attr($ft) ?>" <?= selected($filter_form, $ft, false) ?>>
<?= esc_html($ft) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="button">Filter</button>
<?php if ($search || $filter_ip || $filter_form): ?>
<a href="<?= esc_url($base_url) ?>" class="button">Reset</a>
<?php endif; ?>
<span style="flex:1"></span>
<!-- Clear all logs -->
<form method="post" action="" style="display:inline"
onsubmit="return confirm('Delete ALL log entries permanently?');">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="hp_action" value="clear_logs">
<button type="submit" class="button hp-clear-btn">Clear All Logs</button>
</form>
</div>
<?php if ($tab === 'settings'): ?>
<?php self::render_settings_tab(); ?>
<?php else: ?>
<?php self::render_logs_tab(); ?>
<?php endif; ?>
</div>
<?php
}
/* ── Logs tab ─────────────────────────────────────────────────── */
private static function render_logs_tab() {
$search = sanitize_text_field($_GET['hp_search'] ?? '');
$filter_ip = sanitize_text_field($_GET['hp_ip'] ?? '');
$filter_form = sanitize_text_field($_GET['hp_form'] ?? '');
$paged = max(1, intval($_GET['paged'] ?? 1));
$per_page = self::PER_PAGE;
$offset = ($paged - 1) * $per_page;
$qargs = array_filter([
'ip' => $filter_ip, 'form' => $filter_form,
'search' => $search, 'per_page' => $per_page, 'offset' => $offset,
]);
$rows = SmartHoneypotDB::get_rows($qargs);
$total = SmartHoneypotDB::count($qargs);
$total_ever = SmartHoneypotDB::count();
$form_types = SmartHoneypotDB::get_form_types();
$total_pages = max(1, ceil($total / $per_page));
global $wpdb;
$unique_ips = (int) $wpdb->get_var("SELECT COUNT(DISTINCT ip_address) FROM " . SmartHoneypotDB::table());
$today = (int) $wpdb->get_var("SELECT COUNT(*) FROM " . SmartHoneypotDB::table() . " WHERE blocked_at >= CURDATE()");
$base = admin_url('admin.php?page=' . self::MENU_SLUG . '&tab=logs');
?>
<!-- Stats -->
<div class="hp-stats">
<div class="hp-stat-card"><div class="hp-stat-num"><?= number_format($total_ever) ?></div><div class="hp-stat-lbl">Total Blocked</div></div>
<div class="hp-stat-card"><div class="hp-stat-num"><?= number_format($today) ?></div><div class="hp-stat-lbl">Today</div></div>
<div class="hp-stat-card"><div class="hp-stat-num"><?= number_format($unique_ips) ?></div><div class="hp-stat-lbl">Unique IPs</div></div>
<div class="hp-stat-card"><div class="hp-stat-num"><?= count($form_types) ?></div><div class="hp-stat-lbl">Form Types Hit</div></div>
</div>
<!-- Filters + clear -->
<form method="get">
<input type="hidden" name="page" value="<?= esc_attr(self::MENU_SLUG) ?>">
<input type="hidden" name="tab" value="logs">
<div class="hp-filters">
<input type="text" name="hp_search" placeholder="Search IP, UA, reason…" value="<?= esc_attr($search) ?>" size="28">
<input type="text" name="hp_ip" placeholder="Filter by IP" value="<?= esc_attr($filter_ip) ?>">
<select name="hp_form">
<option value="">All form types</option>
<?php foreach ($form_types as $ft): ?>
<option value="<?= esc_attr($ft) ?>" <?= selected($filter_form, $ft, false) ?>><?= esc_html($ft) ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="button">Filter</button>
<?php if ($search || $filter_ip || $filter_form): ?>
<a href="<?= esc_url($base) ?>" class="button">Reset</a>
<?php endif; ?>
<span style="flex:1"></span>
<form method="post" style="display:inline" onsubmit="return confirm('Delete ALL log entries permanently?')">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="hp_action" value="clear_logs">
<button type="submit" class="button hp-red">Clear All Logs</button>
</form>
</div>
</form>
<p>Showing <strong><?= number_format($total) ?></strong> result<?= $total !== 1 ? 's' : '' ?> (page <?= $paged ?> of <?= $total_pages ?>)</p>
<table class="hp-log widefat">
<thead>
<tr><th>#</th><th>Date / Time</th><th>IP Address</th><th>Form Type</th><th>Reason</th><th>URI</th><th>User Agent</th></tr>
</thead>
<tbody>
<?php if (empty($rows)): ?>
<tr><td colspan="7" style="text-align:center;padding:24px;color:#646970">No blocked attempts recorded yet.</td></tr>
<?php else: foreach ($rows as $row): ?>
<tr>
<td><?= esc_html($row->id) ?></td>
<td style="white-space:nowrap"><?= esc_html($row->blocked_at) ?></td>
<td>
<code><?= esc_html($row->ip_address) ?></code><br>
<a href="<?= esc_url(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'logs', 'hp_ip' => $row->ip_address], admin_url('admin.php'))) ?>" style="font-size:11px">filter</a>
&nbsp;<a href="https://ipinfo.io/<?= esc_attr(urlencode($row->ip_address)) ?>" target="_blank" style="font-size:11px">lookup ↗</a>
</td>
<td><span class="hp-badge"><?= esc_html($row->form_type) ?></span></td>
<td><?= esc_html($row->reason) ?></td>
<td style="font-size:11px;word-break:break-all"><?= esc_html($row->request_uri) ?></td>
<td class="hp-ua"><?= esc_html($row->user_agent) ?></td>
</tr>
<?php endforeach; endif; ?>
</tbody>
</table>
<?php if ($total_pages > 1): ?>
<div class="hp-pager">
<?php if ($paged > 1): ?>
<a href="<?= esc_url(add_query_arg(array_merge($_GET, ['paged' => $paged - 1]))) ?>">← Prev</a>
<?php endif; ?>
<?php for ($p = max(1, $paged-3); $p <= min($total_pages, $paged+3); $p++):
$url = esc_url(add_query_arg(array_merge($_GET, ['paged' => $p]))); ?>
<?php if ($p === $paged): ?>
<span class="current"><?= $p ?></span>
<?php else: ?>
<a href="<?= $url ?>"><?= $p ?></a>
<?php endif; ?>
<?php endfor; ?>
<?php if ($paged < $total_pages): ?>
<a href="<?= esc_url(add_query_arg(array_merge($_GET, ['paged' => $paged + 1]))) ?>">Next →</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php
}
/* ── Settings / API tab ───────────────────────────────────────── */
private static function render_settings_tab() {
$s = SmartHoneypotAPIClient::settings();
$queue_size = SmartHoneypotAPIClient::queue_size();
$next_run = wp_next_scheduled('hp_api_flush');
?>
<div style="max-width:700px;margin-top:20px">
<h2>Central API Settings</h2>
<p style="color:#646970;margin-bottom:16px">
Submit blocked attempts anonymously to a central dashboard for aggregate threat intelligence.
Only anonymised data is sent: masked IPs (first 2 octets only), form type, block reason, and UA family. No site URL, no full IPs.
</p>
<form method="post">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="hp_action" value="save_api_settings">
<table class="form-table">
<tr>
<th>Enable Submission</th>
<td>
<label>
<input type="checkbox" name="hp_api_enabled" value="1" <?= checked($s['enabled']) ?>>
Send blocked attempts to the central API
</label>
</td>
</tr>
<tr>
<th>API Endpoint URL</th>
<td>
<input type="url" name="hp_api_url" value="<?= esc_attr($s['api_url']) ?>"
class="regular-text" placeholder="https://your-api-host:3000">
<p class="description">Base URL of your Honeypot API Docker container.</p>
</td>
</tr>
</table>
<?php submit_button('Save Settings'); ?>
</form>
<p>Showing <strong><?= number_format($total) ?></strong> result<?= $total !== 1 ? 's' : '' ?>
(page <?= $paged ?> of <?= $total_pages ?>)</p>
<hr>
<!-- Log table -->
<table class="hp-log-table widefat">
<thead>
<tr>
<th>#</th>
<th>Date / Time</th>
<th>IP Address</th>
<th>Form Type</th>
<th>Reason</th>
<th>URI</th>
<th>User Agent</th>
</tr>
</thead>
<tbody>
<?php if (empty($rows)): ?>
<tr><td colspan="7" style="text-align:center;padding:24px;color:#646970;">
No blocked attempts recorded yet.
</td></tr>
<?php else: ?>
<?php foreach ($rows as $row): ?>
<tr>
<td><?= esc_html($row->id) ?></td>
<td style="white-space:nowrap"><?= esc_html($row->blocked_at) ?></td>
<td>
<code><?= esc_html($row->ip_address) ?></code><br>
<a href="<?= esc_url(add_query_arg(['page' => self::MENU_SLUG, 'hp_ip' => $row->ip_address], admin_url('admin.php'))) ?>"
style="font-size:11px">filter</a>
&nbsp;
<a href="https://ipinfo.io/<?= esc_attr(urlencode($row->ip_address)) ?>"
target="_blank" style="font-size:11px">lookup ↗</a>
</td>
<td><span class="hp-badge"><?= esc_html($row->form_type) ?></span></td>
<td><?= esc_html($row->reason) ?></td>
<td style="font-size:11px;word-break:break-all"><?= esc_html($row->request_uri) ?></td>
<td class="hp-ua"><?= esc_html($row->user_agent) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
<h3>Submission Status</h3>
<table class="form-table">
<tr>
<th>Status</th>
<td>
<span class="hp-api-status">
<span class="dot <?= $s['enabled'] && $s['api_url'] ? 'dot-on' : 'dot-off' ?>"></span>
<?= $s['enabled'] && $s['api_url'] ? 'Active' : 'Inactive' ?>
</span>
</td>
</tr>
<tr><th>Last Sync</th><td><?= $s['last_sync'] ? esc_html(date('Y-m-d H:i:s', $s['last_sync'])) : 'Never' ?></td></tr>
<tr><th>Total Sent</th><td><?= number_format((int)$s['sent_total']) ?> blocks</td></tr>
<tr><th>Queue Size</th><td><?= number_format($queue_size) ?> pending blocks</td></tr>
<tr><th>Next Auto-Flush</th><td><?= $next_run ? esc_html(date('Y-m-d H:i:s', $next_run)) . ' (every 5 min)' : 'Not scheduled' ?></td></tr>
</table>
<!-- Pagination -->
<?php if ($total_pages > 1): ?>
<div class="hp-pagination">
<?php if ($paged > 1): ?>
<a href="<?= esc_url(add_query_arg(array_merge($_GET, ['paged' => $paged - 1]))) ?>">← Prev</a>
<?php endif; ?>
<?php
$start = max(1, $paged - 3);
$end = min($total_pages, $paged + 3);
for ($p = $start; $p <= $end; $p++):
$url = esc_url(add_query_arg(array_merge($_GET, ['paged' => $p])));
if ($p === $paged): ?>
<span class="current"><?= $p ?></span>
<?php else: ?>
<a href="<?= $url ?>"><?= $p ?></a>
<?php endif;
endfor; ?>
<?php if ($paged < $total_pages): ?>
<a href="<?= esc_url(add_query_arg(array_merge($_GET, ['paged' => $paged + 1]))) ?>">Next →</a>
<?php endif; ?>
</div>
<?php if ($queue_size > 0 && $s['enabled'] && $s['api_url']): ?>
<form method="post" style="margin-top:12px">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="hp_action" value="flush_queue">
<button type="submit" class="button button-secondary">Flush Queue Now (<?= $queue_size ?> pending)</button>
</form>
<?php endif; ?>
</div><!-- /#hp-log-wrap -->
</div>
<?php
}
}
@@ -416,16 +549,9 @@ class SmartHoneypotAdmin {
* ====================================================================*/
class SmartHoneypotAntiSpam {
/** Honeypot text input name — looks like a real field bots want to fill */
private $hp_name;
/** JS challenge token field name */
private $token_name;
/** Timestamp field name */
private $time_name;
/** Set before check_submission() so log_spam() knows which form was hit */
private $current_form_type = 'Unknown';
private const MIN_SUBMIT_TIME = 3;
@@ -443,9 +569,7 @@ class SmartHoneypotAntiSpam {
add_action('init', [$this, 'init']);
}
/* ------------------------------------------------------------------
* INIT
* ----------------------------------------------------------------*/
/* ── Init ──────────────────────────────────────────────────────── */
public function init() {
if (is_admin()) {
add_action('admin_notices', [$this, 'activation_notice']);
@@ -454,38 +578,35 @@ class SmartHoneypotAntiSpam {
// Inject honeypot
add_filter('the_content', [$this, 'add_to_content_forms'], 99);
add_action('comment_form_after_fields', [$this, 'echo_honeypot']);
add_action('comment_form_after_fields', [$this, 'echo_honeypot']);
add_action('comment_form_logged_in_after', [$this, 'echo_honeypot']);
add_action('woocommerce_register_form', [$this, 'echo_honeypot']);
add_action('woocommerce_login_form', [$this, 'echo_honeypot']);
add_action('woocommerce_after_order_notes', [$this, 'echo_honeypot']);
add_action('register_form', [$this, 'echo_honeypot']);
add_action('login_form', [$this, 'echo_honeypot']);
add_action('elementor_pro/forms/render_field', [$this, 'add_to_elementor_form'], 10, 2);
add_action('elementor/widget/render_content', [$this, 'filter_elementor_widget'], 10, 2);
add_filter('gform_form_tag', [$this, 'add_to_gravity_forms'], 10, 2);
add_filter('wpcf7_form_elements', [$this, 'add_to_cf7']);
add_filter('get_search_form', [$this, 'add_to_search_form'], 99);
add_action('woocommerce_register_form', [$this, 'echo_honeypot']);
add_action('woocommerce_login_form', [$this, 'echo_honeypot']);
add_action('woocommerce_after_order_notes',[$this, 'echo_honeypot']);
add_action('register_form', [$this, 'echo_honeypot']);
add_action('login_form', [$this, 'echo_honeypot']);
add_action('elementor_pro/forms/render_field', [$this, 'add_to_elementor_form'], 10, 2);
add_action('elementor/widget/render_content', [$this, 'filter_elementor_widget'], 10, 2);
add_filter('gform_form_tag', [$this, 'add_to_gravity_forms'], 10, 2);
add_filter('wpcf7_form_elements', [$this, 'add_to_cf7']);
add_filter('get_search_form', [$this, 'add_to_search_form'], 99);
// Validate
add_filter('woocommerce_process_registration_errors', [$this, 'validate_wc_registration'], 10, 4);
add_filter('woocommerce_process_login_errors', [$this, 'validate_wc_login'], 10, 3);
add_action('woocommerce_after_checkout_validation', [$this, 'validate_wc_checkout'], 10, 2);
add_filter('registration_errors', [$this, 'validate_wp_registration'], 10, 3);
add_filter('preprocess_comment', [$this, 'validate_comment']);
add_action('elementor_pro/forms/validation', [$this, 'validate_elementor_form'], 10, 2);
add_action('template_redirect', [$this, 'validate_generic_post']);
add_filter('woocommerce_process_login_errors', [$this, 'validate_wc_login'], 10, 3);
add_action('woocommerce_after_checkout_validation', [$this, 'validate_wc_checkout'], 10, 2);
add_filter('registration_errors', [$this, 'validate_wp_registration'], 10, 3);
add_filter('preprocess_comment', [$this, 'validate_comment']);
add_action('elementor_pro/forms/validation', [$this, 'validate_elementor_form'], 10, 2);
add_action('template_redirect', [$this, 'validate_generic_post']);
// CSS & JS
add_action('wp_head', [$this, 'print_css']);
add_action('wp_head', [$this, 'print_css']);
add_action('wp_footer', [$this, 'print_js'], 99);
}
/* ------------------------------------------------------------------
* HONEYPOT HTML
* ----------------------------------------------------------------*/
/* ── Honeypot HTML ─────────────────────────────────────────────── */
private function get_honeypot_html(): string {
$ts = time();
return sprintf(
'<div class="frm-extra-field" aria-hidden="true">
<label for="%1$s">Website URL Confirmation</label>
@@ -496,7 +617,7 @@ class SmartHoneypotAntiSpam {
esc_attr($this->hp_name),
esc_attr($this->token_name),
esc_attr($this->time_name),
esc_attr($ts)
esc_attr(time())
);
}
@@ -504,9 +625,7 @@ class SmartHoneypotAntiSpam {
echo $this->get_honeypot_html();
}
/* ------------------------------------------------------------------
* INJECTION HELPERS
* ----------------------------------------------------------------*/
/* ── Injection helpers ─────────────────────────────────────────── */
public function add_to_content_forms($content) {
if (is_admin() || is_feed()) {
return $content;
@@ -524,10 +643,7 @@ class SmartHoneypotAntiSpam {
public function add_to_elementor_form($field, $instance) {
static $done = false;
if (!$done && $field['type'] === 'submit') {
$done = true;
echo $this->get_honeypot_html();
}
if (!$done && $field['type'] === 'submit') { $done = true; echo $this->get_honeypot_html(); }
}
public function filter_elementor_widget($content, $widget) {
@@ -545,9 +661,7 @@ class SmartHoneypotAntiSpam {
return preg_replace('/(\[submit[^\]]*\])/i', $this->get_honeypot_html() . '$1', $form, 1);
}
/* ------------------------------------------------------------------
* VALIDATION
* ----------------------------------------------------------------*/
/* ── Validation ────────────────────────────────────────────────── */
private function check_submission(bool $require_fields = true): bool {
if ($require_fields && !isset($_POST[$this->hp_name])) {
$this->log_spam('Honeypot field missing (direct POST)');
@@ -571,14 +685,8 @@ class SmartHoneypotAntiSpam {
}
if (isset($_POST[$this->time_name])) {
$diff = time() - intval($_POST[$this->time_name]);
if ($diff < self::MIN_SUBMIT_TIME) {
$this->log_spam("Submitted too fast ({$diff}s — bot behaviour)");
return false;
}
if ($diff > self::MAX_SUBMIT_TIME) {
$this->log_spam("Timestamp expired ({$diff}s old)");
return false;
}
if ($diff < self::MIN_SUBMIT_TIME) { $this->log_spam("Submitted too fast ({$diff}s)"); return false; }
if ($diff > self::MAX_SUBMIT_TIME) { $this->log_spam("Timestamp expired ({$diff}s)"); return false; }
}
$this->clean_post_data();
return true;
@@ -604,7 +712,7 @@ class SmartHoneypotAntiSpam {
$uri = $_SERVER['REQUEST_URI'] ?? '';
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
// Write to DB
// Write to local DB
SmartHoneypotDB::insert([
'ip' => $ip,
'form' => $this->current_form_type,
@@ -613,13 +721,19 @@ class SmartHoneypotAntiSpam {
'ua' => $ua,
]);
// Also write to PHP error log for server-level monitoring
// Queue for central API
SmartHoneypotAPIClient::enqueue([
'ip' => $ip,
'form_type' => $this->current_form_type,
'reason' => $reason,
'user_agent' => $ua,
'blocked_at' => current_time('mysql'),
]);
error_log("[Honeypot] {$reason} | form={$this->current_form_type} | ip={$ip} | uri={$uri}");
}
/* ------------------------------------------------------------------
* RATE LIMITING
* ----------------------------------------------------------------*/
/* ── Rate limiting ─────────────────────────────────────────────── */
private function check_rate_limit(): bool {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if (!$ip) {
@@ -628,17 +742,14 @@ class SmartHoneypotAntiSpam {
$key = 'hp_rate_' . md5($ip);
$count = (int) get_transient($key);
if ($count >= self::RATE_LIMIT) {
$this->current_form_type .= ' (rate limited)';
$this->log_spam("Rate limit hit — {$count} attempts this hour from {$ip}");
$this->log_spam("Rate limit exceeded ({$count}/hr from {$ip})");
return false;
}
set_transient($key, $count + 1, HOUR_IN_SECONDS);
return true;
}
/* ------------------------------------------------------------------
* WOOCOMMERCE
* ----------------------------------------------------------------*/
/* ── WooCommerce ───────────────────────────────────────────────── */
public function validate_wc_registration($errors, $username, $password, $email) {
$this->current_form_type = 'WooCommerce Registration';
if (!$this->check_submission(true)) {
@@ -646,7 +757,7 @@ class SmartHoneypotAntiSpam {
return $errors;
}
if (!$this->check_rate_limit()) {
$errors->add('honeypot_rate', __('<strong>Error</strong>: Too many registration attempts. Try again later.', 'smart-honeypot'));
$errors->add('honeypot_rate', __('<strong>Error</strong>: Too many attempts. Try again later.', 'smart-honeypot'));
}
return $errors;
}
@@ -666,9 +777,7 @@ class SmartHoneypotAntiSpam {
}
}
/* ------------------------------------------------------------------
* WORDPRESS CORE
* ----------------------------------------------------------------*/
/* ── WordPress core ────────────────────────────────────────────── */
public function validate_wp_registration($errors, $login, $email) {
$this->current_form_type = 'WP Registration';
if (!$this->check_submission(true)) {
@@ -696,9 +805,7 @@ class SmartHoneypotAntiSpam {
return $commentdata;
}
/* ------------------------------------------------------------------
* ELEMENTOR
* ----------------------------------------------------------------*/
/* ── Elementor ─────────────────────────────────────────────────── */
public function validate_elementor_form($record, $ajax_handler) {
$this->current_form_type = 'Elementor Form';
if (!$this->check_submission(true)) {
@@ -706,9 +813,7 @@ class SmartHoneypotAntiSpam {
}
}
/* ------------------------------------------------------------------
* GENERIC CATCH-ALL
* ----------------------------------------------------------------*/
/* ── Generic catch-all ─────────────────────────────────────────── */
public function validate_generic_post() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
return;
@@ -716,17 +821,15 @@ class SmartHoneypotAntiSpam {
if (!isset($_POST[$this->hp_name]) && !isset($_POST[$this->token_name])) {
return;
}
// Skip forms handled by specific hooks
if (
isset($_POST['woocommerce-register-nonce']) ||
isset($_POST['woocommerce-login-nonce']) ||
isset($_POST['woocommerce-process-checkout-nonce']) ||
isset($_POST['comment_post_ID']) ||
isset($_POST['woocommerce-register-nonce']) ||
isset($_POST['woocommerce-login-nonce']) ||
isset($_POST['woocommerce-process-checkout-nonce']) ||
isset($_POST['comment_post_ID']) ||
(isset($_POST['action']) && $_POST['action'] === 'elementor_pro_forms_send_form')
) {
return;
}
$this->current_form_type = 'Generic Form';
if (!$this->check_submission(false)) {
if (wp_doing_ajax()) {
@@ -737,21 +840,16 @@ class SmartHoneypotAntiSpam {
}
}
/* ------------------------------------------------------------------
* CSS
* ----------------------------------------------------------------*/
/* ── CSS ───────────────────────────────────────────────────────── */
public function print_css() {
echo '<style>.frm-extra-field{position:absolute!important;left:-9999px!important;top:-9999px!important;height:0!important;width:0!important;overflow:hidden!important;opacity:0!important;pointer-events:none!important;z-index:-1!important;clip:rect(0,0,0,0)!important}</style>';
}
/* ------------------------------------------------------------------
* JS — HMAC token via SubtleCrypto
* ----------------------------------------------------------------*/
/* ── JS — HMAC token via SubtleCrypto ──────────────────────────── */
public function print_js() {
$secret = esc_js($this->secret);
$token_name = esc_js($this->token_name);
$time_name = esc_js($this->time_name);
echo <<<JSBLOCK
<script>
(function(){
@@ -783,15 +881,13 @@ class SmartHoneypotAntiSpam {
JSBLOCK;
}
/* ------------------------------------------------------------------
* ADMIN NOTICE
* ----------------------------------------------------------------*/
/* ── Admin notice ──────────────────────────────────────────────── */
public function activation_notice() {
if (get_transient('smart_honeypot_activated')) {
echo '<div class="notice notice-success is-dismissible">
<p><strong>Honeypot Fields</strong> is now active. All forms are protected. <a href="' .
esc_url(admin_url('admin.php?page=honeypot-logs')) . '">View logs →</a></p>
</div>';
echo '<div class="notice notice-success is-dismissible"><p>
<strong>Honeypot Fields</strong> is now active. All forms are protected.
<a href="' . esc_url(admin_url('admin.php?page=honeypot-logs')) . '">View logs →</a>
</p></div>';
delete_transient('smart_honeypot_activated');
}
}
@@ -803,7 +899,6 @@ JSBLOCK;
define('HP_PLUGIN_FILE', __FILE__);
add_action('plugins_loaded', function () {
// Run DB upgrade if needed
if ((int) get_option(SmartHoneypotDB::TABLE_VERSION_OPTION) < SmartHoneypotDB::TABLE_VERSION) {
SmartHoneypotDB::install();
}
@@ -811,19 +906,29 @@ add_action('plugins_loaded', function () {
SmartHoneypotAdmin::register();
});
// Custom cron interval (5 minutes)
add_filter('cron_schedules', function ($s) {
if (!isset($s['hp_5min'])) {
$s['hp_5min'] = ['interval' => 300, 'display' => 'Every 5 Minutes'];
}
return $s;
});
// Cron hooks
add_action('hp_api_flush', ['SmartHoneypotAPIClient', 'flush']);
add_action('hp_daily_cleanup', function () {
SmartHoneypotDB::delete_older_than_days(90);
});
register_activation_hook(__FILE__, function () {
SmartHoneypotDB::install();
set_transient('smart_honeypot_activated', true, 30);
if (!wp_next_scheduled('hp_api_flush')) wp_schedule_event(time(), 'hp_5min', 'hp_api_flush');
if (!wp_next_scheduled('hp_daily_cleanup')) wp_schedule_event(time(), 'daily', 'hp_daily_cleanup');
});
register_deactivation_hook(__FILE__, function () {
delete_transient('smart_honeypot_activated');
wp_clear_scheduled_hook('hp_api_flush');
wp_clear_scheduled_hook('hp_daily_cleanup');
});
// Auto-prune logs older than 90 days (runs once daily)
add_action('hp_daily_cleanup', function () {
SmartHoneypotDB::delete_older_than_days(90);
});
if (!wp_next_scheduled('hp_daily_cleanup')) {
wp_schedule_event(time(), 'daily', 'hp_daily_cleanup');
}