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:
3
api/.env.example
Normal file
3
api/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
PORT=3000
|
||||
DB_PATH=/data/honeypot.db
|
||||
NODE_ENV=production
|
||||
17
api/Dockerfile
Normal file
17
api/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
# Native deps for better-sqlite3
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /data
|
||||
|
||||
EXPOSE 3000
|
||||
VOLUME ["/data"]
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
22
api/docker-compose.yml
Normal file
22
api/docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
honeypot-api:
|
||||
build: .
|
||||
container_name: honeypot-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- honeypot-data:/data
|
||||
environment:
|
||||
- PORT=3000
|
||||
- DB_PATH=/data/honeypot.db
|
||||
- NODE_ENV=production
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/v1/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
honeypot-data:
|
||||
driver: local
|
||||
17
api/package.json
Normal file
17
api/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "honeypot-api",
|
||||
"version": "1.0.0",
|
||||
"description": "Centralized honeypot block aggregation API + dashboard",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"express": "^4.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
621
api/public/index.html
Normal file
621
api/public/index.html
Normal file
@@ -0,0 +1,621 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HONEYPOT // NETWORK MONITOR</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #000a00;
|
||||
--bg2: #010f01;
|
||||
--green: #00ff41;
|
||||
--green2: #00cc33;
|
||||
--dim: #006600;
|
||||
--muted: #002800;
|
||||
--border: #003300;
|
||||
--red: #ff3333;
|
||||
--amber: #ffaa00;
|
||||
--white: #ccffcc;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html { scrollbar-color: var(--dim) var(--bg); scrollbar-width: thin; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--green);
|
||||
font-family: 'Courier New', 'Lucida Console', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* CRT scanline overlay */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg, transparent, transparent 2px,
|
||||
rgba(0,0,0,.07) 2px, rgba(0,0,0,.07) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.glow { text-shadow: 0 0 12px var(--green), 0 0 24px var(--green); }
|
||||
.glow-sm { text-shadow: 0 0 6px var(--green); }
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────── */
|
||||
header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg2);
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 17px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 4px;
|
||||
color: var(--green);
|
||||
}
|
||||
.logo em { color: var(--amber); font-style: normal; }
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
font-size: 11px;
|
||||
color: var(--green2);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
display: inline-block;
|
||||
width: 8px; height: 8px;
|
||||
background: var(--red);
|
||||
border-radius: 50%;
|
||||
animation: blink 1s step-end infinite;
|
||||
box-shadow: 0 0 6px var(--red);
|
||||
vertical-align: middle;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
/* ── Main ───────────────────────────────────────────────────────────── */
|
||||
main { padding: 14px 16px; max-width: 1700px; margin: 0 auto; }
|
||||
|
||||
/* ── Stat cards ─────────────────────────────────────────────────────── */
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px 12px 12px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 2px;
|
||||
background: var(--muted);
|
||||
transition: background .2s, box-shadow .2s;
|
||||
}
|
||||
.stat-card:hover { border-color: var(--dim); }
|
||||
.stat-card:hover::before { background: var(--green); box-shadow: 0 0 8px var(--green); }
|
||||
|
||||
.stat-num {
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
line-height: 1.1;
|
||||
color: var(--green);
|
||||
}
|
||||
.stat-lbl {
|
||||
font-size: 9px;
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
color: var(--green2);
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* ── Content grid ───────────────────────────────────────────────────── */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 360px;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.left-col { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
/* ── Panel ──────────────────────────────────────────────────────────── */
|
||||
.panel {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.panel-hdr {
|
||||
padding: 7px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 10px;
|
||||
letter-spacing: 3px;
|
||||
color: var(--amber);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.panel-body { padding: 12px 14px; }
|
||||
|
||||
/* ── 24h Chart ──────────────────────────────────────────────────────── */
|
||||
#chart { width: 100%; height: 80px; display: block; }
|
||||
|
||||
/* ── Bar lists ──────────────────────────────────────────────────────── */
|
||||
.bars-2col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
.bar-section-title {
|
||||
font-size: 10px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--amber);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.bar-list { list-style: none; }
|
||||
.bar-item {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr 55px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bar-lbl {
|
||||
color: var(--white);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bar-track { background: var(--muted); height: 8px; }
|
||||
.bar-fill { background: var(--green); height: 100%; transition: width .5s ease; }
|
||||
.bar-cnt { color: var(--green2); font-size: 11px; text-align: right; }
|
||||
|
||||
/* ── Live feed ──────────────────────────────────────────────────────── */
|
||||
.feed-panel { display: flex; flex-direction: column; min-height: 500px; }
|
||||
|
||||
#feed {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 14px;
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
#feed::-webkit-scrollbar { width: 3px; }
|
||||
#feed::-webkit-scrollbar-track { background: var(--bg); }
|
||||
#feed::-webkit-scrollbar-thumb { background: var(--dim); }
|
||||
|
||||
.feed-row {
|
||||
display: grid;
|
||||
grid-template-columns: 62px 90px auto;
|
||||
gap: 6px;
|
||||
border-bottom: 1px solid var(--muted);
|
||||
padding: 1px 0;
|
||||
align-items: start;
|
||||
}
|
||||
.feed-ts { color: var(--dim); }
|
||||
.feed-ip { color: var(--amber); }
|
||||
.feed-form { color: var(--green2); }
|
||||
.feed-reason { color: var(--dim); font-size: 10px; }
|
||||
|
||||
.feed-footer {
|
||||
padding: 7px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 10px;
|
||||
color: var(--dim);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Blinking cursor */
|
||||
.cursor::after { content: '█'; animation: blink 1s step-end infinite; }
|
||||
|
||||
/* ── Attackers table ────────────────────────────────────────────────── */
|
||||
.atk-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.atk-table th {
|
||||
padding: 7px 14px;
|
||||
text-align: left;
|
||||
color: var(--amber);
|
||||
font-size: 10px;
|
||||
letter-spacing: 2px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.atk-table td { padding: 6px 14px; border-bottom: 1px solid var(--muted); }
|
||||
.atk-table tr:hover td { background: var(--muted); }
|
||||
|
||||
.atk-rank { color: var(--dim); }
|
||||
.atk-ip { color: var(--amber); font-weight: bold; }
|
||||
.atk-hits { color: var(--green); font-weight: bold; }
|
||||
|
||||
.mini-bar { height: 8px; background: var(--muted); max-width: 240px; width: 100%; }
|
||||
.mini-fill { background: var(--green); height: 100%; box-shadow: 0 0 4px var(--green); transition: width .5s; }
|
||||
|
||||
/* ── Footer ─────────────────────────────────────────────────────────── */
|
||||
footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 9px 20px;
|
||||
font-size: 10px;
|
||||
color: var(--dim);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
letter-spacing: 1px;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────────────── */
|
||||
@media (max-width: 1100px) {
|
||||
.stats-row { grid-template-columns: repeat(3, 1fr); }
|
||||
.content-grid { grid-template-columns: 1fr; }
|
||||
.feed-panel { min-height: 350px; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.stats-row { grid-template-columns: 1fr 1fr; }
|
||||
.bars-2col { grid-template-columns: 1fr; }
|
||||
.bar-item { grid-template-columns: 110px 1fr 45px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="logo glow">[HONEYPOT<em>]</em> // NETWORK THREAT INTELLIGENCE</div>
|
||||
<div class="header-right">
|
||||
<span id="clock">--:--:--</span>
|
||||
<span><span class="live-dot"></span>LIVE FEED</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<!-- ── Stats row ──────────────────────────────────────────────────── -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-num glow" id="s-total">…</div>
|
||||
<div class="stat-lbl">Total Blocked</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-num glow" id="s-today">…</div>
|
||||
<div class="stat-lbl">Today</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-num" id="s-7d">…</div>
|
||||
<div class="stat-lbl">Last 7 Days</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-num" id="s-30d">…</div>
|
||||
<div class="stat-lbl">Last 30 Days</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-num glow" id="s-sites">…</div>
|
||||
<div class="stat-lbl">Sites Reporting</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Content grid ───────────────────────────────────────────────── -->
|
||||
<div class="content-grid">
|
||||
|
||||
<div class="left-col">
|
||||
|
||||
<!-- 24h chart -->
|
||||
<div class="panel">
|
||||
<div class="panel-hdr">
|
||||
<span>▶ 24H ACTIVITY TREND</span>
|
||||
<span id="chart-peak" style="color:var(--dim);font-size:11px"></span>
|
||||
</div>
|
||||
<div class="panel-body" style="padding:10px 12px">
|
||||
<canvas id="chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bar charts -->
|
||||
<div class="panel">
|
||||
<div class="panel-hdr">▶ ATTACK BREAKDOWN // LAST 30 DAYS</div>
|
||||
<div class="panel-body">
|
||||
<div class="bars-2col">
|
||||
<div>
|
||||
<div class="bar-section-title">FORM TYPES</div>
|
||||
<ul class="bar-list" id="bars-forms"></ul>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bar-section-title">BOT TOOLKIT</div>
|
||||
<ul class="bar-list" id="bars-ua"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block reasons -->
|
||||
<div class="panel">
|
||||
<div class="panel-hdr">▶ BLOCK REASONS // LAST 30 DAYS</div>
|
||||
<div class="panel-body">
|
||||
<ul class="bar-list" id="bars-reasons"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.left-col -->
|
||||
|
||||
<!-- Live feed -->
|
||||
<div class="panel feed-panel">
|
||||
<div class="panel-hdr">
|
||||
<span>▶ LIVE THREAT FEED</span>
|
||||
<span id="feed-count" style="color:var(--dim);font-size:11px">0 events</span>
|
||||
</div>
|
||||
<div id="feed"></div>
|
||||
<div class="feed-footer">
|
||||
<span class="cursor"></span>
|
||||
<span id="feed-status" style="color:var(--dim)">connecting…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.content-grid -->
|
||||
|
||||
<!-- ── Top Attackers ──────────────────────────────────────────────── -->
|
||||
<div class="panel" style="margin-bottom:10px">
|
||||
<div class="panel-hdr">▶ TOP ATTACKERS // LAST 30 DAYS</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table class="atk-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:50px">RANK</th>
|
||||
<th>IP ADDRESS</th>
|
||||
<th style="width:110px">TOTAL HITS</th>
|
||||
<th>FREQUENCY</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="atk-body">
|
||||
<tr><td colspan="4" style="text-align:center;padding:20px;color:var(--dim)">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>HONEYPOT NETWORK MONITOR // ANONYMOUS THREAT INTELLIGENCE // ALL DATA IS ANONYMISED</span>
|
||||
<span>REFRESHED: <span id="last-update">--</span></span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// ── Clock ─────────────────────────────────────────────────────────────────────
|
||||
const clockEl = document.getElementById('clock');
|
||||
function tick() {
|
||||
const d = new Date();
|
||||
clockEl.textContent = d.toISOString().slice(11,19) + ' UTC';
|
||||
}
|
||||
tick(); setInterval(tick, 1000);
|
||||
|
||||
// ── CountUp animation ─────────────────────────────────────────────────────────
|
||||
const ctMap = new Map();
|
||||
function countUp(el, to) {
|
||||
const from = parseInt(el.textContent.replace(/,/g,'')) || 0;
|
||||
if (from === to) return;
|
||||
const steps = 25, diff = to - from;
|
||||
let s = 0;
|
||||
clearInterval(ctMap.get(el));
|
||||
const id = setInterval(() => {
|
||||
s++;
|
||||
el.textContent = Math.round(from + diff * (s / steps)).toLocaleString();
|
||||
if (s >= steps) { el.textContent = to.toLocaleString(); clearInterval(id); }
|
||||
}, 16);
|
||||
ctMap.set(el, id);
|
||||
}
|
||||
|
||||
// ── Bar charts ────────────────────────────────────────────────────────────────
|
||||
function renderBars(listEl, items) {
|
||||
if (!items || !items.length) {
|
||||
listEl.innerHTML = '<li style="color:var(--dim);font-size:11px;padding:4px 0">No data yet</li>';
|
||||
return;
|
||||
}
|
||||
const max = items[0].hits;
|
||||
listEl.innerHTML = items.map(item => {
|
||||
const label = item.form_type || item.ua_family || item.reason || item.ip || '?';
|
||||
const pct = max > 0 ? Math.round((item.hits / max) * 100) : 0;
|
||||
return `<li class="bar-item">
|
||||
<span class="bar-lbl" title="${esc(label)}">${esc(label)}</span>
|
||||
<div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
|
||||
<span class="bar-cnt">${item.hits.toLocaleString()}</span>
|
||||
</li>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── 24h Canvas Chart ──────────────────────────────────────────────────────────
|
||||
const canvas = document.getElementById('chart');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
function drawChart(hourly) {
|
||||
const W = canvas.offsetWidth || 600;
|
||||
const H = 80;
|
||||
canvas.width = W * (window.devicePixelRatio || 1);
|
||||
canvas.height = H * (window.devicePixelRatio || 1);
|
||||
ctx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
if (!hourly || !hourly.length) {
|
||||
ctx.fillStyle = '#005500';
|
||||
ctx.font = '12px Courier New';
|
||||
ctx.fillText('No activity in last 24h', 10, 44);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fill all 24 hours (missing = 0)
|
||||
const base = Math.floor(Date.now() / 1000 / 3600) * 3600;
|
||||
const map = new Map(hourly.map(r => [r.h, r.n]));
|
||||
const hrs = Array.from({length: 24}, (_, i) => ({ h: base - (23 - i) * 3600, n: map.get(base - (23 - i) * 3600) || 0 }));
|
||||
const max = Math.max(...hrs.map(h => h.n), 1);
|
||||
|
||||
const pad = { l: 2, r: 2, t: 6, b: 4 };
|
||||
const cW = W - pad.l - pad.r;
|
||||
const cH = H - pad.t - pad.b;
|
||||
const bW = cW / hrs.length - 1;
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = '#001800';
|
||||
ctx.lineWidth = 1;
|
||||
[0.33, 0.66].forEach(f => {
|
||||
const y = pad.t + cH * (1 - f);
|
||||
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke();
|
||||
});
|
||||
|
||||
// Bars
|
||||
hrs.forEach((h, i) => {
|
||||
const x = pad.l + i * (cW / hrs.length);
|
||||
const bH = Math.max(1, (h.n / max) * cH);
|
||||
const y = pad.t + cH - bH;
|
||||
const grad = ctx.createLinearGradient(0, y, 0, y + bH);
|
||||
grad.addColorStop(0, '#00ff41');
|
||||
grad.addColorStop(1, '#004400');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(x + 0.5, y, Math.max(bW, 1), bH);
|
||||
if (h.n === max) {
|
||||
ctx.shadowColor = '#00ff41'; ctx.shadowBlur = 10;
|
||||
ctx.fillRect(x + 0.5, y, Math.max(bW, 1), bH);
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('chart-peak').textContent =
|
||||
`PEAK ${max.toLocaleString()} / hr | TOTAL ${hrs.reduce((a, h) => a + h.n, 0).toLocaleString()}`;
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => { if (window._hourly) drawChart(window._hourly); });
|
||||
|
||||
// ── Attackers table ───────────────────────────────────────────────────────────
|
||||
function renderAttackers(ips) {
|
||||
const tbody = document.getElementById('atk-body');
|
||||
if (!ips || !ips.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:20px;color:var(--dim)">No data yet</td></tr>';
|
||||
return;
|
||||
}
|
||||
const max = ips[0].hits;
|
||||
tbody.innerHTML = ips.map((row, i) => `
|
||||
<tr>
|
||||
<td class="atk-rank">#${i + 1}</td>
|
||||
<td class="atk-ip">${esc(row.ip)}</td>
|
||||
<td class="atk-hits">${row.hits.toLocaleString()}</td>
|
||||
<td><div class="mini-bar"><div class="mini-fill" style="width:${Math.round(row.hits/max*100)}%"></div></div></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function esc(s) {
|
||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
function fmtTime(ts) {
|
||||
return new Date(ts * 1000).toISOString().slice(11, 19);
|
||||
}
|
||||
|
||||
// ── Live feed ─────────────────────────────────────────────────────────────────
|
||||
let feedCount = 0;
|
||||
let autoScroll = true;
|
||||
const feedEl = document.getElementById('feed');
|
||||
const feedCount$ = document.getElementById('feed-count');
|
||||
const feedStatus = document.getElementById('feed-status');
|
||||
|
||||
feedEl.addEventListener('mouseenter', () => autoScroll = false);
|
||||
feedEl.addEventListener('mouseleave', () => { autoScroll = true; });
|
||||
|
||||
function addRow(row) {
|
||||
feedCount++;
|
||||
feedCount$.textContent = `${feedCount.toLocaleString()} events`;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'feed-row';
|
||||
el.innerHTML = `
|
||||
<span class="feed-ts">${fmtTime(row.received_at)}</span>
|
||||
<span class="feed-ip">${esc(row.ip_masked || row.ip || '?')}</span>
|
||||
<span>
|
||||
<span class="feed-form">${esc(row.form_type || '?')}</span>
|
||||
<br><span class="feed-reason">${esc(row.reason || '')}</span>
|
||||
</span>`;
|
||||
feedEl.prepend(el);
|
||||
while (feedEl.children.length > 120) feedEl.removeChild(feedEl.lastChild);
|
||||
}
|
||||
|
||||
// Seed from recent history on load
|
||||
async function seedFeed() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/stats');
|
||||
const s = await r.json();
|
||||
if (s.recent) [...s.recent].reverse().forEach(addRow);
|
||||
} catch (e) { console.warn('[seed]', e); }
|
||||
}
|
||||
|
||||
// SSE for live updates
|
||||
function connectSSE() {
|
||||
const es = new EventSource('/api/v1/stream');
|
||||
es.onopen = () => { feedStatus.textContent = 'connected'; };
|
||||
es.onmessage = e => {
|
||||
try { JSON.parse(e.data).reverse().forEach(addRow); } catch {}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
feedStatus.textContent = 'reconnecting…';
|
||||
setTimeout(connectSSE, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
// ── Stats polling ─────────────────────────────────────────────────────────────
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/stats');
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
const s = await r.json();
|
||||
|
||||
countUp(document.getElementById('s-total'), s.total);
|
||||
countUp(document.getElementById('s-today'), s.today);
|
||||
countUp(document.getElementById('s-7d'), s.last_7d);
|
||||
countUp(document.getElementById('s-30d'), s.last_30d);
|
||||
countUp(document.getElementById('s-sites'), s.total_sites);
|
||||
|
||||
renderBars(document.getElementById('bars-forms'), s.top_forms);
|
||||
renderBars(document.getElementById('bars-ua'), s.top_ua);
|
||||
renderBars(document.getElementById('bars-reasons'), s.top_reasons);
|
||||
renderAttackers(s.top_ips);
|
||||
|
||||
window._hourly = s.hourly;
|
||||
drawChart(s.hourly);
|
||||
|
||||
document.getElementById('last-update').textContent =
|
||||
new Date().toISOString().slice(11, 19) + ' UTC';
|
||||
} catch (e) { console.error('[stats]', e); }
|
||||
}
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────────────────────────
|
||||
seedFeed();
|
||||
connectSSE();
|
||||
fetchStats();
|
||||
setInterval(fetchStats, 6000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
237
api/server.js
Normal file
237
api/server.js
Normal file
@@ -0,0 +1,237 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
const DB = new Database(process.env.DB_PATH || '/data/honeypot.db');
|
||||
|
||||
// ── Database setup ────────────────────────────────────────────────────────────
|
||||
|
||||
DB.pragma('journal_mode = WAL');
|
||||
DB.pragma('synchronous = NORMAL');
|
||||
DB.pragma('cache_size = -8000');
|
||||
|
||||
DB.exec(`
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
received_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
site_id TEXT NOT NULL DEFAULT '',
|
||||
ip_masked TEXT NOT NULL DEFAULT '',
|
||||
form_type TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
ua_family TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
site_id TEXT PRIMARY KEY,
|
||||
first_seen INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
last_seen INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
block_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_recv ON blocks(received_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ip ON blocks(ip_masked);
|
||||
CREATE INDEX IF NOT EXISTS idx_site ON blocks(site_id);
|
||||
`);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function maskIP(ip = '') {
|
||||
ip = String(ip).trim();
|
||||
if (!ip) return 'x.x.x.x';
|
||||
if (ip.includes(':')) { // IPv6 — keep first 2 groups
|
||||
const p = ip.split(':');
|
||||
return `${p[0]||'x'}:${p[1]||'x'}:…:x`;
|
||||
}
|
||||
const p = ip.split('.'); // IPv4 — keep first 2 octets
|
||||
return p.length === 4 ? `${p[0]}.${p[1]}.x.x` : 'x.x.x.x';
|
||||
}
|
||||
|
||||
const UA_MAP = [
|
||||
[/curl\//i, 'curl'],
|
||||
[/python-requests|python\//i, 'Python'],
|
||||
[/go-http-client/i, 'Go'],
|
||||
[/wget\//i, 'Wget'],
|
||||
[/java\//i, 'Java'],
|
||||
[/ruby/i, 'Ruby'],
|
||||
[/perl\//i, 'Perl'],
|
||||
[/php\//i, 'PHP'],
|
||||
[/scrapy/i, 'Scrapy'],
|
||||
[/postman/i, 'Postman'],
|
||||
[/axios/i, 'Axios'],
|
||||
[/node-fetch|node\.js/i, 'Node.js'],
|
||||
[/headlesschrome|phantomjs/i, 'Headless Browser'],
|
||||
[/(bot|crawler|spider|slurp)/i, 'Bot/Crawler'],
|
||||
[/chrome/i, 'Chrome'],
|
||||
[/firefox/i, 'Firefox'],
|
||||
[/safari/i, 'Safari'],
|
||||
[/edge|edg\//i, 'Edge'],
|
||||
[/opera|opr\//i, 'Opera'],
|
||||
[/msie|trident/i, 'IE'],
|
||||
];
|
||||
|
||||
function parseUA(ua = '') {
|
||||
for (const [re, label] of UA_MAP) if (re.test(ua)) return label;
|
||||
return ua.length ? 'Other' : 'No UA';
|
||||
}
|
||||
|
||||
// ── In-memory rate limiter ────────────────────────────────────────────────────
|
||||
|
||||
const rl = new Map();
|
||||
setInterval(() => { const n = Date.now(); for (const [k, v] of rl) if (n > v.r) rl.delete(k); }, 30_000);
|
||||
|
||||
function allowed(ip, max = 30, win = 60_000) {
|
||||
const n = Date.now();
|
||||
let e = rl.get(ip);
|
||||
if (!e || n > e.r) { e = { c: 0, r: n + win }; rl.set(ip, e); }
|
||||
return ++e.c <= max;
|
||||
}
|
||||
|
||||
// ── Stats cache (30s TTL) ─────────────────────────────────────────────────────
|
||||
|
||||
let _cache = null, _cacheTs = 0;
|
||||
|
||||
function getStats() {
|
||||
if (_cache && Date.now() - _cacheTs < 30_000) return _cache;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
_cache = {
|
||||
total: DB.prepare('SELECT COUNT(*) n FROM blocks').get().n,
|
||||
today: DB.prepare('SELECT COUNT(*) n FROM blocks WHERE received_at > ?').get(now - 86400).n,
|
||||
last_7d: DB.prepare('SELECT COUNT(*) n FROM blocks WHERE received_at > ?').get(now - 604800).n,
|
||||
last_30d: DB.prepare('SELECT COUNT(*) n FROM blocks WHERE received_at > ?').get(now - 2592000).n,
|
||||
total_sites: DB.prepare('SELECT COUNT(*) n FROM sites').get().n,
|
||||
top_ips: DB.prepare(`
|
||||
SELECT ip_masked ip, COUNT(*) hits
|
||||
FROM blocks WHERE received_at > ?
|
||||
GROUP BY ip_masked ORDER BY hits DESC LIMIT 10
|
||||
`).all(now - 2592000),
|
||||
top_forms: DB.prepare(`
|
||||
SELECT form_type, COUNT(*) hits
|
||||
FROM blocks WHERE received_at > ?
|
||||
GROUP BY form_type ORDER BY hits DESC LIMIT 8
|
||||
`).all(now - 2592000),
|
||||
top_reasons: DB.prepare(`
|
||||
SELECT reason, COUNT(*) hits
|
||||
FROM blocks WHERE received_at > ?
|
||||
GROUP BY reason ORDER BY hits DESC LIMIT 8
|
||||
`).all(now - 2592000),
|
||||
top_ua: DB.prepare(`
|
||||
SELECT ua_family, COUNT(*) hits
|
||||
FROM blocks WHERE received_at > ?
|
||||
GROUP BY ua_family ORDER BY hits DESC LIMIT 8
|
||||
`).all(now - 2592000),
|
||||
recent: DB.prepare(`
|
||||
SELECT received_at, ip_masked ip, form_type, reason, ua_family
|
||||
FROM blocks ORDER BY id DESC LIMIT 40
|
||||
`).all(),
|
||||
hourly: DB.prepare(`
|
||||
SELECT (received_at / 3600) * 3600 h, COUNT(*) n
|
||||
FROM blocks WHERE received_at > ?
|
||||
GROUP BY h ORDER BY h ASC
|
||||
`).all(now - 86400),
|
||||
};
|
||||
_cacheTs = Date.now();
|
||||
return _cache;
|
||||
}
|
||||
|
||||
// ── SSE live stream ───────────────────────────────────────────────────────────
|
||||
|
||||
const sseClients = new Set();
|
||||
let lastId = DB.prepare('SELECT MAX(id) id FROM blocks').get().id || 0;
|
||||
|
||||
setInterval(() => {
|
||||
if (!sseClients.size) return;
|
||||
const rows = DB.prepare('SELECT * FROM blocks WHERE id > ? ORDER BY id ASC LIMIT 20').all(lastId);
|
||||
if (!rows.length) return;
|
||||
lastId = rows.at(-1).id;
|
||||
const msg = `data: ${JSON.stringify(rows)}\n\n`;
|
||||
for (const r of sseClients) { try { r.write(msg); } catch { sseClients.delete(r); } }
|
||||
}, 2000);
|
||||
|
||||
// ── Prepared statements ───────────────────────────────────────────────────────
|
||||
|
||||
const stmtIns = DB.prepare(
|
||||
'INSERT INTO blocks (received_at, site_id, ip_masked, form_type, reason, ua_family) VALUES (?,?,?,?,?,?)'
|
||||
);
|
||||
const stmtSite = DB.prepare(`
|
||||
INSERT INTO sites (site_id, first_seen, last_seen, block_count) VALUES (?,?,?,?)
|
||||
ON CONFLICT(site_id) DO UPDATE SET
|
||||
last_seen = excluded.last_seen,
|
||||
block_count = block_count + excluded.block_count
|
||||
`);
|
||||
|
||||
const insertBatch = DB.transaction((siteId, blocks) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for (const b of blocks) {
|
||||
const ts = b.blocked_at ? Math.floor(new Date(b.blocked_at) / 1000) : now;
|
||||
stmtIns.run(
|
||||
ts, siteId,
|
||||
maskIP(b.ip),
|
||||
String(b.form_type || '').slice(0, 100),
|
||||
String(b.reason || '').slice(0, 255),
|
||||
parseUA(b.user_agent || '')
|
||||
);
|
||||
}
|
||||
stmtSite.run(siteId, now, now, blocks.length);
|
||||
});
|
||||
|
||||
// ── Express routes ────────────────────────────────────────────────────────────
|
||||
|
||||
app.use(express.json({ limit: '128kb' }));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// Submit blocks from a WordPress site
|
||||
app.post('/api/v1/submit', (req, res) => {
|
||||
const clientIP = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|
||||
|| req.socket.remoteAddress || '';
|
||||
|
||||
if (!allowed(clientIP)) {
|
||||
return res.status(429).json({ error: 'Rate limit exceeded' });
|
||||
}
|
||||
|
||||
const { site_hash, blocks } = req.body || {};
|
||||
|
||||
if (!site_hash || typeof site_hash !== 'string' || site_hash.length < 8) {
|
||||
return res.status(400).json({ error: 'Invalid site_hash' });
|
||||
}
|
||||
if (!Array.isArray(blocks) || !blocks.length || blocks.length > 50) {
|
||||
return res.status(400).json({ error: 'blocks must be array of 1–50 items' });
|
||||
}
|
||||
|
||||
try {
|
||||
insertBatch(site_hash.slice(0, 20), blocks);
|
||||
_cache = null; // invalidate stats cache
|
||||
res.json({ ok: true, received: blocks.length });
|
||||
} catch (e) {
|
||||
console.error('[submit]', e.message);
|
||||
res.status(500).json({ error: 'Internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Public aggregated stats
|
||||
app.get('/api/v1/stats', (_, res) => res.json(getStats()));
|
||||
|
||||
// SSE live feed
|
||||
app.get('/api/v1/stream', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.write(':\n\n'); // flush headers
|
||||
sseClients.add(res);
|
||||
req.on('close', () => sseClients.delete(res));
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/api/v1/health', (_, res) =>
|
||||
res.json({ ok: true, uptime: process.uptime(), sse_clients: sseClients.size })
|
||||
);
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[honeypot-api] listening on :${PORT}`);
|
||||
console.log(`[honeypot-api] db: ${process.env.DB_PATH || '/data/honeypot.db'}`);
|
||||
});
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
<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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user