更新完善网页逻辑,工具迁移至html逻辑

This commit is contained in:
MasonLiu 2026-09-10 00:22:40 +08:00
parent c3bfce7afb
commit 646754c04d
26 changed files with 1606 additions and 167 deletions

View File

@ -23,6 +23,7 @@ function admin_topbar(string $active = ''): void
'articles' => ['articles.php', '文章管理'],
'exttools' => ['exttools.php', '外部工具'],
'content' => ['content.php', '内容与顺序'],
'visits' => ['visits.php', '访问信息'],
];
foreach ($links as $k => $lk) {
$cls = ($active === $k) ? 'active' : '';

View File

@ -33,7 +33,8 @@ function renumber_sorts(PDO $pdo): void
function msg_block(string $kind, string $text): string
{
return '<div class="' . ($kind === 'ok' ? 'ok-msg' : 'err-msg') . '">' . he($text) . '</div>';
// 改为右下角 Toast注入 hpToastMsg由 common.js 在页面加载后自动弹出
return '<script>window.hpToastMsg = ' . json_encode(['kind' => $kind === 'ok' ? 'ok' : 'err', 'text' => $text], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) . ';</script>';
}
$msgKind = '';
@ -236,7 +237,7 @@ admin_topbar('articles');
<p class="tip">文章库(第五大板块)的文章维护:正文为标准 Markdown图片统一上传到 data/articles/img 目录。</p>
<?php if ($savedFlag): ?>
<div class="ok-msg">保存成功,文章库前台已同步更新。</div>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,文章库前台已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<?php if ($msgText !== ''): echo msg_block($msgKind, $msgText); endif; ?>

View File

@ -31,7 +31,8 @@ function remove_old_logo(): void
function alert_html(string $kind, string $msg): void
{
echo '<div class="' . ($kind === 'ok' ? 'ok-msg' : 'err-msg') . '">' . he($msg) . '</div>';
// 改为右下角 Toast注入 hpToastMsg由 common.js 在页面加载后自动弹出
echo '<script>window.hpToastMsg = ' . json_encode(['kind' => $kind === 'ok' ? 'ok' : 'err', 'text' => $msg], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) . ';</script>';
}
$msgKind = '';
@ -152,6 +153,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$msgKind = 'ok';
$msgText = '首页顶部标识栏背景已保存。';
break;
case 'hero_mode':
$mode = (string)($_POST['hero_mode'] ?? 'weather');
if (!in_array($mode, ['weather', 'info', 'blank'], true)) {
$mode = 'weather';
}
settings_set('hero_mode', $mode);
touch_last_updated();
$modeText = ['weather' => '天气模式', 'info' => '访客信息模式', 'blank' => '空白精简模式'][$mode];
$msgKind = 'ok';
$msgText = '首页顶部组件模式已保存:' . $modeText . '。';
break;
case 'cdn':
settings_set('cdn_ranges', trim((string)($_POST['cdn_ranges'] ?? '')));
touch_last_updated();
@ -401,6 +413,24 @@ admin_topbar('content');
</form>
</div>
<!-- 首页顶部组件模式Hero 下方横幅:天气 / 访客信息 / 空白) -->
<div class="fieldset-card">
<div class="fs-title">首页顶部组件Hero 下方横幅区)</div>
<?php $hm = (string)($all['hero_mode'] ?? 'weather'); ?>
<form method="post">
<?= csrf_field() ?><input type="hidden" name="act" value="hero_mode">
<div class="field-row" style="align-items:center">
<label class="seg-check"><input type="radio" name="hero_mode" value="weather"<?= $hm === 'weather' ? ' checked' : '' ?>> 🌤️ 天气模式(默认)</label>
<label class="seg-check"><input type="radio" name="hero_mode" value="info"<?= $hm === 'info' ? ' checked' : '' ?>> 🖥️ 访客信息模式</label>
<label class="seg-check"><input type="radio" name="hero_mode" value="blank"<?= $hm === 'blank' ? ' checked' : '' ?>> ⬜ 空白模式</label>
</div>
<div style="margin-top:12px"><button type="submit" class="btn btn-primary">保存顶部组件模式</button></div>
</form>
<p class="tip" style="margin:10px 0 0">
天气模式(默认):横幅展示第三方天气条;访客信息模式:横幅显示当前访问者 IP、浏览器、操作系统、内核、屏幕分辨率与系统语言空白模式隐藏横幅仅保留站点信息与时间信息顶部高度自动缩减。
</p>
</div>
<!-- 底部信息 -->
<div class="fieldset-card">
<div class="fs-title">底部信息</div>

View File

@ -135,10 +135,10 @@ admin_topbar('exttools');
</p>
<?php if ($saved): ?>
<div class="ok-msg">保存成功,库首页外部工具区已同步更新。</div>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,库首页外部工具区已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<?php if ($errorMsg !== ''): ?>
<div class="err-msg"><?= he($errorMsg) ?></div>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'err', 'text' => $errorMsg], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<!-- 筛选工具条 -->

View File

@ -8,6 +8,7 @@ require_once dirname(__DIR__) . '/includes/layout.php';
$err = '';
$loggedOut = isset($_GET['out']);
$curIp = client_ip();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim((string)($_POST['username'] ?? ''));
@ -15,12 +16,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($username === '' || $password === '') {
$err = '请输入用户名和密码。';
} elseif (hp_login($username, $password)) {
// 正常登录成功:清零本 IP 风控计数(避免携带历史失败次数,防止管理员自误封)
risk_clear_for_ip($curIp);
header('Location: nav.php');
exit;
} else {
// 轻量防爆破延时
// 轻量防爆破延时(命中黑名单的 IP 在访问本页前即被 access_boot 拦截)
usleep(500000);
$err = '用户名或密码错误。';
$risk = risk_login_fail($curIp);
if ($risk[1]) {
$err = '用户名或密码错误。';
// $err = '登录失败次数过多(本次累计 ' . $risk[0] . ' 次,达到阈值 ' . $risk[2] . ' 次),当前 IP 已被风控自动封禁,全站(含后台登录)访问均被拒绝。请稍后从未被封禁的网络进入后台「访问信息 → IP 黑名单」移除该规则解除,或联系站长处理。';
} else {
$err = '用户名或密码错误。';
}
}
}
@ -47,7 +56,6 @@ layout_head('后台登录');
<button type="submit" class="btn btn-primary" style="width:100%">登录</button>
</div>
</form>
<p class="tip" style="margin-top:12px">初始账号admin / admin123首次登录后请在“内容与顺序”中修改</p>
</div>
</main>
<?php layout_theme_fab(); ?>

View File

@ -162,10 +162,10 @@ admin_topbar('nav');
</div>
<?php if ($saved): ?>
<div class="ok-msg">保存成功,前台页面已同步更新。</div>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,前台页面已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<?php if ($errorMsg !== ''): ?>
<div class="err-msg"><?= he($errorMsg) ?></div>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'err', 'text' => $errorMsg], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<div class="fieldset-card" style="margin-bottom:16px">

View File

@ -148,10 +148,10 @@ admin_topbar('tools');
</p>
<?php if ($saved): ?>
<div class="ok-msg">保存成功,功能区首页已同步更新。</div>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,功能区首页已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<?php if ($errorMsg !== ''): ?>
<div class="err-msg"><?= he($errorMsg) ?></div>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'err', 'text' => $errorMsg], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<div class="fieldset-card">

502
admin/visits.php Normal file
View File

@ -0,0 +1,502 @@
<?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>

12
api/site.php Normal file
View File

@ -0,0 +1,12 @@
<?php
/**
* api/site.php —— 站点公开元信息 JSON 接口
* 供纯静态 HTML 页(如 func/*.html读取当前站点 logo使工具页 favicon / 标识与后台设置保持同步。
* 返回:{ "logo": "data/logo/site_logo_xxx.svg" ""(空 = 使用默认 assets/img/logo.svg }
*/
require_once dirname(__DIR__) . '/includes/db.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
echo json_encode(['logo' => setting_get('site_logo', '')], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

View File

@ -168,6 +168,52 @@ select { width: auto; min-width: 150px; }
/* 第三方天气 iframe 内容为白底,无法跨域改色:深色主题下用反色滤镜融入当前背景 */
[data-theme="dark"] .weather-box iframe { filter: invert(0.9) hue-rotate(180deg) contrast(0.92) saturate(0.9); }
/* ---------- 首页顶部组件访客信息模式hero_mode=info ---------- */
.visitor-box { display: flex; justify-content: center; }
.vb-grid {
width: 100%; max-width: 800px;
display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px;
background: rgba(255, 255, 255, .28);
border: 1px solid rgba(255, 255, 255, .22);
border-radius: 10px; overflow: hidden;
}
.vb-cell { background: rgba(10, 22, 44, .32); padding: 9px 16px; text-align: left; }
.vb-cell span { display: block; font-size: 11px; letter-spacing: .5px; opacity: .72; margin-bottom: 2px; }
.vb-cell b { font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; word-break: break-all; color: #fff; }
/* ---------- 首页顶部组件空白精简模式hero_mode=blank ---------- */
.hero-slim { min-height: 0; padding: 16px 0 12px; }
/* ============ 后台访问信息页 ============ */
.vis-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.vis-stat { background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; }
.vis-stat b { display: block; font-size: 22px; line-height: 1.3; font-variant-numeric: tabular-nums; }
.vis-stat span { font-size: 12px; color: var(--text-3); }
.v-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.v-filter input[type=text] { width: 200px; }
.v-filter input[type=text].w-s { width: 160px; }
.v-scroll { overflow-x: auto; margin-top: 4px; }
.v-tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.v-tbl th, .v-tbl td { padding: 8px 10px; text-align: left; vertical-align: top; }
.v-tbl th { color: var(--text-3); font-weight: 600; border-bottom: 1px solid var(--line-2); }
.v-tbl td { border-bottom: 1px solid var(--line); word-break: break-all; }
.v-tbl tr:last-child td { border-bottom: none; }
.v-nowrap { white-space: nowrap; }
.v-tag { display: inline-block; min-width: 44px; text-align: center; padding: 1px 8px; border-radius: 20px; font-size: 12px; border: 1px solid var(--line-2); color: var(--text-2); }
.v-tag.good { color: var(--ok); border-color: var(--ok); }
.v-tag.info { color: var(--brand); border-color: var(--brand); }
.v-tag.warn { color: var(--warn); border-color: var(--warn); }
.v-tag.bad { color: var(--danger); border-color: var(--danger); }
.pager { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 14px; }
.pager .pg-info { font-size: 13px; color: var(--text-3); margin-right: 4px; }
.bl-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 10px 0; border-top: 1px dashed var(--line); }
.bl-row:first-child { border-top: none; padding-top: 0; }
.bl-rule { font-family: Consolas, Menlo, monospace; background: var(--code-bg); color: var(--code-text); border-radius: 6px; padding: 2px 9px; font-size: 13px; }
.bl-note { color: var(--text-2); flex: 1 1 140px; min-width: 100px; font-size: 13px; }
.bl-time { font-size: 12px; color: var(--text-3); white-space: nowrap; }
.vis-ok-tip { background: rgba(22, 163, 74, .12); color: var(--ok); border-radius: 8px; padding: 7px 12px; font-size: 13px; display: inline-block; }
.vis-warn-tip { background: rgba(217, 119, 6, .12); color: var(--warn); border-radius: 8px; padding: 7px 12px; font-size: 13px; display: inline-block; }
/* ---------- 名言条 ---------- */
.quote-bar { background: var(--card); border-bottom: 1px solid var(--line); }
.quote-inner { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 12px 16px; text-align: center; }
@ -572,6 +618,9 @@ a.nav-btn[data-note]:hover::after { opacity: 1; transform: translateX(-50%) tran
.hero-top { flex-direction: column; text-align: center; }
.hero-meta { text-align: center; }
.weather-box iframe { width: 100%; }
.visitor-box .vb-grid { grid-template-columns: repeat(2, 1fr); }
.vis-stats { grid-template-columns: repeat(2, 1fr); }
.v-filter input[type=text] { width: 100%; }
.quote-inner { flex-wrap: wrap; }
.boards { grid-template-columns: repeat(2, 1fr); gap: 12px; min-height: 0; }
.board-panel { width: 100%; }
@ -761,3 +810,18 @@ a.nav-btn[data-note]:hover::after { opacity: 1; transform: translateX(-50%) tran
.art-tg-txt { display: none; }
.art-topbar-nav { gap: 4px; }
}
/* ---------- 右下角 Toast 通知(后台操作成功/失败即时提示,不影响页面布局) ---------- */
.hp-toasts { position: fixed; right: 18px; bottom: 18px; z-index: 9990; display: flex; flex-direction: column; align-items: flex-end; gap: 10px; max-width: min(380px, calc(100vw - 36px)); pointer-events: none; }
.hp-toast { pointer-events: auto; display: flex; align-items: flex-start; gap: 10px; min-width: 220px; max-width: 100%; box-sizing: border-box; background: var(--card); color: var(--text-1); border: 1px solid var(--line-2); border-left: 4px solid var(--brand); border-radius: 12px; padding: 12px 14px; box-shadow: 0 10px 30px rgba(15,23,42,.18); font-size: 14px; line-height: 1.55; animation: hpToastIn .26s ease; }
.hp-toast.ok { border-left-color: var(--ok); }
.hp-toast.err { border-left-color: var(--danger); }
.hp-toast .ht-ic { flex: none; width: 18px; height: 18px; border-radius: 50%; color: #fff; font-size: 12px; font-weight: 800; display: inline-flex; align-items: center; justify-content: center; margin-top: 2px; }
.hp-toast.ok .ht-ic { background: var(--ok); }
.hp-toast.err .ht-ic { background: var(--danger); }
.hp-toast .ht-tx { flex: 1 1 auto; min-width: 0; word-break: break-word; }
.hp-toast .ht-x { flex: none; align-self: flex-start; margin-left: 2px; padding: 0 2px; border: 0; background: none; color: var(--text-3); font-size: 16px; line-height: 1; cursor: pointer; }
.hp-toast .ht-x:hover { color: var(--danger); }
.hp-toast.hide { opacity: 0; transform: translateY(8px); transition: opacity .22s ease, transform .22s ease; }
@keyframes hpToastIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
@media (max-width: 600px) { .hp-toasts { right: 12px; bottom: 12px; left: 12px; align-items: stretch; } .hp-toast { min-width: 0; } }

BIN
assets/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -1,6 +1,6 @@
/* codec.js /
* 提供UTF-8Base64Base32URLUnicodeSHA 系列Web Crypto 原生
* MD5 / SHA-224 依赖 CryptoJS由页面懒加载 CDN fallback
* MD5 / SHA-224 依赖 CryptoJS优先本地 assets/js/crypto-js.min.js缺失则回退 CDN
*/
(function (global) {
'use strict';
@ -177,10 +177,11 @@
return cryptoJsDigest(algorithm, text);
}
/* ---------- CryptoJS 懒加载MD5 / SHA-224 等需要 ---------- */
/* ---------- CryptoJS 懒加载MD5 / SHA-224 等需要;优先本地,其次 CDN ---------- */
var CDN_CRYPTO_JS = [
'https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js',
'https://cdn.jsdelivr.net/npm/crypto-js@4.2.0/crypto-js.min.js'
'../assets/js/crypto-js.min.js', // 本地内置(离线可用)
'https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js', // 回退 1
'https://cdn.jsdelivr.net/npm/crypto-js@4.2.0/crypto-js.min.js' // 回退 2
];
var cryptoJSPromise = null;
@ -209,7 +210,7 @@
function cryptoJsDigest(algo, text) {
return loadCryptoJS().then(function (C) {
if (!C) throw new Error('计算 ' + algo + ' 需要联网加载加密组件crypto-js失败检查网络');
if (!C) throw new Error('计算 ' + algo + ' 的加密组件加载失败:请将 crypto-js.min.js 放入 assets/js/ 目录离线使用,或检查网络');
var method = CRYPTO_JS_METHODS[algo];
if (!method || typeof C[method] !== 'function') throw new Error('不支持的算法: ' + algo);
return C[method](text).toString(C.enc.Hex);

View File

@ -342,10 +342,90 @@
var f = ev.target;
if (!f || !f.getAttribute || !f.hasAttribute('data-confirm')) return;
ev.preventDefault();
siteConfirm(f.getAttribute('data-confirm')).then(function (ok) { if (ok) f.submit(); });
siteConfirm(f.getAttribute('data-confirm')).then(function (ok) {
if (ok) { f.submit(); } else { hpClearScrollY(); }
});
});
}
/* ---------- 同页 POST 提交后保持滚动位置(整页刷新不再跳回顶部) ---------- */
var HP_SCROLL_KEY = 'hp-scroll-pos';
function hpSamePagePost(f) {
if (!f || !f.method) return false;
if (!/post/i.test(f.method)) return false;
if (f.target === '_blank') return false;
var a = (f.getAttribute('action') || '').trim();
if (a === '') return true; // 无 action = 提交当前页
try {
var cur = new URL(location.href.split('#')[0]);
var u = new URL(a, cur.href);
return u.origin === cur.origin && u.pathname === cur.pathname;
} catch (e) { return false; }
}
function hpSaveScrollY() {
try {
var y = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0;
sessionStorage.setItem(HP_SCROLL_KEY, JSON.stringify({ p: location.pathname + location.search, y: y }));
} catch (e) { /* ignore */ }
}
function hpClearScrollY() {
try { sessionStorage.removeItem(HP_SCROLL_KEY); } catch (e) { /* ignore */ }
}
var hpScrollTarget = -1;
function hpRestoreScrollY() {
if (hpScrollTarget >= 0) { window.scrollTo(0, hpScrollTarget); return; }
try {
var raw = sessionStorage.getItem(HP_SCROLL_KEY);
if (!raw) return;
sessionStorage.removeItem(HP_SCROLL_KEY);
var d = JSON.parse(raw);
if (d && d.p === (location.pathname + location.search) && typeof d.y === 'number' && d.y > 0) {
hpScrollTarget = d.y;
window.scrollTo(0, hpScrollTarget);
}
} catch (e) { hpClearScrollY(); }
}
// 捕获阶段:同页 POST 提交前保存(按回车等也会触发 submit 事件)
document.addEventListener('submit', function (ev) {
if (hpSamePagePost(ev.target)) hpSaveScrollY();
}, true);
// 捕获阶段点击提交按钮时也保存bindConfirmForms 确认后走 f.submit() 不触发 submit 事件)
document.addEventListener('click', function (ev) {
var t = ev.target;
var btn = (t && t.closest) ? t.closest('button[type="submit"], input[type="submit"], button:not([type])') : null;
if (!btn) return;
var f = btn.form || null;
if (hpSamePagePost(f)) hpSaveScrollY();
}, true);
// 资源加载完成后再定位一次,防止图片等异步资源改变高度造成轻微偏移
window.addEventListener('load', function () {
if (hpScrollTarget >= 0) {
window.scrollTo(0, hpScrollTarget);
hpScrollTarget = -1;
}
});
/* ---------- 首页访客信息hero 信息模式:补齐屏幕分辨率 / 系统语言) ---------- */
function initVisitorInfo() {
var box = $('.js-visitor-box');
if (!box) return;
var langEl = $('.js-vlang', box);
if (langEl) {
var lang = '';
try { lang = navigator.language || (navigator.languages && navigator.languages[0]) || ''; } catch (e) { /* ignore */ }
langEl.textContent = lang || '--';
}
var scrEl = $('.js-vscreen', box);
if (scrEl) {
var w = 0, h = 0, dpr = 1;
try { w = window.screen.width || 0; h = window.screen.height || 0; dpr = window.devicePixelRatio || 1; } catch (e) { /* ignore */ }
if (w && h) {
var ratio = Math.round(dpr * 100);
scrEl.textContent = (ratio !== 100) ? (w + ' × ' + h + '' + ratio + '%') : (w + ' × ' + h);
}
}
}
/* ---------- 返回顶部(右下角按钮,滚动后出现;兼容内滚容器页面) ---------- */
function initToTop() {
var btn = document.querySelector('.js-to-top');
@ -380,8 +460,101 @@
measure();
}
/* ---------- 功能区静态工具页:库卡片 ?from=nav 进入时,返回按钮改为“返回上一页” ---------- */
function initFuncBack() {
var el = document.querySelector('.js-fb-back');
if (!el) return;
if (!/(?:^|[?&])from=nav(?:&|$)/.test(location.search)) return;
el.textContent = '← 返回上一页';
el.title = '返回上一页(回到来源库首页)';
el.addEventListener('click', function (ev) {
// 有浏览历史时返回来源页;否则保留默认 href功能区首页
if (window.history.length > 1) {
ev.preventDefault();
window.history.back();
}
});
}
/* ---------- 右下角 Toast 通知(后台整页刷新后的成功/失败提示使用,不影响页面布局) ---------- */
function hpToast(kind, text, dur) {
if (text === '' || text === null || typeof text === 'undefined') return;
kind = (kind === 'ok' || kind === 'err') ? kind : 'ok';
var wrap = document.querySelector('.hp-toasts');
if (!wrap) {
wrap = document.createElement('div');
wrap.className = 'hp-toasts';
document.body.appendChild(wrap);
}
var box = document.createElement('div');
box.className = 'hp-toast ' + kind;
var ic = document.createElement('span');
ic.className = 'ht-ic';
ic.textContent = kind === 'ok' ? '✓' : '!';
var tx = document.createElement('div');
tx.className = 'ht-tx';
tx.textContent = String(text);
var x = document.createElement('button');
x.type = 'button';
x.className = 'ht-x';
x.setAttribute('aria-label', '关闭提示');
x.textContent = '×';
function close() {
if (!box.parentNode) return;
box.classList.add('hide');
setTimeout(function () {
if (box.parentNode) box.parentNode.removeChild(box);
if (wrap.parentNode && !wrap.children.length) wrap.parentNode.removeChild(wrap);
}, 240);
}
x.addEventListener('click', close);
box.appendChild(ic);
box.appendChild(tx);
box.appendChild(x);
wrap.appendChild(box);
var delay = (typeof dur === 'number' && dur > 0) ? dur : (kind === 'err' ? 6000 : 3600);
setTimeout(close, delay);
}
/* 由 PHP 注入的整页操作提示window.hpToastMsg = {kind, text})自动弹出 */
function hpShowPageToast() {
var m = window.hpToastMsg;
if (!m || typeof m !== 'object') return;
hpToast(m.kind, m.text);
}
/* ---------- 静态工具页 favicon 跟随后台站点 logoapi/site.php 探测,异常静默回退默认) ---------- */
function initSiteIcon() {
// 仅纯静态 .html 页需要PHP 页面 favicon 已由 layout_head 服务端输出)
if (!/\.html$/i.test(location.pathname)) return;
var iconEl = document.querySelector('link[rel~="icon"]');
if (!iconEl) return;
var abs = '';
try { abs = new URL(iconEl.getAttribute('href') || '', location.href).href; } catch (e) { return; }
var marker = '/assets/img/logo.svg';
var pos = abs.indexOf(marker);
if (pos < 0) return; // 非默认图标(已自定义)则无需处理
var root = abs.slice(0, pos + 1);
var xhr = new XMLHttpRequest();
try {
xhr.open('GET', root + 'api/site.php', true);
xhr.timeout = 4000;
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
try {
var d = JSON.parse(xhr.responseText || '{}');
if (d && typeof d.logo === 'string' && d.logo !== '') {
iconEl.href = root + d.logo;
}
} catch (e) { /* 接口异常:保持默认图标 */ }
};
xhr.onerror = function () { /* file:// 或网络异常:保持默认图标 */ };
xhr.send();
} catch (e) { /* 同源限制等:保持默认图标 */ }
}
/* ---------- 自动初始化 ---------- */
document.addEventListener('DOMContentLoaded', function () {
initSiteIcon();
initTheme();
initClocks();
initQuoteRefresh();
@ -390,11 +563,16 @@
initIconControls();
initCopyButtons();
bindConfirmForms();
initVisitorInfo();
initToTop();
initFuncBack();
hpShowPageToast();
});
global.HomePage = {
$: $, $$: $$, copyText: copyText, applyTheme: applyTheme, currentTheme: currentTheme,
confirm: siteConfirm, ask: askConfirm
confirm: siteConfirm, ask: askConfirm, toast: hpToast
};
// 底部脚本执行时 DOM 已解析完毕:在首帧绘制前后尽早恢复滚动位置,避免“先停在顶部再跳回”的大幅跳动
hpRestoreScrollY();
})(window);

View File

@ -1,7 +1,7 @@
/* gmcrypto.js —— 国密 SM2 / SM3 / SM4 前端封装
* SM1 为国密硬件算法不公开依赖专用加密芯片无法在浏览器纯软件实现
* 故本页提供可在网页中直接使用的 SM2非对称/ SM3摘要/ SM4对称分组
* 底层使用 sm-crypto@0.3.13运行时按需双 CDN 懒加载jsdelivr unpkg
* 底层使用 sm-crypto@0.3.13优先本地 assets/js/sm-crypto/ 目录缺失则回退 CDNjsdelivr unpkg
*/
(function (global) {
'use strict';
@ -11,22 +11,25 @@
sm2: {
file: 'sm2.js', key: 'sm2',
urls: [
'https://cdn.jsdelivr.net/npm/sm-crypto@' + VERSION + '/dist/sm2.js',
'https://unpkg.com/sm-crypto@' + VERSION + '/dist/sm2.js'
'../assets/js/sm-crypto/sm2.js', // 本地内置(离线可用)
'https://cdn.jsdelivr.net/npm/sm-crypto@' + VERSION + '/dist/sm2.js', // 回退 1
'https://unpkg.com/sm-crypto@' + VERSION + '/dist/sm2.js' // 回退 2
]
},
sm3: {
file: 'sm3.js', key: 'sm3',
urls: [
'https://cdn.jsdelivr.net/npm/sm-crypto@' + VERSION + '/dist/sm3.js',
'https://unpkg.com/sm-crypto@' + VERSION + '/dist/sm3.js'
'../assets/js/sm-crypto/sm3.js', // 本地内置(离线可用)
'https://cdn.jsdelivr.net/npm/sm-crypto@' + VERSION + '/dist/sm3.js', // 回退 1
'https://unpkg.com/sm-crypto@' + VERSION + '/dist/sm3.js' // 回退 2
]
},
sm4: {
file: 'sm4.js', key: 'sm4',
urls: [
'https://cdn.jsdelivr.net/npm/sm-crypto@' + VERSION + '/dist/sm4.js',
'https://unpkg.com/sm-crypto@' + VERSION + '/dist/sm4.js'
'../assets/js/sm-crypto/sm4.js', // 本地内置(离线可用)
'https://cdn.jsdelivr.net/npm/sm-crypto@' + VERSION + '/dist/sm4.js', // 回退 1
'https://unpkg.com/sm-crypto@' + VERSION + '/dist/sm4.js' // 回退 2
]
}
};
@ -137,7 +140,7 @@
function need(name) {
return loadLib(name).then(function (lib) {
if (!lib) throw new Error('国密 ' + name.toUpperCase() + ' 组件加载失败,请检查网络后重试');
if (!lib) throw new Error('国密 ' + name.toUpperCase() + ' 组件加载失败:请将 ' + name + '.js 放入 assets/js/sm-crypto/ 目录离线使用,或检查网络后重试');
return lib;
});
}

View File

@ -6,7 +6,7 @@
*
* 段按出现次数段内全部 IP 原文出现次数之和从高到低排列
* 重点匹配 IP填写的 IP 命中后其所在 /24 段优先置顶对应 IP 红色标出
* CDN 段库由服务端通过 #ipCdnRaw 隐藏文本框注入可在后台维护
* CDN 段库默认内置在页面 #ipCdnRaw 隐藏文本框中func/ip.html可直接用编辑器增补厂商
*/
(function () {
'use strict';

View File

@ -1,18 +1,25 @@
<?php
/**
* func/av.php —— 杀软识别
* 粘贴 tasklist 输出 → 匹配 data/avlist.js 进程表 → 表格输出 + 统计
*/
$P = '../';
require_once dirname(__DIR__) . '/includes/layout.php';
require_once __DIR__ . '/_bar.php';
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="renderer" content="webkit">
<script>(function(){var t;try{t=localStorage.getItem("hp-theme")}catch(e){}if(!t){t=(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)?"dark":"light"}document.documentElement.setAttribute("data-theme",t);})();</script>
<title>杀软识别</title>
<link rel="icon" href="../assets/img/logo.svg">
<link rel="stylesheet" href="../assets/css/common.css">
</head>
<body>
<div class="func-bar"><div class="wrap func-bar-inner">
<a class="btn btn-sm js-fb-back" href="index.php" title="返回功能区首页">← 返回功能区首页</a>
<span class="func-bar-title">杀软识别</span>
<a class="btn btn-sm" href="../index.php">首页</a>
</div></div>
layout_head('杀软识别');
func_bar('杀软识别');
?>
<main class="page-main">
<div class="wrap page-body">
<div class="panel-card">
<p class="tip" style="margin-top:0">🔒 粘贴的 tasklist 输出仅在本机浏览器内解析匹配,不会上传服务器;如需离线独立使用,请将 <code>data/avlist.js</code> 一并拷贝。</p>
<h2>使用说明</h2>
<p class="tip">在目标机器执行 <code>tasklist</code> 复制全部输出,粘贴到下方输入框后点击“识别”。
系统会逐行解析进程名并与预置的安全软件进程表data/avlist.js进行大小写不敏感匹配<strong>表格仅展示识别出的安全软件进程</strong>,常规进程不展示。</p>
@ -37,8 +44,8 @@ func_bar('杀软识别');
</div>
</main>
<?php layout_theme_fab(); ?>
<?php layout_footer(); ?>
<button type="button" class="theme-fab js-theme-btn" title="切换昼夜模式" aria-label="切换昼夜模式"><span class="js-theme-icon"></span></button>
<button type="button" class="theme-fab to-top js-to-top" title="返回顶部" aria-label="返回顶部"></button>
<script src="../data/avlist.js"></script>
<script src="../assets/js/common.js"></script>
<script>
@ -52,7 +59,6 @@ func_bar('杀软识别');
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
// 跳过表头与分隔行
if (/映像名称|Image Name|进程名|PID|===/i.test(line) && !/\d$/.test(line.trim())) continue;
var name = line.split(/[\s,]+/)[0];
if (!name) continue;
@ -99,7 +105,6 @@ func_bar('杀软识别');
}
var body = $('avBody');
body.innerHTML = '';
// 仅保留识别(命中安全软件进程表)的行
var matched = [];
rows.forEach(function (r) {
var info = lookup(r.name);
@ -132,7 +137,6 @@ func_bar('杀软识别');
$('avMsg').innerHTML = '';
$('taskInput').focus();
});
// 输入内容后可用 Ctrl/Cmd + Enter 快速识别
$('taskInput').addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) run();
});

View File

@ -1,19 +1,25 @@
<?php
/**
* func/codec.php —— 编码 / 加解密工具
* Base64 / Base32 / URL / Unicode支持中文与 emoji双向MD5 / SHA-1/224/256/384/512 哈希。
* 输入输出文本区均可自由编辑、可竖向拉伸。
*/
$P = '../';
require_once dirname(__DIR__) . '/includes/layout.php';
require_once __DIR__ . '/_bar.php';
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="renderer" content="webkit">
<script>(function(){var t;try{t=localStorage.getItem("hp-theme")}catch(e){}if(!t){t=(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)?"dark":"light"}document.documentElement.setAttribute("data-theme",t);})();</script>
<title>编码 / 加解密</title>
<link rel="icon" href="../assets/img/logo.svg">
<link rel="stylesheet" href="../assets/css/common.css">
</head>
<body>
<div class="func-bar"><div class="wrap func-bar-inner">
<a class="btn btn-sm js-fb-back" href="index.php" title="返回功能区首页">← 返回功能区首页</a>
<span class="func-bar-title">编码 / 加解密</span>
<a class="btn btn-sm" href="../index.php">首页</a>
</div></div>
layout_head('编码 / 加解密');
func_bar('编码 / 加解密');
?>
<main class="page-main">
<div class="wrap page-body">
<div class="panel-card">
<p class="tip" style="margin-top:0">🔒 本页全部运算均在浏览器本地完成粘贴的文本不会上传到任何服务器可放心处理大段内容。MD5 / SHA-224 在无内置加密库时需联网加载组件(仅下载代码,不含你的内容)。</p>
<div class="field-row" style="align-items:center">
<label class="fl" style="margin:0" for="selAlgo">算法</label>
<select id="selAlgo">
@ -58,8 +64,8 @@ func_bar('编码 / 加解密');
</div>
</main>
<?php layout_theme_fab(); ?>
<?php layout_footer(); ?>
<button type="button" class="theme-fab js-theme-btn" title="切换昼夜模式" aria-label="切换昼夜模式"><span class="js-theme-icon"></span></button>
<button type="button" class="theme-fab to-top js-to-top" title="返回顶部" aria-label="返回顶部"></button>
<script src="../assets/js/common.js"></script>
<script src="../assets/js/codec.js"></script>
<script>

View File

@ -1,21 +1,26 @@
<?php
/**
* func/gmcodec.php —— 国密加解密
* SM2 非对称加解密 / SM3 摘要 / SM4 对称加解密。
* 说明SM1 为国密硬件算法(不公开、依赖专用芯片),无法在浏览器纯软件实现,故未提供。
*/
$P = '../';
require_once dirname(__DIR__) . '/includes/layout.php';
require_once __DIR__ . '/_bar.php';
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="renderer" content="webkit">
<script>(function(){var t;try{t=localStorage.getItem("hp-theme")}catch(e){}if(!t){t=(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)?"dark":"light"}document.documentElement.setAttribute("data-theme",t);})();</script>
<title>国密加解密</title>
<link rel="icon" href="../assets/img/logo.svg">
<link rel="stylesheet" href="../assets/css/common.css">
</head>
<body>
<div class="func-bar"><div class="wrap func-bar-inner">
<a class="btn btn-sm js-fb-back" href="index.php" title="返回功能区首页">← 返回功能区首页</a>
<span class="func-bar-title">国密加解密</span>
<a class="btn btn-sm" href="../index.php">首页</a>
</div></div>
layout_head('国密加解密');
func_bar('国密加解密');
?>
<main class="page-main">
<div class="wrap page-body">
<div class="panel-card">
<p class="tip" style="margin-top:0">支持 SM2 / SM3 / SM4 三种国密算法SM1 为不公开的硬件算法(需国密芯片),无法纯软件实现。
SM2/SM4 运算依赖在线加载 <code>sm-crypto</code> 组件(双 CDN加载失败会明确提示</p>
<p class="tip" style="margin-top:0">🔒 SM2 / SM3 / SM4 运算全部在本机浏览器完成,明文 / 密钥 / 密文不会上传服务器。算法组件优先加载本地 <code>assets/js/sm-crypto/</code> 下的 <code>sm2.js / sm3.js / sm4.js</code>,未找到时自动回退公共 CDN仅加载代码不含你的内容</p>
<p class="tip" style="margin-top:0">支持 SM2 / SM3 / SM4 三种国密算法SM1 为不公开的硬件算法(需国密芯片),无法纯软件实现</p>
<div class="field-row" style="align-items:center;margin-top:12px">
<label class="fl" style="margin:0" for="gmAlgo">算法</label>
@ -69,7 +74,7 @@ func_bar('国密加解密');
</div>
<!-- SM3 提示区 -->
<div id="gmSm3Tip" class="tip" hidden style="margin-top:8px">SM3 输出固定 64 位十六进制摘要HMAC 场景暂不提供,如需请使用后台维护的其它方案)。</div>
<div id="gmSm3Tip" class="tip" hidden style="margin-top:8px">SM3 输出固定 64 位十六进制摘要HMAC 场景暂不提供)。</div>
<div id="gmMsg"></div>
</div>
@ -93,8 +98,8 @@ func_bar('国密加解密');
</div>
</main>
<?php layout_theme_fab(); ?>
<?php layout_footer(); ?>
<button type="button" class="theme-fab js-theme-btn" title="切换昼夜模式" aria-label="切换昼夜模式"><span class="js-theme-icon"></span></button>
<button type="button" class="theme-fab to-top js-to-top" title="返回顶部" aria-label="返回顶部"></button>
<script src="../assets/js/common.js"></script>
<script src="../assets/js/gmcrypto.js"></script>
<script>

View File

@ -1,27 +1,25 @@
<?php
/**
* func/ip.php —— IP 地址处理
* 提取输入文本 / 上传文本文件中的全部 IPv4按 /24 段聚合输出纯文本结果:
* IP段 发现数量(段内去重IP数) IP段性质 出现次数
* 1.2.3.0/24 8 公网 56
* 1.2.3.4 出现次数
*
* 出现次数:具体 IP 为其在原文中的出现总次数;段行出现次数为段内全部 IP 次数之和。
* 段默认按出现次数从高到低排列,命中「重点匹配 IP」的段优先置顶。
* 段性质区分:内网(含保留)/ CDN / 公网IPv6 暂不参与。
* 支持上传常见文本格式文件txt / json / log / md / csv / conf / ini自动载入
* 支持将结果导出为 UTF-8 TXT 文件。
*/
$P = '../';
require_once dirname(__DIR__) . '/includes/layout.php';
require_once __DIR__ . '/_bar.php';
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="renderer" content="webkit">
<script>(function(){var t;try{t=localStorage.getItem("hp-theme")}catch(e){}if(!t){t=(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)?"dark":"light"}document.documentElement.setAttribute("data-theme",t);})();</script>
<title>IP 地址处理</title>
<link rel="icon" href="../assets/img/logo.svg">
<link rel="stylesheet" href="../assets/css/common.css">
</head>
<body>
<div class="func-bar"><div class="wrap func-bar-inner">
<a class="btn btn-sm js-fb-back" href="index.php" title="返回功能区首页">← 返回功能区首页</a>
<span class="func-bar-title">IP 地址处理</span>
<a class="btn btn-sm" href="../index.php">首页</a>
</div></div>
layout_head('IP 地址处理');
func_bar('IP 地址处理');
?>
<main class="page-main">
<div class="wrap page-body">
<div class="panel-card">
<p class="tip" style="margin-top:0">🔒 粘贴的日志 / 上传的文本文件<b>全部在本机浏览器内解析</b>,不会上传到服务器,可放心处理大文件(≤ 20MB 由浏览器本地读取)。</p>
<h2>使用说明</h2>
<p class="tip">粘贴任意日志 / 文本,或直接<b>上传文本文件</b>txt / json / log / md / csv / conf / ini≤ 20MB
系统提取其中全部 <strong>IPv4</strong>IPv6 暂不参与),按 <strong>/24 段</strong>聚合:
@ -56,15 +54,31 @@ func_bar('IP 地址处理');
</div>
<div id="ipPreview" class="ip-pre"></div>
<p class="tip" id="ipStat" style="margin:8px 0"></p>
<p class="tip">段行为 <code>IP段 / 发现数量 / 性质 / 出现次数</code>(出现次数为段内全部 IP 原文出现次数之和);具体 IP 行为 <code>IP / 出现次数</code>。段按出现次数降序排列,含重点 IP 的段置顶;复制或导出的纯文本不含颜色。若 /24 段内混有不同性质按数量多者标注“xx为主(混合)”。</p>
<p class="tip">段行为 <code>IP段 / 发现数量 / 性质 / 出现次数</code>(出现次数为段内全部 IP 原文出现次数之和);具体 IP 行为 <code>IP / 出现次数</code>。段按出现次数降序排列,含重点 IP 的段置顶;复制或导出的纯文本不含颜色。若 /24 段内混有不同性质按数量多者标注“xx为主(混合)”。CDN 段库为页面内置默认,可用文本编辑器在本 HTML 文件的 <code>ipCdnRaw</code> 中增补厂商。</p>
</div>
</div>
</div>
</main>
<?php layout_theme_fab(); ?>
<?php layout_footer(); ?>
<textarea id="ipCdnRaw" hidden><?= he(cdn_ranges_text()) ?></textarea>
<textarea id="ipCdnRaw" hidden># 内置默认Cloudflare 官方 IPv4 段(可在本 HTML 的 ipCdnRaw 内补充其它厂商每行CIDR + 空格 + 厂商名;# 开头为注释)
103.21.244.0/22 Cloudflare
103.22.200.0/22 Cloudflare
103.31.4.0/22 Cloudflare
104.16.0.0/13 Cloudflare
104.24.0.0/14 Cloudflare
108.162.192.0/18 Cloudflare
131.0.72.0/22 Cloudflare
141.101.64.0/18 Cloudflare
162.158.0.0/15 Cloudflare
172.64.0.0/13 Cloudflare
173.245.48.0/20 Cloudflare
188.114.96.0/20 Cloudflare
190.93.240.0/20 Cloudflare
197.234.240.0/22 Cloudflare
198.41.128.0/17 Cloudflare</textarea>
<button type="button" class="theme-fab js-theme-btn" title="切换昼夜模式" aria-label="切换昼夜模式"><span class="js-theme-icon"></span></button>
<button type="button" class="theme-fab to-top js-to-top" title="返回顶部" aria-label="返回顶部"></button>
<script src="../assets/js/common.js"></script>
<script src="../assets/js/ip.js"></script>
</body>

View File

@ -1,18 +1,25 @@
<?php
/**
* func/password.php —— 随机密码生成
* 字符集多选 + 自定义字符集 + 长度 + 一次生成多组 + 复制
*/
$P = '../';
require_once dirname(__DIR__) . '/includes/layout.php';
require_once __DIR__ . '/_bar.php';
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="renderer" content="webkit">
<script>(function(){var t;try{t=localStorage.getItem("hp-theme")}catch(e){}if(!t){t=(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)?"dark":"light"}document.documentElement.setAttribute("data-theme",t);})();</script>
<title>随机密码生成</title>
<link rel="icon" href="../assets/img/logo.svg">
<link rel="stylesheet" href="../assets/css/common.css">
</head>
<body>
<div class="func-bar"><div class="wrap func-bar-inner">
<a class="btn btn-sm js-fb-back" href="index.php" title="返回功能区首页">← 返回功能区首页</a>
<span class="func-bar-title">随机密码生成</span>
<a class="btn btn-sm" href="../index.php">首页</a>
</div></div>
layout_head('随机密码生成');
func_bar('随机密码生成');
?>
<main class="page-main">
<div class="wrap page-body">
<div class="panel-card">
<p class="tip" style="margin-top:0">🔒 密码在本机浏览器内随机生成,不会上传服务器,生成后请妥善保存。</p>
<div class="field-row">
<label class="seg-check"><input type="checkbox" id="ckUpper" checked> 大写 A-Z</label>
<label class="seg-check"><input type="checkbox" id="ckLower" checked> 小写 a-z</label>
@ -39,8 +46,8 @@ func_bar('随机密码生成');
</div>
</main>
<?php layout_theme_fab(); ?>
<?php layout_footer(); ?>
<button type="button" class="theme-fab js-theme-btn" title="切换昼夜模式" aria-label="切换昼夜模式"><span class="js-theme-icon"></span></button>
<button type="button" class="theme-fab to-top js-to-top" title="返回顶部" aria-label="返回顶部"></button>
<script src="../assets/js/common.js"></script>
<script>
(function () {
@ -78,7 +85,6 @@ func_bar('随机密码生成');
function genOne(chars, len) {
var out = '';
// 确保自定义字符集大小时每个字符都可能出现
for (var i = 0; i < len; i++) {
out += chars.charAt(randInt(chars.length));
}
@ -108,7 +114,7 @@ func_bar('随机密码生成');
var line = document.createElement('div');
line.className = 'pwd-line';
var code = document.createElement('code');
code.textContent = pwd; // 用 textContent 写入,避免 HTML 注入
code.textContent = pwd;
var copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'btn btn-sm js-copy';

View File

@ -1,20 +1,13 @@
<?php
/**
* func/persist.php —— 辅助维权(教学辅助)
* 仅面向内网教学 / 授权实验环境。三个模块:
* 1) 反弹 Shell 构建:输入监听 IP / 端口,按 Linux / Windows 目标机分别生成三类语句
* (普通反弹 / Python 动态 Shell / Base64 编码版本);
* 2) Windows 辅助SAM、SYSTEM 注册表配置单元提取、mimikatz 常用命令与票据提取参考;
* 3) Linux 自启服务:输入执行文件绝对路径,生成 systemd高版本或 init.d低版本
* 可编辑配置,默认不产生 / 不落盘日志且崩溃自动重启。
*/
$P = '../';
require_once dirname(__DIR__) . '/includes/layout.php';
require_once __DIR__ . '/_bar.php';
layout_head('辅助维权');
func_bar('辅助维权');
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="renderer" content="webkit">
<script>(function(){var t;try{t=localStorage.getItem("hp-theme")}catch(e){}if(!t){t=(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)?"dark":"light"}document.documentElement.setAttribute("data-theme",t);})();</script>
<title>辅助维权</title>
<link rel="icon" href="../assets/img/logo.svg">
<link rel="stylesheet" href="../assets/css/common.css">
<style>
.p-sec { margin-top: 16px; }
.p-sec-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
@ -24,9 +17,18 @@ func_bar('辅助维权');
.b64-note { font-size: 12px; color: var(--text-3); margin: 6px 0 4px; }
.p-sec code, .tip code { font-family: Consolas, Menlo, monospace; }
</style>
</head>
<body>
<div class="func-bar"><div class="wrap func-bar-inner">
<a class="btn btn-sm js-fb-back" href="index.php" title="返回功能区首页">← 返回功能区首页</a>
<span class="func-bar-title">辅助维权</span>
<a class="btn btn-sm" href="../index.php">首页</a>
</div></div>
<main class="page-main">
<div class="wrap page-body">
<div class="panel-card">
<p class="tip" style="margin-top:0">🔒 全部配置 / 命令均由本机浏览器本地生成,输入内容不会上传服务器。</p>
<h2>辅助维权 · 教学辅助</h2>
<p class="tip">本工具<b>仅供内网教学 / 授权实验环境使用</b>,请勿用于任何未经授权的真实目标。
三类输出全部在浏览器本地生成:① <b>反弹 Shell 构建</b>Linux / Windows 目标机含普通、Python 动态与 Base64 编码版本);
@ -166,8 +168,8 @@ mimikatz.exe "kerberos::ptt 1-40a0000-xxx.kirbi" exit
</div>
</main>
<?php layout_theme_fab(); ?>
<?php layout_footer(); ?>
<button type="button" class="theme-fab js-theme-btn" title="切换昼夜模式" aria-label="切换昼夜模式"><span class="js-theme-icon"></span></button>
<button type="button" class="theme-fab to-top js-to-top" title="返回顶部" aria-label="返回顶部"></button>
<script src="../assets/js/common.js"></script>
<script src="../assets/js/persist.js"></script>
</body>

View File

@ -1,18 +1,25 @@
<?php
/**
* func/qrcode.php —— 二维码生成器
* 使用 qrcodejsCDN 双源加载);支持尺寸调节、放大预览与 PNG 下载。
*/
$P = '../';
require_once dirname(__DIR__) . '/includes/layout.php';
require_once __DIR__ . '/_bar.php';
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="renderer" content="webkit">
<script>(function(){var t;try{t=localStorage.getItem("hp-theme")}catch(e){}if(!t){t=(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)?"dark":"light"}document.documentElement.setAttribute("data-theme",t);})();</script>
<title>二维码生成器</title>
<link rel="icon" href="../assets/img/logo.svg">
<link rel="stylesheet" href="../assets/css/common.css">
</head>
<body>
<div class="func-bar"><div class="wrap func-bar-inner">
<a class="btn btn-sm js-fb-back" href="index.php" title="返回功能区首页">← 返回功能区首页</a>
<span class="func-bar-title">二维码生成器</span>
<a class="btn btn-sm" href="../index.php">首页</a>
</div></div>
layout_head('二维码生成器');
func_bar('二维码生成器');
?>
<main class="page-main">
<div class="wrap page-body">
<div class="panel-card">
<p class="tip" style="margin-top:0">🔒 二维码内容仅在本机浏览器内编码,不会上传服务器。组件优先加载本地 <code>assets/js/qrcode.min.js</code>,未找到时自动回退公共 CDN仅加载代码不含你的内容</p>
<div class="field-row">
<input type="text" id="qrText" placeholder="输入需要生成二维码的内容,如网址 / 文本…" style="flex:1;min-width:260px">
<button type="button" class="btn btn-primary" id="qrGen">生成二维码</button>
@ -35,15 +42,16 @@ func_bar('二维码生成器');
</div>
</main>
<?php layout_theme_fab(); ?>
<?php layout_footer(); ?>
<button type="button" class="theme-fab js-theme-btn" title="切换昼夜模式" aria-label="切换昼夜模式"><span class="js-theme-icon"></span></button>
<button type="button" class="theme-fab to-top js-to-top" title="返回顶部" aria-label="返回顶部"></button>
<script src="../assets/js/common.js"></script>
<script>
(function () {
'use strict';
var CDNS = [
'https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js',
'https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js'
'../assets/js/qrcode.min.js', // 本地内置(离线可用)
'https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js', // 回退 1
'https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js' // 回退 2
];
function $(id) { return document.getElementById(id); }
@ -52,7 +60,7 @@ func_bar('二维码生成器');
var idx = 0;
(function next() {
if (window.QRCode) { cb(null); return; }
if (idx >= CDNS.length) { cb(new Error('二维码组件加载失败,请检查网络后重试')); return; }
if (idx >= CDNS.length) { cb(new Error('二维码组件加载失败,请检查网络后重试,或将 qrcode.min.js 放入 assets/js/ 目录离线使用')); return; }
var s = document.createElement('script');
s.src = CDNS[idx++];
s.onload = function () { cb(window.QRCode ? null : new Error('二维码组件加载失败')); };
@ -69,7 +77,7 @@ func_bar('二维码生成器');
if (!value) { msg.innerHTML = '<div class="err-msg">请输入内容。</div>'; $('qrText').focus(); return; }
loadQR(function (err) {
if (err) { msg.innerHTML = '<div class="err-msg">' + err.message + '</div>'; return; }
if ($('qrText').value.trim() !== value) return; // 期间内容已变
if ($('qrText').value.trim() !== value) return;
var size = parseInt($('qrSize').value, 10);
var box = $('qrBox');
box.innerHTML = '';

View File

@ -119,8 +119,27 @@ function db_init(PDO $pdo): void
created_at TEXT DEFAULT \'\',
updated_at TEXT DEFAULT \'\'
)');
// ---------- IP 黑名单 ----------
$pdo->exec('CREATE TABLE IF NOT EXISTS ip_blacklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule TEXT NOT NULL,
note TEXT DEFAULT \'\',
created_at TEXT NOT NULL DEFAULT \'\',
source TEXT NOT NULL DEFAULT \'manual\'
)');
// 兼容旧库categories 补充 icon 列
// ---------- 自动风控计数(登录失败 / 404 次数 / 每分钟请求频率) ----------
$pdo->exec('CREATE TABLE IF NOT EXISTS ip_risk (
ip TEXT PRIMARY KEY,
login_fail INTEGER NOT NULL DEFAULT 0,
not_found INTEGER NOT NULL DEFAULT 0,
rate_ts INTEGER NOT NULL DEFAULT 0,
rate_count INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT \'\'
)');
// 兼容旧库ip_blacklist 补 source 列categories 补充 icon 列
db_ensure_column($pdo, 'ip_blacklist', 'source', "TEXT NOT NULL DEFAULT 'manual'");
db_ensure_column($pdo, 'categories', 'icon', "TEXT DEFAULT ''");
// 兼容旧库quick_links / func_tools 若缺列一并补齐
db_ensure_column($pdo, 'quick_links', 'note', "TEXT DEFAULT ''");
@ -168,6 +187,7 @@ function db_init(PDO $pdo): void
'search_engines'=> '', // 搜索引擎列表 JSON空 = 使用默认 4 个
'last_updated' => '',
'hero_bg' => '', // 首页顶部标识栏背景(纯色 / CSS 渐变),空 = 主题默认
'hero_mode' => 'weather', // 首页顶部组件模式weather=天气(默认) / info=访客信息 / blank=空白精简
'cdn_ranges' => '', // CDN 段库文本;空 = 使用默认内置列表
'article_note' => '', // 文章区首页顶部注要Markdown 风格,纯文本)
];
@ -186,12 +206,13 @@ function db_init(PDO $pdo): void
}
// ---------- 功能区工具默认种子 ----------
$toolCount = (int)$pdo->query('SELECT COUNT(*) FROM func_tools')->fetchColumn(); if ($toolCount === 0) {
$toolCount = (int)$pdo->query('SELECT COUNT(*) FROM func_tools')->fetchColumn();
if ($toolCount === 0) {
$seedTools = [
['编码 / 加解密', '🔐', 'Base64 / Base32 / URL / Unicode / MD5 / SHA 系列', 'codec.php', 0],
['随机密码生成', '🔑', '自定义字符集与长度,一次生成多组', 'password.php', 0],
['二维码生成器', '▦', '任意内容生成二维码,可放大并下载 PNG', 'qrcode.php', 0],
['杀软识别', '🛡', '粘贴 tasklist 输出,匹配进程对应的安全软件', 'av.php', 0],
['编码 / 加解密', '🔐', 'Base64 / Base32 / URL / Unicode / MD5 / SHA 系列', 'codec.html', 0],
['随机密码生成', '🔑', '自定义字符集与长度,一次生成多组', 'password.html', 0],
['二维码生成器', '▦', '任意内容生成二维码,可放大并下载 PNG', 'qrcode.html', 0],
['杀软识别', '🛡', '粘贴 tasklist 输出,匹配进程对应的安全软件', 'av.html', 0],
];
$st = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, sort) VALUES (?,?,?,?,?,1,?)');
$sort = 0;
@ -202,9 +223,9 @@ function db_init(PDO $pdo): void
// ---------- 后期新增功能页默认登记(按 url 去重,允许后台再停用/删除) ----------
$extraTools = [
['国密加解密', '🔏', 'SM2 非对称加解密 / SM3 摘要 / SM4 对称加解密SM1 为不公开硬件算法)', 'gmcodec.php'],
['IP 地址处理', '🌐', '提取日志中的全部 IPv4 并统计出现次数,再按内网 / CDN / 公网区分展示', 'ip.php'],
['辅助维权', '🛠️', '反弹 Shell 构建 / Windows SAM·mimikatz·票据提取 / Linux 自启服务systemd 与 init.d', 'persist.php'],
['国密加解密', '🔏', 'SM2 非对称加解密 / SM3 摘要 / SM4 对称加解密SM1 为不公开硬件算法)', 'gmcodec.html'],
['IP 地址处理', '🌐', '提取日志中的全部 IPv4 并统计出现次数,再按内网 / CDN / 公网区分展示', 'ip.html'],
['辅助维权', '🛠️', '反弹 Shell 构建 / Windows SAM·mimikatz·票据提取 / Linux 自启服务systemd 与 init.d', 'persist.html'],
];
$chkUrl = $pdo->prepare('SELECT COUNT(*) FROM func_tools WHERE url = ?');
$insTool = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, sort) VALUES (?,?,?,?,0,1,?)');
@ -265,6 +286,21 @@ MD;
$stA = $pdo->prepare('INSERT INTO articles (part, title, summary, markdown, pinned, enabled, sort, created_at, updated_at) VALUES (?,?,?,?,1,1,1,?,?)');
$stA->execute(['使用说明', '文章库使用说明', '了解分区、三栏阅读与 Markdown 图片引用的基本用法。', $sampleMd, $now, $now]);
}
// ---------- 功能区工具登记:旧 PHP 外壳 → 静态 HTML.php 已改为 302 重定向,入口自动同步迁移) ----------
$toolHtmlMap = [
'codec.php' => 'codec.html',
'password.php' => 'password.html',
'qrcode.php' => 'qrcode.html',
'av.php' => 'av.html',
'ip.php' => 'ip.html',
'persist.php' => 'persist.html',
'gmcodec.php' => 'gmcodec.html',
];
$updUrl = $pdo->prepare('UPDATE func_tools SET url = ? WHERE url = ?');
foreach ($toolHtmlMap as $oldUrl => $newUrl) {
$updUrl->execute([$newUrl, $oldUrl]);
}
}
/** 标记内容已更新(首页展示"上次更新时间" */
@ -291,3 +327,468 @@ function setting_get(string $key, string $default = ''): string
}
return array_key_exists($key, $cache) ? $cache[$key] : $default;
}
// ============================================================
// 访问日志 + IP 黑名单(自动拦截 / 自动记录)
// ============================================================
/** 访问日志文件路径data/vistors.log追加写入、完整保留可自行归档 / 删除) */
const ACCESS_LOG_FILE = DATA_DIR . '/vistors.log';
/** 当前客户端 IPREMOTE_ADDR忽略转发头以免被伪造 */
function client_ip(): string
{
static $cached = null;
if ($cached !== null) {
return $cached;
}
$ip = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
$ip = '';
}
$cached = $ip;
return $ip;
}
/** IPv4 转无符号整数;失败返回 -1 */
function ip4_int(string $ip): int
{
$n = ip2long($ip);
if ($n === false) {
return -1;
}
return $n < 0 ? $n + 4294967296 : $n;
}
/**
* 校验并解析黑名单规则,返回 [ok, net(掩码后网络号), prefix, 错误说明]
* 支持:单 IP203.0.113.7、CIDR203.0.113.0/24、星号通配203.0.113.* / 192.168.*.*
*/
function blacklist_parse_rule(string $raw): array
{
$rule = trim($raw);
if ($rule === '') {
return [false, 0, 0, '规则不能为空。'];
}
// 单 IPv4
if (filter_var($rule, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return [true, ip4_int($rule), 32, ''];
}
// CIDR 网段
if (preg_match('#^(\d{1,3}(?:\.\d{1,3}){3})/(\d{1,2})$#', $rule, $m)) {
$prefix = (int)$m[2];
if ($prefix > 32 || !filter_var($m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return [false, 0, 0, '网段格式不正确,掩码需在 0-32 之间。'];
}
$mask = $prefix === 0 ? 0 : ((0xFFFFFFFF << (32 - $prefix)) & 0xFFFFFFFF);
return [true, ip4_int($m[1]) & $mask, $prefix, ''];
}
// 星号通配段:* 只能连续出现在末尾
if (strpos($rule, '*') !== false) {
$parts = explode('.', $rule);
if (count($parts) !== 4) {
return [false, 0, 0, '仅支持标准 IPv4 / CIDR 网段 / 星号通配段。'];
}
$bytes = [0, 0, 0, 0];
$prefix = 0;
$seen = false;
foreach ($parts as $i => $p) {
if ($p === '*') {
if (!$seen) {
$seen = true;
$prefix = $i * 8;
}
continue;
}
if ($seen || $p === '' || !ctype_digit($p) || (int)$p > 255) {
return [false, 0, 0, '通配段格式不正确,* 只能连续出现在末尾。'];
}
$bytes[$i] = (int)$p;
}
if (!$seen) {
return [false, 0, 0, '规则格式不正确。'];
}
$net = (($bytes[0] << 24) | ($bytes[1] << 16) | ($bytes[2] << 8) | $bytes[3]) & 0xFFFFFFFF;
return [true, $net, $prefix, ''];
}
return [false, 0, 0, '仅支持标准 IPv4、CIDR 网段或星号通配段。'];
}
/** 读取全部黑名单规则(仅返回解析成功的记录,静态缓存避免单次请求重复查询) */
function blacklist_rules(bool $refresh = false): array
{
static $cache = null;
if ($cache !== null && !$refresh) {
return $cache;
}
$cache = [];
try {
foreach (db()->query('SELECT id, rule, note, created_at FROM ip_blacklist ORDER BY id ASC') as $row) {
[$ok, $net, $prefix] = blacklist_parse_rule((string)$row['rule']);
if (!$ok) {
continue;
}
$cache[] = [
'id' => (int)$row['id'],
'rule' => (string)$row['rule'],
'note' => (string)$row['note'],
'created_at' => (string)$row['created_at'],
'net' => $net,
'prefix' => $prefix,
];
}
} catch (Throwable $e) {
$cache = [];
}
return $cache;
}
/** 当前 IPv4 是否命中黑名单;未命中或非 IPv4 返回 null命中返回规则记录 */
function blacklist_ip_hit(string $ip): ?array
{
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return null;
}
$int = ip4_int($ip);
foreach (blacklist_rules() as $r) {
if ($r['prefix'] === 32) {
if ($r['net'] === $int) {
return $r;
}
continue;
}
$mask = $r['prefix'] === 0 ? 0 : ((0xFFFFFFFF << (32 - $r['prefix'])) & 0xFFFFFFFF);
if (($int & $mask) === $r['net']) {
return $r;
}
}
return null;
}
/** 请求结束统一追加写入一条访问日志(含被黑名单拦截的 403 */
function access_log_flush(): void
{
try {
$ip = client_ip();
if ($ip === '') {
return;
}
$method = (string)($_SERVER['REQUEST_METHOD'] ?? '');
$method = $method !== '' ? strtoupper($method) : 'GET';
$uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
// 登录口令、CSRF 等敏感 query 参数脱敏后再写日志
$uri = preg_replace('/([?&](?:password|passwd|old_pwd|new_pwd|new_pwd2|csrf|token|secret)[^=]*=)[^&]*/i', '$1***', $uri);
$uri = substr((string)$uri, 0, 600);
$code = http_response_code();
if (!is_int($code) || $code < 100 || $code > 599) {
$code = 200;
}
// 每行:时间 / IP / 请求方式 / 响应码 / 访问 URL制表符分隔
$line = date('Y-m-d H:i:s') . "\t" . $ip . "\t" . $method . "\t" . $code . "\t" . $uri . "\n";
@file_put_contents(ACCESS_LOG_FILE, $line, FILE_APPEND | LOCK_EX);
// 自动风控:前台 404 响应计入累计(是否达到阈值触发封禁见 risk_count_not_found / access_boot 自检)
$isAdmin = strpos((string)($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0;
if (!$isAdmin && $code === 404) {
risk_count_not_found($ip);
}
} catch (Throwable $e) {
// 日志写入失败不影响页面主流程
}
}
/** 读取访问日志文件,解析为记录数组(新→旧排序),供后台“访问信息”页使用 */
function visitors_entries(): array
{
$out = [];
if (!is_file(ACCESS_LOG_FILE)) {
return $out;
}
$fh = @fopen(ACCESS_LOG_FILE, 'rb');
if (!$fh) {
return $out;
}
while (($line = fgets($fh)) !== false) {
$line = rtrim($line, "\r\n");
if ($line === '') {
continue;
}
$p = explode("\t", $line);
if (count($p) < 5) {
continue;
}
$out[] = [
'created_at' => (string)$p[0],
'ip' => (string)$p[1],
'method' => (string)$p[2],
'status' => (int)$p[3],
'url' => (string)$p[4],
];
}
fclose($fh);
return array_reverse($out); // 新→旧
}
/** 自动启动:注册访问日志;黑名单对所有请求(前台与后台登录页)统一拦截;自动风控自检仅针对前台 */
function access_boot(): void
{
if (PHP_SAPI === 'cli') {
return;
}
register_shutdown_function('access_log_flush');
$ip = client_ip();
if ($ip === '') {
return;
}
$isAdmin = strpos((string)($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0;
// 1) 已在黑名单(手动 / 自动):前台与后台一致拦截,统一返回风控提示页
if (blacklist_ip_hit($ip) !== null) {
risk_block_page();
}
// 2) 自动风控自检仅针对前台(后台正常维护不计入 404 / 频率,避免管理员被自身操作误伤)
if (!$isAdmin) {
$cfg = risk_rule_cfg();
// 2a) 请求频率:先累计本请求,超过阈值即封禁
if ($cfg['rate']['on'] && risk_bump_rate($ip, (int)$cfg['rate']['n'])) {
risk_ban($ip, '请求频率超过阈值60 秒内 ' . (int)$cfg['rate']['n'] . ' 次)');
risk_block_page();
}
// 2b) 404 累计已达标(若上一请求刚触发封禁,这里会因命中黑名单直接拦截,此分支为兑底)
if ($cfg['not_found']['on']) {
$row = risk_row($ip);
if ((int)$row['not_found'] >= (int)$cfg['not_found']['n']) {
risk_ban($ip, '404 累计次数达阈值(' . (int)$cfg['not_found']['n'] . ' 次)');
risk_block_page();
}
}
}
}
// ============================================================
// 自动风控:登录失败 / 404 次数 / 每分钟请求频率(可启停、可设阈值)
// ============================================================
/** 读取 settings 键值全表(规避 setting_get 的静态缓存;供风控配置与拦截提示使用) */
function risk_settings_map(): array
{
$map = [];
try {
foreach (db()->query('SELECT key, value FROM settings') as $r) {
$map[(string)$r['key']] = (string)$r['value'];
}
} catch (Throwable $e) {
$map = [];
}
return $map;
}
/**
* 站长自定义拦截提示main=主提示 sub=次行说明(留空回落默认文案);
* 另附 raw_main / raw_sub 原始保存值(空串表示未自定义)。
*/
function risk_block_tip(): array
{
$map = risk_settings_map();
$rawMain = trim((string)($map['risk_tip_main'] ?? ''));
$rawSub = trim((string)($map['risk_tip_sub'] ?? ''));
$main = $rawMain !== '' ? $rawMain : '您触发了本站风控,请稍后再访问';
$sub = $rawSub !== '' ? $rawSub : '如为正常访问,请稍后重试;若频繁误判,请联系站点管理员处理。';
return ['main' => $main, 'sub' => $sub, 'raw_main' => $rawMain, 'raw_sub' => $rawSub];
}
/**
* 读取风控规则配置(始终直查 settings 表,避免缓存旧值)。
* 返回login_fail / not_found / rate每项含 on(启用) n(阈值) tip(说明)
*/
function risk_rule_cfg(): array
{
$defs = [
'login_fail' => [1, 10, '后台登录失败累计达到阈值后,该 IP 被自动加入黑名单(永久封禁)。'],
'not_found' => [1, 100, '前台访问返回 404 累计达到阈值后,该 IP 被自动加入黑名单(永久封禁)。'],
'rate' => [0, 120, '60 秒内请求次数超过阈值后,该 IP 被自动加入黑名单(永久封禁);多人共享同一出口 IP 时建议关闭。'],
];
$map = risk_settings_map();
$out = [];
// 存储键别名:后台保存使用 risk_login_* / risk_404_* / risk_rate_*
$alias = ['login_fail' => 'login', 'not_found' => '404', 'rate' => 'rate'];
foreach ($defs as $k => $d) {
$pre = $alias[$k];
$out[$k] = [
'on' => (($map['risk_' . $pre . '_on'] ?? (string)$d[0]) === '1'),
'n' => max(1, min(1000000, (int)($map['risk_' . $pre . '_n'] ?? (string)$d[1]))),
'tip' => $d[2],
];
}
// 拦截提示(含原始保存值,供后台编辑回显与占位提示)
$tip = risk_block_tip();
$out['_tip'] = [
'main' => $tip['main'],
'sub' => $tip['sub'],
'raw_main' => $tip['raw_main'],
'raw_sub' => $tip['raw_sub'],
];
return $out;
}
/** 读取某 IP 的风控计数行(不存在时返回全 0 */
function risk_row(string $ip): array
{
$empty = ['ip' => $ip, 'login_fail' => 0, 'not_found' => 0, 'rate_ts' => 0, 'rate_count' => 0, 'updated_at' => ''];
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $empty;
}
try {
$st = db()->prepare('SELECT ip, login_fail, not_found, rate_ts, rate_count, updated_at FROM ip_risk WHERE ip = ? LIMIT 1');
$st->execute([$ip]);
$r = $st->fetch();
return $r ? $r : $empty;
} catch (Throwable $e) {
return $empty;
}
}
/** 写入某 IP 的风控计数 */
function risk_upsert(string $ip, int $loginFail, int $notFound, int $rateTs, int $rateCount): void
{
try {
$st = db()->prepare('INSERT INTO ip_risk (ip, login_fail, not_found, rate_ts, rate_count, updated_at) VALUES (?,?,?,?,?,?)
ON CONFLICT(ip) DO UPDATE SET login_fail=excluded.login_fail, not_found=excluded.not_found,
rate_ts=excluded.rate_ts, rate_count=excluded.rate_count, updated_at=excluded.updated_at');
$st->execute([$ip, $loginFail, $notFound, $rateTs, $rateCount, date('Y-m-d H:i:s')]);
} catch (Throwable $e) {
// 风控写入失败不影响页面主流程
}
}
/** 将某 IPv4 永久封禁(幂等:已存在该单 IP 规则则不再重复添加),并刷新黑名单缓存 */
function risk_ban(string $ip, string $reason): void
{
$ip = trim($ip);
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return;
}
$int = ip4_int($ip);
foreach (blacklist_rules() as $r) {
if ($r['prefix'] === 32 && $r['net'] === $int) {
return; // 已存在
}
}
try {
$st = db()->prepare('INSERT INTO ip_blacklist (rule, note, created_at, source) VALUES (?,?,?,?)');
$st->execute([$ip, '风控自动封禁:' . $reason, date('Y-m-d H:i:s'), 'auto']);
blacklist_rules(true);
} catch (Throwable $e) {
// 忽略:避免风控本身拖垮请求
}
}
/** 后台登录失败累计;规则启用且达到阈值时自动封禁。返回 [当前失败次数, 本次是否触发封禁, 阈值] */
function risk_login_fail(string $ip): array
{
$empty = [0, false, 0];
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $empty;
}
$cfg = risk_rule_cfg();
$row = risk_row($ip);
$lf = (int)$row['login_fail'] + 1;
$n = (int)$cfg['login_fail']['n'];
risk_upsert($ip, $lf, (int)$row['not_found'], (int)$row['rate_ts'], (int)$row['rate_count']);
$banned = $cfg['login_fail']['on'] && $lf >= $n;
if ($banned) {
risk_ban($ip, '后台登录失败累计达阈值(' . $n . ' 次)');
}
return [$lf, $banned, $n];
}
/** 请求频率(每分钟窗口)累计;返回本次累计后是否达到阈值 */
function risk_bump_rate(string $ip, int $threshold): bool
{
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return false;
}
$win = (int)floor(time() / 60);
$row = risk_row($ip);
$cnt = ((int)$row['rate_ts'] === $win) ? ((int)$row['rate_count'] + 1) : 1;
risk_upsert($ip, (int)$row['login_fail'], (int)$row['not_found'], $win, $cnt);
return $cnt >= $threshold;
}
/** 前台 404 响应累计;规则启用且达到阈值时自动封禁(本响应结束后生效,下一请求即被拦截) */
function risk_count_not_found(string $ip): void
{
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return;
}
$cfg = risk_rule_cfg();
$row = risk_row($ip);
$nf = (int)$row['not_found'] + 1;
risk_upsert($ip, (int)$row['login_fail'], $nf, (int)$row['rate_ts'], (int)$row['rate_count']);
if ($cfg['not_found']['on'] && $nf >= (int)$cfg['not_found']['n']) {
risk_ban($ip, '404 累计次数达阈值(' . (int)$cfg['not_found']['n'] . ' 次)');
}
}
/** 清除某 IP 的风控计数(解封单 IP 黑名单时调用) */
function risk_clear_for_ip(string $ip): void
{
$ip = trim($ip);
if ($ip === '') {
return;
}
try {
$st = db()->prepare('DELETE FROM ip_risk WHERE ip = ?');
$st->execute([$ip]);
} catch (Throwable $e) {
// ignore
}
}
/** 清空全部风控计数(不影响黑名单本身) */
function risk_clear_all(): void
{
try {
db()->exec('DELETE FROM ip_risk');
} catch (Throwable $e) {
// ignore
}
}
/** 命中黑名单 / 触发风控时的统一提示页403 */
function risk_block_page(): void
{
if (!headers_sent()) {
http_response_code(403);
header('Content-Type: text/html; charset=utf-8');
}
$site = '本站';
try {
$site = setting_get('site_name', '知识导航站');
} catch (Throwable $e) {
// ignore
}
$tip = risk_block_tip();
$s = htmlspecialchars($site, ENT_QUOTES, 'UTF-8');
$m = htmlspecialchars($tip['main'], ENT_QUOTES, 'UTF-8');
$sb = htmlspecialchars($tip['sub'], ENT_QUOTES, 'UTF-8');
echo '<!DOCTYPE html>' . "\n"
. '<html lang="zh-CN"><head><meta charset="utf-8">'
. '<meta name="viewport" content="width=device-width, initial-scale=1">'
. '<meta name="robots" content="noindex,nofollow"><title>访问受限 - ' . $s . '</title>'
. '<style>html,body{margin:0;height:100%}body{display:flex;align-items:center;justify-content:center;'
. 'background:#f4f6f9;font-family:-apple-system,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif}'
. '.card{max-width:420px;margin:20px;padding:38px 30px;background:#fff;border-radius:16px;'
. 'box-shadow:0 10px 34px rgba(15,23,42,.10);text-align:center}.code{font-size:52px;font-weight:800;color:#e11d48;line-height:1}'
. '.t1{margin:14px 0 6px;font-size:17px;color:#0f172a}.t2{margin:0;font-size:13px;color:#64748b;line-height:1.7}'
. '.st{display:inline-block;margin-top:18px;font-size:12px;color:#94a3b8}'
. '@media (prefers-color-scheme:dark){body{background:#0b1220}.card{background:#111a2c;'
. 'box-shadow:0 10px 34px rgba(0,0,0,.5)}.t1{color:#e6ecf7}.t2{color:#8ea0bd}.st{color:#526180}}'
. '</style></head><body>'
. '<div class="card"><div class="code">403</div>'
. '<div class="t1">' . $m . '</div>'
. '<div class="t2">' . $sb . '</div>'
. '<span class="st">' . $s . '</span>'
. '</div></body></html>';
exit;
}
access_boot();

View File

@ -115,6 +115,75 @@ function home_quote(): string
return $lines[array_rand($lines)];
}
/** 首页访客信息hero 信息模式IP 与浏览器/系统/内核由服务端解析 UA 提供,屏幕/语言由前端补齐 */
function visitor_brief(): array
{
$ip = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) {
$ip = '未知';
}
$ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
$browser = '未知';
$kernel = '未知';
$os = '未知';
if ($ua !== '') {
// 浏览器 / 内核
if (strpos($ua, 'MicroMessenger') !== false) {
$browser = '微信内置浏览器';
$kernel = 'WebKit';
} elseif (preg_match('/Edg\/([\d.]+)/', $ua, $m)) {
$browser = 'Edge ' . $m[1];
$kernel = 'Blink';
} elseif (preg_match('/OPR\/([\d.]+)/', $ua, $m)) {
$browser = 'Opera ' . $m[1];
$kernel = 'Blink';
} elseif (strpos($ua, 'Firefox/') !== false) {
$browser = 'Firefox';
$kernel = 'Gecko';
if (preg_match('/Firefox\/([\d.]+)/', $ua, $m)) $browser .= ' ' . $m[1];
} elseif (preg_match('/(?:Chrome|CriOS)\/([\d.]+)/', $ua, $m)) {
$browser = 'Chrome ' . $m[1];
$kernel = 'Blink';
} elseif (strpos($ua, 'Safari/') !== false) {
$browser = 'Safari';
$kernel = 'WebKit';
if (preg_match('/Version\/([\d.]+)/', $ua, $m)) $browser .= ' ' . $m[1];
}
if ($kernel === '未知') {
if (strpos($ua, 'Gecko/') !== false) $kernel = 'Gecko';
elseif (strpos($ua, 'AppleWebKit/') !== false) $kernel = 'WebKit';
}
// 操作系统
if (strpos($ua, 'Windows NT 10.0') !== false) {
$os = 'Windows 10 / 11';
} elseif (strpos($ua, 'Windows NT 6.3') !== false) {
$os = 'Windows 8.1';
} elseif (strpos($ua, 'Windows NT 6.2') !== false) {
$os = 'Windows 8';
} elseif (strpos($ua, 'Windows NT 6.1') !== false) {
$os = 'Windows 7';
} elseif (strpos($ua, 'Windows NT 5.1') !== false) {
$os = 'Windows XP';
} elseif (strpos($ua, 'Windows Phone') !== false) {
$os = 'Windows Phone';
} elseif (strpos($ua, 'Android') !== false) {
$os = 'Android';
if (preg_match('/Android ([\d.]+)/', $ua, $m)) $os .= ' ' . $m[1];
} elseif (strpos($ua, 'iPad') !== false || strpos($ua, 'iPhone') !== false || strpos($ua, 'iPod') !== false) {
$os = 'iOS';
if (preg_match('/OS ([\d_]+)/', $ua, $m)) $os .= ' ' . str_replace('_', '.', $m[1]);
} elseif (strpos($ua, 'Mac OS X') !== false) {
$os = 'macOS';
if (preg_match('/Mac OS X ([\d_]+)/', $ua, $m)) $os .= ' ' . str_replace('_', '.', $m[1]);
} elseif (strpos($ua, 'CrOS') !== false) {
$os = 'ChromeOS';
} elseif (strpos($ua, 'Linux') !== false) {
$os = 'Linux';
}
}
return ['ip' => $ip, 'browser' => $browser, 'os' => $os, 'kernel' => $kernel];
}
/** 图标值是否属于图片dataURL / http / 常见图片扩展 / 相对路径) */
function icon_is_image(string $raw): bool
{

View File

@ -9,6 +9,15 @@ require_once __DIR__ . '/includes/layout.php';
$siteName = setting_get('site_name', '知识导航站');
$siteSlogan = setting_get('site_slogan', '');
$heroBg = setting_get('hero_bg', ''); // 后台自定义顶部背景(纯色 / CSS 渐变)
// 首页顶部组件模式weather=天气(默认) / info=访客信息 / blank=空白精简
$heroMode = setting_get('hero_mode', 'weather');
if (!in_array($heroMode, ['weather', 'info', 'blank'], true)) {
$heroMode = 'weather';
}
$visitor = [];
if ($heroMode === 'info') {
$visitor = visitor_brief();
}
$lastUpdated = setting_get('last_updated', '');
if ($lastUpdated === '') {
$lastUpdated = '暂无更新记录';
@ -60,7 +69,7 @@ $catHint = [
layout_head('');
?>
<!-- Hero 顶部展示栏 -->
<header class="hero"<?php if ($heroBg !== ''): ?> style="background:<?= he($heroBg) ?>"<?php endif; ?>>
<header class="hero<?= $heroMode === 'blank' ? ' hero-slim' : '' ?>"<?php if ($heroBg !== ''): ?> style="background:<?= he($heroBg) ?>"<?php endif; ?>>
<div class="wrap hero-inner">
<div class="hero-top">
<div class="brand">
@ -77,9 +86,24 @@ layout_head('');
<div class="hm-row"><span>上次更新&nbsp;</span><span class="val"><?= he($lastUpdated) ?></span></div>
</div>
</div>
<div class="weather-box">
<iframe width="800" height="150" src="https://i.tianqi.com/?c=code&a=getcode&id=48&num=6&icon=1" frameborder="0"></iframe>
</div>
<?php if ($heroMode === 'weather'): ?>
<!-- 天气模式(默认):第三方天气条 -->
<div class="weather-box">
<iframe width="800" height="150" src="https://i.tianqi.com/?c=code&a=getcode&id=48&num=6&icon=1" frameborder="0"></iframe>
</div>
<?php elseif ($heroMode === 'info'): ?>
<!-- 访客信息模式IP / 浏览器 / 系统 / 内核 / 屏幕 / 语言 -->
<div class="visitor-box js-visitor-box">
<div class="vb-grid">
<div class="vb-cell"><span>访问者 IP</span><b><?= he((string)($visitor['ip'] ?? '未知')) ?></b></div>
<div class="vb-cell"><span>浏览器</span><b><?= he((string)($visitor['browser'] ?? '未知')) ?></b></div>
<div class="vb-cell"><span>操作系统</span><b><?= he((string)($visitor['os'] ?? '未知')) ?></b></div>
<div class="vb-cell"><span>内核 / 渲染引擎</span><b><?= he((string)($visitor['kernel'] ?? '未知')) ?></b></div>
<div class="vb-cell"><span>屏幕分辨率</span><b class="js-vscreen">--</b></div>
<div class="vb-cell"><span>系统语言</span><b class="js-vlang">--</b></div>
</div>
</div>
<?php endif; ?>
</div>
</header>

View File

@ -227,10 +227,10 @@ layout_head($catName);
<div class="wrap">
<!-- 主导航:分区文本居中,点击下滑线滑动指示;库切换请从首页板块入口进入 -->
<nav class="lnx-nav" id="lnxNav">
<button type="button" class="lnx-tab active" data-t="nav">🧭 导航区</button>
<button type="button" class="lnx-tab" data-t="tools">🧰 内部功能区</button>
<button type="button" class="lnx-tab" data-t="art">📚 文章区</button>
<button type="button" class="lnx-tab" data-t="ext">🔗 外部工具区</button>
<button type="button" class="lnx-tab active" data-t="nav">导航区</button>
<button type="button" class="lnx-tab" data-t="tools">功能区</button>
<button type="button" class="lnx-tab" data-t="art">文章区</button>
<button type="button" class="lnx-tab" data-t="ext">工具区</button>
<span class="lnx-ink" id="lnxInk" aria-hidden="true"></span>
</nav>