fix: CF7 bypass, auto-flush, layout, contrast, IP geo v2.4.0

CF7:
- Add wpcf7_spam filter registered before is_admin() early-return so
  CF7 AJAX submissions (admin-ajax.php) are properly validated
- Exclude CF7 posts from generic catch-all (prevent double-checking)

Auto-flush:
- Add maybe_flush_overdue() with 5-min transient lock, hooked to
  shutdown action so every PHP request can trigger a flush if overdue
- No longer depends solely on WP-Cron firing

Dashboard layout:
- Top Attackers moved into right column below live feed
- Viewport-fill layout: body/main use flex+overflow:hidden so content
  stays in view; left col scrolls independently if needed
- Feed panel takes flex:1, attackers panel capped at 260px

Colors:
- --dim: #006600 → #44bb77 (legible secondary text, ~5:1 contrast)
- --dim2: #228844 added for slightly darker secondary use
- --muted kept dark for backgrounds only; border lightened slightly

IP geo (server-side, async, non-blocking):
- country + asn columns added to blocks table (migration-safe)
- enrichIP() calls ip-api.com free HTTP API per unique IP, cached 1h
- Background job enriches historic rows missing country (5 per 20s)
- Stats and live feed now include country code + ASN
- Dashboard shows country flag emoji in feed rows and attackers table
- Full AS name shown as tooltip on ASN column

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 07:57:16 +01:00
parent 01a15007cb
commit 07b5025b0b
3 changed files with 437 additions and 414 deletions

View File

@@ -9,18 +9,18 @@
--bg: #000a00; --bg: #000a00;
--bg2: #010f01; --bg2: #010f01;
--green: #00ff41; --green: #00ff41;
--green2: #00cc33; --green2: #00dd44;
--dim: #006600; --dim: #44bb77; /* was #006600 — now legible secondary text */
--muted: #002800; --dim2: #228844; /* slightly darker secondary for non-critical labels */
--border: #003300; --muted: #002800; /* backgrounds only */
--red: #ff3333; --border: #003a00;
--red: #ff4040;
--amber: #ffaa00; --amber: #ffaa00;
--white: #ccffcc; --white: #d4ffd4;
} }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scrollbar-color: var(--dim2) var(--bg); scrollbar-width: thin; }
html { scrollbar-color: var(--dim) var(--bg); scrollbar-width: thin; }
body { body {
background: var(--bg); background: var(--bg);
@@ -28,7 +28,10 @@ body {
font-family: 'Courier New', 'Lucida Console', monospace; font-family: 'Courier New', 'Lucida Console', monospace;
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.5;
min-height: 100vh; height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
} }
/* CRT scanline overlay */ /* CRT scanline overlay */
@@ -38,7 +41,7 @@ body::after {
inset: 0; inset: 0;
background: repeating-linear-gradient( background: repeating-linear-gradient(
0deg, transparent, transparent 2px, 0deg, transparent, transparent 2px,
rgba(0,0,0,.07) 2px, rgba(0,0,0,.07) 4px rgba(0,0,0,.06) 2px, rgba(0,0,0,.06) 4px
); );
pointer-events: none; pointer-events: none;
z-index: 9999; z-index: 9999;
@@ -50,17 +53,18 @@ body::after {
/* ── Header ─────────────────────────────────────────────────────────── */ /* ── Header ─────────────────────────────────────────────────────────── */
header { header {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
padding: 10px 20px; padding: 8px 20px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background: var(--bg2); background: var(--bg2);
flex-shrink: 0;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 6px;
} }
.logo { .logo {
font-size: 17px; font-size: 16px;
font-weight: bold; font-weight: bold;
letter-spacing: 4px; letter-spacing: 4px;
color: var(--green); color: var(--green);
@@ -70,7 +74,7 @@ header {
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 14px;
font-size: 11px; font-size: 11px;
color: var(--green2); color: var(--green2);
letter-spacing: 1px; letter-spacing: 1px;
@@ -84,248 +88,247 @@ header {
animation: blink 1s step-end infinite; animation: blink 1s step-end infinite;
box-shadow: 0 0 6px var(--red); box-shadow: 0 0 6px var(--red);
vertical-align: middle; vertical-align: middle;
margin-right: 5px; margin-right: 4px;
} }
@keyframes blink { 50% { opacity: 0; } } @keyframes blink { 50% { opacity: 0; } }
/* ── Lang switcher ───────────────────────────────────────────────────── */ .lang-switcher { display: flex; gap: 3px; align-items: center; }
.lang-switcher { display: flex; gap: 4px; align-items: center; }
.lang-btn { .lang-btn {
background: none; background: none; border: none; cursor: pointer;
border: none; padding: 1px 2px; line-height: 1;
cursor: pointer;
padding: 1px 2px;
line-height: 1;
transition: opacity .15s, font-size .15s; transition: opacity .15s, font-size .15s;
} }
.lang-btn.active { font-size: 20px; opacity: 1; } .lang-btn.active { font-size: 20px; opacity: 1; }
.lang-btn.inactive { font-size: 14px; opacity: 0.4; } .lang-btn.inactive { font-size: 14px; opacity: 0.35; }
/* ── Main ───────────────────────────────────────────────────────────── */ /* ── Main ───────────────────────────────────────────────────────────── */
main { padding: 14px 16px; max-width: 1700px; margin: 0 auto; } main {
flex: 1;
min-height: 0;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 8px;
overflow: hidden;
}
/* ── Stat cards ─────────────────────────────────────────────────────── */ /* ── Stat cards ─────────────────────────────────────────────────────── */
.stats-row { .stats-row {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 10px; gap: 8px;
margin-bottom: 14px; flex-shrink: 0;
} }
.stat-card { .stat-card {
background: var(--bg2); background: var(--bg2);
border: 1px solid var(--border); border: 1px solid var(--border);
padding: 14px 12px 12px; padding: 10px 10px 8px;
text-align: center; text-align: center;
position: relative; position: relative;
transition: border-color .2s; transition: border-color .2s;
} }
.stat-card::before { .stat-card::before {
content: ''; content: ''; position: absolute;
position: absolute;
top: 0; left: 0; right: 0; height: 2px; top: 0; left: 0; right: 0; height: 2px;
background: var(--muted); background: var(--muted);
transition: background .2s, box-shadow .2s; transition: background .2s, box-shadow .2s;
} }
.stat-card:hover { border-color: var(--dim); } .stat-card:hover { border-color: var(--dim2); }
.stat-card:hover::before { background: var(--green); box-shadow: 0 0 8px var(--green); } .stat-card:hover::before { background: var(--green); box-shadow: 0 0 8px var(--green); }
.stat-num { .stat-num {
font-size: 30px; font-size: 26px; font-weight: bold;
font-weight: bold; letter-spacing: 2px; line-height: 1.1;
letter-spacing: 2px;
line-height: 1.1;
color: var(--green); color: var(--green);
} }
.stat-lbl { .stat-lbl {
font-size: 9px; font-size: 9px; letter-spacing: 2px;
letter-spacing: 3px;
text-transform: uppercase; 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 130px 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); color: var(--dim);
display: flex; margin-top: 3px;
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;
align-items: center;
letter-spacing: 1px;
flex-wrap: wrap;
gap: 6px;
}
footer a { color: var(--dim); text-decoration: none; }
footer a:hover { color: var(--green2); }
.footer-eu { display: flex; align-items: center; gap: 6px; }
/* ── Top target banner ──────────────────────────────────────────────── */ /* ── Top target banner ──────────────────────────────────────────────── */
#top-target { #top-target {
background: var(--bg2); background: var(--bg2);
border: 1px solid #2a0000; border: 1px solid #2a0000;
border-left: 3px solid var(--red); border-left: 3px solid var(--red);
padding: 10px 16px; padding: 7px 14px;
margin-bottom: 14px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 14px;
flex-wrap: wrap; flex-wrap: wrap;
flex-shrink: 0;
} }
#top-target .tt-label { #top-target .tt-label { font-size: 10px; letter-spacing: 2px; color: var(--dim2); }
font-size: 10px; #top-target .tt-form { font-size: 14px; font-weight: bold; color: var(--red); text-shadow: 0 0 10px var(--red); }
letter-spacing: 3px;
color: var(--dim);
}
#top-target .tt-form {
font-size: 15px;
font-weight: bold;
color: var(--red);
text-shadow: 0 0 10px var(--red);
letter-spacing: 1px;
}
#top-target .tt-hits { font-size: 11px; color: var(--amber); } #top-target .tt-hits { font-size: 11px; color: var(--amber); }
#top-target .tt-pct { font-size: 11px; color: var(--dim); } #top-target .tt-pct { font-size: 11px; color: var(--dim); }
/* ── Content grid ───────────────────────────────────────────────────── */
.content-grid {
display: grid;
grid-template-columns: 1fr 420px;
gap: 8px;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ── Left column ────────────────────────────────────────────────────── */
.left-col {
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
min-height: 0;
}
.left-col::-webkit-scrollbar { width: 3px; }
.left-col::-webkit-scrollbar-track { background: var(--bg); }
.left-col::-webkit-scrollbar-thumb { background: var(--dim2); }
/* ── Right column ───────────────────────────────────────────────────── */
.right-col {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow: hidden;
}
/* ── Panel ──────────────────────────────────────────────────────────── */
.panel { background: var(--bg2); border: 1px solid var(--border); }
.panel-hdr {
padding: 6px 12px;
border-bottom: 1px solid var(--border);
font-size: 10px; letter-spacing: 2px;
color: var(--amber);
display: flex; align-items: center; justify-content: space-between;
}
.panel-body { padding: 10px 12px; }
/* ── 24h Chart ──────────────────────────────────────────────────────── */
#chart { width: 100%; height: 72px; display: block; }
/* ── Bar lists ──────────────────────────────────────────────────────── */
.bars-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.bar-section-title { font-size: 10px; letter-spacing: 2px; color: var(--amber); margin-bottom: 6px; }
.bar-list { list-style: none; }
.bar-item {
display: grid;
grid-template-columns: 140px 1fr 50px;
align-items: center;
gap: 6px; padding: 2px 0; font-size: 11px;
}
.bar-lbl { color: var(--white); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-track { background: var(--muted); height: 6px; }
.bar-fill { background: var(--green); height: 100%; transition: width .5s ease; }
.bar-cnt { color: var(--dim); font-size: 11px; text-align: right; }
/* ── Live feed ──────────────────────────────────────────────────────── */
.feed-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
#feed {
flex: 1;
overflow-y: auto;
padding: 6px 12px;
font-size: 11px;
line-height: 1.6;
min-height: 0;
}
#feed::-webkit-scrollbar { width: 3px; }
#feed::-webkit-scrollbar-track { background: var(--bg); }
#feed::-webkit-scrollbar-thumb { background: var(--dim2); }
.feed-row {
display: grid;
grid-template-columns: 58px 110px auto;
gap: 5px;
border-bottom: 1px solid var(--muted);
padding: 1px 0;
align-items: start;
}
.feed-ts { color: var(--dim2); }
.feed-ip { color: var(--amber); }
.feed-geo { color: var(--dim); font-size: 10px; }
.feed-form { color: var(--green2); }
.feed-reason { color: var(--dim); font-size: 10px; }
.feed-footer {
padding: 5px 12px;
border-top: 1px solid var(--border);
font-size: 10px; color: var(--dim);
display: flex; justify-content: space-between;
flex-shrink: 0;
}
.cursor::after { content: '█'; animation: blink 1s step-end infinite; }
/* ── Attackers (compact, in right col) ──────────────────────────────── */
.atk-panel {
flex-shrink: 0;
max-height: 260px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.atk-scroll { overflow-y: auto; flex: 1; }
.atk-scroll::-webkit-scrollbar { width: 3px; }
.atk-scroll::-webkit-scrollbar-track { background: var(--bg); }
.atk-scroll::-webkit-scrollbar-thumb { background: var(--dim2); }
.atk-table { width: 100%; border-collapse: collapse; font-size: 11px; }
.atk-table th {
padding: 5px 10px;
text-align: left; color: var(--amber);
font-size: 9px; letter-spacing: 2px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
position: sticky; top: 0; background: var(--bg2);
}
.atk-table td { padding: 4px 10px; border-bottom: 1px solid var(--muted); }
.atk-table tr:hover td { background: var(--muted); }
.atk-rank { color: var(--dim2); font-size: 10px; }
.atk-ip { color: var(--amber); font-weight: bold; }
.atk-hits { color: var(--green); font-weight: bold; }
.atk-asn { color: var(--dim); font-size: 10px; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mini-bar { height: 6px; background: var(--muted); min-width: 40px; 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: 6px 20px;
font-size: 10px; color: var(--dim2);
display: flex; justify-content: space-between; align-items: center;
letter-spacing: 1px; flex-shrink: 0; flex-wrap: wrap; gap: 4px;
}
footer a { color: var(--dim); text-decoration: none; }
footer a:hover { color: var(--green2); }
.footer-eu { display: flex; align-items: center; gap: 5px; }
/* ── Responsive ─────────────────────────────────────────────────────── */ /* ── Responsive ─────────────────────────────────────────────────────── */
@media (max-width: 1100px) { @media (max-width: 1100px) {
body { overflow: auto; height: auto; }
.stats-row { grid-template-columns: repeat(3, 1fr); } .stats-row { grid-template-columns: repeat(3, 1fr); }
.content-grid { grid-template-columns: 1fr; } .content-grid { grid-template-columns: 1fr; height: auto; }
.feed-panel { min-height: 350px; } .left-col { overflow: visible; }
.right-col { height: 700px; }
.feed-panel { flex: 1; }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.stats-row { grid-template-columns: 1fr 1fr; } .stats-row { grid-template-columns: 1fr 1fr; }
.bars-2col { grid-template-columns: 1fr; } .bars-2col { grid-template-columns: 1fr; }
.bar-item { grid-template-columns: 110px 1fr 45px; } .bar-item { grid-template-columns: 100px 1fr 42px; }
} }
</style> </style>
</head> </head>
@@ -346,7 +349,6 @@ footer a:hover { color: var(--green2); }
<main> <main>
<!-- ── Stats row ──────────────────────────────────────────────────── -->
<div class="stats-row"> <div class="stats-row">
<div class="stat-card"> <div class="stat-card">
<div class="stat-num glow" id="s-total"></div> <div class="stat-num glow" id="s-total"></div>
@@ -370,7 +372,6 @@ footer a:hover { color: var(--green2); }
</div> </div>
</div> </div>
<!-- ── Most attacked form ────────────────────────────────────────── -->
<div id="top-target"> <div id="top-target">
<span class="tt-label" id="tt-label" data-i18n="top_target_label">▶ MOST ATTACKED FORM (30D):</span> <span class="tt-label" id="tt-label" data-i18n="top_target_label">▶ MOST ATTACKED FORM (30D):</span>
<span class="tt-form" id="tt-form"></span> <span class="tt-form" id="tt-form"></span>
@@ -378,23 +379,21 @@ footer a:hover { color: var(--green2); }
<span class="tt-pct" id="tt-pct"></span> <span class="tt-pct" id="tt-pct"></span>
</div> </div>
<!-- ── Content grid ───────────────────────────────────────────────── -->
<div class="content-grid"> <div class="content-grid">
<!-- Left: charts & bars -->
<div class="left-col"> <div class="left-col">
<!-- 24h chart -->
<div class="panel"> <div class="panel">
<div class="panel-hdr"> <div class="panel-hdr">
<span data-i18n="chart_title">▶ 24H ACTIVITY TREND</span> <span data-i18n="chart_title">▶ 24H ACTIVITY TREND</span>
<span id="chart-peak" style="color:var(--dim);font-size:11px"></span> <span id="chart-peak" style="color:var(--dim);font-size:11px"></span>
</div> </div>
<div class="panel-body" style="padding:10px 12px"> <div class="panel-body" style="padding:8px 10px">
<canvas id="chart"></canvas> <canvas id="chart"></canvas>
</div> </div>
</div> </div>
<!-- Bar charts -->
<div class="panel"> <div class="panel">
<div class="panel-hdr" data-i18n="breakdown_title">▶ ATTACK BREAKDOWN // LAST 30 DAYS</div> <div class="panel-hdr" data-i18n="breakdown_title">▶ ATTACK BREAKDOWN // LAST 30 DAYS</div>
<div class="panel-body"> <div class="panel-body">
@@ -411,7 +410,6 @@ footer a:hover { color: var(--green2); }
</div> </div>
</div> </div>
<!-- Block reasons -->
<div class="panel"> <div class="panel">
<div class="panel-hdr" data-i18n="reasons_title">▶ BLOCK REASONS // LAST 30 DAYS</div> <div class="panel-hdr" data-i18n="reasons_title">▶ BLOCK REASONS // LAST 30 DAYS</div>
<div class="panel-body"> <div class="panel-body">
@@ -421,7 +419,9 @@ footer a:hover { color: var(--green2); }
</div><!-- /.left-col --> </div><!-- /.left-col -->
<!-- Live feed --> <!-- Right: live feed + top attackers -->
<div class="right-col">
<div class="panel feed-panel"> <div class="panel feed-panel">
<div class="panel-hdr"> <div class="panel-hdr">
<span data-i18n="feed_title">▶ LIVE THREAT FEED</span> <span data-i18n="feed_title">▶ LIVE THREAT FEED</span>
@@ -430,32 +430,33 @@ footer a:hover { color: var(--green2); }
<div id="feed"></div> <div id="feed"></div>
<div class="feed-footer"> <div class="feed-footer">
<span class="cursor"></span> <span class="cursor"></span>
<span id="feed-status" style="color:var(--dim)" data-i18n="connecting">connecting…</span> <span id="feed-status" data-i18n="connecting">connecting…</span>
</div> </div>
</div> </div>
</div><!-- /.content-grid --> <div class="panel atk-panel">
<!-- ── Top Attackers ──────────────────────────────────────────────── -->
<div class="panel" style="margin-bottom:10px">
<div class="panel-hdr" data-i18n="attackers_title">▶ TOP ATTACKERS // LAST 30 DAYS</div> <div class="panel-hdr" data-i18n="attackers_title">▶ TOP ATTACKERS // LAST 30 DAYS</div>
<div style="overflow-x:auto"> <div class="atk-scroll">
<table class="atk-table"> <table class="atk-table">
<thead> <thead>
<tr> <tr>
<th style="width:50px" data-i18n="col_rank">RANK</th> <th>#</th>
<th data-i18n="col_ip">IP ADDRESS</th> <th data-i18n="col_ip">IP ADDRESS</th>
<th style="width:110px" data-i18n="col_hits">TOTAL HITS</th> <th data-i18n="col_hits">HITS</th>
<th data-i18n="col_freq">FREQUENCY</th> <th data-i18n="col_asn">AS</th>
</tr> </tr>
</thead> </thead>
<tbody id="atk-body"> <tbody id="atk-body">
<tr><td colspan="4" style="text-align:center;padding:20px;color:var(--dim)" data-i18n="loading">Loading…</td></tr> <tr><td colspan="4" style="text-align:center;padding:14px;color:var(--dim)" data-i18n="loading">Loading…</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div><!-- /.right-col -->
</div><!-- /.content-grid -->
</main> </main>
<footer> <footer>
@@ -472,106 +473,64 @@ footer a:hover { color: var(--green2); }
// ── i18n ────────────────────────────────────────────────────────────────────── // ── i18n ──────────────────────────────────────────────────────────────────────
const I18N = { const I18N = {
en: { en: {
live_feed: 'LIVE FEED', live_feed: 'LIVE FEED', stat_total: 'TOTAL BLOCKED', stat_today: 'TODAY',
stat_total: 'TOTAL BLOCKED', stat_7d: 'LAST 7 DAYS', stat_30d: 'LAST 30 DAYS', stat_sites: 'SITES REPORTING',
stat_today: 'TODAY',
stat_7d: 'LAST 7 DAYS',
stat_30d: 'LAST 30 DAYS',
stat_sites: 'SITES REPORTING',
top_target_label: '▶ MOST ATTACKED FORM (30D):', top_target_label: '▶ MOST ATTACKED FORM (30D):',
chart_title: '▶ 24H ACTIVITY TREND', chart_title: '▶ 24H ACTIVITY TREND',
breakdown_title: '▶ ATTACK BREAKDOWN // LAST 30 DAYS', breakdown_title: '▶ ATTACK BREAKDOWN // LAST 30 DAYS',
form_types: 'FORM TYPES', form_types: 'FORM TYPES', bot_toolkit: 'BOT TOOLKIT',
bot_toolkit: 'BOT TOOLKIT',
reasons_title: '▶ BLOCK REASONS // LAST 30 DAYS', reasons_title: '▶ BLOCK REASONS // LAST 30 DAYS',
feed_title: '▶ LIVE THREAT FEED', feed_title: '▶ LIVE THREAT FEED', events: 'events',
events: 'events', connecting: 'connecting…', connected: 'connected', reconnecting: 'reconnecting…',
connecting: 'connecting…',
connected: 'connected',
reconnecting: 'reconnecting…',
attackers_title: '▶ TOP ATTACKERS // LAST 30 DAYS', attackers_title: '▶ TOP ATTACKERS // LAST 30 DAYS',
col_rank: 'RANK', col_ip: 'IP ADDRESS', col_hits: 'HITS', col_asn: 'AS',
col_ip: 'IP ADDRESS', loading: 'Loading…', no_data: 'No data yet',
col_hits: 'TOTAL HITS',
col_freq: 'FREQUENCY',
loading: 'Loading…',
no_data: 'No data yet',
footer_copy: 'HONEYPOT NETWORK MONITOR // CENTRALIZED THREAT INTELLIGENCE', footer_copy: 'HONEYPOT NETWORK MONITOR // CENTRALIZED THREAT INTELLIGENCE',
refreshed: 'REFRESHED:', refreshed: 'REFRESHED:', made_in_eu: '🇪🇺 Made & hosted in the EU by',
made_in_eu: '🇪🇺 Made & hosted in the EU by',
}, },
es: { es: {
live_feed: 'EN VIVO', live_feed: 'EN VIVO', stat_total: 'TOTAL BLOQUEADOS', stat_today: 'HOY',
stat_total: 'TOTAL BLOQUEADOS', stat_7d: 'ÚLTIMOS 7 DÍAS', stat_30d: 'ÚLTIMOS 30 DÍAS', stat_sites: 'SITIOS REPORTANDO',
stat_today: 'HOY',
stat_7d: 'ÚLTIMOS 7 DÍAS',
stat_30d: 'ÚLTIMOS 30 DÍAS',
stat_sites: 'SITIOS REPORTANDO',
top_target_label: '▶ FORMULARIO MÁS ATACADO (30D):', top_target_label: '▶ FORMULARIO MÁS ATACADO (30D):',
chart_title: '▶ TENDENCIA DE ACTIVIDAD 24H', chart_title: '▶ TENDENCIA 24H',
breakdown_title: '▶ DESGLOSE DE ATAQUES // ÚLTIMOS 30 DÍAS', breakdown_title: '▶ DESGLOSE DE ATAQUES // ÚLTIMOS 30 DÍAS',
form_types: 'TIPOS DE FORMULARIO', form_types: 'TIPOS DE FORMULARIO', bot_toolkit: 'HERRAMIENTAS BOT',
bot_toolkit: 'HERRAMIENTAS BOT',
reasons_title: '▶ MOTIVOS DE BLOQUEO // ÚLTIMOS 30 DÍAS', reasons_title: '▶ MOTIVOS DE BLOQUEO // ÚLTIMOS 30 DÍAS',
feed_title: '▶ FEED DE AMENAZAS EN VIVO', feed_title: '▶ FEED EN VIVO', events: 'eventos',
events: 'eventos', connecting: 'conectando…', connected: 'conectado', reconnecting: 'reconectando…',
connecting: 'conectando…',
connected: 'conectado',
reconnecting: 'reconectando…',
attackers_title: '▶ TOP ATACANTES // ÚLTIMOS 30 DÍAS', attackers_title: '▶ TOP ATACANTES // ÚLTIMOS 30 DÍAS',
col_rank: 'RANGO', col_ip: 'DIRECCIÓN IP', col_hits: 'IMPACTOS', col_asn: 'AS',
col_ip: 'DIRECCIÓN IP', loading: 'Cargando…', no_data: 'Sin datos aún',
col_hits: 'TOTAL IMPACTOS', footer_copy: 'MONITOR HONEYPOT // INTELIGENCIA CENTRALIZADA DE AMENAZAS',
col_freq: 'FRECUENCIA', refreshed: 'ACTUALIZADO:', made_in_eu: '🇪🇺 Hecho y alojado en la UE por',
loading: 'Cargando…',
no_data: 'Sin datos aún',
footer_copy: 'MONITOR DE RED HONEYPOT // INTELIGENCIA CENTRALIZADA DE AMENAZAS',
refreshed: 'ACTUALIZADO:',
made_in_eu: '🇪🇺 Hecho y alojado en la UE por',
}, },
ro: { ro: {
live_feed: 'LIVE', live_feed: 'LIVE', stat_total: 'TOTAL BLOCATE', stat_today: 'AZI',
stat_total: 'TOTAL BLOCATE', stat_7d: 'ULTIMELE 7 ZILE', stat_30d: 'ULTIMELE 30 ZILE', stat_sites: 'SITE-URI RAPORTÂND',
stat_today: 'AZI',
stat_7d: 'ULTIMELE 7 ZILE',
stat_30d: 'ULTIMELE 30 ZILE',
stat_sites: 'SITE-URI RAPORTÂND',
top_target_label: '▶ FORMULARUL CEL MAI ATACAT (30Z):', top_target_label: '▶ FORMULARUL CEL MAI ATACAT (30Z):',
chart_title: '▶ TENDINȚĂ ACTIVITATE 24H', chart_title: '▶ TENDINȚĂ 24H',
breakdown_title: '▶ ANALIZA ATACURILOR // ULTIMELE 30 ZILE', breakdown_title: '▶ ANALIZA ATACURILOR // ULTIMELE 30 ZILE',
form_types: 'TIPURI FORMULAR', form_types: 'TIPURI FORMULAR', bot_toolkit: 'INSTRUMENTE BOT',
bot_toolkit: 'INSTRUMENTE BOT',
reasons_title: '▶ MOTIVE BLOCARE // ULTIMELE 30 ZILE', reasons_title: '▶ MOTIVE BLOCARE // ULTIMELE 30 ZILE',
feed_title: '▶ FLUX LIVE AMENINȚĂRI', feed_title: '▶ FLUX LIVE AMENINȚĂRI', events: 'evenimente',
events: 'evenimente', connecting: 'conectare…', connected: 'conectat', reconnecting: 'reconectare…',
connecting: 'conectare…',
connected: 'conectat',
reconnecting: 'reconectare…',
attackers_title: '▶ TOP ATACATORI // ULTIMELE 30 ZILE', attackers_title: '▶ TOP ATACATORI // ULTIMELE 30 ZILE',
col_rank: 'RANG', col_ip: 'ADRESĂ IP', col_hits: 'ACCESĂRI', col_asn: 'AS',
col_ip: 'ADRESĂ IP', loading: 'Se încarcă…', no_data: 'Fără date încă',
col_hits: 'TOTAL ACCESĂRI',
col_freq: 'FRECVENȚĂ',
loading: 'Se încarcă…',
no_data: 'Fără date încă',
footer_copy: 'MONITOR REȚEA HONEYPOT // INFORMAȚII CENTRALIZATE DESPRE AMENINȚĂRI', footer_copy: 'MONITOR REȚEA HONEYPOT // INFORMAȚII CENTRALIZATE DESPRE AMENINȚĂRI',
refreshed: 'ACTUALIZAT:', refreshed: 'ACTUALIZAT:', made_in_eu: '🇪🇺 Realizat și găzduit în UE de',
made_in_eu: '🇪🇺 Realizat și găzduit în UE de',
}, },
}; };
function detectLang() { function detectLang() {
const saved = localStorage.getItem('hp_lang'); const s = localStorage.getItem('hp_lang');
if (saved && I18N[saved]) return saved; if (s && I18N[s]) return s;
const nav = (navigator.language || 'en').slice(0, 2).toLowerCase(); const nav = (navigator.language || 'en').slice(0, 2).toLowerCase();
return I18N[nav] ? nav : 'en'; return I18N[nav] ? nav : 'en';
} }
let currentLang = detectLang(); let currentLang = detectLang();
function t(key) { function t(k) { return (I18N[currentLang] || I18N.en)[k] || (I18N.en[k] || k); }
return (I18N[currentLang] || I18N.en)[key] || (I18N.en[key] || key);
}
function setLang(lang) { function setLang(lang) {
if (!I18N[lang]) return; if (!I18N[lang]) return;
@@ -584,35 +543,34 @@ function setLang(lang) {
function applyTranslations() { function applyTranslations() {
document.querySelectorAll('[data-i18n]').forEach(el => { document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n'); if (el.children.length === 0) el.textContent = t(el.getAttribute('data-i18n'));
// Don't overwrite elements that also have dynamic child content
if (el.children.length === 0) el.textContent = t(key);
}); });
document.title = t('chart_title').replace('▶ ', '').split('//')[0].trim() + ' // NETWORK MONITOR';
} }
function updateLangButtons() { function updateLangButtons() {
document.querySelectorAll('.lang-btn').forEach(btn => { document.querySelectorAll('.lang-btn').forEach(btn => {
const active = btn.dataset.lang === currentLang; btn.className = 'lang-btn ' + (btn.dataset.lang === currentLang ? 'active' : 'inactive');
btn.className = 'lang-btn ' + (active ? 'active' : 'inactive');
}); });
} }
// ── Country flag helper ───────────────────────────────────────────────────────
function flag(cc) {
if (!cc || cc.length !== 2) return '';
// Regional indicator symbols: offset 127397 from ASCII
return String.fromCodePoint(...[...cc.toUpperCase()].map(c => c.charCodeAt(0) + 127397));
}
// ── Clock ───────────────────────────────────────────────────────────────────── // ── Clock ─────────────────────────────────────────────────────────────────────
const clockEl = document.getElementById('clock'); const clockEl = document.getElementById('clock');
function tick() { function tick() { clockEl.textContent = new Date().toISOString().slice(11,19) + ' UTC'; }
const d = new Date();
clockEl.textContent = d.toISOString().slice(11,19) + ' UTC';
}
tick(); setInterval(tick, 1000); tick(); setInterval(tick, 1000);
// ── CountUp animation ───────────────────────────────────────────────────────── // ── CountUp ───────────────────────────────────────────────────────────────────
const ctMap = new Map(); const ctMap = new Map();
function countUp(el, to) { function countUp(el, to) {
const from = parseInt(el.textContent.replace(/,/g,'')) || 0; const from = parseInt(el.textContent.replace(/,/g,'')) || 0;
if (from === to) return; if (from === to) return;
const steps = 25, diff = to - from; const steps = 25, diff = to - from; let s = 0;
let s = 0;
clearInterval(ctMap.get(el)); clearInterval(ctMap.get(el));
const id = setInterval(() => { const id = setInterval(() => {
s++; s++;
@@ -625,13 +583,13 @@ function countUp(el, to) {
// ── Bar charts ──────────────────────────────────────────────────────────────── // ── Bar charts ────────────────────────────────────────────────────────────────
function renderBars(listEl, items) { function renderBars(listEl, items) {
if (!items || !items.length) { if (!items || !items.length) {
listEl.innerHTML = `<li style="color:var(--dim);font-size:11px;padding:4px 0">${t('no_data')}</li>`; listEl.innerHTML = `<li style="color:var(--dim);font-size:11px;padding:3px 0">${t('no_data')}</li>`;
return; return;
} }
const max = items[0].hits; const max = items[0].hits;
listEl.innerHTML = items.map(item => { listEl.innerHTML = items.map(item => {
const label = item.form_type || item.ua_family || item.reason || item.ip || '?'; const label = item.form_type || item.ua_family || item.reason || item.ip || '?';
const pct = max > 0 ? Math.round((item.hits / max) * 100) : 0; const pct = max > 0 ? Math.round(item.hits / max * 100) : 0;
return `<li class="bar-item"> return `<li class="bar-item">
<span class="bar-lbl" title="${esc(label)}">${esc(label)}</span> <span class="bar-lbl" title="${esc(label)}">${esc(label)}</span>
<div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div> <div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
@@ -645,48 +603,37 @@ const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
function drawChart(hourly) { function drawChart(hourly) {
const W = canvas.offsetWidth || 600; const W = canvas.offsetWidth || 600, H = 72;
const H = 80;
canvas.width = W * (window.devicePixelRatio || 1); canvas.width = W * (window.devicePixelRatio || 1);
canvas.height = H * (window.devicePixelRatio || 1); canvas.height = H * (window.devicePixelRatio || 1);
ctx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1); ctx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1);
ctx.clearRect(0, 0, W, H); ctx.clearRect(0, 0, W, H);
if (!hourly || !hourly.length) { if (!hourly || !hourly.length) {
ctx.fillStyle = '#005500'; ctx.fillStyle = '#226644'; ctx.font = '12px Courier New';
ctx.font = '12px Courier New'; ctx.fillText(t('no_data'), 10, 38); return;
ctx.fillText(t('no_data'), 10, 44);
return;
} }
// Fill all 24 hours (missing = 0)
const base = Math.floor(Date.now() / 1000 / 3600) * 3600; const base = Math.floor(Date.now() / 1000 / 3600) * 3600;
const map = new Map(hourly.map(r => [r.h, r.n])); 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 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 max = Math.max(...hrs.map(h => h.n), 1);
const pad = { l:2, r:2, t:5, b:3 };
const pad = { l: 2, r: 2, t: 6, b: 4 }; const cW = W - pad.l - pad.r, cH = H - pad.t - pad.b;
const cW = W - pad.l - pad.r;
const cH = H - pad.t - pad.b;
const bW = cW / hrs.length - 1; const bW = cW / hrs.length - 1;
// Grid lines ctx.strokeStyle = '#001a00'; ctx.lineWidth = 1;
ctx.strokeStyle = '#001800';
ctx.lineWidth = 1;
[0.33, 0.66].forEach(f => { [0.33, 0.66].forEach(f => {
const y = pad.t + cH*(1-f); const y = pad.t + cH*(1-f);
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W-pad.r, y); ctx.stroke(); ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W-pad.r, y); ctx.stroke();
}); });
// Bars
hrs.forEach((h, i) => { hrs.forEach((h, i) => {
const x = pad.l + i*(cW/hrs.length); const x = pad.l + i*(cW/hrs.length);
const bH = Math.max(1, (h.n / max) * cH); const bH = Math.max(1, h.n/max*cH), y = pad.t+cH-bH;
const y = pad.t + cH - bH; const g = ctx.createLinearGradient(0, y, 0, y+bH);
const grad = ctx.createLinearGradient(0, y, 0, y + bH); g.addColorStop(0, '#00ff41'); g.addColorStop(1, '#004400');
grad.addColorStop(0, '#00ff41'); ctx.fillStyle = g;
grad.addColorStop(1, '#004400');
ctx.fillStyle = grad;
ctx.fillRect(x+0.5, y, Math.max(bW,1), bH); ctx.fillRect(x+0.5, y, Math.max(bW,1), bH);
if (h.n === max) { if (h.n === max) {
ctx.shadowColor = '#00ff41'; ctx.shadowBlur = 10; ctx.shadowColor = '#00ff41'; ctx.shadowBlur = 10;
@@ -696,55 +643,53 @@ function drawChart(hourly) {
}); });
document.getElementById('chart-peak').textContent = document.getElementById('chart-peak').textContent =
`PEAK ${max.toLocaleString()} / hr | TOTAL ${hrs.reduce((a, h) => a + h.n, 0).toLocaleString()}`; `PEAK ${max.toLocaleString()}/hr TOTAL ${hrs.reduce((a,h)=>a+h.n,0).toLocaleString()}`;
} }
window.addEventListener('resize', () => { if (window._hourly) drawChart(window._hourly); }); window.addEventListener('resize', () => { if (window._hourly) drawChart(window._hourly); });
// ── Attackers table ─────────────────────────────────────────────────────────── // ── Attackers table ───────────────────────────────────────────────────────────
function renderAttackers(ips) { function renderAttackers(ips) {
const tbody = document.getElementById('atk-body'); const tbody = document.getElementById('atk-body');
if (!ips || !ips.length) { if (!ips || !ips.length) {
tbody.innerHTML = `<tr><td colspan="4" style="text-align:center;padding:20px;color:var(--dim)">${t('no_data')}</td></tr>`; tbody.innerHTML = `<tr><td colspan="4" style="text-align:center;padding:14px;color:var(--dim)">${t('no_data')}</td></tr>`;
return; return;
} }
const max = ips[0].hits; const max = ips[0].hits;
tbody.innerHTML = ips.map((row, i) => ` tbody.innerHTML = ips.map((row, i) => {
<tr> const f = flag(row.country);
const asnNo = (row.asn || '').split(' ')[0]; // e.g. "AS12345"
return `<tr>
<td class="atk-rank">#${i+1}</td> <td class="atk-rank">#${i+1}</td>
<td class="atk-ip">${esc(row.ip)}</td> <td class="atk-ip">${f ? f+' ' : ''}${esc(row.ip)}</td>
<td class="atk-hits">${row.hits.toLocaleString()}</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> <td class="atk-asn" title="${esc(row.asn || '')}">${esc(asnNo)}</td>
</tr>`).join(''); </tr>`;
}).join('');
} }
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
function esc(s) { function esc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
} }
function fmtTime(ts) { function fmtTime(ts) { return new Date(ts*1000).toISOString().slice(11,19); }
return new Date(ts * 1000).toISOString().slice(11, 19);
}
// ── Live feed ───────────────────────────────────────────────────────────────── // ── Live feed ─────────────────────────────────────────────────────────────────
let feedCount = 0; let feedCount = 0;
let autoScroll = true;
const feedEl = document.getElementById('feed'); const feedEl = document.getElementById('feed');
const feedCount$ = document.getElementById('feed-count'); const feedCount$ = document.getElementById('feed-count');
const feedStatus = document.getElementById('feed-status'); const feedStatus = document.getElementById('feed-status');
feedEl.addEventListener('mouseenter', () => autoScroll = false);
feedEl.addEventListener('mouseleave', () => { autoScroll = true; });
function addRow(row) { function addRow(row) {
feedCount++; feedCount++;
feedCount$.textContent = `${feedCount.toLocaleString()} ${t('events')}`; feedCount$.textContent = `${feedCount.toLocaleString()} ${t('events')}`;
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'feed-row'; el.className = 'feed-row';
const f = flag(row.country || '');
el.innerHTML = ` el.innerHTML = `
<span class="feed-ts">${fmtTime(row.received_at)}</span> <span class="feed-ts">${fmtTime(row.received_at)}</span>
<span class="feed-ip">${esc(row.ip_masked || row.ip || '?')}</span> <span class="feed-ip">${esc(row.ip_masked || row.ip || '?')}</span>
<span> <span>
${f ? `<span class="feed-geo">${f} ${esc(row.country||'')}</span><br>` : ''}
<span class="feed-form">${esc(row.form_type||'?')}</span> <span class="feed-form">${esc(row.form_type||'?')}</span>
<br><span class="feed-reason">${esc(row.reason||'')}</span> <br><span class="feed-reason">${esc(row.reason||'')}</span>
</span>`; </span>`;
@@ -752,22 +697,18 @@ function addRow(row) {
while (feedEl.children.length > 120) feedEl.removeChild(feedEl.lastChild); while (feedEl.children.length > 120) feedEl.removeChild(feedEl.lastChild);
} }
// Seed from recent history on load
async function seedFeed() { async function seedFeed() {
try { try {
const r = await fetch('/api/v1/stats'); const r = await fetch('/api/v1/stats');
const s = await r.json(); const s = await r.json();
if (s.recent) [...s.recent].reverse().forEach(addRow); if (s.recent) [...s.recent].reverse().forEach(addRow);
} catch (e) { console.warn('[seed]', e); } } catch {}
} }
// SSE for live updates
function connectSSE() { function connectSSE() {
const es = new EventSource('/api/v1/stream'); const es = new EventSource('/api/v1/stream');
es.onopen = () => { feedStatus.textContent = t('connected'); }; es.onopen = () => { feedStatus.textContent = t('connected'); };
es.onmessage = e => { es.onmessage = e => { try { JSON.parse(e.data).reverse().forEach(addRow); } catch {} };
try { JSON.parse(e.data).reverse().forEach(addRow); } catch {}
};
es.onerror = () => { es.onerror = () => {
es.close(); es.close();
feedStatus.textContent = t('reconnecting'); feedStatus.textContent = t('reconnecting');
@@ -779,7 +720,7 @@ function connectSSE() {
async function fetchStats() { async function fetchStats() {
try { try {
const r = await fetch('/api/v1/stats'); const r = await fetch('/api/v1/stats');
if (!r.ok) throw new Error(r.status); if (!r.ok) return;
const s = await r.json(); const s = await r.json();
countUp(document.getElementById('s-total'), s.total); countUp(document.getElementById('s-total'), s.total);
@@ -793,7 +734,6 @@ async function fetchStats() {
renderBars(document.getElementById('bars-reasons'), s.top_reasons); renderBars(document.getElementById('bars-reasons'), s.top_reasons);
renderAttackers(s.top_ips); renderAttackers(s.top_ips);
// Top attacked form banner
if (s.top_forms && s.top_forms.length) { if (s.top_forms && s.top_forms.length) {
const top = s.top_forms[0]; const top = s.top_forms[0];
document.getElementById('tt-label').textContent = t('top_target_label'); document.getElementById('tt-label').textContent = t('top_target_label');
@@ -805,10 +745,8 @@ async function fetchStats() {
window._hourly = s.hourly; window._hourly = s.hourly;
drawChart(s.hourly); drawChart(s.hourly);
document.getElementById('last-update').textContent = new Date().toISOString().slice(11,19) + ' UTC';
document.getElementById('last-update').textContent = } catch {}
new Date().toISOString().slice(11, 19) + ' UTC';
} catch (e) { console.error('[stats]', e); }
} }
// ── Boot ────────────────────────────────────────────────────────────────────── // ── Boot ──────────────────────────────────────────────────────────────────────
@@ -820,6 +758,5 @@ connectSSE();
fetchStats(); fetchStats();
setInterval(fetchStats, 6000); setInterval(fetchStats, 6000);
</script> </script>
</body> </body>
</html> </html>

View File

@@ -3,6 +3,7 @@
const express = require('express'); const express = require('express');
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const path = require('path'); const path = require('path');
const http = require('http');
const { timingSafeEqual } = require('crypto'); const { timingSafeEqual } = require('crypto');
const app = express(); const app = express();
@@ -23,7 +24,9 @@ DB.exec(`
ip_masked TEXT NOT NULL DEFAULT '', ip_masked TEXT NOT NULL DEFAULT '',
form_type TEXT NOT NULL DEFAULT '', form_type TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL DEFAULT '', reason TEXT NOT NULL DEFAULT '',
ua_family TEXT NOT NULL DEFAULT '' ua_family TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
asn TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS sites ( CREATE TABLE IF NOT EXISTS sites (
site_id TEXT PRIMARY KEY, site_id TEXT PRIMARY KEY,
@@ -36,6 +39,10 @@ DB.exec(`
CREATE INDEX IF NOT EXISTS idx_site ON blocks(site_id); CREATE INDEX IF NOT EXISTS idx_site ON blocks(site_id);
`); `);
// Add country/asn columns to existing tables (migration — ignored if already present)
try { DB.exec(`ALTER TABLE blocks ADD COLUMN country TEXT NOT NULL DEFAULT ''`); } catch {}
try { DB.exec(`ALTER TABLE blocks ADD COLUMN asn TEXT NOT NULL DEFAULT ''`); } catch {}
// ── Auth token ──────────────────────────────────────────────────────────────── // ── Auth token ────────────────────────────────────────────────────────────────
const API_TOKEN = (process.env.API_TOKEN || '').trim(); const API_TOKEN = (process.env.API_TOKEN || '').trim();
@@ -90,6 +97,51 @@ function parseUA(ua = '') {
return ua.length ? 'Other' : 'No UA'; return ua.length ? 'Other' : 'No UA';
} }
// ── IP geo-enrichment (ip-api.com free tier, HTTP) ────────────────────────────
const stmtEnrich = DB.prepare('UPDATE blocks SET country=?, asn=? WHERE id=?');
const enrichCache = new Map(); // ip -> expiry ms (deduplicate within 1h)
function isPrivateIP(ip) {
return /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|127\.|::1$|fc|fd)/.test(ip);
}
function enrichIP(rowId, ip) {
if (!ip || ip === '?' || isPrivateIP(ip)) return;
const now = Date.now();
if ((enrichCache.get(ip) || 0) > now) return; // recently done
enrichCache.set(ip, now + 3_600_000); // cache 1 h
http.get(
`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,countryCode,as`,
{ timeout: 5000 },
res => {
let data = '';
res.on('data', d => data += d);
res.on('end', () => {
try {
const j = JSON.parse(data);
if (j.status === 'success') {
stmtEnrich.run(
(j.countryCode || '').slice(0, 2),
(j.as || '').slice(0, 50),
rowId
);
}
} catch {}
});
}
).on('error', () => enrichCache.delete(ip)); // retry next time
}
// Background enrichment: fill any rows missing country (e.g. from history import)
const stmtUnenriched = DB.prepare(
"SELECT id, ip_masked FROM blocks WHERE country='' AND ip_masked != '' AND ip_masked != '?' LIMIT 5"
);
setInterval(() => {
for (const row of stmtUnenriched.all()) enrichIP(row.id, row.ip_masked);
}, 20_000);
// ── In-memory rate limiter ──────────────────────────────────────────────────── // ── In-memory rate limiter ────────────────────────────────────────────────────
const rl = new Map(); const rl = new Map();
@@ -117,7 +169,7 @@ function getStats() {
last_30d: DB.prepare('SELECT COUNT(*) n FROM blocks WHERE received_at > ?').get(now - 2592000).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, total_sites: DB.prepare('SELECT COUNT(*) n FROM sites').get().n,
top_ips: DB.prepare(` top_ips: DB.prepare(`
SELECT ip_masked ip, COUNT(*) hits SELECT ip_masked ip, country, asn, COUNT(*) hits
FROM blocks WHERE received_at > ? FROM blocks WHERE received_at > ?
GROUP BY ip_masked ORDER BY hits DESC LIMIT 10 GROUP BY ip_masked ORDER BY hits DESC LIMIT 10
`).all(now - 2592000), `).all(now - 2592000),
@@ -137,7 +189,7 @@ function getStats() {
GROUP BY ua_family ORDER BY hits DESC LIMIT 8 GROUP BY ua_family ORDER BY hits DESC LIMIT 8
`).all(now - 2592000), `).all(now - 2592000),
recent: DB.prepare(` recent: DB.prepare(`
SELECT received_at, ip_masked ip, form_type, reason, ua_family SELECT received_at, ip_masked ip, country, form_type, reason, ua_family
FROM blocks ORDER BY id DESC LIMIT 40 FROM blocks ORDER BY id DESC LIMIT 40
`).all(), `).all(),
hourly: DB.prepare(` hourly: DB.prepare(`
@@ -167,7 +219,7 @@ setInterval(() => {
// ── Prepared statements ─────────────────────────────────────────────────────── // ── Prepared statements ───────────────────────────────────────────────────────
const stmtIns = DB.prepare( const stmtIns = DB.prepare(
'INSERT INTO blocks (received_at, site_id, ip_masked, form_type, reason, ua_family) VALUES (?,?,?,?,?,?)' 'INSERT INTO blocks (received_at, site_id, ip_masked, form_type, reason, ua_family, country, asn) VALUES (?,?,?,?,?,?,?,?)'
); );
const stmtSite = DB.prepare(` const stmtSite = DB.prepare(`
INSERT INTO sites (site_id, first_seen, last_seen, block_count) VALUES (?,?,?,?) INSERT INTO sites (site_id, first_seen, last_seen, block_count) VALUES (?,?,?,?)
@@ -178,17 +230,21 @@ const stmtSite = DB.prepare(`
const insertBatch = DB.transaction((siteId, blocks) => { const insertBatch = DB.transaction((siteId, blocks) => {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const ids = [];
for (const b of blocks) { for (const b of blocks) {
const ts = b.blocked_at ? Math.floor(new Date(b.blocked_at) / 1000) : now; const ts = b.blocked_at ? Math.floor(new Date(b.blocked_at) / 1000) : now;
stmtIns.run( const ip = sanitizeIP(b.ip);
ts, siteId, const r = stmtIns.run(
sanitizeIP(b.ip), ts, siteId, ip,
String(b.form_type || '').slice(0, 100), String(b.form_type || '').slice(0, 100),
String(b.reason || '').slice(0, 255), String(b.reason || '').slice(0, 255),
parseUA(b.user_agent || '') parseUA(b.user_agent || ''),
'', '' // country / asn filled async
); );
ids.push({ id: Number(r.lastInsertRowid), ip });
} }
stmtSite.run(siteId, now, now, blocks.length); stmtSite.run(siteId, now, now, blocks.length);
return ids;
}); });
// ── Express routes ──────────────────────────────────────────────────────────── // ── Express routes ────────────────────────────────────────────────────────────
@@ -215,8 +271,10 @@ app.post('/api/v1/submit', requireToken, (req, res) => {
} }
try { try {
insertBatch(site_hash.slice(0, 20), blocks); const ids = insertBatch(site_hash.slice(0, 20), blocks);
_cache = null; // invalidate stats cache _cache = null; // invalidate stats cache
// Enrich IPs asynchronously without blocking the response
setImmediate(() => ids.forEach(({ id, ip }) => enrichIP(id, ip)));
res.json({ ok: true, received: blocks.length }); res.json({ ok: true, received: blocks.length });
} catch (e) { } catch (e) {
console.error('[submit]', e.message); console.error('[submit]', e.message);

View File

@@ -3,7 +3,7 @@
* Plugin Name: Honeypot Fields * Plugin Name: Honeypot Fields
* Plugin URI: https://informatiq.services * 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. * 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.3.0 * Version: 2.4.0
* Author: Malin * Author: Malin
* Author URI: https://malin.ro * Author URI: https://malin.ro
* License: GPL v2 or later * License: GPL v2 or later
@@ -378,6 +378,20 @@ class SmartHoneypotAPIClient {
return count((array) get_option(self::OPT_QUEUE, [])); 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. * Returns the API token.
* Checks the HP_API_TOKEN constant (defined in wp-config.php) first, * Checks the HP_API_TOKEN constant (defined in wp-config.php) first,
@@ -1181,6 +1195,10 @@ class SmartHoneypotAntiSpam {
/* ── Init ──────────────────────────────────────────────────────── */ /* ── Init ──────────────────────────────────────────────────────── */
public function init() { public function init() {
// CF7 submits via admin-ajax.php where is_admin()=true, so hook here
// before the early return so spam validation still runs.
add_filter('wpcf7_spam', [$this, 'validate_cf7_spam'], 10, 2);
if (is_admin()) { if (is_admin()) {
add_action('admin_notices', [$this, 'activation_notice']); add_action('admin_notices', [$this, 'activation_notice']);
return; return;
@@ -1423,6 +1441,13 @@ class SmartHoneypotAntiSpam {
} }
} }
/* ── 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 ─────────────────────────────────────────── */ /* ── Generic catch-all ─────────────────────────────────────────── */
public function validate_generic_post() { public function validate_generic_post() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
@@ -1436,6 +1461,7 @@ class SmartHoneypotAntiSpam {
isset($_POST['woocommerce-login-nonce']) || isset($_POST['woocommerce-login-nonce']) ||
isset($_POST['woocommerce-process-checkout-nonce']) || isset($_POST['woocommerce-process-checkout-nonce']) ||
isset($_POST['comment_post_ID']) || 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['action']) && $_POST['action'] === 'elementor_pro_forms_send_form')
) { ) {
return; return;
@@ -1514,6 +1540,8 @@ add_action('plugins_loaded', function () {
} }
new SmartHoneypotAntiSpam(); new SmartHoneypotAntiSpam();
SmartHoneypotAdmin::register(); 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) // Custom cron interval (5 minutes)