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:
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
const express = require('express');
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const { timingSafeEqual } = require('crypto');
|
||||
|
||||
const app = express();
|
||||
@@ -23,7 +24,9 @@ DB.exec(`
|
||||
ip_masked TEXT NOT NULL DEFAULT '',
|
||||
form_type 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 (
|
||||
site_id TEXT PRIMARY KEY,
|
||||
@@ -36,6 +39,10 @@ DB.exec(`
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const API_TOKEN = (process.env.API_TOKEN || '').trim();
|
||||
@@ -90,6 +97,51 @@ function parseUA(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 ────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
total_sites: DB.prepare('SELECT COUNT(*) n FROM sites').get().n,
|
||||
top_ips: DB.prepare(`
|
||||
SELECT ip_masked ip, COUNT(*) hits
|
||||
SELECT ip_masked ip, country, asn, COUNT(*) hits
|
||||
FROM blocks WHERE received_at > ?
|
||||
GROUP BY ip_masked ORDER BY hits DESC LIMIT 10
|
||||
`).all(now - 2592000),
|
||||
@@ -137,7 +189,7 @@ function getStats() {
|
||||
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
|
||||
SELECT received_at, ip_masked ip, country, form_type, reason, ua_family
|
||||
FROM blocks ORDER BY id DESC LIMIT 40
|
||||
`).all(),
|
||||
hourly: DB.prepare(`
|
||||
@@ -167,7 +219,7 @@ setInterval(() => {
|
||||
// ── Prepared statements ───────────────────────────────────────────────────────
|
||||
|
||||
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(`
|
||||
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 now = Math.floor(Date.now() / 1000);
|
||||
const ids = [];
|
||||
for (const b of blocks) {
|
||||
const ts = b.blocked_at ? Math.floor(new Date(b.blocked_at) / 1000) : now;
|
||||
stmtIns.run(
|
||||
ts, siteId,
|
||||
sanitizeIP(b.ip),
|
||||
const ts = b.blocked_at ? Math.floor(new Date(b.blocked_at) / 1000) : now;
|
||||
const ip = sanitizeIP(b.ip);
|
||||
const r = stmtIns.run(
|
||||
ts, siteId, ip,
|
||||
String(b.form_type || '').slice(0, 100),
|
||||
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);
|
||||
return ids;
|
||||
});
|
||||
|
||||
// ── Express routes ────────────────────────────────────────────────────────────
|
||||
@@ -215,8 +271,10 @@ app.post('/api/v1/submit', requireToken, (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
insertBatch(site_hash.slice(0, 20), blocks);
|
||||
const ids = insertBatch(site_hash.slice(0, 20), blocks);
|
||||
_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 });
|
||||
} catch (e) {
|
||||
console.error('[submit]', e.message);
|
||||
|
||||
Reference in New Issue
Block a user