503 lines
26 KiB
PHP
503 lines
26 KiB
PHP
<?php
|
||
/**
|
||
* admin/visits.php —— 访问信息
|
||
* 访问日志查询(精简 / 完整两种模式 + IP / 关键词筛选 + 分页)与 IP 黑名单管理。
|
||
* 黑名单命中规则后前台与后台(含登录页)请求均被直接拒绝(与前台同一风控提示页)。
|
||
*/
|
||
$P = '../';
|
||
require_once __DIR__ . '/_guard.php';
|
||
|
||
$pdo = db();
|
||
|
||
function settings_set(string $key, string $value): void
|
||
{
|
||
$st = db()->prepare('INSERT INTO settings (key, value) VALUES (?, ?)
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value');
|
||
$st->execute([$key, $value]);
|
||
}
|
||
|
||
/* 批量删除黑名单(按 id 列表),同步清除单 IP 规则的风控计数;返回删除条数 */
|
||
function bl_delete_ids(PDO $pdo, array $ids): int
|
||
{
|
||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), function ($v) { return $v > 0; })));
|
||
if (!$ids) return 0;
|
||
$ph = implode(',', array_fill(0, count($ids), '?'));
|
||
$st = $pdo->prepare('SELECT rule FROM ip_blacklist WHERE id IN (' . $ph . ')');
|
||
$st->execute($ids);
|
||
$rules = $st->fetchAll();
|
||
$pdo->prepare('DELETE FROM ip_blacklist WHERE id IN (' . $ph . ')')->execute($ids);
|
||
foreach ($rules as $r) {
|
||
if (filter_var((string)$r['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||
risk_clear_for_ip((string)$r['rule']);
|
||
}
|
||
}
|
||
return count($rules);
|
||
}
|
||
|
||
/* ---------- POST:黑名单增删 / 清空日志 / 风控规则 ---------- */
|
||
$msgKind = '';
|
||
$msgText = '';
|
||
$banChanged = false;
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
if (!csrf_verify()) {
|
||
$msgKind = 'err';
|
||
$msgText = '安全校验失败,请刷新页面重试。';
|
||
} else {
|
||
$act = (string)($_POST['act'] ?? '');
|
||
switch ($act) {
|
||
case 'ban_add':
|
||
$rule = trim((string)($_POST['rule'] ?? ''));
|
||
$note = trim((string)($_POST['note'] ?? ''));
|
||
if ($rule === '') {
|
||
$msgKind = 'err';
|
||
$msgText = '请填写要拦截的 IP / 网段。';
|
||
break;
|
||
}
|
||
$parsed = blacklist_parse_rule($rule);
|
||
if (!$parsed[0]) {
|
||
$msgKind = 'err';
|
||
$msgText = $parsed[3];
|
||
break;
|
||
}
|
||
$st = $pdo->prepare('INSERT INTO ip_blacklist (rule, note, created_at, source) VALUES (?,?,?,?)');
|
||
$st->execute([$rule, $note, date('Y-m-d H:i:s'), 'manual']);
|
||
$banChanged = true;
|
||
$msgKind = 'ok';
|
||
$msgText = '黑名单规则已添加,匹配的访问将立即被拒绝。';
|
||
break;
|
||
case 'ban_del':
|
||
$id = max(0, (int)($_POST['id'] ?? 0));
|
||
if ($id > 0) {
|
||
$bs = $pdo->prepare('SELECT rule FROM ip_blacklist WHERE id = ? LIMIT 1');
|
||
$bs->execute([$id]);
|
||
$brow = $bs->fetch();
|
||
$pdo->prepare('DELETE FROM ip_blacklist WHERE id = ?')->execute([$id]);
|
||
// 解封单 IP 时同步清除其风控计数,避免“刚解封又被历史计数封回”
|
||
if ($brow && filter_var((string)$brow['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||
risk_clear_for_ip((string)$brow['rule']);
|
||
}
|
||
$banChanged = true;
|
||
$msgKind = 'ok';
|
||
$msgText = '黑名单规则已移除。';
|
||
} else {
|
||
$msgKind = 'err';
|
||
$msgText = '无效的黑名单记录。';
|
||
}
|
||
break;
|
||
case 'log_clear':
|
||
@file_put_contents(ACCESS_LOG_FILE, '', LOCK_EX);
|
||
$msgKind = 'ok';
|
||
$msgText = '访问日志已清空(data/vistors.log)。';
|
||
break;
|
||
case 'risk_save':
|
||
$clip = function ($v, $d) { $raw = trim((string)($v ?? '')); $x = ($raw === '') ? (int)$d : (int)$raw; return max(1, min(1000000, $x)); };
|
||
settings_set('risk_login_on', isset($_POST['r_login_on']) ? '1' : '0');
|
||
settings_set('risk_login_n', (string)$clip($_POST['r_login_n'] ?? null, 10));
|
||
settings_set('risk_404_on', isset($_POST['r_404_on']) ? '1' : '0');
|
||
settings_set('risk_404_n', (string)$clip($_POST['r_404_n'] ?? null, 100));
|
||
settings_set('risk_rate_on', isset($_POST['r_rate_on']) ? '1' : '0');
|
||
settings_set('risk_rate_n', (string)$clip($_POST['r_rate_n'] ?? null, 120));
|
||
settings_set('risk_tip_main', trim((string)($_POST['r_tip_main'] ?? '')));
|
||
settings_set('risk_tip_sub', trim((string)($_POST['r_tip_sub'] ?? '')));
|
||
$msgKind = 'ok';
|
||
$msgText = '风控规则已保存并即时生效。';
|
||
break;
|
||
case 'risk_clear':
|
||
risk_clear_all();
|
||
$msgKind = 'ok';
|
||
$msgText = '全部风险计数已清空(黑名单本身不受影响)。';
|
||
break;
|
||
case 'ban_batch':
|
||
$n = bl_delete_ids($pdo, (array)($_POST['bid'] ?? []));
|
||
$banChanged = true;
|
||
$msgKind = $n > 0 ? 'ok' : 'err';
|
||
$msgText = $n > 0 ? '已批量移除 ' . $n . ' 条黑名单规则(对应单 IP 计数已清除)。' : '未勾选任何需要移除的规则。';
|
||
break;
|
||
case 'ban_clear_auto':
|
||
$rows = $pdo->query("SELECT rule FROM ip_blacklist WHERE source = 'auto'")->fetchAll();
|
||
$cnt = count($rows);
|
||
$pdo->exec("DELETE FROM ip_blacklist WHERE source = 'auto'");
|
||
foreach ($rows as $r) {
|
||
if (filter_var((string)$r['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||
risk_clear_for_ip((string)$r['rule']);
|
||
}
|
||
}
|
||
blacklist_rules(true);
|
||
$banChanged = true;
|
||
$msgKind = 'ok';
|
||
$msgText = '已一键移除全部风控自动封禁(共 ' . $cnt . ' 条),相关 IP 风控计数已同步清除。';
|
||
break;
|
||
case 'ban_clear_all':
|
||
$pdo->exec('DELETE FROM ip_blacklist');
|
||
risk_clear_all();
|
||
blacklist_rules(true);
|
||
$banChanged = true;
|
||
$msgKind = 'ok';
|
||
$msgText = '已清空全部黑名单,风险计数一并清零。';
|
||
break;
|
||
default:
|
||
$msgKind = 'err';
|
||
$msgText = '未知操作。';
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ---------- 概要统计(数据来源:data/vistors.log) ---------- */
|
||
$entries = visitors_entries();
|
||
$statTotal = count($entries);
|
||
$ipSet = [];
|
||
foreach ($entries as $e) {
|
||
$ipSet[$e['ip']] = 1;
|
||
}
|
||
$statIps = count($ipSet);
|
||
$statToday = 0;
|
||
$todayStart = date('Y-m-d 00:00:00');
|
||
foreach ($entries as $e) {
|
||
if ($e['created_at'] >= $todayStart) {
|
||
$statToday++;
|
||
}
|
||
}
|
||
function vis_scalar(PDO $pdo, string $sql): int
|
||
{
|
||
$v = $pdo->query($sql)->fetchColumn();
|
||
return (int)$v;
|
||
}
|
||
$statBan = vis_scalar($pdo, 'SELECT COUNT(*) FROM ip_blacklist');
|
||
|
||
/* ---------- 筛选与分页 ---------- */
|
||
$mode = (string)($_GET['mode'] ?? 'full');
|
||
if (!in_array($mode, ['full', 'compact'], true)) {
|
||
$mode = 'full';
|
||
}
|
||
$ipF = trim((string)($_GET['ip'] ?? ''));
|
||
$q = trim((string)($_GET['q'] ?? ''));
|
||
$ipF = substr($ipF, 0, 64);
|
||
$q = substr($q, 0, 100);
|
||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||
$perPage = 30;
|
||
|
||
// entries 已按新→旧排列,内存中过滤 / 聚合后分页
|
||
$logs = [];
|
||
$total = 0;
|
||
$totalPages = 1;
|
||
if ($mode === 'compact') {
|
||
// 精简模式:按 IP 聚合(最近访问时间 / IP / 访问次数),首次出现即该 IP 最新一条
|
||
$grp = [];
|
||
$order = [];
|
||
foreach ($entries as $e) {
|
||
if ($ipF !== '' && stripos($e['ip'], $ipF) === false) continue;
|
||
if ($q !== '' && stripos($e['url'], $q) === false && stripos($e['method'], $q) === false) continue;
|
||
if (!isset($grp[$e['ip']])) {
|
||
$grp[$e['ip']] = ['ip' => $e['ip'], 'last_at' => $e['created_at'], 'cnt' => 0];
|
||
$order[] = $e['ip'];
|
||
}
|
||
$grp[$e['ip']]['cnt']++;
|
||
}
|
||
$all = [];
|
||
foreach ($order as $ip) {
|
||
$all[] = $grp[$ip];
|
||
}
|
||
$total = count($all);
|
||
$totalPages = max(1, (int)ceil($total / $perPage));
|
||
if ($page > $totalPages) {
|
||
$page = $totalPages;
|
||
}
|
||
$off = ($page - 1) * $perPage;
|
||
$logs = array_slice($all, $off, $perPage);
|
||
} else {
|
||
// 完整模式:逐条明细
|
||
$all = [];
|
||
foreach ($entries as $e) {
|
||
if ($ipF !== '' && stripos($e['ip'], $ipF) === false) continue;
|
||
if ($q !== '' && stripos($e['url'], $q) === false && stripos($e['method'], $q) === false) continue;
|
||
$all[] = $e;
|
||
}
|
||
$total = count($all);
|
||
$totalPages = max(1, (int)ceil($total / $perPage));
|
||
if ($page > $totalPages) {
|
||
$page = $totalPages;
|
||
}
|
||
$off = ($page - 1) * $perPage;
|
||
$logs = array_slice($all, $off, $perPage);
|
||
}
|
||
|
||
/* ---------- 链接工具(保留当前筛选参数) ---------- */
|
||
function vis_url(array $ov): string
|
||
{
|
||
$p = $_GET;
|
||
foreach ($ov as $k => $v) {
|
||
if ($v === null || $v === '') {
|
||
unset($p[$k]);
|
||
} else {
|
||
$p[$k] = $v;
|
||
}
|
||
}
|
||
if (!$p) {
|
||
return 'visits.php';
|
||
}
|
||
return 'visits.php?' . http_build_query($p);
|
||
}
|
||
|
||
/* ---------- 当前访问者信息(用于黑名单自查提示) ---------- */
|
||
$curIp = client_ip();
|
||
if ($curIp === '') {
|
||
$curIp = '未知';
|
||
}
|
||
$curHit = null;
|
||
if ($curIp !== '未知') {
|
||
if ($banChanged) {
|
||
blacklist_rules(true);
|
||
}
|
||
$curHit = blacklist_ip_hit($curIp);
|
||
}
|
||
|
||
/* ---------- 自动风控规则配置与当前 IP 计数 ---------- */
|
||
$rcfg = risk_rule_cfg();
|
||
$curRisk = ($curIp !== '未知') ? risk_row($curIp) : null;
|
||
|
||
/* ---------- 黑名单规则(直接读表展示,便于核对备注与来源) ---------- */
|
||
$banRows = $pdo->query('SELECT id, rule, note, created_at, source FROM ip_blacklist ORDER BY id DESC')->fetchAll();
|
||
$banAutoCount = 0;
|
||
foreach ($banRows as $b) {
|
||
if (((string)($b['source'] ?? 'manual')) === 'auto') $banAutoCount++;
|
||
}
|
||
|
||
layout_head('访问信息');
|
||
admin_topbar('visits');
|
||
?>
|
||
<main class="page-main">
|
||
<div class="wrap page-body">
|
||
<h2 style="margin-bottom:6px">访问信息</h2>
|
||
<p class="tip">记录访客对本站的动态请求(含后台),完整模式逐条展示访问明细,精简模式按 IP 汇总次数;黑名单 / 自动风控对所有访问统一生效,命中即返回 403 风控提示页,后台与登录页不例外。</p>
|
||
|
||
<?php if ($msgText !== ''): ?>
|
||
<script>window.hpToastMsg = <?= json_encode(['kind' => $msgKind === 'ok' ? 'ok' : 'err', 'text' => $msgText], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
|
||
<?php endif; ?>
|
||
|
||
<!-- 概要统计 -->
|
||
<div class="fieldset-card">
|
||
<div class="fs-title">访问概况</div>
|
||
<div class="vis-stats">
|
||
<div class="vis-stat"><b><?= $statTotal ?></b><span>累计访问</span></div>
|
||
<div class="vis-stat"><b><?= $statIps ?></b><span>独立 IP</span></div>
|
||
<div class="vis-stat"><b><?= $statToday ?></b><span>今日访问</span></div>
|
||
<div class="vis-stat"><b><?= $statBan ?></b><span>黑名单规则</span></div>
|
||
</div>
|
||
<form method="post" data-confirm="确认清空全部访问日志?此操作不可恢复。" style="margin-top:14px">
|
||
<?= csrf_field() ?><input type="hidden" name="act" value="log_clear">
|
||
<button type="submit" class="btn btn-sm btn-danger">清空访问日志</button>
|
||
<span class="tip" style="margin-left:8px">日志按行追加写入 <code>data/vistors.log</code>(制表符分隔:时间 / IP / 请求方式 / 响应码 / URL),完整保留不设上限;可自行归档或删除该文件。</span>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- 筛选 + 日志列表 -->
|
||
<div class="fieldset-card">
|
||
<div class="fs-title">访问记录<?= $mode === 'compact' ? '(精简模式:按 IP 汇总)' : '(完整模式:逐条明细)' ?></div>
|
||
<form method="get" class="v-filter">
|
||
<select name="mode" title="展示模式" onchange="this.form.submit()">
|
||
<option value="full"<?= $mode === 'full' ? ' selected' : '' ?>>完整模式(逐条明细)</option>
|
||
<option value="compact"<?= $mode === 'compact' ? ' selected' : '' ?>>精简模式(仅时间 / IP / 次数)</option>
|
||
</select>
|
||
<input type="text" name="ip" value="<?= he($ipF) ?>" placeholder="按 IP 筛选,支持模糊(如 203.0)">
|
||
<input type="text" name="q" class="w-s" value="<?= he($q) ?>" placeholder="关键词:地址 / 请求方式">
|
||
<button type="submit" class="btn btn-primary">筛选</button>
|
||
<a class="btn" href="visits.php">重置</a>
|
||
</form>
|
||
|
||
<?php if ($logs): ?>
|
||
<div class="tip" style="margin:10px 0 6px">
|
||
共命中 <?= $total ?> <?= $mode === 'compact' ? '个 IP' : '条记录' ?>;
|
||
当前第 <?= $page ?> / <?= $totalPages ?> 页,每页 <?= $perPage ?> 条。
|
||
</div>
|
||
<div class="v-scroll">
|
||
<?php if ($mode === 'compact'): ?>
|
||
<table class="v-tbl">
|
||
<thead>
|
||
<tr><th>最近访问时间</th><th>IP 地址</th><th>访问次数</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($logs as $r): ?>
|
||
<tr>
|
||
<td class="v-nowrap"><?= he((string)$r['last_at']) ?></td>
|
||
<td class="v-nowrap"><?= he((string)$r['ip']) ?></td>
|
||
<td><b><?= (int)$r['cnt'] ?></b> 次</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
<?php else: ?>
|
||
<table class="v-tbl">
|
||
<thead>
|
||
<tr>
|
||
<th>访问时间</th><th>IP 地址</th><th>请求方式</th><th>访问 URL</th><th>响应码</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($logs as $r): ?>
|
||
<tr>
|
||
<td class="v-nowrap"><?= he((string)$r['created_at']) ?></td>
|
||
<td class="v-nowrap"><?= he((string)$r['ip']) ?></td>
|
||
<td><span class="v-tag"><?= he((string)$r['method']) ?></span></td>
|
||
<td><?= he((string)$r['url']) ?></td>
|
||
<td class="v-nowrap"><?php
|
||
$code = (int)$r['status'];
|
||
$cls = ($code >= 200 && $code < 300) ? 'good' : (($code >= 300 && $code < 400) ? 'info' : (($code >= 400 && $code < 500) ? 'warn' : 'bad'));
|
||
?><span class="v-tag <?= $cls ?>"><?= $code ?></span>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<?php if ($totalPages > 1): ?>
|
||
<div class="pager">
|
||
<span class="pg-info">共 <?= $total ?> 条</span>
|
||
<?php if ($page > 1): ?><a class="btn btn-sm" href="<?= he(vis_url(['page' => $page - 1])) ?>">← 上一页</a><?php endif; ?>
|
||
<?php
|
||
$win = 2;
|
||
$start = max(1, $page - $win);
|
||
$end = min($totalPages, $page + $win);
|
||
if ($start > 1) echo '<span class="tip">…</span>';
|
||
for ($p = $start; $p <= $end; $p++) {
|
||
if ($p === $page) {
|
||
echo '<span class="btn btn-sm btn-primary">' . $p . '</span>';
|
||
} else {
|
||
echo '<a class="btn btn-sm" href="' . he(vis_url(['page' => $p])) . '">' . $p . '</a>';
|
||
}
|
||
}
|
||
if ($end < $totalPages) echo '<span class="tip">…</span>';
|
||
?>
|
||
<?php if ($page < $totalPages): ?><a class="btn btn-sm" href="<?= he(vis_url(['page' => $page + 1])) ?>">下一页 →</a><?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php else: ?>
|
||
<div class="err-msg" style="margin-top:12px"><?= ($ipF !== '' || $q !== '') ? '该筛选条件下暂无访问记录,可点击「重置」查看全部。' : '还没有访问记录,访问站点前台页面后将在这里实时展示。' ?></div>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<!-- 自动风控规则 -->
|
||
<div class="fieldset-card">
|
||
<div class="fs-title">自动风控规则<span class="tip" style="font-weight:400">(达到阈值自动加入黑名单;命中前台与后台均返回 403 风控提示页,提示文案可在下方自定义)</span></div>
|
||
<form method="post">
|
||
<?= csrf_field() ?><input type="hidden" name="act" value="risk_save">
|
||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:9px 0">
|
||
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_login_on" value="1"<?= $rcfg['login_fail']['on'] ? ' checked' : '' ?>> 后台登录失败封禁</label>
|
||
<span class="tip">失败次数 ≥ <input type="number" name="r_login_n" min="1" max="1000000" value="<?= (int)$rcfg['login_fail']['n'] ?>" style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit"> 次 → 永久封禁(建议 5-20)</span>
|
||
</div>
|
||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:9px 0">
|
||
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_404_on" value="1"<?= $rcfg['not_found']['on'] ? ' checked' : '' ?>> 前台 404 封禁</label>
|
||
<span class="tip">累计次数 ≥ <input type="number" name="r_404_n" min="1" max="1000000" value="<?= (int)$rcfg['not_found']['n'] ?>" style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit"> 次 → 永久封禁(建议 50-300)</span>
|
||
</div>
|
||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:9px 0">
|
||
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_rate_on" value="1"<?= $rcfg['rate']['on'] ? ' checked' : '' ?>> 请求频率封禁</label>
|
||
<span class="tip">60 秒内 ≥ <input type="number" name="r_rate_n" min="1" max="1000000" value="<?= (int)$rcfg['rate']['n'] ?>" style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit"> 次 → 永久封禁(多人共用出口 IP 时建议关闭)</span>
|
||
</div>
|
||
<p class="tip" style="margin:8px 0 6px">计数方式:404 与登录失败只要发生即累计(规则关闭也照常计数,便于后台观察);规则启用且达到阈值时自动封禁,保存后即时生效。自动封禁在黑名单中标注“风控自动”,可随时移除解封(解封时同步清除该 IP 计数)。</p>
|
||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:0 0 8px">
|
||
<span class="tip" style="min-width:148px">主提示(留空=默认)</span>
|
||
<input type="text" name="r_tip_main" maxlength="100" value="<?= he((string)$rcfg['_tip']['raw_main']) ?>" placeholder="例:您触发了本站风控,请稍后再访问" title="黑名单命中时提示页的大号主文案" style="flex:1 1 300px;min-width:260px;padding:6px 10px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
|
||
</div>
|
||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:0 0 12px">
|
||
<span class="tip" style="min-width:148px">次行说明(留空=默认)</span>
|
||
<input type="text" name="r_tip_sub" maxlength="200" value="<?= he((string)$rcfg['_tip']['raw_sub']) ?>" placeholder="例:如为正常访问,请稍后重试;若频繁误判,请联系站点管理员处理。" title="主提示下方的次要说明文案" style="flex:1 1 300px;min-width:260px;padding:6px 10px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
|
||
</div>
|
||
<button type="submit" class="btn btn-primary">保存风控规则</button>
|
||
</form>
|
||
<form method="post" data-confirm="确认清空全部风险计数?各 IP 将从 0 重新累计,不影响已封禁的黑名单。" style="margin-top:14px">
|
||
<?= csrf_field() ?><input type="hidden" name="act" value="risk_clear">
|
||
<button type="submit" class="btn btn-sm">清空全部风险计数</button>
|
||
<?php if ($curRisk): ?>
|
||
<span class="tip" style="margin-left:8px">当前访问 IP(<?= he($curIp) ?>)计数:登录失败 <?= (int)$curRisk['login_fail'] ?> 次 · 404 <?= (int)$curRisk['not_found'] ?> 次<?php if ($rcfg['rate']['on']): ?> · 本分钟请求 <?= (int)$curRisk['rate_count'] ?> 次<?php endif; ?></span>
|
||
<?php endif; ?>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- IP 黑名单 -->
|
||
<div class="fieldset-card">
|
||
<div class="fs-title">IP 黑名单(全站拦截前台与后台,命中统一返回 403 风控提示页)</div>
|
||
<form method="post" class="v-filter">
|
||
<?= csrf_field() ?><input type="hidden" name="act" value="ban_add">
|
||
<input type="text" name="rule" required placeholder="如:203.0.113.7 / 203.0.113.0/24 / 203.0.113.*" style="flex:1 1 260px;max-width:420px">
|
||
<input type="text" name="note" placeholder="备注(可选,如:恶意扫描器)" style="flex:1 1 220px;max-width:340px">
|
||
<button type="submit" class="btn btn-primary">加入黑名单</button>
|
||
</form>
|
||
<p class="tip" style="margin:10px 0 2px">
|
||
支持三种写法:单个 IP、CIDR 网段(如 <code>203.0.113.0/24</code>)、星号通配段(如 <code>203.0.113.*</code> 或 <code>192.168.*.*</code>);
|
||
目前仅按 IPv4 匹配。黑名单即时生效且不受「站长提示」开关影响,命中 IP 的<strong>前台与后台(含登录页)访问一律被拒绝</strong>。如需解除,请从未被封禁的网络进入后台移除该规则,或直接在 data/homepage.db 的 ip_blacklist 表中删除记录。
|
||
</p>
|
||
<div style="margin:6px 0 10px">
|
||
<?php if ($curHit): ?>
|
||
<span class="vis-warn-tip">当前访问 IP:<?= he($curIp) ?> —— 命中规则「<?= he($curHit['rule']) ?>」(<?= ($curHit['note'] !== '') ? he((string)$curHit['note']) : '无备注' ?>),全站(前台与后台)访问将被拦截</span>
|
||
<?php else: ?>
|
||
<span class="vis-ok-tip">当前访问 IP:<?= he($curIp) ?> —— 未命中任何黑名单规则</span>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<?php if ($banRows): ?>
|
||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:8px 0 4px">
|
||
<form method="post" id="blBatchForm" data-confirm="确认移除选中的黑名单规则?对应单 IP 风控计数将同步清除。">
|
||
<?= csrf_field() ?><input type="hidden" name="act" value="ban_batch">
|
||
<label class="tip" style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;margin:0 4px 0 0"><input type="checkbox" id="blCheckAll"> 全选</label>
|
||
<button type="submit" class="btn btn-sm btn-danger" id="blBatchBtn" disabled>移除选中(0)</button>
|
||
</form>
|
||
<?php if ($banAutoCount > 0): ?>
|
||
<form method="post" data-confirm="确认一键移除全部风控自动封禁(共 <?= $banAutoCount ?> 条)?相关 IP 风控计数将同步清除。">
|
||
<?= csrf_field() ?><input type="hidden" name="act" value="ban_clear_auto">
|
||
<button type="submit" class="btn btn-sm">一键移除风控自动(<?= $banAutoCount ?>)</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
<form method="post" data-confirm="确认清空全部黑名单?全部风险计数将一并清零。">
|
||
<?= csrf_field() ?><input type="hidden" name="act" value="ban_clear_all">
|
||
<button type="submit" class="btn btn-sm btn-danger">清空全部</button>
|
||
</form>
|
||
</div>
|
||
<?php foreach ($banRows as $b): ?>
|
||
<div class="bl-row">
|
||
<label class="tip" style="display:inline-flex;align-items:center;margin:0" title="勾选后可在上方批量移除"><input type="checkbox" form="blBatchForm" class="js-bl-check" name="bid[]" value="<?= (int)$b['id'] ?>"></label>
|
||
<code class="bl-rule"><?= he((string)$b['rule']) ?></code>
|
||
<?php if (((string)($b['source'] ?? 'manual')) === 'auto'): ?>
|
||
<span class="v-tag warn" style="min-width:0">风控自动</span>
|
||
<?php else: ?>
|
||
<span class="v-tag info" style="min-width:0">手动</span>
|
||
<?php endif; ?>
|
||
<span class="bl-note"><?= $b['note'] !== '' ? he((string)$b['note']) : '<span class="tip">(无备注)</span>' ?></span>
|
||
<span class="bl-time"><?= he((string)$b['created_at']) ?></span>
|
||
<form method="post" data-confirm="确认移除黑名单规则「<?= he((string)$b['rule']) ?>」?">
|
||
<?= csrf_field() ?><input type="hidden" name="act" value="ban_del"><input type="hidden" name="id" value="<?= (int)$b['id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-danger">移除</button>
|
||
</form>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
<?php else: ?>
|
||
<p class="tip" style="margin:6px 0 0">尚未配置黑名单规则。启用上方「自动风控」后,异常 IP 会自动记录到这里;也可在下方手动添加规则。</p>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<?php layout_theme_fab(); ?>
|
||
<script src="../assets/js/common.js"></script>
|
||
<script>
|
||
(function () {
|
||
var all = document.getElementById('blCheckAll');
|
||
var btn = document.getElementById('blBatchBtn');
|
||
var boxes = Array.prototype.slice.call(document.querySelectorAll('.js-bl-check'));
|
||
if (!all || !btn || !boxes.length) return;
|
||
function refresh() {
|
||
var n = boxes.filter(function (c) { return c.checked; }).length;
|
||
btn.disabled = n === 0;
|
||
btn.textContent = '移除选中(' + n + ')';
|
||
all.checked = n > 0 && n === boxes.length;
|
||
}
|
||
all.addEventListener('change', function () {
|
||
boxes.forEach(function (c) { c.checked = all.checked; });
|
||
refresh();
|
||
});
|
||
boxes.forEach(function (c) { c.addEventListener('change', refresh); });
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|