diff --git a/admin/_guard.php b/admin/_guard.php index 65fce23..1a19aca 100644 --- a/admin/_guard.php +++ b/admin/_guard.php @@ -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' : ''; diff --git a/admin/articles.php b/admin/articles.php index f2095f1..fce61f3 100644 --- a/admin/articles.php +++ b/admin/articles.php @@ -33,7 +33,8 @@ function renumber_sorts(PDO $pdo): void function msg_block(string $kind, string $text): string { - return '
' . he($text) . '
'; + // 改为右下角 Toast:注入 hpToastMsg,由 common.js 在页面加载后自动弹出 + return ''; } $msgKind = ''; @@ -236,7 +237,7 @@ admin_topbar('articles');

文章库(第五大板块)的文章维护:正文为标准 Markdown,图片统一上传到 data/articles/img 目录。

-
保存成功,文章库前台已同步更新。
+ diff --git a/admin/content.php b/admin/content.php index 70aaac7..6efb9ea 100644 --- a/admin/content.php +++ b/admin/content.php @@ -31,7 +31,8 @@ function remove_old_logo(): void function alert_html(string $kind, string $msg): void { - echo '
' . he($msg) . '
'; + // 改为右下角 Toast:注入 hpToastMsg,由 common.js 在页面加载后自动弹出 + echo ''; } $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'); + +
+
首页顶部组件(Hero 下方横幅区)
+ +
+ +
+ + + +
+
+
+

+ 天气模式(默认):横幅展示第三方天气条;访客信息模式:横幅显示当前访问者 IP、浏览器、操作系统、内核、屏幕分辨率与系统语言;空白模式:隐藏横幅,仅保留站点信息与时间信息,顶部高度自动缩减。 +

+
+
底部信息
diff --git a/admin/exttools.php b/admin/exttools.php index 9c0b7df..720b0c8 100644 --- a/admin/exttools.php +++ b/admin/exttools.php @@ -135,10 +135,10 @@ admin_topbar('exttools');

-
保存成功,库首页外部工具区已同步更新。
+ -
+ diff --git a/admin/login.php b/admin/login.php index f7d726c..b41f10d 100644 --- a/admin/login.php +++ b/admin/login.php @@ -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('后台登录');
-

初始账号:admin / admin123(首次登录后请在“内容与顺序”中修改)

diff --git a/admin/nav.php b/admin/nav.php index 6583070..111e710 100644 --- a/admin/nav.php +++ b/admin/nav.php @@ -162,10 +162,10 @@ admin_topbar('nav'); -
保存成功,前台页面已同步更新。
+ -
+
diff --git a/admin/tools.php b/admin/tools.php index ba17919..851ed14 100644 --- a/admin/tools.php +++ b/admin/tools.php @@ -148,10 +148,10 @@ admin_topbar('tools');

-
保存成功,功能区首页已同步更新。
+ -
+
diff --git a/admin/visits.php b/admin/visits.php new file mode 100644 index 0000000..2d4f145 --- /dev/null +++ b/admin/visits.php @@ -0,0 +1,502 @@ +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'); +?> +
+
+

访问信息

+

记录访客对本站的动态请求(含后台),完整模式逐条展示访问明细,精简模式按 IP 汇总次数;黑名单 / 自动风控对所有访问统一生效,命中即返回 403 风控提示页,后台与登录页不例外。

+ + + + + + +
+
访问概况
+
+
累计访问
+
独立 IP
+
今日访问
+
黑名单规则
+
+
+ + + 日志按行追加写入 data/vistors.log(制表符分隔:时间 / IP / 请求方式 / 响应码 / URL),完整保留不设上限;可自行归档或删除该文件。 +
+
+ + +
+
访问记录
+
+ + + + + 重置 +
+ + +
+ 共命中 ; + 当前第 / 页,每页 条。 +
+
+ + + + + + + + + + + + + + +
最近访问时间IP 地址访问次数
+ + + + + + + + + + + + + + + + + + +
访问时间IP 地址请求方式访问 URL响应码
= 200 && $code < 300) ? 'good' : (($code >= 300 && $code < 400) ? 'info' : (($code >= 400 && $code < 500) ? 'warn' : 'bad')); + ?> +
+ +
+ + 1): ?> +
+ + 1): ?>← 上一页 + 1) echo ''; + for ($p = $start; $p <= $end; $p++) { + if ($p === $page) { + echo '' . $p . ''; + } else { + echo '' . $p . ''; + } + } + if ($end < $totalPages) echo ''; + ?> + 下一页 → +
+ + +
+ +
+ + +
+
自动风控规则(达到阈值自动加入黑名单;命中前台与后台均返回 403 风控提示页,提示文案可在下方自定义)
+
+ +
+ + 失败次数 ≥ 次 → 永久封禁(建议 5-20) +
+
+ + 累计次数 ≥ 次 → 永久封禁(建议 50-300) +
+
+ + 60 秒内 ≥ 次 → 永久封禁(多人共用出口 IP 时建议关闭) +
+

计数方式:404 与登录失败只要发生即累计(规则关闭也照常计数,便于后台观察);规则启用且达到阈值时自动封禁,保存后即时生效。自动封禁在黑名单中标注“风控自动”,可随时移除解封(解封时同步清除该 IP 计数)。

+
+ 主提示(留空=默认) + +
+
+ 次行说明(留空=默认) + +
+ +
+
+ + + + 当前访问 IP()计数:登录失败 次 · 404 · 本分钟请求 + +
+
+ + +
+
IP 黑名单(全站拦截前台与后台,命中统一返回 403 风控提示页)
+
+ + + + +
+

+ 支持三种写法:单个 IP、CIDR 网段(如 203.0.113.0/24)、星号通配段(如 203.0.113.*192.168.*.*); + 目前仅按 IPv4 匹配。黑名单即时生效且不受「站长提示」开关影响,命中 IP 的前台与后台(含登录页)访问一律被拒绝。如需解除,请从未被封禁的网络进入后台移除该规则,或直接在 data/homepage.db 的 ip_blacklist 表中删除记录。 +

+
+ + 当前访问 IP: —— 命中规则「」(),全站(前台与后台)访问将被拦截 + + 当前访问 IP: —— 未命中任何黑名单规则 + +
+ + +
+
+ + + +
+ 0): ?> +
+ + +
+ +
+ + +
+
+ +
+ + + + 风控自动 + + 手动 + + (无备注)' ?> + +
+ + +
+
+ + +

尚未配置黑名单规则。启用上方「自动风控」后,异常 IP 会自动记录到这里;也可在下方手动添加规则。

+ +
+
+
+ + + + + + diff --git a/api/site.php b/api/site.php new file mode 100644 index 0000000..0b7345c --- /dev/null +++ b/api/site.php @@ -0,0 +1,12 @@ + setting_get('site_logo', '')], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); diff --git a/assets/css/common.css b/assets/css/common.css index 719a5f2..5365dc4 100644 --- a/assets/css/common.css +++ b/assets/css/common.css @@ -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; } } diff --git a/assets/img/favicon.ico b/assets/img/favicon.ico new file mode 100644 index 0000000..edab233 Binary files /dev/null and b/assets/img/favicon.ico differ diff --git a/assets/js/codec.js b/assets/js/codec.js index 2ce13db..cd7995d 100644 --- a/assets/js/codec.js +++ b/assets/js/codec.js @@ -1,6 +1,6 @@ /* codec.js —— 编码/加解密纯前端算法库 * 提供:UTF-8、Base64、Base32、URL、Unicode、SHA 系列(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); diff --git a/assets/js/common.js b/assets/js/common.js index bb786ce..012388c 100644 --- a/assets/js/common.js +++ b/assets/js/common.js @@ -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 跟随后台站点 logo(api/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); diff --git a/assets/js/gmcrypto.js b/assets/js/gmcrypto.js index 53bb9c7..c534b0e 100644 --- a/assets/js/gmcrypto.js +++ b/assets/js/gmcrypto.js @@ -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/ 目录,缺失则回退 CDN(jsdelivr → 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; }); } diff --git a/assets/js/ip.js b/assets/js/ip.js index 70556e8..56e59cb 100644 --- a/assets/js/ip.js +++ b/assets/js/ip.js @@ -6,7 +6,7 @@ * * 段按出现次数(段内全部 IP 原文出现次数之和)从高到低排列; * 「重点匹配 IP」:填写的 IP 命中后,其所在 /24 段优先置顶、对应 IP 红色标出。 - * CDN 段库由服务端通过 #ipCdnRaw 隐藏文本框注入,可在后台维护。 + * CDN 段库默认内置在页面 #ipCdnRaw 隐藏文本框中(func/ip.html),可直接用编辑器增补厂商。 */ (function () { 'use strict'; diff --git a/func/av.php b/func/av.html similarity index 77% rename from func/av.php rename to func/av.html index e0b515e..9d5b984 100644 --- a/func/av.php +++ b/func/av.html @@ -1,18 +1,25 @@ - + + + + + + +杀软识别 + + + + +
+ ← 返回功能区首页 + 杀软识别 + 首页 +
-layout_head('杀软识别'); -func_bar('杀软识别'); -?>
+

🔒 粘贴的 tasklist 输出仅在本机浏览器内解析匹配,不会上传服务器;如需离线独立使用,请将 data/avlist.js 一并拷贝。

使用说明

在目标机器执行 tasklist 复制全部输出,粘贴到下方输入框后点击“识别”。 系统会逐行解析进程名并与预置的安全软件进程表(data/avlist.js)进行大小写不敏感匹配,表格仅展示识别出的安全软件进程,常规进程不展示。

@@ -37,8 +44,8 @@ func_bar('杀软识别');
- - + + +编码 / 加解密 + + + + +
+ ← 返回功能区首页 + 编码 / 加解密 + 首页 +
-layout_head('编码 / 加解密'); -func_bar('编码 / 加解密'); -?>
+

🔒 本页全部运算均在浏览器本地完成,粘贴的文本不会上传到任何服务器,可放心处理大段内容。MD5 / SHA-224 在无内置加密库时需联网加载组件(仅下载代码,不含你的内容)。

+ + + + diff --git a/func/password.php b/func/password.html similarity index 78% rename from func/password.php rename to func/password.html index f20f2c7..4e17c8f 100644 --- a/func/password.php +++ b/func/password.html @@ -1,18 +1,25 @@ - + + + + + + +随机密码生成 + + + + +
+ ← 返回功能区首页 + 随机密码生成 + 首页 +
-layout_head('随机密码生成'); -func_bar('随机密码生成'); -?>
+

🔒 密码在本机浏览器内随机生成,不会上传服务器,生成后请妥善保存。

@@ -39,8 +46,8 @@ func_bar('随机密码生成');
- - + + +辅助维权 + + + + +
+ ← 返回功能区首页 + 辅助维权 + 首页 +
+
+

🔒 全部配置 / 命令均由本机浏览器本地生成,输入内容不会上传服务器。

辅助维权 · 教学辅助

本工具仅供内网教学 / 授权实验环境使用,请勿用于任何未经授权的真实目标。 三类输出全部在浏览器本地生成:① 反弹 Shell 构建(Linux / Windows 目标机,含普通、Python 动态与 Base64 编码版本); @@ -166,8 +168,8 @@ mimikatz.exe "kerberos::ptt 1-40a0000-xxx.kirbi" exit

- - + + diff --git a/func/qrcode.php b/func/qrcode.html similarity index 74% rename from func/qrcode.php rename to func/qrcode.html index 73c6c7c..0308d57 100644 --- a/func/qrcode.php +++ b/func/qrcode.html @@ -1,18 +1,25 @@ - + + + + + + +二维码生成器 + + + + +
+ ← 返回功能区首页 + 二维码生成器 + 首页 +
-layout_head('二维码生成器'); -func_bar('二维码生成器'); -?>
+

🔒 二维码内容仅在本机浏览器内编码,不会上传服务器。组件优先加载本地 assets/js/qrcode.min.js,未找到时自动回退公共 CDN(仅加载代码,不含你的内容)。

@@ -35,15 +42,16 @@ func_bar('二维码生成器');
- - + +