Files
HoneypotFields/honeypot-fields.php
Malin 5f91b979eb feat: protect WP login, password reset, and fix Elementor AJAX bypass
- Move elementor_pro/forms/validation hook before is_admin() early return
  (same AJAX bypass bug as CF7 — Elementor submits to admin-ajax.php)
- Add login_head + login_footer hooks so CSS/JS HMAC token loads on
  wp-login.php (wp_head/footer do not fire on that page)
- Add lostpassword_form + woocommerce_lostpassword_form injection hooks
- Add authenticate filter (validate_wp_login) for WP native login,
  guarded to skip WC login and non-form auth calls
- Add lostpassword_post action (validate_lost_password) for password reset,
  covering both WP and WC My Account lost-password forms
- Exclude woocommerce-lost-password-nonce from generic catch-all to avoid
  double-processing WC lost-password submissions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 07:08:50 +01:00

1612 lines
76 KiB
PHP

<?php
/**
* 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.4.0
* Author: Malin
* Author URI: https://malin.ro
* License: GPL v2 or later
* Text Domain: smart-honeypot
*/
if (!defined('ABSPATH')) {
exit;
}
/* ======================================================================
* DATABASE HELPER
* ====================================================================*/
class SmartHoneypotDB {
const TABLE_VERSION = 1;
const TABLE_VERSION_OPTION = 'hp_db_version';
public static function table(): string {
global $wpdb;
return $wpdb->prefix . 'honeypot_log';
}
public static function install() {
global $wpdb;
$table = self::table();
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
blocked_at DATETIME NOT NULL,
ip_address VARCHAR(45) NOT NULL DEFAULT '',
form_type VARCHAR(100) NOT NULL DEFAULT '',
reason VARCHAR(255) NOT NULL DEFAULT '',
request_uri VARCHAR(1000) NOT NULL DEFAULT '',
user_agent TEXT NOT NULL,
PRIMARY KEY (id),
KEY ip_address (ip_address),
KEY blocked_at (blocked_at),
KEY form_type (form_type)
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
update_option(self::TABLE_VERSION_OPTION, self::TABLE_VERSION);
}
public static function insert(array $data) {
global $wpdb;
$wpdb->insert(
self::table(),
[
'blocked_at' => current_time('mysql'),
'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'] ?? ''),
],
['%s', '%s', '%s', '%s', '%s', '%s']
);
}
public static function get_rows(array $args = []): array {
global $wpdb;
$table = self::table();
$limit = max(1, intval($args['per_page'] ?? 25));
$offset = max(0, intval($args['offset'] ?? 0));
$where = '1=1';
$params = [];
if (!empty($args['ip'])) {
$where .= ' AND ip_address = %s';
$params[] = sanitize_text_field($args['ip']);
}
if (!empty($args['form'])) {
$where .= ' AND form_type = %s';
$params[] = sanitize_text_field($args['form']);
}
if (!empty($args['search'])) {
$like = '%' . $wpdb->esc_like($args['search']) . '%';
$where .= ' AND (ip_address LIKE %s OR user_agent LIKE %s OR reason LIKE %s)';
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
$params[] = $limit;
$params[] = $offset;
$sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY blocked_at DESC LIMIT %d OFFSET %d";
return $wpdb->get_results($wpdb->prepare($sql, $params)) ?: [];
}
public static function count(array $args = []): int {
global $wpdb;
$table = self::table();
$where = '1=1';
$params = [];
if (!empty($args['ip'])) {
$where .= ' AND ip_address = %s';
$params[] = sanitize_text_field($args['ip']);
}
if (!empty($args['form'])) {
$where .= ' AND form_type = %s';
$params[] = sanitize_text_field($args['form']);
}
if (!empty($args['search'])) {
$like = '%' . $wpdb->esc_like($args['search']) . '%';
$where .= ' AND (ip_address LIKE %s OR user_agent LIKE %s OR reason LIKE %s)';
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
$sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
return (int) $wpdb->get_var($params ? $wpdb->prepare($sql, $params) : $sql);
}
public static function get_form_types(): array {
global $wpdb;
return $wpdb->get_col("SELECT DISTINCT form_type FROM " . self::table() . " ORDER BY form_type ASC") ?: [];
}
public static function clear(): void {
global $wpdb;
$wpdb->query("TRUNCATE TABLE " . self::table());
}
public static function delete_older_than_days(int $days): void {
global $wpdb;
$wpdb->query(
$wpdb->prepare(
"DELETE FROM " . self::table() . " WHERE blocked_at < DATE_SUB(NOW(), INTERVAL %d DAY)",
$days
)
);
}
}
/* ======================================================================
* 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' => '',
'api_token' => '',
'last_sync' => 0,
'sent_total' => 0,
'connection_ok' => null, // null=untested, true=ok, false=failed
'last_verified' => 0,
'last_error' => '',
];
}
/**
* Tests reachability (health endpoint) and auth (submit with bad payload).
* Returns ['ok' => bool, 'message' => string]
*/
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::resolve_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()];
}
$code = wp_remote_retrieve_response_code($health);
if ($code !== 200) {
return ['ok' => false, 'message' => "API returned HTTP {$code}. Verify the URL is correct."];
}
// Step 2 — token validation: POST with an intentionally invalid payload.
// Auth passes → 400 (bad payload). Wrong/missing token → 403.
$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 request failed: ' . $auth->get_error_message()];
}
$auth_code = wp_remote_retrieve_response_code($auth);
if ($auth_code === 403) {
return ['ok' => false, 'message' => 'API reachable but token rejected (HTTP 403). Check the token matches API_TOKEN in Docker.'];
}
// 400 = auth passed, payload correctly rejected — exactly what we expect
return ['ok' => true, 'message' => 'Connection verified. API is reachable and token is accepted.'];
}
/**
* Sends one batch of existing local log records to the central API.
* Picks up from where it left off using the hp_history_last_id option.
*
* Returns ['ok', 'sent', 'remaining', 'has_more', 'message']
*/
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 = SmartHoneypotDB::table();
$last_id = (int) get_option('hp_history_last_id', 0);
$total = SmartHoneypotDB::count();
$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::resolve_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()];
}
$code = wp_remote_retrieve_response_code($response);
if ($code !== 200) {
return ['ok' => false, 'message' => "API returned HTTP {$code}. Check connection settings."];
}
$new_last_id = (int) end($rows)['id'];
$sent_total = (int) get_option('hp_history_total_sent', 0) + count($rows);
update_option('hp_history_last_id', $new_last_id);
update_option('hp_history_total_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),
];
}
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());
$headers = ['Content-Type' => 'application/json'];
$token = self::resolve_token();
if ($token !== '') {
$headers['Authorization'] = 'Bearer ' . $token;
}
$response = wp_remote_post(
trailingslashit(esc_url_raw($s['api_url'])) . 'api/v1/submit',
[
'timeout' => 15,
'blocking' => true,
'headers' => $headers,
'body' => wp_json_encode([
'site_hash' => $site_hash,
'blocks' => $batch,
]),
]
);
if (is_wp_error($response)) {
$s['connection_ok'] = false;
$s['last_error'] = 'Flush failed: ' . $response->get_error_message();
update_option(self::OPT_SETTINGS, $s);
error_log('[Honeypot] flush() wp_error: ' . $response->get_error_message());
return;
}
$code = wp_remote_retrieve_response_code($response);
if ($code === 200) {
update_option(self::OPT_QUEUE, $queue, false);
$s['last_sync'] = time();
$s['sent_total'] = ($s['sent_total'] ?? 0) + count($batch);
$s['last_error'] = '';
$s['connection_ok'] = true;
update_option(self::OPT_SETTINGS, $s);
} else {
$s['connection_ok'] = false;
$s['last_error'] = "Flush failed: API returned HTTP {$code}. Check URL and token.";
update_option(self::OPT_SETTINGS, $s);
error_log("[Honeypot] flush() API returned HTTP {$code}");
}
}
/** Number of items currently waiting to be sent. */
public static function queue_size(): int {
return count((array) get_option(self::OPT_QUEUE, []));
}
/**
* Fallback flush triggered on every PHP shutdown.
* A transient lock ensures we attempt at most once per 5 minutes
* regardless of traffic volume, so WP-Cron is not the sole trigger.
*/
public static function maybe_flush_overdue(): void {
$s = self::settings();
if (!$s['enabled'] || empty($s['api_url'])) return;
if (self::queue_size() === 0) return;
if (get_transient('hp_flush_lock')) return;
set_transient('hp_flush_lock', 1, 300); // 5-min lock
self::flush();
}
/**
* Returns the API token.
* Checks the HP_API_TOKEN constant (defined in wp-config.php) first,
* then falls back to the value stored in the database.
*/
private static function resolve_token(): string {
if (defined('HP_API_TOKEN') && HP_API_TOKEN !== '') {
return (string) HP_API_TOKEN;
}
return self::settings()['api_token'] ?? '';
}
}
/* ======================================================================
* INTERNATIONALISATION
* ====================================================================*/
class SmartHoneypotI18n {
const USER_META = 'hp_lang';
// phpcs:disable Generic.Files.LineLength
const STRINGS = [
'en' => [
'tab_logs' => 'Blocked Logs',
'tab_api' => 'Central API',
'cleared' => 'Logs cleared.',
'saved' => 'API settings saved.',
'flushed' => 'Queue flushed to central API.',
'history_reset_msg' => 'History sync progress has been reset. You can re-send from the beginning.',
'click_next' => 'Click "Send Next Batch" to continue.',
'total_blocked' => 'Total Blocked',
'today' => 'Today',
'unique_ips' => 'Unique IPs',
'form_types_hit' => 'Form Types Hit',
'search_ph' => 'Search IP, UA, reason…',
'filter_ip_ph' => 'Filter by IP',
'all_forms' => 'All form types',
'filter_btn' => 'Filter',
'reset_btn' => 'Reset',
'clear_logs' => 'Clear All Logs',
'clear_confirm' => 'Delete ALL log entries permanently?',
'col_date' => 'Date / Time',
'col_ip' => 'IP Address',
'col_form' => 'Form Type',
'col_reason' => 'Reason',
'col_uri' => 'URI',
'col_ua' => 'User Agent',
'no_results' => 'No blocked attempts recorded yet.',
'filter_link' => 'filter',
'lookup_link' => 'lookup ↗',
'prev' => '← Prev',
'next' => 'Next →',
'showing' => 'Showing',
'results' => 'result(s)',
'page_of' => 'page %d of %d',
'settings_title' => 'Central API Settings',
'settings_desc' => 'Submit blocked attempts to a central dashboard for aggregate threat intelligence. Data sent: full IP address, form type, block reason, UA family, and timestamp. No site URL or usernames are ever sent.',
'enable_label' => 'Enable Submission',
'enable_cb' => 'Send blocked attempts to the central API',
'url_label' => 'API Endpoint URL',
'url_desc' => 'Base URL of your Honeypot API Docker container.',
'token_label' => 'API Token',
'token_desc' => 'Must match the <code>API_TOKEN</code> set in your Docker container\'s environment. Leave empty only if the API runs without a token (not recommended).',
'token_const_note' => 'Token is set via the <code>HP_API_TOKEN</code> constant in <code>wp-config.php</code>.',
'save_settings' => 'Save Settings',
'conn_status' => 'Connection Status',
'conn_label' => 'Connection',
'conn_ok' => 'Verified — connected and token accepted',
'conn_fail' => 'Connection failed',
'conn_untested' => 'Not tested yet',
'last_verified' => 'Last Verified',
'enabled_lbl' => 'Enabled',
'yes' => 'Yes',
'no' => 'No',
'last_sync' => 'Last Sync',
'never' => 'Never',
'total_sent' => 'Total Sent',
'blocks_unit' => 'blocks',
'queue_size' => 'Queue Size',
'pending_unit' => 'pending',
'next_flush' => 'Next Auto-Flush',
'not_scheduled' => 'Not scheduled',
'test_conn' => 'Test Connection',
'flush_queue' => 'Flush Queue Now',
'history_title' => 'Send History to API',
'history_desc' => 'Populate the central dashboard with your existing log so charts and stats are meaningful right away, without waiting for new attacks. Records are sent in batches of 50.',
'local_log' => 'Local Log',
'local_log_val' => '%s records in this site\'s database',
'sent_api' => 'Sent to API',
'remaining_lbl' => 'Remaining',
'remaining_val' => '%s records not yet sent',
'send_history' => 'Send History',
'send_next' => 'Send Next Batch',
'all_sent' => '✓ All history sent',
'reset_progress' => 'Reset Progress',
],
'es' => [
'tab_logs' => 'Registros Bloqueados',
'tab_api' => 'API Central',
'cleared' => 'Registros eliminados.',
'saved' => 'Configuración de API guardada.',
'flushed' => 'Cola enviada a la API central.',
'history_reset_msg' => 'El progreso de sincronización se ha restablecido. Puedes reenviar desde el principio.',
'click_next' => 'Haz clic en "Enviar Siguiente Lote" para continuar.',
'total_blocked' => 'Total Bloqueados',
'today' => 'Hoy',
'unique_ips' => 'IPs Únicas',
'form_types_hit' => 'Tipos de Formulario',
'search_ph' => 'Buscar IP, UA, razón…',
'filter_ip_ph' => 'Filtrar por IP',
'all_forms' => 'Todos los tipos',
'filter_btn' => 'Filtrar',
'reset_btn' => 'Reiniciar',
'clear_logs' => 'Borrar Todos los Registros',
'clear_confirm' => '¿Eliminar TODOS los registros permanentemente?',
'col_date' => 'Fecha / Hora',
'col_ip' => 'Dirección IP',
'col_form' => 'Tipo de Formulario',
'col_reason' => 'Razón',
'col_uri' => 'URI',
'col_ua' => 'User Agent',
'no_results' => 'No hay intentos bloqueados aún.',
'filter_link' => 'filtrar',
'lookup_link' => 'consultar ↗',
'prev' => '← Anterior',
'next' => 'Siguiente →',
'showing' => 'Mostrando',
'results' => 'resultado(s)',
'page_of' => 'página %d de %d',
'settings_title' => 'Configuración de la API Central',
'settings_desc' => 'Envía intentos bloqueados a un panel central de inteligencia de amenazas. Se envía: IP completa, tipo de formulario, razón de bloqueo, familia de UA y marca de tiempo. Nunca se envían URLs del sitio ni nombres de usuario.',
'enable_label' => 'Activar Envío',
'enable_cb' => 'Enviar intentos bloqueados a la API central',
'url_label' => 'URL del Endpoint de la API',
'url_desc' => 'URL base del contenedor Docker de la API Honeypot.',
'token_label' => 'Token de la API',
'token_desc' => 'Debe coincidir con <code>API_TOKEN</code> en el entorno del contenedor Docker.',
'token_const_note' => 'El token está definido mediante la constante <code>HP_API_TOKEN</code> en <code>wp-config.php</code>.',
'save_settings' => 'Guardar Configuración',
'conn_status' => 'Estado de la Conexión',
'conn_label' => 'Conexión',
'conn_ok' => 'Verificada — conectada y token aceptado',
'conn_fail' => 'Error de conexión',
'conn_untested' => 'No probada todavía',
'last_verified' => 'Última Verificación',
'enabled_lbl' => 'Activado',
'yes' => 'Sí',
'no' => 'No',
'last_sync' => 'Última Sincronización',
'never' => 'Nunca',
'total_sent' => 'Total Enviados',
'blocks_unit' => 'bloqueos',
'queue_size' => 'Tamaño de Cola',
'pending_unit' => 'pendientes',
'next_flush' => 'Próximo Envío Automático',
'not_scheduled' => 'No programado',
'test_conn' => 'Probar Conexión',
'flush_queue' => 'Enviar Cola Ahora',
'history_title' => 'Enviar Historial a la API',
'history_desc' => 'Rellena el panel central con tu registro existente para que las estadísticas sean significativas de inmediato, sin esperar nuevos ataques. Los registros se envían en lotes de 50.',
'local_log' => 'Registro Local',
'local_log_val' => '%s registros en la base de datos de este sitio',
'sent_api' => 'Enviados a la API',
'remaining_lbl' => 'Restantes',
'remaining_val' => '%s registros aún no enviados',
'send_history' => 'Enviar Historial',
'send_next' => 'Enviar Siguiente Lote',
'all_sent' => '✓ Todo el historial enviado',
'reset_progress' => 'Reiniciar Progreso',
],
'ro' => [
'tab_logs' => 'Jurnale Blocate',
'tab_api' => 'API Central',
'cleared' => 'Jurnale șterse.',
'saved' => 'Setări API salvate.',
'flushed' => 'Coada trimisă la API-ul central.',
'history_reset_msg' => 'Progresul sincronizării a fost resetat. Poți retrimite de la început.',
'click_next' => 'Apasă „Trimite Lotul Următor" pentru a continua.',
'total_blocked' => 'Total Blocate',
'today' => 'Azi',
'unique_ips' => 'IP-uri Unice',
'form_types_hit' => 'Tipuri de Formulare Atacate',
'search_ph' => 'Caută IP, UA, motiv…',
'filter_ip_ph' => 'Filtrează după IP',
'all_forms' => 'Toate tipurile',
'filter_btn' => 'Filtrează',
'reset_btn' => 'Resetează',
'clear_logs' => 'Șterge Toate Jurnalele',
'clear_confirm' => 'Ștergi TOATE înregistrările permanent?',
'col_date' => 'Dată / Oră',
'col_ip' => 'Adresă IP',
'col_form' => 'Tip Formular',
'col_reason' => 'Motiv',
'col_uri' => 'URI',
'col_ua' => 'User Agent',
'no_results' => 'Nu există tentative blocate încă.',
'filter_link' => 'filtrează',
'lookup_link' => 'caută ↗',
'prev' => '← Anterior',
'next' => 'Următor →',
'showing' => 'Afișând',
'results' => 'rezultat(e)',
'page_of' => 'pagina %d din %d',
'settings_title' => 'Setări API Central',
'settings_desc' => 'Trimite tentativele blocate la un panou central de informații despre amenințări. Date trimise: IP complet, tip formular, motiv blocare, familie UA și marcă temporală. Nu se trimit niciodată URL-ul site-ului sau nume de utilizatori.',
'enable_label' => 'Activează Trimiterea',
'enable_cb' => 'Trimite tentativele blocate la API-ul central',
'url_label' => 'URL Endpoint API',
'url_desc' => 'URL-ul de bază al containerului Docker API Honeypot.',
'token_label' => 'Token API',
'token_desc' => 'Trebuie să coincidă cu <code>API_TOKEN</code> din mediul containerului Docker.',
'token_const_note' => 'Tokenul este setat prin constanta <code>HP_API_TOKEN</code> în <code>wp-config.php</code>.',
'save_settings' => 'Salvează Setările',
'conn_status' => 'Status Conexiune',
'conn_label' => 'Conexiune',
'conn_ok' => 'Verificată — conectată și token acceptat',
'conn_fail' => 'Conexiune eșuată',
'conn_untested' => 'Netestată încă',
'last_verified' => 'Ultima Verificare',
'enabled_lbl' => 'Activat',
'yes' => 'Da',
'no' => 'Nu',
'last_sync' => 'Ultima Sincronizare',
'never' => 'Niciodată',
'total_sent' => 'Total Trimise',
'blocks_unit' => 'blocări',
'queue_size' => 'Dimensiune Coadă',
'pending_unit' => 'în așteptare',
'next_flush' => 'Următoarea Trimitere Automată',
'not_scheduled' => 'Neprogramată',
'test_conn' => 'Testează Conexiunea',
'flush_queue' => 'Trimite Coada Acum',
'history_title' => 'Trimite Istoricul la API',
'history_desc' => 'Populează panoul central cu jurnalul existent pentru ca statisticile să fie relevante imediat, fără a aștepta noi atacuri. Înregistrările se trimit în loturi de 50.',
'local_log' => 'Jurnal Local',
'local_log_val' => '%s înregistrări în baza de date a acestui site',
'sent_api' => 'Trimise la API',
'remaining_lbl' => 'Rămase',
'remaining_val' => '%s înregistrări netrimise',
'send_history' => 'Trimite Istoricul',
'send_next' => 'Trimite Lotul Următor',
'all_sent' => '✓ Tot istoricul a fost trimis',
'reset_progress' => 'Resetează Progresul',
],
];
// phpcs:enable
public static function get_lang(): string {
$user_id = get_current_user_id();
$lang = $user_id ? (string) get_user_meta($user_id, self::USER_META, true) : '';
return in_array($lang, ['en', 'es', 'ro'], true) ? $lang : 'en';
}
public static function t(string $key): string {
$lang = self::get_lang();
$strings = self::STRINGS[$lang] ?? self::STRINGS['en'];
return $strings[$key] ?? (self::STRINGS['en'][$key] ?? $key);
}
public static function flag_switcher(): string {
$lang = self::get_lang();
$nonce = wp_nonce_field('hp_admin_action', '_wpnonce', true, false);
$tab = sanitize_key($_GET['tab'] ?? 'logs');
$html = '<div style="display:inline-flex;gap:4px;align-items:center">';
foreach (['en' => '🇬🇧', 'es' => '🇪🇸', 'ro' => '🇷🇴'] as $code => $flag) {
$style = $lang === $code
? 'background:none;border:none;cursor:pointer;padding:2px;font-size:22px;opacity:1'
: 'background:none;border:none;cursor:pointer;padding:2px;font-size:16px;opacity:0.45';
$html .= sprintf(
'<form method="post" style="margin:0">%s'
. '<input type="hidden" name="tab" value="%s">'
. '<input type="hidden" name="hp_action" value="set_language">'
. '<input type="hidden" name="hp_lang" value="%s">'
. '<button type="submit" title="%s" style="%s">%s</button></form>',
$nonce,
esc_attr($tab),
esc_attr($code),
esc_attr(strtoupper($code)),
esc_attr($style),
$flag
);
}
$html .= '</div>';
return $html;
}
}
/* ======================================================================
* ADMIN PAGE
* ====================================================================*/
class SmartHoneypotAdmin {
const MENU_SLUG = 'honeypot-logs';
const NONCE_ACTION = 'hp_admin_action';
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_filter('plugin_action_links_' . plugin_basename(HP_PLUGIN_FILE), [self::class, 'plugin_links']);
}
public static function plugin_links($links) {
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;
}
public static function add_menu() {
add_menu_page(
'Honeypot Logs',
'Honeypot Logs',
'manage_options',
self::MENU_SLUG,
[self::class, 'render_page'],
'dashicons-shield-alt',
81
);
}
public static function enqueue_styles($hook) {
if ($hook !== 'toplevel_page_' . self::MENU_SLUG) {
return;
}
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() {
if (!isset($_POST['hp_action']) || !check_admin_referer(self::NONCE_ACTION)) {
return;
}
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, 'tab' => 'logs', 'cleared' => 1], admin_url('admin.php')));
exit;
}
if ($_POST['hp_action'] === 'save_api_settings') {
$current = SmartHoneypotAPIClient::settings();
$new_url = esc_url_raw(trim($_POST['hp_api_url'] ?? ''));
$new_token = defined('HP_API_TOKEN') ? $current['api_token'] : sanitize_text_field($_POST['hp_api_token'] ?? '');
$url_changed = $new_url !== $current['api_url'];
$tok_changed = $new_token !== $current['api_token'];
$new = [
'enabled' => !empty($_POST['hp_api_enabled']),
'api_url' => $new_url,
'api_token' => $new_token,
'last_sync' => $current['last_sync'],
'sent_total' => $current['sent_total'],
// Reset verification if URL or token changed
'connection_ok' => ($url_changed || $tok_changed) ? null : $current['connection_ok'],
'last_verified' => ($url_changed || $tok_changed) ? 0 : $current['last_verified'],
'last_error' => ($url_changed || $tok_changed) ? '' : $current['last_error'],
];
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'] === 'test_connection') {
$result = SmartHoneypotAPIClient::test_connection();
$s = SmartHoneypotAPIClient::settings();
$s['connection_ok'] = $result['ok'];
$s['last_verified'] = time();
$s['last_error'] = $result['ok'] ? '' : $result['message'];
update_option(SmartHoneypotAPIClient::OPT_SETTINGS, $s);
set_transient('hp_conn_result', $result, 60);
wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'tested' => 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;
}
if ($_POST['hp_action'] === 'send_history') {
$result = SmartHoneypotAPIClient::send_history_batch();
set_transient('hp_history_result', $result, 60);
wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'history' => 1], admin_url('admin.php')));
exit;
}
if ($_POST['hp_action'] === 'reset_history') {
delete_option('hp_history_last_id');
delete_option('hp_history_total_sent');
wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => 'settings', 'history_reset' => 1], admin_url('admin.php')));
exit;
}
if ($_POST['hp_action'] === 'set_language') {
$lang = sanitize_key($_POST['hp_lang'] ?? 'en');
if (in_array($lang, ['en', 'es', 'ro'], true)) {
update_user_meta(get_current_user_id(), SmartHoneypotI18n::USER_META, $lang);
}
$tab = sanitize_key($_GET['tab'] ?? 'logs');
wp_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => $tab], admin_url('admin.php')));
exit;
}
}
public static function render_page() {
if (!current_user_can('manage_options')) {
return;
}
$tab = sanitize_key($_GET['tab'] ?? 'logs');
?>
<div class="wrap" id="hp-wrap">
<h1 class="wp-heading-inline">Honeypot Fields</h1>
<span style="margin-left:14px;vertical-align:middle"><?= SmartHoneypotI18n::flag_switcher() ?></span>
<?php if (!empty($_GET['cleared'])): ?>
<div class="notice notice-success is-dismissible"><p><?= SmartHoneypotI18n::t('cleared') ?></p></div>
<?php endif; ?>
<?php if (!empty($_GET['saved'])): ?>
<div class="notice notice-success is-dismissible"><p><?= SmartHoneypotI18n::t('saved') ?></p></div>
<?php endif; ?>
<?php if (!empty($_GET['flushed'])): ?>
<div class="notice notice-success is-dismissible"><p><?= SmartHoneypotI18n::t('flushed') ?></p></div>
<?php endif; ?>
<?php if (!empty($_GET['tested'])):
$res = get_transient('hp_conn_result');
if ($res):
$cls = $res['ok'] ? 'notice-success' : 'notice-error'; ?>
<div class="notice <?= $cls ?> is-dismissible"><p><?= esc_html($res['message']) ?></p></div>
<?php endif;
endif; ?>
<?php if (!empty($_GET['history'])):
$res = get_transient('hp_history_result');
if ($res):
$cls = $res['ok'] ? 'notice-success' : 'notice-error'; ?>
<div class="notice <?= $cls ?> is-dismissible">
<p><?= esc_html($res['message']) ?>
<?php if (!empty($res['has_more'])): ?>
&nbsp;<strong><?= SmartHoneypotI18n::t('click_next') ?></strong>
<?php endif; ?>
</p>
</div>
<?php endif;
endif; ?>
<?php if (!empty($_GET['history_reset'])): ?>
<div class="notice notice-info is-dismissible"><p><?= SmartHoneypotI18n::t('history_reset_msg') ?></p></div>
<?php endif; ?>
<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' : '' ?>">
<?= SmartHoneypotI18n::t('tab_logs') ?>
</a>
<a href="<?= esc_url(admin_url('admin.php?page=' . self::MENU_SLUG . '&tab=settings')) ?>"
class="nav-tab <?= $tab === 'settings' ? 'nav-tab-active' : '' ?>">
<?= SmartHoneypotI18n::t('tab_api') ?>
</a>
</nav>
<?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() {
$T = fn(string $k): string => SmartHoneypotI18n::t($k);
$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"><?= $T('total_blocked') ?></div></div>
<div class="hp-stat-card"><div class="hp-stat-num"><?= number_format($today) ?></div><div class="hp-stat-lbl"><?= $T('today') ?></div></div>
<div class="hp-stat-card"><div class="hp-stat-num"><?= number_format($unique_ips) ?></div><div class="hp-stat-lbl"><?= $T('unique_ips') ?></div></div>
<div class="hp-stat-card"><div class="hp-stat-num"><?= count($form_types) ?></div><div class="hp-stat-lbl"><?= $T('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="<?= esc_attr($T('search_ph')) ?>" value="<?= esc_attr($search) ?>" size="28">
<input type="text" name="hp_ip" placeholder="<?= esc_attr($T('filter_ip_ph')) ?>" value="<?= esc_attr($filter_ip) ?>">
<select name="hp_form">
<option value=""><?= $T('all_forms') ?></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"><?= $T('filter_btn') ?></button>
<?php if ($search || $filter_ip || $filter_form): ?>
<a href="<?= esc_url($base) ?>" class="button"><?= $T('reset_btn') ?></a>
<?php endif; ?>
<span style="flex:1"></span>
<form method="post" style="display:inline" onsubmit="return confirm('<?= esc_js($T('clear_confirm')) ?>')">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="hp_action" value="clear_logs">
<button type="submit" class="button hp-red"><?= $T('clear_logs') ?></button>
</form>
</div>
</form>
<p><?= $T('showing') ?> <strong><?= number_format($total) ?></strong> <?= $T('results') ?> (<?= sprintf($T('page_of'), $paged, $total_pages) ?>)</p>
<table class="hp-log widefat">
<thead>
<tr><th>#</th><th><?= $T('col_date') ?></th><th><?= $T('col_ip') ?></th><th><?= $T('col_form') ?></th><th><?= $T('col_reason') ?></th><th><?= $T('col_uri') ?></th><th><?= $T('col_ua') ?></th></tr>
</thead>
<tbody>
<?php if (empty($rows)): ?>
<tr><td colspan="7" style="text-align:center;padding:24px;color:#646970"><?= $T('no_results') ?></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"><?= $T('filter_link') ?></a>
&nbsp;<a href="https://ipinfo.io/<?= esc_attr(urlencode($row->ip_address)) ?>" target="_blank" style="font-size:11px"><?= $T('lookup_link') ?></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]))) ?>"><?= $T('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]))) ?>"><?= $T('next') ?></a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php
}
/* ── Settings / API tab ───────────────────────────────────────── */
private static function render_settings_tab() {
$T = fn(string $k): string => SmartHoneypotI18n::t($k);
$s = SmartHoneypotAPIClient::settings();
$queue_size = SmartHoneypotAPIClient::queue_size();
$next_run = wp_next_scheduled('hp_api_flush');
$token_via_const = defined('HP_API_TOKEN') && HP_API_TOKEN !== '';
?>
<div style="max-width:700px;margin-top:20px">
<h2><?= $T('settings_title') ?></h2>
<p style="color:#646970;margin-bottom:16px"><?= $T('settings_desc') ?></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><?= $T('enable_label') ?></th>
<td>
<label>
<input type="checkbox" name="hp_api_enabled" value="1" <?= checked($s['enabled']) ?>>
<?= $T('enable_cb') ?>
</label>
</td>
</tr>
<tr>
<th><?= $T('url_label') ?></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"><?= $T('url_desc') ?></p>
</td>
</tr>
<tr>
<th><?= $T('token_label') ?></th>
<td>
<?php if ($token_via_const): ?>
<input type="text" class="regular-text" value="••••••••••••" disabled>
<p class="description"><?= $T('token_const_note') ?></p>
<?php else: ?>
<input type="password" name="hp_api_token" value="<?= esc_attr($s['api_token']) ?>"
class="regular-text" placeholder="Bearer token (matches API_TOKEN in docker-compose)">
<p class="description"><?= $T('token_desc') ?></p>
<?php endif; ?>
</td>
</tr>
</table>
<?php submit_button($T('save_settings')); ?>
</form>
<hr>
<h3><?= $T('conn_status') ?></h3>
<?php
$conn = $s['connection_ok'];
if ($conn === true) {
$dot_class = 'dot-on';
$dot_label = $T('conn_ok');
$dot_style = '';
} elseif ($conn === false) {
$dot_class = 'dot-fail';
$dot_label = $T('conn_fail');
$dot_style = 'background:#b32d2e;';
} else {
$dot_class = 'dot-off';
$dot_label = $T('conn_untested');
$dot_style = '';
}
?>
<table class="form-table">
<tr>
<th><?= $T('conn_label') ?></th>
<td>
<span class="hp-api-status">
<span class="dot <?= $dot_class ?>" style="<?= $dot_style ?>"></span>
<?= esc_html($dot_label) ?>
</span>
<?php if ($conn === false && $s['last_error']): ?>
<p class="description" style="color:#b32d2e;margin-top:4px"><?= esc_html($s['last_error']) ?></p>
<?php endif; ?>
</td>
</tr>
<?php if ($s['last_verified']): ?>
<tr><th><?= $T('last_verified') ?></th><td><?= esc_html(date('Y-m-d H:i:s', $s['last_verified'])) ?></td></tr>
<?php endif; ?>
<tr><th><?= $T('enabled_lbl') ?></th><td><?= $s['enabled'] ? $T('yes') : $T('no') ?></td></tr>
<tr><th><?= $T('last_sync') ?></th><td><?= $s['last_sync'] ? esc_html(date('Y-m-d H:i:s', $s['last_sync'])) : $T('never') ?></td></tr>
<tr><th><?= $T('total_sent') ?></th><td><?= number_format((int) $s['sent_total']) ?> <?= $T('blocks_unit') ?></td></tr>
<tr><th><?= $T('queue_size') ?></th><td><?= number_format($queue_size) ?> <?= $T('pending_unit') ?></td></tr>
<tr><th><?= $T('next_flush') ?></th><td><?= $next_run ? esc_html(date('Y-m-d H:i:s', $next_run)) . ' (every 5 min)' : $T('not_scheduled') ?></td></tr>
</table>
<div style="display:flex;gap:10px;margin-top:14px;flex-wrap:wrap;align-items:center">
<?php if ($s['api_url']): ?>
<form method="post">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="hp_action" value="test_connection">
<button type="submit" class="button button-primary"><?= $T('test_conn') ?></button>
</form>
<?php endif; ?>
<?php if ($queue_size > 0 && $s['enabled'] && $s['api_url']): ?>
<form method="post">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="hp_action" value="flush_queue">
<button type="submit" class="button button-secondary"><?= $T('flush_queue') ?> (<?= $queue_size ?> <?= $T('pending_unit') ?>)</button>
</form>
<?php endif; ?>
</div>
<?php if ($s['api_url'] && $s['enabled']): ?>
<hr>
<h3><?= $T('history_title') ?></h3>
<p style="color:#646970;margin-bottom:12px"><?= $T('history_desc') ?></p>
<?php
$local_total = SmartHoneypotDB::count();
$history_sent = (int) get_option('hp_history_total_sent', 0);
$remaining = max(0, $local_total - $history_sent);
$pct = $local_total > 0 ? min(100, round($history_sent / $local_total * 100)) : 0;
?>
<table class="form-table">
<tr>
<th><?= $T('local_log') ?></th>
<td><?= sprintf($T('local_log_val'), number_format($local_total)) ?></td>
</tr>
<tr>
<th><?= $T('sent_api') ?></th>
<td>
<?= number_format($history_sent) ?> (<?= $pct ?>%)
<?php if ($local_total > 0): ?>
<div style="margin-top:6px;background:#f0f0f1;border-radius:3px;height:8px;max-width:300px">
<div style="background:#2271b1;height:100%;border-radius:3px;width:<?= $pct ?>%;transition:width .4s"></div>
</div>
<?php endif; ?>
</td>
</tr>
<tr>
<th><?= $T('remaining_lbl') ?></th>
<td><?= sprintf($T('remaining_val'), number_format($remaining)) ?></td>
</tr>
</table>
<div style="display:flex;gap:10px;margin-top:12px;flex-wrap:wrap;align-items:center">
<?php if ($remaining > 0): ?>
<form method="post">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="hp_action" value="send_history">
<button type="submit" class="button button-secondary">
<?= $history_sent === 0 ? $T('send_history') : $T('send_next') ?>
(<?= min(50, $remaining) ?> of <?= number_format($remaining) ?> <?= $T('pending_unit') ?>)
</button>
</form>
<?php else: ?>
<span style="color:#00a32a;font-weight:600"><?= $T('all_sent') ?></span>
<?php endif; ?>
<?php if ($history_sent > 0): ?>
<form method="post" onsubmit="return confirm('Reset history sync progress? This allows re-sending all records from the beginning.')">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="hp_action" value="reset_history">
<button type="submit" class="button"><?= $T('reset_progress') ?></button>
</form>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php
}
}
/* ======================================================================
* MAIN PLUGIN CLASS
* ====================================================================*/
class SmartHoneypotAntiSpam {
private $hp_name;
private $token_name;
private $time_name;
private $current_form_type = 'Unknown';
private const MIN_SUBMIT_TIME = 3;
private const MAX_SUBMIT_TIME = 7200;
private const RATE_LIMIT = 3;
private $secret;
public function __construct() {
$this->secret = wp_hash('honeypot_plugin_secret_v2');
$this->hp_name = 'website_url_confirm';
$this->token_name = 'form_session_id';
$this->time_name = 'form_render_ts';
add_action('init', [$this, 'init']);
}
/* ── Init ──────────────────────────────────────────────────────── */
public function init() {
// CF7 and Elementor submit via admin-ajax.php where is_admin()=true,
// so register their validation hooks before the early return.
add_filter('wpcf7_spam', [$this, 'validate_cf7_spam'], 10, 2);
add_action('elementor_pro/forms/validation', [$this, 'validate_elementor_form'], 10, 2);
if (is_admin()) {
add_action('admin_notices', [$this, 'activation_notice']);
return;
}
// 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_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('lostpassword_form', [$this, 'echo_honeypot']);
add_action('woocommerce_lostpassword_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('authenticate', [$this, 'validate_wp_login'], 20, 3);
add_action('lostpassword_post', [$this, 'validate_lost_password'], 10, 2);
add_filter('preprocess_comment', [$this, 'validate_comment']);
add_action('template_redirect', [$this, 'validate_generic_post']);
// CSS & JS (wp-login.php uses login_head/login_footer, not wp_head/footer)
add_action('wp_head', [$this, 'print_css']);
add_action('wp_footer', [$this, 'print_js'], 99);
add_action('login_head', [$this, 'print_css']);
add_action('login_footer', [$this, 'print_js'], 99);
}
/* ── Honeypot HTML ─────────────────────────────────────────────── */
private function get_honeypot_html(): string {
return sprintf(
'<div class="frm-extra-field" aria-hidden="true">
<label for="%1$s">Website URL Confirmation</label>
<input type="text" id="%1$s" name="%1$s" value="" tabindex="-1" autocomplete="off" />
<input type="hidden" name="%2$s" value="" />
<input type="hidden" name="%3$s" value="%4$s" />
</div>',
esc_attr($this->hp_name),
esc_attr($this->token_name),
esc_attr($this->time_name),
esc_attr(time())
);
}
public function echo_honeypot() {
echo $this->get_honeypot_html();
}
/* ── Injection helpers ─────────────────────────────────────────── */
public function add_to_content_forms($content) {
if (is_admin() || is_feed()) {
return $content;
}
return preg_replace_callback(
'/(<form\b[^>]*>)(.*?)(<\/form>)/is',
fn($m) => $m[1] . $m[2] . $this->get_honeypot_html() . $m[3],
$content
);
}
public function add_to_search_form($form) {
return preg_replace('/(<\/form>)/i', $this->get_honeypot_html() . '$1', $form, 1);
}
public function add_to_elementor_form($field, $instance) {
static $done = false;
if (!$done && $field['type'] === 'submit') { $done = true; echo $this->get_honeypot_html(); }
}
public function filter_elementor_widget($content, $widget) {
if ($widget->get_name() === 'form') {
$content = preg_replace('/(<\/form>)/i', $this->get_honeypot_html() . '$1', $content, 1);
}
return $content;
}
public function add_to_gravity_forms($tag, $form) {
return preg_replace('/(<div class="gform_footer)/i', $this->get_honeypot_html() . '$1', $tag, 1);
}
public function add_to_cf7($form) {
return preg_replace('/(\[submit[^\]]*\])/i', $this->get_honeypot_html() . '$1', $form, 1);
}
/* ── 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)');
return false;
}
if (isset($_POST[$this->hp_name]) && $_POST[$this->hp_name] !== '') {
$this->log_spam('Honeypot field was filled in');
return false;
}
if ($require_fields && empty($_POST[$this->token_name])) {
$this->log_spam('JS token absent (no JavaScript)');
return false;
}
if (!empty($_POST[$this->token_name]) && !$this->validate_js_token($_POST[$this->token_name])) {
$this->log_spam('JS token invalid or tampered');
return false;
}
if ($require_fields && !isset($_POST[$this->time_name])) {
$this->log_spam('Timestamp field missing');
return false;
}
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)"); return false; }
if ($diff > self::MAX_SUBMIT_TIME) { $this->log_spam("Timestamp expired ({$diff}s)"); return false; }
}
$this->clean_post_data();
return true;
}
private function validate_js_token(string $token): bool {
$parts = explode('|', $token);
if (count($parts) !== 2) {
return false;
}
$expected = hash_hmac('sha256', $parts[0] . '|honeypot_js_proof', $this->secret);
return hash_equals(substr($expected, 0, 16), $parts[1]);
}
private function clean_post_data() {
foreach ([$this->hp_name, $this->token_name, $this->time_name] as $k) {
unset($_POST[$k], $_REQUEST[$k]);
}
}
private function log_spam(string $reason) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$uri = $_SERVER['REQUEST_URI'] ?? '';
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
// Write to local DB
SmartHoneypotDB::insert([
'ip' => $ip,
'form' => $this->current_form_type,
'reason' => $reason,
'uri' => $uri,
'ua' => $ua,
]);
// 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 ─────────────────────────────────────────────── */
private function check_rate_limit(): bool {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if (!$ip) {
return false;
}
$key = 'hp_rate_' . md5($ip);
$count = (int) get_transient($key);
if ($count >= self::RATE_LIMIT) {
$this->log_spam("Rate limit exceeded ({$count}/hr from {$ip})");
return false;
}
set_transient($key, $count + 1, HOUR_IN_SECONDS);
return true;
}
/* ── WooCommerce ───────────────────────────────────────────────── */
public function validate_wc_registration($errors, $username, $password, $email) {
$this->current_form_type = 'WooCommerce Registration';
if (!$this->check_submission(true)) {
$errors->add('honeypot_spam', __('<strong>Error</strong>: Registration blocked. Enable JavaScript and try again.', 'smart-honeypot'));
return $errors;
}
if (!$this->check_rate_limit()) {
$errors->add('honeypot_rate', __('<strong>Error</strong>: Too many attempts. Try again later.', 'smart-honeypot'));
}
return $errors;
}
public function validate_wc_login($errors, $username, $password) {
$this->current_form_type = 'WooCommerce Login';
if (!$this->check_submission(true)) {
$errors->add('honeypot_spam', __('<strong>Error</strong>: Login blocked. Enable JavaScript and try again.', 'smart-honeypot'));
}
return $errors;
}
public function validate_wc_checkout($data, $errors) {
$this->current_form_type = 'WooCommerce Checkout';
if (!$this->check_submission(false)) {
$errors->add('honeypot_spam', __('Order could not be processed. Please try again.', 'smart-honeypot'));
}
}
/* ── WordPress core ────────────────────────────────────────────── */
public function validate_wp_registration($errors, $login, $email) {
$this->current_form_type = 'WP Registration';
if (!$this->check_submission(true)) {
$errors->add('honeypot_spam', __('<strong>Error</strong>: Registration blocked. Enable JavaScript and try again.', 'smart-honeypot'));
}
if (!$this->check_rate_limit()) {
$errors->add('honeypot_rate', __('<strong>Error</strong>: Too many attempts. Try again later.', 'smart-honeypot'));
}
return $errors;
}
public function validate_wp_login($user, $username, $password) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
return $user;
}
// Only validate if our honeypot was injected (fields present in POST)
if (!isset($_POST[$this->hp_name]) && !isset($_POST[$this->token_name])) {
return $user;
}
// WooCommerce login has its own validator
if (isset($_POST['woocommerce-login-nonce'])) {
return $user;
}
$this->current_form_type = 'WP Login';
if (!$this->check_submission(true)) {
return new \WP_Error(
'honeypot_spam',
__('<strong>Error</strong>: Login blocked. Enable JavaScript and try again.', 'smart-honeypot')
);
}
return $user;
}
public function validate_lost_password($errors, $user_data) {
$this->current_form_type = 'WP Password Reset';
if (!$this->check_submission(true)) {
$errors->add(
'honeypot_spam',
__('<strong>Error</strong>: Request blocked. Enable JavaScript and try again.', 'smart-honeypot')
);
}
}
public function validate_comment($commentdata) {
if (is_user_logged_in() && current_user_can('moderate_comments')) {
$this->clean_post_data();
return $commentdata;
}
$this->current_form_type = 'Comment Form';
if (!$this->check_submission(true)) {
wp_die(
__('Comment blocked as spam. Enable JavaScript and try again.', 'smart-honeypot'),
__('Spam Detected', 'smart-honeypot'),
['response' => 403, 'back_link' => true]
);
}
return $commentdata;
}
/* ── Elementor ─────────────────────────────────────────────────── */
public function validate_elementor_form($record, $ajax_handler) {
$this->current_form_type = 'Elementor Form';
if (!$this->check_submission(true)) {
$ajax_handler->add_error('honeypot', __('Spam detected. Please try again.', 'smart-honeypot'));
}
}
/* ── Contact Form 7 ────────────────────────────────────────────── */
public function validate_cf7_spam($spam, $submission) {
if ($spam) return true; // already flagged
$this->current_form_type = 'Contact Form 7';
return !$this->check_submission(true);
}
/* ── Generic catch-all ─────────────────────────────────────────── */
public function validate_generic_post() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
return;
}
if (!isset($_POST[$this->hp_name]) && !isset($_POST[$this->token_name])) {
return;
}
if (
isset($_POST['woocommerce-register-nonce']) ||
isset($_POST['woocommerce-login-nonce']) ||
isset($_POST['woocommerce-process-checkout-nonce']) ||
isset($_POST['comment_post_ID']) ||
isset($_POST['_wpcf7']) || // CF7: handled by wpcf7_spam filter
(isset($_POST['action']) && $_POST['action'] === 'elementor_pro_forms_send_form') ||
isset($_POST['woocommerce-lost-password-nonce']) // WC lost password: handled by lostpassword_post
) {
return;
}
$this->current_form_type = 'Generic Form';
if (!$this->check_submission(false)) {
if (wp_doing_ajax()) {
wp_send_json_error(['message' => __('Spam detected.', 'smart-honeypot')]);
}
wp_safe_redirect(wp_get_referer() ?: home_url());
exit;
}
}
/* ── 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 ──────────────────────────── */
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(){
function computeToken(ts,secret){
var msg=ts+'|honeypot_js_proof',enc=new TextEncoder();
return crypto.subtle.importKey('raw',enc.encode(secret),{name:'HMAC',hash:'SHA-256'},false,['sign'])
.then(function(k){return crypto.subtle.sign('HMAC',k,enc.encode(msg));})
.then(function(s){
var h=Array.from(new Uint8Array(s)).map(function(b){return b.toString(16).padStart(2,'0')}).join('');
return h.substring(0,16);
});
}
function fillTokens(){
document.querySelectorAll('input[name="{$token_name}"]').forEach(function(inp){
var p=inp.closest('div')||inp.parentElement;
var tsInp=p?p.querySelector('input[name="{$time_name}"]'):null;
var ts=tsInp?tsInp.value:String(Math.floor(Date.now()/1000));
computeToken(ts,'{$secret}').then(function(h){inp.value=ts+'|'+h;});
});
}
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fillTokens);}else{fillTokens();}
new MutationObserver(function(mm){
mm.forEach(function(m){m.addedNodes.forEach(function(n){
if(n.nodeType===1&&n.querySelector&&n.querySelector('input[name="{$token_name}"]'))fillTokens();
});});
}).observe(document.body,{childList:true,subtree:true});
})();
</script>
JSBLOCK;
}
/* ── 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>';
delete_transient('smart_honeypot_activated');
}
}
}
/* ======================================================================
* BOOT
* ====================================================================*/
define('HP_PLUGIN_FILE', __FILE__);
add_action('plugins_loaded', function () {
if ((int) get_option(SmartHoneypotDB::TABLE_VERSION_OPTION) < SmartHoneypotDB::TABLE_VERSION) {
SmartHoneypotDB::install();
}
new SmartHoneypotAntiSpam();
SmartHoneypotAdmin::register();
// Fallback: flush queue on every request's shutdown (rate-limited via transient)
add_action('shutdown', ['SmartHoneypotAPIClient', 'maybe_flush_overdue']);
});
// 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');
});