From bf772046cc66e793948b94af019754be018db688 Mon Sep 17 00:00:00 2001 From: MasonLiu <2857911564@qq.com> Date: Sat, 12 Sep 2026 01:36:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9Enavcat=E8=A7=A3=E5=AF=86?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E3=80=81=E4=BC=98=E5=8C=96=E5=90=8E=E5=8F=B0?= =?UTF-8?q?UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GUIDE.md | 27 +- README.md | 3 +- admin/_guard.php | 68 ++- admin/_visits_lib.php | 274 +++++++++++ admin/articles.php | 795 ++++++++++++++++--------------- admin/content.php | 991 ++++++++++++++++++++------------------ admin/exttools.php | 1058 +++++++++++++++++++++-------------------- admin/login.php | 40 +- admin/nav.php | 771 +++++++++++++++--------------- admin/tools.php | 550 ++++++++++----------- admin/visits.php | 625 ++++++++++++------------ admin/visits_data.php | 39 ++ assets/css/common.css | 84 +++- assets/js/common.js | 253 +++++++++- assets/js/navicat.js | 347 ++++++++++++++ func/index.php | 20 +- func/navicat.html | 194 ++++++++ includes/auth.php | 18 +- includes/db.php | 377 +++++++++++---- nav.php | 545 ++++++++++++++------- 20 files changed, 4409 insertions(+), 2670 deletions(-) create mode 100644 admin/_visits_lib.php create mode 100644 admin/visits_data.php create mode 100644 assets/js/navicat.js create mode 100644 func/navicat.html diff --git a/GUIDE.md b/GUIDE.md index a96eefe..0a6119c 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -48,14 +48,14 @@ 入口:站点域名 `/admin`,登录后进入;顶栏依次为: -### 1. 导航内容管理(nav.php) +### 1. 导航管理(nav.php) - 顶部切换当前要编辑的库(科普 / 红队 / 蓝队) - 每个块可改标题、增删链接、拖拽排序;删除后有确认弹窗 - **批量导入**:在导入面板粘贴文本,格式支持 `名称 网址` 或 `名称 | 网址`,自动拆分成导航块;解析后可核对再「保存全部修改」 - 操作结果以右下角 Toast 提示,且保持原滚动位置 -### 2. 功能区管理(tools.php) +### 2. 内部功能(tools.php) - 添加工具:填写名称、图标(emoji)、简介、**入口地址**与**展示库范围**(未勾选=所有库通用) - 站内页面:填 `codec.html` 等相对地址(工具本体放在 `func/`) @@ -73,7 +73,7 @@ - 维护各库推荐的外部站点:名称、网址、简介、分组(栏目)、展示范围(cat_keys) - 保存后库首页「外部工具区」即时更新;同样支持批量导入 -### 5. 内容与顺序(content.php) +### 5. 网站管理(content.php) 集中站点配置: @@ -85,15 +85,26 @@ - **首页顶部组件**:天气(默认)/ 访客信息 / 空白精简 - **修改密码**:建议首次登录即修改默认 `admin/admin123` -### 6. 访问信息(visits.php)—— 日志与安全 +### 6. 风控管理(visits.php)—— 日志与安全 **访问概况**:累计访问 / 独立 IP / 今日访问 / 黑名单规则数。 **访问记录**: - 完整模式逐条明细;精简模式按 IP 聚合(最近访问 / 次数) - 支持 IP 模糊筛选、URL/方法关键词搜索、分页 +- 筛选与翻页为**页面内局部刷新**(由 `visits_data.php` 查询),不会整页跳转、也不会跳回页面顶部 - 日志按行追加写于 `data/vistors.log`,无上限;可一键清空 +**登录信息**: + +- 顶部展示当前用户、本次登录时间 / 登录 IP / 登录设备(操作系统 / 手机型号 / 浏览器 / 屏幕等,能获取到什么就记录什么,由浏览器采集 + 服务端 UA 解析) +- 「历史登录记录」列出最近成功登录(时间 / IP / 设备 / 用户名) + +**登录失败日志**: + +- 按「IP × 日期」聚合:同一 IP 当天的失败次数、最近失败时间与设备信息 +- 支持按 IP 模糊筛选(同样为页面内局部刷新);可一键清空全部登录日志(含成功与失败,存于数据库 `login_log` 表) + **自动风控规则**(阈值到达即永久封禁): | 规则 | 默认 | 建议 | |---|---|---| @@ -114,16 +125,16 @@ ## 三、安全运维 -- **改口令**:登录后台 → 内容与顺序 → 修改密码(至少 6 位) +- **改口令**:登录后台 → 网站管理 → 修改密码(至少 6 位) - **被自动风控误封**:从其它未被封的网络登录后台移除对应规则;或直接编辑数据库 `data/homepage.db` 中 `ip_blacklist` 表删除该行(同表可删除“风控自动”记录) - **备份与升级**:站点数据集中在 `data/`(数据库、访问日志、上传、文章图片)。升级前备份整个 `data/` 目录即可无损迁移 - **恢复出厂**:删除 `data/homepage.db`,刷新后自动重建默认内容 ## 四、常见问题(FAQ) -1. **登录提示“已被风控自动封禁,全站访问被拒绝”**:说明该 IP 登录失败达阈值,已被自动封禁。请从未被封的网络登录后台,在「访问信息 → IP 黑名单」移除即可。 -2. **想让某个 IP 永久无法访问**:后台「访问信息」加入黑名单(单 IP / 网段 / 通配段均可),即刻全站生效。 -3. **工具页图标不是自己的 logo**:后台「内容与顺序」上传过 logo 后,强刷(Ctrl+F5)工具页即可同步;浏览器对旧图标有缓存,可多刷新一次。 +1. **登录提示“已被风控自动封禁,全站访问被拒绝”**:说明该 IP 登录失败达阈值,已被自动封禁。请从未被封的网络登录后台,在「风控管理 → IP 黑名单」移除即可。 +2. **想让某个 IP 永久无法访问**:后台「风控管理」加入黑名单(单 IP / 网段 / 通配段均可),即刻全站生效。 +3. **工具页图标不是自己的 logo**:后台「网站管理」上传过 logo 后,强刷(Ctrl+F5)工具页即可同步;浏览器对旧图标有缓存,可多刷新一次。 4. **工具页想完全离线使用**:把第三方库放入对应位置即可(在线未放时自动回退 CDN): ``` assets/js/qrcode.min.js qrcodejs 1.0.0 diff --git a/README.md b/README.md index 8279039..3a5344b 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ Markdown 写作与三栏阅读(标题栏 / 段落目录 / 正文),分区 ### 访问审计与风控 - 每次访问自动追加写入 `data/vistors.log`(完整保留、可归档) -- **访问信息页**:完整 / 精简(按 IP 聚合)双模式,IP 筛选与关键词搜索、分页 +- **风控管理页**:完整 / 精简(按 IP 聚合)双模式,IP 筛选与关键词搜索、分页(页面内局部刷新,不跳回顶部) +- **登录日志**:成功登录记录(时间 / IP / 设备)与失败日志(按 IP × 日期聚合次数与设备);设备信息含系统 / 手机型号 / 屏幕等,由浏览器采集 - **IP 黑名单**:支持单 IP、CIDR 网段、星号通配;命中后**全站统一拦截**(含后台与登录页) - **自动风控**:登录失败、404 次数、请求频率三条规则,阈值可配、即时启停;自动封禁支持一键 / 批量移除 - 拦截提示文案可自定义;后台操作结果以右下角 Toast 反馈 diff --git a/admin/_guard.php b/admin/_guard.php index 1a19aca..df58cca 100644 --- a/admin/_guard.php +++ b/admin/_guard.php @@ -1,37 +1,65 @@
'; - echo ''; - layout_logo_img('logo', $site); - echo '' . he($site) . ' · 后台'; + $admin = (string) ($_SESSION['admin'] ?? ''); + // 立即执行:标记 body 供侧栏布局使用,并恢复折叠状态(避免首帧闪动) + echo '' . "\n"; + + // 小屏顶部条(仅移动端显示:菜单按钮 + 站名 + 退出) + echo '
'; + echo ''; + echo ''; + layout_logo_img('amb-logo', $site); + echo '' . he($site) . ' · 后台'; + echo '退出'; + echo '
' . "\n"; + + echo '
' . "\n"; + echo ''; + // 本页功能(由 common.js 依据当前页区块自动生成) + echo ''; + // 底部:前台入口 / 退出 / 当前管理员 + echo '
'; + echo '查看前台'; + echo '退出登录'; + echo '
当前用户(' . he($admin) . ')
'; + echo '
'; + echo '' . "\n"; + echo '
' . "\n"; } diff --git a/admin/_visits_lib.php b/admin/_visits_lib.php new file mode 100644 index 0000000..e6ecc80 --- /dev/null +++ b/admin/_visits_lib.php @@ -0,0 +1,274 @@ += $todayStart) { + $today++; + } + } + return [ + 'total' => count($entries), + 'ips' => count($ips), + 'today' => $today, + 'ban' => (int) db()->query('SELECT COUNT(*) FROM ip_blacklist')->fetchColumn(), + ]; +} + +/** 访问记录查询(完整 / 精简 + IP、关键词筛选 + 分页) */ +function vis_query_access(string $mode, string $ipF, string $q, int $page, int $perPage = 30): array +{ + if (!in_array($mode, ['full', 'compact'], true)) { + $mode = 'full'; + } + $entries = visitors_entries(); + $all = []; + 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']++; + } + foreach ($order as $ip) { + $all[] = $grp[$ip]; + } + } else { + // 完整模式:逐条明细 + 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; + } + } + $page = max(1, $page); + $total = count($all); + $totalPages = max(1, (int) ceil($total / $perPage)); + if ($page > $totalPages) { + $page = $totalPages; + } + return [ + 'mode' => $mode, + 'page' => $page, + 'perPage' => $perPage, + 'total' => $total, + 'totalPages' => $totalPages, + 'logs' => array_slice($all, ($page - 1) * $perPage, $perPage), + 'query' => ['mode' => $mode, 'ip' => $ipF, 'q' => $q], + 'hasFilter' => ($ipF !== '' || $q !== ''), + ]; +} + +/** 生成带参数的链接($q 为基础参数,$ov 为覆盖项) */ +function vis_make_url(array $q, array $ov = []): string +{ + foreach ($ov as $k => $v) { + if ($v === null || $v === '') { + unset($q[$k]); + } else { + $q[$k] = $v; + } + } + return $q ? ('visits.php?' . http_build_query($q)) : 'visits.php'; +} + +/** 分页链接的 AJAX 查询串(供 data-vis-page 使用) */ +function vis_page_query(array $q, int $page): string +{ + $q['page'] = $page; + return http_build_query($q); +} + +/** 渲染访问记录片段(统计行 + 表格 + 分页 / 空态) */ +function vis_render_access(array $d): string +{ + $logs = $d['logs']; + $mode = $d['mode']; + $q = $d['query']; + $page = (int) $d['page']; + $totalPages = (int) $d['totalPages']; + ob_start(); + if ($logs) { + ?> +
+ 共命中 ; + 当前第 / 页,每页 条。 +
+
+ + + + + + + + + + + + + + + + + + +
最近访问时间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 ''; + } + ?> + + 下一页 → + +
+ + +
+ +
+ $lf, + 'rows' => login_fail_daily_stats($lf, $limit), + ]; +} + +/** 登录失败日志片段渲染(表格 / 空态) */ +function vis_render_loginfail(array $d): string +{ + $rows = $d['rows']; + ob_start(); + if ($rows) { + ?> +
+ + + + + + + + + + + + + + + + + + + + + +
日期IP 地址失败次数最近失败时间设备信息
+
+ +
+ +
+ prepare('INSERT INTO settings (key, value) VALUES (?, ?) + $st = db()->prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'); - $st->execute([$key, $value]); + $st->execute([$key, $value]); } function renumber_sorts(PDO $pdo): void { - $rows = $pdo->query('SELECT id, part, sort FROM articles ORDER BY part ASC, sort ASC, id ASC')->fetchAll(); - $idxByPart = []; - $st = $pdo->prepare('UPDATE articles SET sort = ? WHERE id = ?'); - foreach ($rows as $r) { - $key = (string)$r['part']; - if (!isset($idxByPart[$key])) { - $idxByPart[$key] = 1; - } - $st->execute([$idxByPart[$key], (int)$r['id']]); - $idxByPart[$key]++; + $rows = $pdo->query('SELECT id, part, sort FROM articles ORDER BY part ASC, sort ASC, id ASC')->fetchAll(); + $idxByPart = []; + $st = $pdo->prepare('UPDATE articles SET sort = ? WHERE id = ?'); + foreach ($rows as $r) { + $key = (string) $r['part']; + if (!isset($idxByPart[$key])) { + $idxByPart[$key] = 1; } + $st->execute([$idxByPart[$key], (int) $r['id']]); + $idxByPart[$key]++; + } } function msg_block(string $kind, string $text): string { - // 改为右下角 Toast:注入 hpToastMsg,由 common.js 在页面加载后自动弹出 - return ''; + // 改为右下角 Toast:注入 hpToastMsg,由 common.js 在页面加载后自动弹出 + return ''; } $msgKind = ''; $msgText = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { - if (!csrf_verify()) { - $msgKind = 'err'; - $msgText = '安全校验失败,请刷新页面重试。'; - } else { - $act = (string)($_POST['act'] ?? ''); - switch ($act) { - case 'note': - settings_set('article_note', trim((string)($_POST['note'] ?? ''))); - touch_last_updated(); - $msgKind = 'ok'; - $msgText = '文章区首页注要已保存。'; - break; - case 'save': - $id = max(0, (int)($_POST['id'] ?? 0)); - $part = trim((string)($_POST['part'] ?? '')); - $title = trim((string)($_POST['title'] ?? '')); - $summary = trim((string)($_POST['summary'] ?? '')); - $md = (string)($_POST['markdown'] ?? ''); - $sort = max(0, (int)($_POST['sort'] ?? 0)); - $pinned = (isset($_POST['pinned']) && $_POST['pinned'] === '1') ? 1 : 0; - $enabled = (isset($_POST['enabled']) && $_POST['enabled'] === '1') ? 1 : 0; - // 分发到库首页(文章区):board_cats 逗号分隔;未勾选=不展示到库页 - $bcKept = []; - foreach ((array)($_POST['board_cats'] ?? []) as $bk) { - $bk = trim((string)$bk); - if (in_array($bk, ['popular', 'red', 'blue'], true) && !in_array($bk, $bcKept, true)) { - $bcKept[] = $bk; - } - } - $boardCats = implode(',', $bcKept); - if ($part === '' || $part === '__new__') { - $part = '未分类'; - } - if ($title === '') { - $msgKind = 'err'; - $msgText = '文章标题不能为空。'; - } elseif ($id > 0) { - $st = $pdo->prepare('UPDATE articles SET part=?, title=?, summary=?, markdown=?, pinned=?, enabled=?, sort=?, board_cats=?, updated_at=? WHERE id=?'); - $st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, date('Y-m-d H:i:s'), $id]); - $msgKind = 'ok'; - $msgText = '文章已保存。'; - } else { - $st = $pdo->prepare('INSERT INTO articles (part, title, summary, markdown, pinned, enabled, sort, board_cats, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)'); - $now = date('Y-m-d H:i:s'); - $st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, $now, $now]); - $id = (int)$pdo->lastInsertId(); - $msgKind = 'ok'; - $msgText = '文章已发布(可继续编辑)。'; - } - if ($msgKind === 'ok') { - renumber_sorts($pdo); - touch_last_updated(); - header('Location: articles.php?edit=' . $id . '&ok=1'); - exit; - } - break; - case 'del': - $id = (int)($_POST['id'] ?? 0); - if ($id > 0) { - $pdo->prepare('DELETE FROM articles WHERE id = ?')->execute([$id]); - renumber_sorts($pdo); - touch_last_updated(); - $msgKind = 'ok'; - $msgText = '文章已删除。'; - } - break; - case 'toggle': - $id = (int)($_POST['id'] ?? 0); - $field = (string)($_POST['field'] ?? ''); - if ($id > 0 && ($field === 'pinned' || $field === 'enabled')) { - $cur = (int)$pdo->query('SELECT ' . $field . ' FROM articles WHERE id = ' . $id)->fetchColumn(); - $pdo->prepare('UPDATE articles SET ' . $field . ' = ? WHERE id = ?')->execute([$cur ? 0 : 1, $id]); - $msgKind = 'ok'; - $msgText = $field === 'pinned' ? '置顶状态已更新。' : '启停状态已更新。'; - } - break; - case 'move': - $id = (int)($_POST['id'] ?? 0); - $dir = ((string)($_POST['dir'] ?? '') === 'down') ? 'down' : 'up'; - $rows = $pdo->query('SELECT id, part, sort FROM articles ORDER BY part ASC, sort ASC, id ASC')->fetchAll(); - $idx = -1; - foreach ($rows as $i => $r) { - if ((int)$r['id'] === $id) { - $idx = $i; - break; - } - } - if ($idx >= 0) { - $target = ($dir === 'down') ? $idx + 1 : $idx - 1; - if ($target >= 0 && $target < count($rows) && $rows[$target]['part'] === $rows[$idx]['part']) { - $tmp = $rows[$idx]; - $rows[$idx] = $rows[$target]; - $rows[$target] = $tmp; - renumber_sorts($pdo); - $msgKind = 'ok'; - $msgText = '顺序已调整。'; - } - } - break; - case 'part_order': - // 分区(分类)拖动排序后持久化到设置:CSV 顺序 - header('Content-Type: application/json; charset=utf-8'); - $parts = []; - foreach (explode(',', (string)($_POST['order'] ?? '')) as $po) { - $po = trim((string)$po); - if ($po !== '' && !in_array($po, $parts, true)) { $parts[] = $po; } - } - settings_set('article_part_order', implode(',', $parts)); - touch_last_updated(); - echo json_encode(['ok' => true], JSON_UNESCAPED_UNICODE); - exit; - case 'upload': - // 图片上传(供编辑器 XHR 使用,返回 JSON) - header('Content-Type: application/json; charset=utf-8'); - $up = $_FILES['file'] ?? null; - if (!$up || ($up['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { - echo json_encode(['ok' => false, 'msg' => '未收到文件'], JSON_UNESCAPED_UNICODE); - exit; - } - if (($up['size'] ?? 0) > 2 * 1024 * 1024) { - echo json_encode(['ok' => false, 'msg' => '图片不能超过 2MB'], JSON_UNESCAPED_UNICODE); - exit; - } - $extMap = ['png' => 'png', 'jpg' => 'jpg', 'jpeg' => 'jpg', 'gif' => 'gif', 'webp' => 'webp', 'svg' => 'svg']; - $ext = strtolower(pathinfo((string)$up['name'], PATHINFO_EXTENSION)); - if (!isset($extMap[$ext])) { - echo json_encode(['ok' => false, 'msg' => '仅支持 png / jpg / gif / webp / svg'], JSON_UNESCAPED_UNICODE); - exit; - } - $dir = DATA_DIR . '/articles/img'; - if (!is_dir($dir)) { - @mkdir($dir, 0777, true); - } - $filename = 'art_' . date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . $extMap[$ext]; - if (!@move_uploaded_file($up['tmp_name'], $dir . '/' . $filename)) { - echo json_encode(['ok' => false, 'msg' => '保存失败,请检查 data/articles/img 目录权限'], JSON_UNESCAPED_UNICODE); - exit; - } - echo json_encode(['ok' => true, 'file' => 'data/articles/img/' . $filename], JSON_UNESCAPED_UNICODE); - exit; - default: - $msgKind = 'err'; - $msgText = '未知操作。'; + if (!csrf_verify()) { + $msgKind = 'err'; + $msgText = '安全校验失败,请刷新页面重试。'; + } else { + $act = (string) ($_POST['act'] ?? ''); + switch ($act) { + case 'note': + settings_set('article_note', trim((string) ($_POST['note'] ?? ''))); + touch_last_updated(); + $msgKind = 'ok'; + $msgText = '文章区首页注要已保存。'; + break; + case 'save': + $id = max(0, (int) ($_POST['id'] ?? 0)); + $part = trim((string) ($_POST['part'] ?? '')); + $title = trim((string) ($_POST['title'] ?? '')); + $summary = trim((string) ($_POST['summary'] ?? '')); + $md = (string) ($_POST['markdown'] ?? ''); + $sort = max(0, (int) ($_POST['sort'] ?? 0)); + $pinned = (isset($_POST['pinned']) && $_POST['pinned'] === '1') ? 1 : 0; + $enabled = (isset($_POST['enabled']) && $_POST['enabled'] === '1') ? 1 : 0; + // 分发到库首页(文章区):board_cats 逗号分隔;未勾选=不展示到库页 + $bcKept = []; + foreach ((array) ($_POST['board_cats'] ?? []) as $bk) { + $bk = trim((string) $bk); + if (in_array($bk, ['popular', 'red', 'blue'], true) && !in_array($bk, $bcKept, true)) { + $bcKept[] = $bk; + } } + $boardCats = implode(',', $bcKept); + if ($part === '' || $part === '__new__') { + $part = '未分类'; + } + if ($title === '') { + $msgKind = 'err'; + $msgText = '文章标题不能为空。'; + } elseif ($id > 0) { + $st = $pdo->prepare('UPDATE articles SET part=?, title=?, summary=?, markdown=?, pinned=?, enabled=?, sort=?, board_cats=?, updated_at=? WHERE id=?'); + $st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, date('Y-m-d H:i:s'), $id]); + $msgKind = 'ok'; + $msgText = '文章已保存。'; + } else { + $st = $pdo->prepare('INSERT INTO articles (part, title, summary, markdown, pinned, enabled, sort, board_cats, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)'); + $now = date('Y-m-d H:i:s'); + $st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, $now, $now]); + $id = (int) $pdo->lastInsertId(); + $msgKind = 'ok'; + $msgText = '文章已发布(可继续编辑)。'; + } + if ($msgKind === 'ok') { + renumber_sorts($pdo); + touch_last_updated(); + header('Location: articles.php?edit=' . $id . '&ok=1'); + exit; + } + break; + case 'del': + $id = (int) ($_POST['id'] ?? 0); + if ($id > 0) { + $pdo->prepare('DELETE FROM articles WHERE id = ?')->execute([$id]); + renumber_sorts($pdo); + touch_last_updated(); + $msgKind = 'ok'; + $msgText = '文章已删除。'; + } + break; + case 'toggle': + $id = (int) ($_POST['id'] ?? 0); + $field = (string) ($_POST['field'] ?? ''); + if ($id > 0 && ($field === 'pinned' || $field === 'enabled')) { + $cur = (int) $pdo->query('SELECT ' . $field . ' FROM articles WHERE id = ' . $id)->fetchColumn(); + $pdo->prepare('UPDATE articles SET ' . $field . ' = ? WHERE id = ?')->execute([$cur ? 0 : 1, $id]); + $msgKind = 'ok'; + $msgText = $field === 'pinned' ? '置顶状态已更新。' : '启停状态已更新。'; + } + break; + case 'move': + $id = (int) ($_POST['id'] ?? 0); + $dir = ((string) ($_POST['dir'] ?? '') === 'down') ? 'down' : 'up'; + $rows = $pdo->query('SELECT id, part, sort FROM articles ORDER BY part ASC, sort ASC, id ASC')->fetchAll(); + $idx = -1; + foreach ($rows as $i => $r) { + if ((int) $r['id'] === $id) { + $idx = $i; + break; + } + } + if ($idx >= 0) { + $target = ($dir === 'down') ? $idx + 1 : $idx - 1; + if ($target >= 0 && $target < count($rows) && $rows[$target]['part'] === $rows[$idx]['part']) { + $tmp = $rows[$idx]; + $rows[$idx] = $rows[$target]; + $rows[$target] = $tmp; + renumber_sorts($pdo); + $msgKind = 'ok'; + $msgText = '顺序已调整。'; + } + } + break; + case 'part_order': + // 分区(分类)拖动排序后持久化到设置:CSV 顺序 + header('Content-Type: application/json; charset=utf-8'); + $parts = []; + foreach (explode(',', (string) ($_POST['order'] ?? '')) as $po) { + $po = trim((string) $po); + if ($po !== '' && !in_array($po, $parts, true)) { + $parts[] = $po; + } + } + settings_set('article_part_order', implode(',', $parts)); + touch_last_updated(); + echo json_encode(['ok' => true], JSON_UNESCAPED_UNICODE); + exit; + case 'upload': + // 图片上传(供编辑器 XHR 使用,返回 JSON) + header('Content-Type: application/json; charset=utf-8'); + $up = $_FILES['file'] ?? null; + if (!$up || ($up['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + echo json_encode(['ok' => false, 'msg' => '未收到文件'], JSON_UNESCAPED_UNICODE); + exit; + } + if (($up['size'] ?? 0) > 2 * 1024 * 1024) { + echo json_encode(['ok' => false, 'msg' => '图片不能超过 2MB'], JSON_UNESCAPED_UNICODE); + exit; + } + $extMap = ['png' => 'png', 'jpg' => 'jpg', 'jpeg' => 'jpg', 'gif' => 'gif', 'webp' => 'webp', 'svg' => 'svg']; + $ext = strtolower(pathinfo((string) $up['name'], PATHINFO_EXTENSION)); + if (!isset($extMap[$ext])) { + echo json_encode(['ok' => false, 'msg' => '仅支持 png / jpg / gif / webp / svg'], JSON_UNESCAPED_UNICODE); + exit; + } + $dir = DATA_DIR . '/articles/img'; + if (!is_dir($dir)) { + @mkdir($dir, 0777, true); + } + $filename = 'art_' . date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . $extMap[$ext]; + if (!@move_uploaded_file($up['tmp_name'], $dir . '/' . $filename)) { + echo json_encode(['ok' => false, 'msg' => '保存失败,请检查 data/articles/img 目录权限'], JSON_UNESCAPED_UNICODE); + exit; + } + echo json_encode(['ok' => true, 'file' => 'data/articles/img/' . $filename], JSON_UNESCAPED_UNICODE); + exit; + default: + $msgKind = 'err'; + $msgText = '未知操作。'; } + } } $savedFlag = isset($_GET['ok']); @@ -193,39 +195,48 @@ $list = $pdo->query('SELECT * FROM articles ORDER BY part ASC, sort ASC, id ASC' // —— 文章按分区(分类)分组;分区顺序:后台拖动保存的 article_part_order 优先,其余按各组最新更新时间降序 —— $artGroups = []; foreach ($list as $a) { - $p = trim((string)$a['part']); - if ($p === '') { $p = '未分类'; } - if (!isset($artGroups[$p])) { $artGroups[$p] = ['ts' => (string)$a['updated_at'], 'rows' => []]; } - $artGroups[$p]['rows'][] = $a; - if (strcmp((string)$a['updated_at'], $artGroups[$p]['ts']) > 0) { $artGroups[$p]['ts'] = (string)$a['updated_at']; } + $p = trim((string) $a['part']); + if ($p === '') { + $p = '未分类'; + } + if (!isset($artGroups[$p])) { + $artGroups[$p] = ['ts' => (string) $a['updated_at'], 'rows' => []]; + } + $artGroups[$p]['rows'][] = $a; + if (strcmp((string) $a['updated_at'], $artGroups[$p]['ts']) > 0) { + $artGroups[$p]['ts'] = (string) $a['updated_at']; + } } $poIdx = []; -$poCfg = trim((string)setting_get('article_part_order', '')); +$poCfg = trim((string) setting_get('article_part_order', '')); if ($poCfg !== '') { - foreach (explode(',', $poCfg) as $po) { - $po = trim($po); - if ($po !== '' && isset($artGroups[$po])) { $poIdx[$po] = count($poIdx); } + foreach (explode(',', $poCfg) as $po) { + $po = trim($po); + if ($po !== '' && isset($artGroups[$po])) { + $poIdx[$po] = count($poIdx); } + } } uksort($artGroups, static function ($pa, $pb) use ($poIdx, $artGroups) { - $ia = $poIdx[$pa] ?? PHP_INT_MAX; - $ib = $poIdx[$pb] ?? PHP_INT_MAX; - if ($ia !== $ib) return $ia <=> $ib; - $d = strcmp($artGroups[$pb]['ts'], $artGroups[$pa]['ts']); - return $d !== 0 ? $d : strcmp($pa, $pb); + $ia = $poIdx[$pa] ?? PHP_INT_MAX; + $ib = $poIdx[$pb] ?? PHP_INT_MAX; + if ($ia !== $ib) + return $ia <=> $ib; + $d = strcmp($artGroups[$pb]['ts'], $artGroups[$pa]['ts']); + return $d !== 0 ? $d : strcmp($pa, $pb); }); $noteText = setting_get('article_note', ''); $editing = false; $row = null; -$editId = (int)($_GET['edit'] ?? 0); +$editId = (int) ($_GET['edit'] ?? 0); if ($editId > 0) { - $st = $pdo->prepare('SELECT * FROM articles WHERE id = ?'); - $st->execute([$editId]); - $row = $st->fetch(); - if ($row) { - $editing = true; - } + $st = $pdo->prepare('SELECT * FROM articles WHERE id = ?'); + $st->execute([$editId]); + $row = $st->fetch(); + if ($row) { + $editing = true; + } } layout_head('文章管理'); @@ -239,39 +250,53 @@ admin_topbar('articles'); - + (int)$row['id'], 'part' => (string)$row['part'], 'title' => (string)$row['title'], - 'summary' => (string)$row['summary'], 'md' => (string)$row['markdown'], - 'sort' => (int)$row['sort'], 'pinned' => (int)$row['pinned'], 'enabled' => (int)$row['enabled'], - 'board_cats' => trim((string)($row['board_cats'] ?? '')), + 'id' => (int) $row['id'], + 'part' => (string) $row['part'], + 'title' => (string) $row['title'], + 'summary' => (string) $row['summary'], + 'md' => (string) $row['markdown'], + 'sort' => (int) $row['sort'], + 'pinned' => (int) $row['pinned'], + 'enabled' => (int) $row['enabled'], + 'board_cats' => trim((string) ($row['board_cats'] ?? '')), ] : ['id' => 0, 'part' => '未分类', 'title' => '', 'summary' => '', 'md' => '', 'sort' => 0, 'pinned' => 0, 'enabled' => 1, 'board_cats' => '']; // 分区下拉选项:未分类 + 全部已有分区;编辑中的自定义分区自动补充,保证能回显选中 - $curPart = trim((string)$cur['part']); - if ($curPart === '') { $curPart = '未分类'; } + $curPart = trim((string) $cur['part']); + if ($curPart === '') { + $curPart = '未分类'; + } $partOptions = ['未分类']; foreach ($list as $a2) { - $p2 = trim((string)$a2['part']); - if ($p2 === '' || $p2 === '未分类') continue; - if (!in_array($p2, $partOptions, true)) { $partOptions[] = $p2; } + $p2 = trim((string) $a2['part']); + if ($p2 === '' || $p2 === '未分类') + continue; + if (!in_array($p2, $partOptions, true)) { + $partOptions[] = $p2; + } + } + if (!in_array($curPart, $partOptions, true)) { + array_unshift($partOptions, $curPart); } - if (!in_array($curPart, $partOptions, true)) { array_unshift($partOptions, $curPart); } ?>
- +
@@ -284,26 +309,28 @@ admin_topbar('articles');
- +
- - + +
- +
- - - + + +
- +
- + 上传后自动在正文末尾插入 ![说明](data/articles/img/文件.png) 形式引用;引用路径相对站点根目录,渲染时自动补全前缀。
@@ -318,7 +345,7 @@ admin_topbar('articles');
-
文章区首页注要(顶部展示给读者的说明文字)
+
顶部说明管理
@@ -329,7 +356,7 @@ admin_topbar('articles');
- 文章列表(共 篇,按分类分组) + 文章列表 + 新增文章 @@ -354,43 +381,58 @@ admin_topbar('articles');
- - - + - - - - + + + + - + + + + + + + + + +
标题更新时间状态操作
- -
-
- 置顶 - 可见隐藏 - - - - - -
- - -
-
- - -
-
- - -
-
- - -
-
标题更新时间状态操作
+ + +
+
+ 置顶 + 可见隐藏 + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
@@ -407,43 +449,43 @@ admin_topbar('articles'); - + + \ No newline at end of file diff --git a/admin/content.php b/admin/content.php index 6efb9ea..1abd629 100644 --- a/admin/content.php +++ b/admin/content.php @@ -1,6 +1,6 @@ prepare('INSERT INTO settings (key, value) VALUES (?, ?) + $st = db()->prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'); - $st->execute([$key, $value]); + $st->execute([$key, $value]); } /** 删除当前已上传的自定义 logo 文件(仅限 data/logo 目录内) */ function remove_old_logo(): void { - $old = setting_get('site_logo', ''); - if ($old !== '' && strpos($old, 'data/logo/') === 0) { - $file = DATA_DIR . '/logo/' . basename($old); - if (is_file($file)) { - @unlink($file); - } + $old = setting_get('site_logo', ''); + if ($old !== '' && strpos($old, 'data/logo/') === 0) { + $file = DATA_DIR . '/logo/' . basename($old); + if (is_file($file)) { + @unlink($file); } + } } function alert_html(string $kind, string $msg): void { - // 改为右下角 Toast:注入 hpToastMsg,由 common.js 在页面加载后自动弹出 - echo ''; + // 改为右下角 Toast:注入 hpToastMsg,由 common.js 在页面加载后自动弹出 + echo ''; } $msgKind = ''; $msgText = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { - if (!csrf_verify()) { - $msgKind = 'err'; - $msgText = '安全校验失败,请刷新页面重试。'; - } else { - $act = (string)($_POST['act'] ?? ''); - switch ($act) { - case 'site': - settings_set('site_name', trim((string)($_POST['site_name'] ?? '知识导航站'))); - settings_set('site_slogan', trim((string)($_POST['site_slogan'] ?? ''))); - touch_last_updated(); - $msgKind = 'ok'; - $msgText = '站点信息已保存。'; - break; - case 'tips': - header('Content-Type: application/json; charset=utf-8'); - settings_set('show_st_tips', (isset($_POST['show_tips']) && (string)$_POST['show_tips'] === '1') ? '1' : '0'); - touch_last_updated(); - echo json_encode(['ok' => true, 'msg' => '站长提示开关已保存。'], JSON_UNESCAPED_UNICODE); - exit; - case 'boards': - $names = (array)($_POST['b_name'] ?? []); - $descs = (array)($_POST['b_desc'] ?? []); - $icons = (array)($_POST['b_icon'] ?? []); - $sorts = (array)($_POST['b_sort'] ?? []); - $stUpd = $pdo->prepare('UPDATE categories SET name = ?, description = ?, icon = ?, sort = ? WHERE id = ?'); - foreach (array_keys($names) as $i) { - $name = trim((string)($names[$i] ?? '')); - $desc = trim((string)($descs[$i] ?? '')); - $icon = trim((string)($icons[$i] ?? '')); - $sort = max(0, (int)($sorts[$i] ?? 0)); - $id = (int)$i; - $key = $pdo->query('SELECT key FROM categories WHERE id = ' . $id)->fetchColumn(); - if ($name === '' || !$key || !in_array((string)$key, $catKeys, true)) continue; - $stUpd->execute([$name, $desc, $icon, $sort, $id]); - } - touch_last_updated(); - $msgKind = 'ok'; - $msgText = '板块名称 / 说明 / 图标 / 顺序已保存。'; - break; - case 'footer': - settings_set('icp_no', trim((string)($_POST['icp_no'] ?? ''))); - settings_set('gongan_no', trim((string)($_POST['gongan_no'] ?? ''))); - settings_set('gongan_link', trim((string)($_POST['gongan_link'] ?? ''))); - settings_set('copyright', trim((string)($_POST['copyright'] ?? ''))); - settings_set('footer_text', trim((string)($_POST['footer_text'] ?? ''))); - touch_last_updated(); - $msgKind = 'ok'; - $msgText = '底部信息已保存。'; - break; - case 'quotes': - $content = (string)($_POST['quotes'] ?? ''); - $file = DATA_DIR . '/quota.txt'; - if (!is_dir(DATA_DIR)) @mkdir(DATA_DIR, 0777, true); - $savedBytes = @file_put_contents($file, $content); - if ($savedBytes === false) { - $msgKind = 'err'; - $msgText = '名言文件写入失败,请检查 data 目录权限。'; - } else { - touch_last_updated(); - $msgKind = 'ok'; - $msgText = '名言库已保存。'; - } - break; - case 'logo': - $up = $_FILES['logo_file'] ?? null; - if (!$up || ($up['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { - $msgKind = 'err'; - $msgText = '未收到有效图片,请重新选择。'; - break; - } - if (($up['size'] ?? 0) > 2 * 1024 * 1024) { - $msgKind = 'err'; - $msgText = '图片不能超过 2MB。'; - break; - } - $extMap = [ - 'png' => 'png', 'jpg' => 'jpg', 'jpeg' => 'jpg', - 'gif' => 'gif', 'webp' => 'webp', 'svg' => 'svg', 'ico' => 'ico', - ]; - $ext = strtolower(pathinfo((string)$up['name'], PATHINFO_EXTENSION)); - if (!isset($extMap[$ext])) { - $msgKind = 'err'; - $msgText = '仅支持 png / jpg / gif / webp / svg / ico 图片。'; - break; - } - $logoDir = DATA_DIR . '/logo'; - if (!is_dir($logoDir)) { - @mkdir($logoDir, 0777, true); - } - $filename = 'site_logo_' . date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . $extMap[$ext]; - if (!@move_uploaded_file($up['tmp_name'], $logoDir . '/' . $filename)) { - $msgKind = 'err'; - $msgText = '文件保存失败,请检查 data 目录写入权限。'; - break; - } - remove_old_logo(); - settings_set('site_logo', 'data/logo/' . $filename); - touch_last_updated(); - $msgKind = 'ok'; - $msgText = 'LOGO 已更新并应用到全站。'; - break; - case 'logo_clear': - remove_old_logo(); - settings_set('site_logo', ''); - touch_last_updated(); - $msgKind = 'ok'; - $msgText = '已恢复默认 LOGO。'; - break; - case 'hero': - settings_set('hero_bg', trim((string)($_POST['hero_bg'] ?? ''))); - touch_last_updated(); - $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(); - $msgKind = 'ok'; - $msgText = 'CDN IP 段库已保存。'; - break; - case 'engines': - $raw = (string)($_POST['data'] ?? ''); - $rows = json_decode($raw, true); - if (!is_array($rows)) { - $msgKind = 'err'; - $msgText = '引擎数据格式错误。'; - break; - } - $list = []; - foreach ($rows as $e) { - if (!is_array($e)) continue; - $name = trim((string)($e['name'] ?? '')); - $url = trim((string)($e['url'] ?? '')); - $icon = trim((string)($e['icon'] ?? '')); - if ($name === '' || $url === '' || strpos($url, '{kw}') === false) continue; - $list[] = [ - 'key' => 'e' . (count($list) + 1), - 'name' => $name, - 'icon' => ($icon !== '' ? $icon : 'S'), - 'url' => $url, - ]; - } - if (!$list) { - $msgKind = 'err'; - $msgText = '至少需要保留一个有效搜索引擎(名称与含 {kw} 的地址必填)。'; - break; - } - settings_set('search_engines', json_encode($list, JSON_UNESCAPED_UNICODE)); - touch_last_updated(); - $msgKind = 'ok'; - $msgText = '已保存 ' . count($list) . ' 个搜索引擎。'; - break; - case 'engines_reset': - settings_set('search_engines', ''); - touch_last_updated(); - $msgKind = 'ok'; - $msgText = '已恢复默认搜索引擎(Bing / 百度 / GitHub / Google)。'; - break; - case 'quicklinks': - $raw = (string)($_POST['data'] ?? ''); - $rows = json_decode($raw, true); - if (!is_array($rows)) { - $msgKind = 'err'; - $msgText = '快捷站点数据格式错误。'; - break; - } - $clean = []; - foreach ($rows as $i => $r) { - if (!is_array($r)) continue; - $name = trim((string)($r['name'] ?? '')); - $url = trim((string)($r['url'] ?? '')); - $icon = trim((string)($r['icon'] ?? '')); - $note = trim((string)($r['note'] ?? '')); - if ($name === '' && $url === '') continue; - if ($name === '' || $url === '') { - $msgKind = 'err'; - $msgText = '快捷站点第 ' . ((int)$i + 1) . ' 行:名称与地址均不能为空。'; - break; - } - if (!preg_match('#^https?://#i', $url)) { - $msgKind = 'err'; - $msgText = '快捷站点第 ' . ((int)$i + 1) . ' 行:地址需为完整 http(s) 链接。'; - break; - } - $clean[] = [ - 'id' => (int)($r['id'] ?? 0), - 'icon' => ($icon !== '' ? $icon : '🔗'), - 'name' => $name, - 'url' => $url, - 'note' => $note, - 'enabled' => (((string)($r['enabled'] ?? '1') === '1')) ? 1 : 0, - ]; - } - if ($msgKind === '' && !$clean) { - $msgKind = 'err'; - $msgText = '至少需要保留一个快捷站点。'; - } - if ($msgKind === '') { - try { - $pdo->beginTransaction(); - $oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM quick_links')->fetchAll(), 'id')); - $keep = []; - foreach ($clean as $c) { - if ($c['id'] > 0) $keep[] = $c['id']; - } - $del = array_values(array_diff($oldIds, $keep)); - foreach ($del as $did) { - $pdo->prepare('DELETE FROM quick_links WHERE id = ?')->execute([$did]); - } - $stUpd = $pdo->prepare('UPDATE quick_links SET name=?, url=?, icon=?, note=?, enabled=?, sort=? WHERE id=?'); - $stIns = $pdo->prepare('INSERT INTO quick_links (name, url, icon, note, enabled, sort) VALUES (?,?,?,?,?,?)'); - $sort = 0; - foreach ($clean as $c) { - $sort++; - if ($c['id'] > 0 && in_array($c['id'], $oldIds, true)) { - $stUpd->execute([$c['name'], $c['url'], $c['icon'], $c['note'], $c['enabled'], $sort, $c['id']]); - } else { - $stIns->execute([$c['name'], $c['url'], $c['icon'], $c['note'], $c['enabled'], $sort]); - } - } - $pdo->commit(); - touch_last_updated(); - $msgKind = 'ok'; - $msgText = '快捷站点已保存。'; - } catch (Throwable $e) { - if ($pdo->inTransaction()) { - $pdo->rollBack(); - } - $msgKind = 'err'; - $msgText = '保存失败:' . $e->getMessage(); - } - } - break; - case 'qtoggle': - header('Content-Type: application/json; charset=utf-8'); - $qlId = (int)($_POST['id'] ?? 0); - $qlEn = ((string)($_POST['enabled'] ?? '') === '1') ? 1 : 0; - if ($qlId > 0) { - $pdo->prepare('UPDATE quick_links SET enabled = ? WHERE id = ?')->execute([$qlEn, $qlId]); - touch_last_updated(); - echo json_encode(['ok' => true]); - } else { - echo json_encode(['ok' => false, 'msg' => '无效的记录 ID。'], JSON_UNESCAPED_UNICODE); - } - exit; - case 'pwd': - $np = (string)($_POST['new_pwd'] ?? ''); - $np2 = (string)($_POST['new_pwd2'] ?? ''); - if ($np !== $np2) { - $msgKind = 'err'; - $msgText = '两次输入的新密码不一致。'; - } else { - [$ok, $text] = hp_change_password((string)($_POST['old_pwd'] ?? ''), $np); - $msgKind = $ok ? 'ok' : 'err'; - $msgText = $text; - } - break; - default: - $msgKind = 'err'; - $msgText = '未知操作。'; + if (!csrf_verify()) { + $msgKind = 'err'; + $msgText = '安全校验失败,请刷新页面重试。'; + } else { + $act = (string) ($_POST['act'] ?? ''); + switch ($act) { + case 'site': + settings_set('site_name', trim((string) ($_POST['site_name'] ?? '知识导航站'))); + settings_set('site_slogan', trim((string) ($_POST['site_slogan'] ?? ''))); + touch_last_updated(); + $msgKind = 'ok'; + $msgText = '站点信息已保存。'; + break; + case 'tips': + header('Content-Type: application/json; charset=utf-8'); + settings_set('show_st_tips', (isset($_POST['show_tips']) && (string) $_POST['show_tips'] === '1') ? '1' : '0'); + touch_last_updated(); + echo json_encode(['ok' => true, 'msg' => '站长提示开关已保存。'], JSON_UNESCAPED_UNICODE); + exit; + case 'boards': + $names = (array) ($_POST['b_name'] ?? []); + $descs = (array) ($_POST['b_desc'] ?? []); + $icons = (array) ($_POST['b_icon'] ?? []); + $sorts = (array) ($_POST['b_sort'] ?? []); + $stUpd = $pdo->prepare('UPDATE categories SET name = ?, description = ?, icon = ?, sort = ? WHERE id = ?'); + foreach (array_keys($names) as $i) { + $name = trim((string) ($names[$i] ?? '')); + $desc = trim((string) ($descs[$i] ?? '')); + $icon = trim((string) ($icons[$i] ?? '')); + $sort = max(0, (int) ($sorts[$i] ?? 0)); + $id = (int) $i; + $key = $pdo->query('SELECT key FROM categories WHERE id = ' . $id)->fetchColumn(); + if ($name === '' || !$key || !in_array((string) $key, $catKeys, true)) + continue; + $stUpd->execute([$name, $desc, $icon, $sort, $id]); } + touch_last_updated(); + $msgKind = 'ok'; + $msgText = '板块名称 / 说明 / 图标 / 顺序已保存。'; + break; + case 'footer': + settings_set('icp_no', trim((string) ($_POST['icp_no'] ?? ''))); + settings_set('gongan_no', trim((string) ($_POST['gongan_no'] ?? ''))); + settings_set('gongan_link', trim((string) ($_POST['gongan_link'] ?? ''))); + settings_set('copyright', trim((string) ($_POST['copyright'] ?? ''))); + settings_set('footer_text', trim((string) ($_POST['footer_text'] ?? ''))); + touch_last_updated(); + $msgKind = 'ok'; + $msgText = '底部信息已保存。'; + break; + case 'quotes': + $content = (string) ($_POST['quotes'] ?? ''); + $file = DATA_DIR . '/quota.txt'; + if (!is_dir(DATA_DIR)) + @mkdir(DATA_DIR, 0777, true); + $savedBytes = @file_put_contents($file, $content); + if ($savedBytes === false) { + $msgKind = 'err'; + $msgText = '名言文件写入失败,请检查 data 目录权限。'; + } else { + touch_last_updated(); + $msgKind = 'ok'; + $msgText = '名言库已保存。'; + } + break; + case 'logo': + $up = $_FILES['logo_file'] ?? null; + if (!$up || ($up['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + $msgKind = 'err'; + $msgText = '未收到有效图片,请重新选择。'; + break; + } + if (($up['size'] ?? 0) > 2 * 1024 * 1024) { + $msgKind = 'err'; + $msgText = '图片不能超过 2MB。'; + break; + } + $extMap = [ + 'png' => 'png', + 'jpg' => 'jpg', + 'jpeg' => 'jpg', + 'gif' => 'gif', + 'webp' => 'webp', + 'svg' => 'svg', + 'ico' => 'ico', + ]; + $ext = strtolower(pathinfo((string) $up['name'], PATHINFO_EXTENSION)); + if (!isset($extMap[$ext])) { + $msgKind = 'err'; + $msgText = '仅支持 png / jpg / gif / webp / svg / ico 图片。'; + break; + } + $logoDir = DATA_DIR . '/logo'; + if (!is_dir($logoDir)) { + @mkdir($logoDir, 0777, true); + } + $filename = 'site_logo_' . date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . $extMap[$ext]; + if (!@move_uploaded_file($up['tmp_name'], $logoDir . '/' . $filename)) { + $msgKind = 'err'; + $msgText = '文件保存失败,请检查 data 目录写入权限。'; + break; + } + remove_old_logo(); + settings_set('site_logo', 'data/logo/' . $filename); + touch_last_updated(); + $msgKind = 'ok'; + $msgText = 'LOGO 已更新并应用到全站。'; + break; + case 'logo_clear': + remove_old_logo(); + settings_set('site_logo', ''); + touch_last_updated(); + $msgKind = 'ok'; + $msgText = '已恢复默认 LOGO。'; + break; + case 'hero': + settings_set('hero_bg', trim((string) ($_POST['hero_bg'] ?? ''))); + touch_last_updated(); + $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(); + $msgKind = 'ok'; + $msgText = 'CDN IP 段库已保存。'; + break; + case 'engines': + $raw = (string) ($_POST['data'] ?? ''); + $rows = json_decode($raw, true); + if (!is_array($rows)) { + $msgKind = 'err'; + $msgText = '引擎数据格式错误。'; + break; + } + $list = []; + foreach ($rows as $e) { + if (!is_array($e)) + continue; + $name = trim((string) ($e['name'] ?? '')); + $url = trim((string) ($e['url'] ?? '')); + $icon = trim((string) ($e['icon'] ?? '')); + if ($name === '' || $url === '' || strpos($url, '{kw}') === false) + continue; + $list[] = [ + 'key' => 'e' . (count($list) + 1), + 'name' => $name, + 'icon' => ($icon !== '' ? $icon : 'S'), + 'url' => $url, + ]; + } + if (!$list) { + $msgKind = 'err'; + $msgText = '至少需要保留一个有效搜索引擎(名称与含 {kw} 的地址必填)。'; + break; + } + settings_set('search_engines', json_encode($list, JSON_UNESCAPED_UNICODE)); + touch_last_updated(); + $msgKind = 'ok'; + $msgText = '已保存 ' . count($list) . ' 个搜索引擎。'; + break; + case 'engines_reset': + settings_set('search_engines', ''); + touch_last_updated(); + $msgKind = 'ok'; + $msgText = '已恢复默认搜索引擎(Bing / 百度 / GitHub / Google)。'; + break; + case 'quicklinks': + $raw = (string) ($_POST['data'] ?? ''); + $rows = json_decode($raw, true); + if (!is_array($rows)) { + $msgKind = 'err'; + $msgText = '快捷站点数据格式错误。'; + break; + } + $clean = []; + foreach ($rows as $i => $r) { + if (!is_array($r)) + continue; + $name = trim((string) ($r['name'] ?? '')); + $url = trim((string) ($r['url'] ?? '')); + $icon = trim((string) ($r['icon'] ?? '')); + $note = trim((string) ($r['note'] ?? '')); + if ($name === '' && $url === '') + continue; + if ($name === '' || $url === '') { + $msgKind = 'err'; + $msgText = '快捷站点第 ' . ((int) $i + 1) . ' 行:名称与地址均不能为空。'; + break; + } + if (!preg_match('#^https?://#i', $url)) { + $msgKind = 'err'; + $msgText = '快捷站点第 ' . ((int) $i + 1) . ' 行:地址需为完整 http(s) 链接。'; + break; + } + $clean[] = [ + 'id' => (int) ($r['id'] ?? 0), + 'icon' => ($icon !== '' ? $icon : '🔗'), + 'name' => $name, + 'url' => $url, + 'note' => $note, + 'enabled' => (((string) ($r['enabled'] ?? '1') === '1')) ? 1 : 0, + ]; + } + if ($msgKind === '' && !$clean) { + $msgKind = 'err'; + $msgText = '至少需要保留一个快捷站点。'; + } + if ($msgKind === '') { + try { + $pdo->beginTransaction(); + $oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM quick_links')->fetchAll(), 'id')); + $keep = []; + foreach ($clean as $c) { + if ($c['id'] > 0) + $keep[] = $c['id']; + } + $del = array_values(array_diff($oldIds, $keep)); + foreach ($del as $did) { + $pdo->prepare('DELETE FROM quick_links WHERE id = ?')->execute([$did]); + } + $stUpd = $pdo->prepare('UPDATE quick_links SET name=?, url=?, icon=?, note=?, enabled=?, sort=? WHERE id=?'); + $stIns = $pdo->prepare('INSERT INTO quick_links (name, url, icon, note, enabled, sort) VALUES (?,?,?,?,?,?)'); + $sort = 0; + foreach ($clean as $c) { + $sort++; + if ($c['id'] > 0 && in_array($c['id'], $oldIds, true)) { + $stUpd->execute([$c['name'], $c['url'], $c['icon'], $c['note'], $c['enabled'], $sort, $c['id']]); + } else { + $stIns->execute([$c['name'], $c['url'], $c['icon'], $c['note'], $c['enabled'], $sort]); + } + } + $pdo->commit(); + touch_last_updated(); + $msgKind = 'ok'; + $msgText = '快捷站点已保存。'; + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + $msgKind = 'err'; + $msgText = '保存失败:' . $e->getMessage(); + } + } + break; + case 'qtoggle': + header('Content-Type: application/json; charset=utf-8'); + $qlId = (int) ($_POST['id'] ?? 0); + $qlEn = ((string) ($_POST['enabled'] ?? '') === '1') ? 1 : 0; + if ($qlId > 0) { + $pdo->prepare('UPDATE quick_links SET enabled = ? WHERE id = ?')->execute([$qlEn, $qlId]); + touch_last_updated(); + echo json_encode(['ok' => true]); + } else { + echo json_encode(['ok' => false, 'msg' => '无效的记录 ID。'], JSON_UNESCAPED_UNICODE); + } + exit; + case 'pwd': + $np = (string) ($_POST['new_pwd'] ?? ''); + $np2 = (string) ($_POST['new_pwd2'] ?? ''); + if ($np !== $np2) { + $msgKind = 'err'; + $msgText = '两次输入的新密码不一致。'; + } else { + [$ok, $text] = hp_change_password((string) ($_POST['old_pwd'] ?? ''), $np); + $msgKind = $ok ? 'ok' : 'err'; + $msgText = $text; + } + break; + default: + $msgKind = 'err'; + $msgText = '未知操作。'; } + } } // 板块数据(顺序展示,sort 同值按 id 兜底) $cats = $pdo->query('SELECT * FROM categories ORDER BY sort ASC, id ASC')->fetchAll(); // 板块说明 seed(未编辑过时为空则给占位) $boardDefaults = [ - 'popular' => '面向大众与初学者的计算机科学、网络安全与前沿技术科普。', - 'red' => '面向渗透测试与攻防演练的红队资源集。', - 'blue' => '面向防御侧:应急响应、威胁狩猎、取证溯源。', - 'tool' => '日常效率与安全实用小工具,直达功能区。', - 'article' => '站长原创与整理收录的图文与 Markdown 文章合集。', + 'popular' => '面向大众与初学者的计算机科学、网络安全与前沿技术科普。', + 'red' => '面向渗透测试与攻防演练的红队资源集。', + 'blue' => '面向防御侧:应急响应、威胁狩猎、取证溯源。', + 'tool' => '日常效率与安全实用小工具,直达功能区。', + 'article' => '站长原创与整理收录的图文与 Markdown 文章合集。', ]; $logo = setting_get('site_logo', ''); $quotesFile = DATA_DIR . '/quota.txt'; -$quotesContent = is_file($quotesFile) ? (string)@file_get_contents($quotesFile) : ''; +$quotesContent = is_file($quotesFile) ? (string) @file_get_contents($quotesFile) : ''; $all = []; foreach ($pdo->query('SELECT key, value FROM settings') as $r) { - $all[$r['key']] = (string)$r['value']; + $all[$r['key']] = (string) $r['value']; } $site = $all['site_name'] ?? '知识导航站'; -layout_head('内容与顺序'); +layout_head('网站管理'); admin_topbar('content'); ?>
-

内容与顺序

+

网站管理

对全站展示内容与 logo 进行维护,保存后自动更新首页"上次更新时间"。

- +
-
站长提示(全站辅助说明文案总开关)
+
提示开关
- + 开启时,前台显示“可切换搜索引擎进行搜索…”等辅助旁白
-

前台类似“可切换搜索引擎进行搜索(4 个可用,后台可维护)”、各分区说明条等辅助旁白,以及文案中带后台入口字样的引导句,均带 st-tip 标记;关闭后这些文案在全部前台页面统一隐藏,站点导航、搜索、内容等核心功能不受影响。各分区/页面在没有数据时展示的空态占位框(如“该库还没有导航内容”)不受本开关影响,会始终显示以避免空页突兀。

+

前台类似“可切换搜索引擎进行搜索(4 个可用,后台可维护)”、各分区说明条等辅助旁白,以及文案中带后台入口字样的引导句,均带 + st-tip + 标记;关闭后这些文案在全部前台页面统一隐藏,站点导航、搜索、内容等核心功能不受影响。各分区/页面在没有数据时展示的空态占位框(如“该库还没有导航内容”)不受本开关影响,会始终显示以避免空页突兀。

@@ -371,7 +388,7 @@ admin_topbar('content');
-
站点 LOGO(浏览器标签页图标与页面内容同步生效)
+
站点LOGO
当前logo 当前生效的 logo;更换后浏览器标签页图标(favicon)与页面头部 logo 都会更新。 @@ -394,35 +411,42 @@ admin_topbar('content');
-
首页顶部标识栏背景(Hero 区,支持纯色或 CSS 渐变)
+
首页顶部背景
- +
- + - - - - + + + +
-

纯色直接填色值(如 #2563eb);渐变填完整 CSS,如 linear-gradient(135deg, #1f3a5f 0%, #2563eb 100%)。夜间模式沿用同一背景。

+

纯色直接填色值(如 #2563eb);渐变填完整 CSS,如 + linear-gradient(135deg, #1f3a5f 0%, #2563eb 100%)。夜间模式沿用同一背景。

-
首页顶部组件(Hero 下方横幅区)
- +
首页顶部组件
+
- - - + + +
@@ -441,7 +465,8 @@ admin_topbar('content'); - + @@ -452,17 +477,21 @@ admin_topbar('content');
-
搜索引擎(导航区搜索栏可切换的引擎)
+
搜索引擎管理
-

图标用于搜索栏切换按钮常驻展示;搜索引擎全称在展开下拉列表时才显示。搜索地址需包含 {kw} 占位符,例如 https://www.bing.com/search?q={kw}。列表按从上到下顺序生效,可先删行再重新添加来调整顺序。

+

图标用于搜索栏切换按钮常驻展示;搜索引擎全称在展开下拉列表时才显示。搜索地址需包含 {kw} 占位符,例如 + https://www.bing.com/search?q={kw}。列表按从上到下顺序生效,可先删行再重新添加来调整顺序。

- +
- - - + + +
@@ -480,30 +509,33 @@ admin_topbar('content');
-
名言库(data/quota.txt,首页随机展示)
+
滚动信息栏
- -
+ +
-
CDN IP 段库(供“IP 地址处理”工具识别 CDN 来源)
+
CDN库
- +
-

识别时会按“先命中即归为 CDN”处理;未命中且非内网的 IP 一律归为公网。默认内置 Cloudflare 官方 IPv4 段,可按需补充阿里云 / 腾讯云等厂商段。

+

识别时会按“先命中即归为 CDN”处理;未命中且非内网的 IP 一律归为公网。默认内置 Cloudflare 官方 IPv4 + 段,可按需补充阿里云 / 腾讯云等厂商段。

-
修改登录密码
+
修改密码
@@ -516,26 +548,30 @@ admin_topbar('content');
-
-
四大板块(名称 / 悬停说明文案 / 顺序)
+
板块设置
- +
- - + +
- +
- - + +
@@ -545,7 +581,7 @@ admin_topbar('content');
-
首页快捷站点(四大板块上方的胶囊按钮,新标签页打开)
+
首页快捷站点
@@ -553,17 +589,21 @@ admin_topbar('content');
query('SELECT * FROM quick_links ORDER BY sort ASC, id ASC')->fetchAll(); ?> -
+
- + - - - - + + + + @@ -585,196 +625,197 @@ admin_topbar('content'); - + + \ No newline at end of file diff --git a/admin/exttools.php b/admin/exttools.php index 720b0c8..b98e2de 100644 --- a/admin/exttools.php +++ b/admin/exttools.php @@ -18,44 +18,46 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save' if (!csrf_verify()) { $errorMsg = '安全校验失败,请刷新页面重试。'; } else { - $raw = (string)($_POST['data'] ?? ''); + $raw = (string) ($_POST['data'] ?? ''); $rows = json_decode($raw, true); if (!is_array($rows)) { $errorMsg = '提交数据格式错误。'; } else { $clean = []; foreach ($rows as $i => $r) { - if (!is_array($r)) continue; - $name = trim((string)($r['name'] ?? '')); - $grp = trim(preg_replace('/\s+/', ' ', (string)($r['grp'] ?? ''))); - $desc = trim((string)($r['description'] ?? '')); - $url = trim((string)($r['url'] ?? '')); + if (!is_array($r)) + continue; + $name = trim((string) ($r['name'] ?? '')); + $grp = trim(preg_replace('/\s+/', ' ', (string) ($r['grp'] ?? ''))); + $desc = trim((string) ($r['description'] ?? '')); + $url = trim((string) ($r['url'] ?? '')); $cats = []; if (isset($r['cats']) && is_array($r['cats'])) { foreach ($r['cats'] as $ck) { - $s = trim((string)$ck); + $s = trim((string) $ck); if (in_array($s, ['popular', 'red', 'blue'], true) && !in_array($s, $cats, true)) { $cats[] = $s; } } } - if ($name === '' && $url === '') continue; // 空行忽略 + if ($name === '' && $url === '') + continue; // 空行忽略 if ($name === '' || $url === '') { - $errorMsg = '第 ' . ((int)$i + 1) . ' 行:名称与网址均不能为空。'; + $errorMsg = '第 ' . ((int) $i + 1) . ' 行:名称与网址均不能为空。'; break; } if (!preg_match('#^https?://#i', $url)) { - $errorMsg = '第 ' . ((int)$i + 1) . ' 行:外部工具需以 http(s):// 开头的完整网址。'; + $errorMsg = '第 ' . ((int) $i + 1) . ' 行:外部工具需以 http(s):// 开头的完整网址。'; break; } $clean[] = [ - 'id' => (int)($r['id'] ?? 0), + 'id' => (int) ($r['id'] ?? 0), 'name' => $name, 'grp' => $grp, 'description' => $desc, 'url' => $url, 'cats' => implode(',', $cats), - 'enabled' => (((string)($r['enabled'] ?? '1') === '1')) ? 1 : 0, + 'enabled' => (((string) ($r['enabled'] ?? '1') === '1')) ? 1 : 0, ]; } if ($errorMsg === '') { @@ -63,7 +65,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save' $pdo->beginTransaction(); $oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM ext_links')->fetchAll(), 'id')); $keep = []; - foreach ($clean as $c) { if ($c['id'] > 0) $keep[] = $c['id']; } + foreach ($clean as $c) { + if ($c['id'] > 0) + $keep[] = $c['id']; + } $del = array_values(array_diff($oldIds, $keep)); foreach ($del as $did) { $pdo->prepare('DELETE FROM ext_links WHERE id = ?')->execute([$did]); @@ -84,7 +89,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save' header('Location: exttools.php?ok=1'); exit; } catch (Throwable $e) { - if ($pdo->inTransaction()) $pdo->rollBack(); + if ($pdo->inTransaction()) + $pdo->rollBack(); $errorMsg = '保存失败:' . $e->getMessage(); } } @@ -98,8 +104,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'toggl if (!csrf_verify()) { echo json_encode(['ok' => false, 'msg' => '安全校验失败,请刷新页面后重试。'], JSON_UNESCAPED_UNICODE); } else { - $tid = (int)($_POST['id'] ?? 0); - $ten = ((string)($_POST['enabled'] ?? '') === '1') ? 1 : 0; + $tid = (int) ($_POST['id'] ?? 0); + $ten = ((string) ($_POST['enabled'] ?? '') === '1') ? 1 : 0; if ($tid > 0) { $pdo->prepare('UPDATE ext_links SET enabled = ? WHERE id = ?')->execute([$ten, $tid]); touch_last_updated(); @@ -116,7 +122,7 @@ $tools = $pdo->query('SELECT * FROM ext_links ORDER BY sort ASC, id ASC')->fetch // 供“分区”筛选下拉使用(按出现顺序去重) $grpNames = []; foreach ($tools as $t) { - $g = trim((string)($t['grp'] ?? '')); + $g = trim((string) ($t['grp'] ?? '')); if ($g !== '' && !in_array($g, $grpNames, true)) { $grpNames[] = $g; } @@ -126,536 +132,550 @@ layout_head('外部工具区管理'); admin_topbar('exttools'); ?>
-
-

外部工具区管理

-

- 链接较多时可先筛选再编辑:按 分区/栏目、启停状态、展示范围、关键词 快速过滤(仅影响当前浏览,不影响保存)。 - 行首“分区 / 栏目”即前台分组标题(如“合集 / 导航”),留空 = 不分组平铺; - 展示范围勾选库,全部不勾选 = 所有库通用。 -

+
+

外部工具区管理

+

+ 链接较多时可先筛选再编辑:按 分区/栏目、启停状态、展示范围、关键词 快速过滤(仅影响当前浏览,不影响保存)。 + 行首“分区 / 栏目”即前台分组标题(如“合集 / 导航”),留空 = 不分组平铺; + 展示范围勾选库,全部不勾选 = 所有库通用。 +

- - - - - - + + + + + + - -
-
- - - - - - - -
-
- - -
-
批量导入外部工具(快速导入,自动带分区)
- -

- 数组内可穿插分区标记 {"section":"栏目名"}(其后的工具归入该分区,直至下一分区标记); - 工具行为 {"name","url","description"},名称与网址缺失的空行会自动忽略。 - 点击「解析并追加」后将追加到对应分区下方,核对后统一点「保存外部工具」生效。 -

-
-
- - -
- - - - - -
- $gItems): - $isNone = ($gk === "\x00"); - $gLabel = $isNone ? '未分组' : $gk; - $gData = $isNone ? '__none__' : $gk; - $gN = count($gItems); - $gCnt = ['popular' => 0, 'red' => 0, 'blue' => 0]; - foreach ($gItems as $gt) { - foreach (array_map('trim', explode(',', trim((string)$gt['cat_keys']))) as $ck) { - if (isset($gCnt[$ck])) { $gCnt[$ck]++; } - } - } - ?> -
-
- - - - - - 分区留空的工具在此平铺 - - - - 整组库 - - - - - -
-
- -
- - - - - - 展示于 - - - - - - - - - - -
- -
+ +
+
+ + + + + + +
-
-
- - - 筛选只是便于浏览,全部行都会随「保存」提交 + +
+
批量导入
+ +

+ 数组内可穿插分区标记 {"section":"栏目名"}(其后的工具归入该分区,直至下一分区标记); + 工具行为 {"name","url","description"},名称与网址缺失的空行会自动忽略。 + 点击「解析并追加」后将追加到对应分区下方,核对后统一点「保存外部工具」生效。 +

+
+
+ + +
+ + + + + +
+ $gItems): + $isNone = ($gk === "\x00"); + $gLabel = $isNone ? '未分组' : $gk; + $gData = $isNone ? '__none__' : $gk; + $gN = count($gItems); + $gCnt = ['popular' => 0, 'red' => 0, 'blue' => 0]; + foreach ($gItems as $gt) { + foreach (array_map('trim', explode(',', trim((string) $gt['cat_keys']))) as $ck) { + if (isset($gCnt[$ck])) { + $gCnt[$ck]++; + } + } + } + ?> +
+
+ + + + + + 分区留空的工具在此平铺 + + + + 整组库 + + + + + +
+
+ +
+ + + + + + 展示于 + + + + + + + + + + +
+ +
+
+ +
+ +
+ + + 筛选只是便于浏览,全部行都会随「保存」提交 +
+
-
-
- + + \ No newline at end of file diff --git a/admin/login.php b/admin/login.php index b41f10d..7f391a8 100644 --- a/admin/login.php +++ b/admin/login.php @@ -11,26 +11,26 @@ $loggedOut = isset($_GET['out']); $curIp = client_ip(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $username = trim((string)($_POST['username'] ?? '')); - $password = (string)($_POST['password'] ?? ''); - if ($username === '' || $password === '') { - $err = '请输入用户名和密码。'; - } elseif (hp_login($username, $password)) { - // 正常登录成功:清零本 IP 风控计数(避免携带历史失败次数,防止管理员自误封) - risk_clear_for_ip($curIp); - header('Location: nav.php'); - exit; + $username = trim((string) ($_POST['username'] ?? '')); + $password = (string) ($_POST['password'] ?? ''); + if ($username === '' || $password === '') { + $err = '请输入用户名和密码。'; + } elseif (hp_login($username, $password, (string) ($_POST['dev'] ?? ''))) { + // 正常登录成功:清零本 IP 风控计数(避免携带历史失败次数,防止管理员自误封) + risk_clear_for_ip($curIp); + header('Location: nav.php'); + exit; + } else { + // 轻量防爆破延时(命中黑名单的 IP 在访问本页前即被 access_boot 拦截) + usleep(500000); + $risk = risk_login_fail($curIp); + if ($risk[1]) { + $err = '用户名或密码错误。'; + // $err = '登录失败次数过多(本次累计 ' . $risk[0] . ' 次,达到阈值 ' . $risk[2] . ' 次),当前 IP 已被风控自动封禁,全站(含后台登录)访问均被拒绝。请稍后从未被封禁的网络进入后台「风控管理 → IP 黑名单」移除该规则解除,或联系站长处理。'; } else { - // 轻量防爆破延时(命中黑名单的 IP 在访问本页前即被 access_boot 拦截) - usleep(500000); - $risk = risk_login_fail($curIp); - if ($risk[1]) { - $err = '用户名或密码错误。'; - // $err = '登录失败次数过多(本次累计 ' . $risk[0] . ' 次,达到阈值 ' . $risk[2] . ' 次),当前 IP 已被风控自动封禁,全站(含后台登录)访问均被拒绝。请稍后从未被封禁的网络进入后台「访问信息 → IP 黑名单」移除该规则解除,或联系站长处理。'; - } else { - $err = '用户名或密码错误。'; - } + $err = '用户名或密码错误。'; } + } } $site = setting_get('site_name', '知识导航站'); @@ -52,6 +52,7 @@ layout_head('后台登录'); +
@@ -61,4 +62,5 @@ layout_head('后台登录'); - + + \ No newline at end of file diff --git a/admin/nav.php b/admin/nav.php index 111e710..ace0120 100644 --- a/admin/nav.php +++ b/admin/nav.php @@ -1,6 +1,6 @@ beginTransaction(); // 现有块 $st = $pdo->prepare('SELECT id FROM nav_blocks WHERE cat_id = ?'); - $st->execute([(int)$catRow['id']]); + $st->execute([(int) $catRow['id']]); $oldBlocks = array_map('intval', array_column($st->fetchAll(), 'id')); // 现有链接(按块分组) $oldItemsByBlock = []; @@ -51,11 +51,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save' $keepItems = []; foreach ($data as $b) { if (is_array($b) && isset($b['title'])) { - $id = (int)($b['id'] ?? 0); - if ($id > 0) $keepBlocks[] = $id; + $id = (int) ($b['id'] ?? 0); + if ($id > 0) + $keepBlocks[] = $id; foreach (($b['items'] ?? []) as $it) { - if (is_array($it) && (int)($it['id'] ?? 0) > 0) { - $keepItems[] = (int)$it['id']; + if (is_array($it) && (int) ($it['id'] ?? 0) > 0) { + $keepItems[] = (int) $it['id']; } } } @@ -81,25 +82,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save' $stUpdItem = $pdo->prepare('UPDATE nav_items SET label = ?, url = ?, note = ?, sort = ? WHERE id = ? AND block_id = ?'); $blockSort = 0; foreach ($data as $b) { - if (!is_array($b) || !isset($b['title'])) continue; - $title = trim((string)$b['title']); - if ($title === '') continue; - $bid = (int)($b['id'] ?? 0); + if (!is_array($b) || !isset($b['title'])) + continue; + $title = trim((string) $b['title']); + if ($title === '') + continue; + $bid = (int) ($b['id'] ?? 0); $blockBelongs = in_array($bid, $oldBlocks, true); if ($bid > 0 && $blockBelongs) { $stUpdBlock->execute([$title, $blockSort, $bid]); } else { - $stInsBlock->execute([(int)$catRow['id'], $title, $blockSort]); - $bid = (int)$pdo->lastInsertId(); + $stInsBlock->execute([(int) $catRow['id'], $title, $blockSort]); + $bid = (int) $pdo->lastInsertId(); } $itemSort = 0; foreach (($b['items'] ?? []) as $it) { - if (!is_array($it)) continue; - $label = trim((string)($it['label'] ?? '')); - $url = trim((string)($it['url'] ?? '')); - $note = trim((string)($it['note'] ?? '')); - if ($label === '' && $url === '') continue; - $iid = (int)($it['id'] ?? 0); + if (!is_array($it)) + continue; + $label = trim((string) ($it['label'] ?? '')); + $url = trim((string) ($it['url'] ?? '')); + $note = trim((string) ($it['note'] ?? '')); + if ($label === '' && $url === '') + continue; + $iid = (int) ($it['id'] ?? 0); if ($iid > 0 && in_array($iid, $oldItemsByBlock[$bid] ?? [], true)) { $stUpdItem->execute([$label, $url, $note, $itemSort, $iid, $bid]); } else { @@ -125,7 +130,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save' // ---------- 读取现有数据用于展示 ---------- $st = $pdo->prepare('SELECT * FROM nav_blocks WHERE cat_id = ? ORDER BY sort ASC, id ASC'); -$st->execute([(int)$catRow['id']]); +$st->execute([(int) $catRow['id']]); $blocks = $st->fetchAll(); $itemsMap = []; if ($blocks) { @@ -134,7 +139,7 @@ if ($blocks) { $st = $pdo->prepare("SELECT * FROM nav_items WHERE block_id IN ($in) ORDER BY sort ASC, id ASC"); $st->execute($ids); foreach ($st->fetchAll() as $it) { - $itemsMap[(int)$it['block_id']][] = $it; + $itemsMap[(int) $it['block_id']][] = $it; } } @@ -146,403 +151,411 @@ foreach ($catKeys as $ck) { $csrfVal = csrf_token(); -layout_head('导航内容管理'); +layout_head('导航管理'); admin_topbar('nav'); ?>
-
-

导航内容管理

-

管理 的导航块与链接;块内最多 15 个链接(3 小列 × 5 行),超出部分请拆分到新块。

+
+

导航管理

+

管理 的导航块与链接;块内最多 15 个链接(3 小列 × 5 行),超出部分请拆分到新块。

- -
- - - -
+ +
+ + + +
- - - - - - + + + + + + -
-
⚡ 批量导入导航链接(
-

在下面一次粘贴多行内容,点击「解析导入」后会按格式自动生成导航块,并追加到下方编辑器末尾,可再编辑确认后统一点「保存全部修改」。
- 每行一条,支持几种写法: - 名称 | 网址 [ | 备注](也兼容 网址 | 名称)或 [名称](网址)名称 https://网址;以 ## 块名 开头的行开启一个新导航块;未写块名则归入“批量导入”。 - 单个块最多 15 个链接,超出会自动拆分为 块名 2块名 3

- -
- - - 导入仅是“填入编辑区”,不会立即生效,请务必点击下方的「保存全部修改」。 -
-
+
+ + + 导入仅是“填入编辑区”,不会立即生效,请务必点击下方的「保存全部修改」。 +
+
+
+ +
- - -
- + + \ No newline at end of file diff --git a/admin/tools.php b/admin/tools.php index 851ed14..a379fda 100644 --- a/admin/tools.php +++ b/admin/tools.php @@ -1,6 +1,6 @@ $r) { - if (!is_array($r)) continue; - $name = trim((string)($r['name'] ?? '')); - $url = trim((string)($r['url'] ?? '')); - $icon = trim((string)($r['icon'] ?? '')); - $desc = trim((string)($r['description'] ?? '')); - $ext = ((string)($r['ext'] ?? '0') === '1') ? 1 : 0; - if ($name === '' && $url === '') continue; // 空行忽略 + if (!is_array($r)) + continue; + $name = trim((string) ($r['name'] ?? '')); + $url = trim((string) ($r['url'] ?? '')); + $icon = trim((string) ($r['icon'] ?? '')); + $desc = trim((string) ($r['description'] ?? '')); + $ext = ((string) ($r['ext'] ?? '0') === '1') ? 1 : 0; + if ($name === '' && $url === '') + continue; // 空行忽略 // 库首页“内部功能区”展示范围(空=所有库通用) $cats = []; if (isset($r['cats']) && is_array($r['cats'])) { foreach ($r['cats'] as $ck) { - $s = trim((string)$ck); + $s = trim((string) $ck); if (in_array($s, ['popular', 'red', 'blue'], true) && !in_array($s, $cats, true)) { $cats[] = $s; } @@ -43,32 +45,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save' } $boardCats = implode(',', $cats); if ($name === '' || $url === '') { - $errorMsg = '第 ' . ((int)$i + 1) . ' 行:名称与地址均不能为空。'; + $errorMsg = '第 ' . ((int) $i + 1) . ' 行:名称与地址均不能为空。'; break; } if ($ext === 1) { if (!preg_match('#^https?://#i', $url)) { - $errorMsg = '第 ' . ((int)$i + 1) . ' 行:外部链接需以 http(s):// 开头。'; + $errorMsg = '第 ' . ((int) $i + 1) . ' 行:外部链接需以 http(s):// 开头。'; break; } } else { if (preg_match('#^https?://#i', $url)) { - $errorMsg = '第 ' . ((int)$i + 1) . ' 行:站内页面地址请勿填写完整网址(填如 codec.php),如需外链请选择“外部链接”。'; + $errorMsg = '第 ' . ((int) $i + 1) . ' 行:站内页面地址请勿填写完整网址(填如 codec.php),如需外链请选择“外部链接”。'; break; } if (preg_match('/^[a-z][a-z0-9+.-]*:/i', $url) || strpos($url, '//') === 0) { - $errorMsg = '第 ' . ((int)$i + 1) . ' 行:站内页面仅支持相对地址(如 codec.php),不允许填写协议或跨协议地址。'; + $errorMsg = '第 ' . ((int) $i + 1) . ' 行:站内页面仅支持相对地址(如 codec.php),不允许填写协议或跨协议地址。'; break; } } $clean[] = [ - 'id' => (int)($r['id'] ?? 0), + 'id' => (int) ($r['id'] ?? 0), 'icon' => ($icon !== '' ? $icon : '🧩'), 'name' => $name, 'description' => $desc, 'url' => $url, 'ext' => $ext, - 'enabled' => (((string)($r['enabled'] ?? '1') === '1')) ? 1 : 0, + 'enabled' => (((string) ($r['enabled'] ?? '1') === '1')) ? 1 : 0, 'board_cats' => $boardCats, ]; } @@ -79,34 +81,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save' try { $pdo->beginTransaction(); $oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM func_tools')->fetchAll(), 'id')); - $keep = []; - foreach ($clean as $c) { - if ($c['id'] > 0) $keep[] = $c['id']; - } - $del = array_values(array_diff($oldIds, $keep)); - foreach ($del as $did) { - $pdo->prepare('DELETE FROM func_tools WHERE id = ?')->execute([$did]); - } - $stUpd = $pdo->prepare('UPDATE func_tools SET name=?, icon=?, description=?, url=?, is_external=?, enabled=?, board_cats=?, sort=? WHERE id=?'); - $stIns = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, board_cats, sort) VALUES (?,?,?,?,?,?,?,?)'); - $sort = 0; - foreach ($clean as $c) { - $sort++; - if ($c['id'] > 0 && in_array($c['id'], $oldIds, true)) { - $stUpd->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $c['board_cats'], $sort, $c['id']]); - } else { - $stIns->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $c['board_cats'], $sort]); + $keep = []; + foreach ($clean as $c) { + if ($c['id'] > 0) + $keep[] = $c['id']; } - } - $pdo->commit(); - touch_last_updated(); - header('Location: tools.php?ok=1'); - exit; - } catch (Throwable $e) { - if ($pdo->inTransaction()) { - $pdo->rollBack(); - } - $errorMsg = '保存失败:' . $e->getMessage(); + $del = array_values(array_diff($oldIds, $keep)); + foreach ($del as $did) { + $pdo->prepare('DELETE FROM func_tools WHERE id = ?')->execute([$did]); + } + $stUpd = $pdo->prepare('UPDATE func_tools SET name=?, icon=?, description=?, url=?, is_external=?, enabled=?, board_cats=?, sort=? WHERE id=?'); + $stIns = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, board_cats, sort) VALUES (?,?,?,?,?,?,?,?)'); + $sort = 0; + foreach ($clean as $c) { + $sort++; + if ($c['id'] > 0 && in_array($c['id'], $oldIds, true)) { + $stUpd->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $c['board_cats'], $sort, $c['id']]); + } else { + $stIns->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $c['board_cats'], $sort]); + } + } + $pdo->commit(); + touch_last_updated(); + header('Location: tools.php?ok=1'); + exit; + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + $errorMsg = '保存失败:' . $e->getMessage(); } } } @@ -120,8 +123,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'toggl if (!csrf_verify()) { echo json_encode(['ok' => false, 'msg' => '安全校验失败,请刷新页面后重试。'], JSON_UNESCAPED_UNICODE); } else { - $tid = (int)($_POST['id'] ?? 0); - $ten = ((string)($_POST['enabled'] ?? '') === '1') ? 1 : 0; + $tid = (int) ($_POST['id'] ?? 0); + $ten = ((string) ($_POST['enabled'] ?? '') === '1') ? 1 : 0; if ($tid > 0) { $pdo->prepare('UPDATE func_tools SET enabled = ? WHERE id = ?')->execute([$ten, $tid]); touch_last_updated(); @@ -136,254 +139,261 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'toggl // ---------- 展示现有工具 ---------- $tools = $pdo->query('SELECT * FROM func_tools ORDER BY sort ASC, id ASC')->fetchAll(); -layout_head('功能区管理'); +layout_head('内部功能'); admin_topbar('tools'); ?>
-
-

功能区管理

-

- 管理功能区首页展示的工具卡片。站内工具页需先在 func/ 下开发对应页面,再到此处登记入口; - 也可登记外部网址作为外链卡片(新窗口打开)。 -

+
+

内部功能

+

+ 管理功能区首页展示的工具卡片。站内工具页需先在 func/ 下开发对应页面,再到此处登记入口; + 也可登记外部网址作为外链卡片(新窗口打开)。 +

- - - - - - + + + + + + -
-
- - - +
+
工具卡片管理
+ + + + -
- -
- - - - - - - - - - - - - - 库首页 - - - - - - - - - - -
- +
+ +
+ + + + + + + + + + + + + + 库首页 + + + + + + + + + + +
+ +
+ +
+ + + 工具数量与展示顺序实时反映到前台功能区首页 +
+
- -
- - - 工具数量与展示顺序实时反映到前台功能区首页 -
-
-
- + + \ No newline at end of file diff --git a/admin/visits.php b/admin/visits.php index 2d4f145..4588ad5 100644 --- a/admin/visits.php +++ b/admin/visits.php @@ -1,37 +1,41 @@ prepare('INSERT INTO settings (key, value) VALUES (?, ?) + $st = db()->prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'); - $st->execute([$key, $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']); - } + $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); + } + return count($rules); } /* ---------- POST:黑名单增删 / 清空日志 / 风控规则 ---------- */ @@ -39,235 +43,181 @@ $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 = '未知操作。'; + 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 'loginlog_clear': + login_log_clear(); + $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'); +$visStat = vis_stat_all(); +$statTotal = $visStat['total']; +$statIps = $visStat['ips']; +$statToday = $visStat['today']; +$statBan = $visStat['ban']; -/* ---------- 筛选与分页 ---------- */ -$mode = (string)($_GET['mode'] ?? 'full'); +/* ---------- 筛选参数(供表单回显)与访问记录查询 ---------- */ +$mode = (string) ($_GET['mode'] ?? 'full'); if (!in_array($mode, ['full', 'compact'], true)) { - $mode = 'full'; + $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)); +$ipF = substr(trim((string) ($_GET['ip'] ?? '')), 0, 64); +$q = substr(trim((string) ($_GET['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); -} +$visAccess = vis_query_access($mode, $ipF, $q, $page, $perPage); /* ---------- 当前访问者信息(用于黑名单自查提示) ---------- */ $curIp = client_ip(); if ($curIp === '') { - $curIp = '未知'; + $curIp = '未知'; } $curHit = null; if ($curIp !== '未知') { - if ($banChanged) { - blacklist_rules(true); - } - $curHit = blacklist_ip_hit($curIp); + if ($banChanged) { + blacklist_rules(true); + } + $curHit = blacklist_ip_hit($curIp); } /* ---------- 自动风控规则配置与当前 IP 计数 ---------- */ $rcfg = risk_rule_cfg(); $curRisk = ($curIp !== '未知') ? risk_row($curIp) : null; +/* ---------- 登录信息:当前会话 / 历史成功登录 / 登录失败按「IP × 日期」聚合 ---------- */ +$lgStat = login_log_stat(); +$lgRecent = login_log_recent_ok(15); +$lgFailF = substr(trim((string) ($_GET['lf'] ?? '')), 0, 64); +$lgFailData = vis_query_loginfail($lgFailF, 200); +$curAdmin = (string) ($_SESSION['admin'] ?? ''); +$curLoginAt = (string) ($_SESSION['login_at'] ?? ''); +$curLoginIp = (string) ($_SESSION['login_ip'] ?? ($curIp !== '未知' ? $curIp : '')); +$curLoginUa = (string) ($_SESSION['login_ua'] ?? ''); +$curLoginDevice = (string) ($_SESSION['login_device'] ?? ''); +if ($curLoginDevice === '') { + $curLoginDevice = login_ua_brief((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')); +} + /* ---------- 黑名单规则(直接读表展示,便于核对备注与来源) ---------- */ $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++; + if (((string) ($b['source'] ?? 'manual')) === 'auto') + $banAutoCount++; } -layout_head('访问信息'); +layout_head('风控管理'); admin_topbar('visits'); ?>
-

访问信息

+

风控管理

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

@@ -286,122 +236,138 @@ admin_topbar('visits');
- 日志按行追加写入 data/vistors.log(制表符分隔:时间 / IP / 请求方式 / 响应码 / URL),完整保留不设上限;可自行归档或删除该文件。 + 日志按行追加写入 data/vistors.log(制表符分隔:时间 / IP / 请求方式 / 响应码 / + URL),完整保留不设上限;可自行归档或删除该文件。 +
+
+ + +
+
登录信息
+
+
当前用户
+
本次登录时间
+
本次登录 + IP
+
本次登录设备
+
+

历史登录记录(最近 条成功登录;今日成功 + 次) +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
登录时间登录 IP登录设备(浏览器)用户名
暂无成功登录记录(本次登录为首次记录)。
+
+
+ + +
+
登录失败日志
+
+
今日失败次数
+
失败涉及 IP
+
今日成功登录
+
登录日志总数
+
+
+ + + 重置 + 按「IP × 日期」聚合:同一 IP 当天的失败次数与最近一次设备信息 +
+
+
+ + + 记录存于数据库 login_log 表(含成功与失败、时间 / 用户名 / IP / 设备)。
-
访问记录
-
- + + - 重置 + 重置
- -
- 共命中 ; - 当前第 / 页,每页 条。 -
-
- - - - - - - - - - - - - - -
最近访问时间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) + + 失败次数 ≥ + 次 → 永久封禁(建议 5-20)
- - 累计次数 ≥ 次 → 永久封禁(建议 50-300) + + 累计次数 ≥ + 次 → 永久封禁(建议 50-300)
- - 60 秒内 ≥ 次 → 永久封禁(多人共用出口 IP 时建议关闭) + + 60 秒内 ≥ + 次 → 永久封禁(多人共用出口 IP 时建议关闭)
-

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

+

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

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

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

- 当前访问 IP: —— 命中规则「」(),全站(前台与后台)访问将被拦截 + 当前访问 IP: —— + 命中规则「」(),全站(前台与后台)访问将被拦截 当前访问 IP: —— 未命中任何黑名单规则 @@ -439,7 +412,9 @@ admin_topbar('visits');
- +
0): ?> @@ -455,17 +430,20 @@ admin_topbar('visits');
- - - + + + 风控自动 手动 - (无备注)' ?> - -
- + (无备注)' ?> + + +
@@ -480,23 +458,24 @@ admin_topbar('visits'); - + + \ No newline at end of file diff --git a/admin/visits_data.php b/admin/visits_data.php new file mode 100644 index 0000000..f3a4ef1 --- /dev/null +++ b/admin/visits_data.php @@ -0,0 +1,39 @@ +登录状态已失效,请刷新页面后重新登录。
'; + exit; +} + +header('Content-Type: text/html; charset=utf-8'); +header('Cache-Control: no-store, no-cache, must-revalidate'); + +$section = (string) ($_GET['section'] ?? 'access'); + +if ($section === 'loginfail') { + $lf = substr(trim((string) ($_GET['lf'] ?? '')), 0, 64); + echo vis_render_loginfail(vis_query_loginfail($lf, 200)); + exit; +} + +$mode = (string) ($_GET['mode'] ?? 'full'); +if (!in_array($mode, ['full', 'compact'], true)) { + $mode = 'full'; +} +$ipF = substr(trim((string) ($_GET['ip'] ?? '')), 0, 64); +$q = substr(trim((string) ($_GET['q'] ?? '')), 0, 100); +$page = max(1, (int) ($_GET['page'] ?? 1)); + +echo vis_render_access(vis_query_access($mode, $ipF, $q, $page, 30)); diff --git a/assets/css/common.css b/assets/css/common.css index 5365dc4..fe00f3f 100644 --- a/assets/css/common.css +++ b/assets/css/common.css @@ -184,7 +184,7 @@ select { width: auto; min-width: 150px; } /* ---------- 首页顶部组件:空白精简模式(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; } @@ -471,7 +471,7 @@ a.nav-btn[data-note]:hover::after { opacity: 1; transform: translateX(-50%) tran /* 跨整行的设置卡片(内容较多的底部大卡) */ .fieldset-card.col-span-2 { grid-column: 1 / -1; } -/* 后台导航内容管理 */ +/* 后台导航管理 */ .m-block { border: 1px solid var(--line); border-radius: 12px; margin-bottom: 16px; overflow: hidden; } .m-block-head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--bg-2); flex-wrap: wrap; } .m-block-head input[type=text] { max-width: 320px; } @@ -825,3 +825,83 @@ a.nav-btn[data-note]:hover::after { opacity: 1; transform: translateX(-50%) tran .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; } } + +/* ---------- 后台侧边栏(原顶部分区栏改为可收缩侧栏,含“本页功能”锚点) ---------- */ +body.hp-admin-side { padding-left: 232px; transition: padding-left .2s ease; } +body.hp-admin-side.hp-side-collapsed { padding-left: 68px; } + +.admin-mobilebar { display: none; } + +.admin-side { + position: fixed; top: 0; left: 0; bottom: 0; width: 232px; z-index: 920; + display: flex; flex-direction: column; gap: 4px; + background: var(--brand-2); color: #fff; + padding: 12px 10px 10px; overflow-y: auto; box-sizing: border-box; + transition: width .2s ease, transform .2s ease; +} +.admin-side a { color: rgba(255, 255, 255, .92); } + +.as-head { display: flex; align-items: center; gap: 8px; padding: 4px 6px 10px; border-bottom: 1px solid rgba(255, 255, 255, .16); margin-bottom: 8px; } +.as-brand { display: flex; align-items: center; gap: 9px; min-width: 0; flex: 1 1 auto; } +.as-logo { width: 32px; height: 32px; border-radius: 8px; object-fit: contain; background: #fff; padding: 2px; flex: none; } +.as-brand-tx { display: flex; flex-direction: column; min-width: 0; font-weight: 700; font-size: 14px; line-height: 1.25; } +.as-brand-tx em { font-style: normal; font-weight: 400; font-size: 11px; opacity: .7; } +.as-toggle { flex: none; width: 26px; height: 26px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, .28); background: rgba(255, 255, 255, .08); color: #fff; cursor: pointer; font-size: 13px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; } +.as-toggle:hover { background: rgba(255, 255, 255, .2); } +.as-toggle-ic { display: inline-block; transition: transform .2s ease; } + +.as-nav { display: flex; flex-direction: column; gap: 2px; } +.as-link { display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: 10px; font-size: 14px; white-space: nowrap; } +.as-link:hover { background: rgba(255, 255, 255, .12); } +.as-link.active { background: rgba(255, 255, 255, .2); font-weight: 700; } +.as-ic { flex: none; width: 20px; text-align: center; font-size: 15px; } + +.as-sec { margin-top: 10px; border-top: 1px solid rgba(255, 255, 255, .16); padding-top: 8px; } +.as-sec-t { font-size: 11px; letter-spacing: .06em; opacity: .6; padding: 2px 10px 6px; } +.as-sec-list { display: flex; flex-direction: column; gap: 1px; } +.as-sub { display: block; padding: 6px 10px 6px 12px; border-radius: 8px; font-size: 12.5px; color: rgba(255, 255, 255, .78); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 2px solid transparent; } +.as-sub:hover { background: rgba(255, 255, 255, .12); color: #fff; } +.as-sub.active { background: rgba(255, 255, 255, .18); color: #fff; border-left-color: #fff; font-weight: 600; } + +.as-foot { margin-top: auto; border-top: 1px solid rgba(255, 255, 255, .16); padding-top: 8px; } +.as-user { font-size: 13px; opacity: .78; padding: 4px 10px 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.admin-side-mask { display: none; position: fixed; inset: 0; background: rgba(0, 0, 0, .45); z-index: 919; } + +/* 滚动定位到区块时预留间距(避免贴顶) */ +.fieldset-card { scroll-margin-top: 18px; } + +/* 收缩态:仅图标 */ +body.hp-side-collapsed .as-brand-tx, +body.hp-side-collapsed .as-tx { display: none; } +body.hp-side-collapsed .admin-side { width: 68px; } +body.hp-side-collapsed .as-head { flex-direction: column; gap: 6px; } +body.hp-side-collapsed .as-link { justify-content: center; padding: 9px 0; } +body.hp-side-collapsed .as-toggle-ic { transform: rotate(180deg); } +body.hp-side-collapsed .as-sec { display: none; } + +/* 小屏:侧栏改为抽屉 */ +@media (max-width: 900px) { + body.hp-admin-side { padding-left: 0; } + .admin-mobilebar { + display: flex; align-items: center; gap: 10px; position: sticky; top: 0; z-index: 921; + background: var(--brand-2); color: #fff; padding: 8px 12px; + } + .amb-btn { border: 1px solid rgba(255, 255, 255, .3); background: rgba(255, 255, 255, .1); color: #fff; border-radius: 8px; width: 34px; height: 30px; font-size: 15px; cursor: pointer; } + .amb-brand { display: flex; align-items: center; gap: 8px; min-width: 0; color: #fff; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .amb-logo { width: 26px; height: 26px; border-radius: 6px; object-fit: contain; background: #fff; padding: 1px; flex: none; } + .amb-out { margin-left: auto; color: rgba(255, 255, 255, .9); font-size: 13px; } + .admin-side { width: 240px; transform: translateX(-100%); box-shadow: 2px 0 18px rgba(0, 0, 0, .3); } + body.hp-side-open .admin-side { transform: none; } + body.hp-side-open .admin-side-mask { display: block; } + /* 小屏忽略收缩态(始终保持完整文字) */ + body.hp-side-collapsed .admin-side { width: 240px; } + body.hp-side-collapsed .as-tx { display: block; } + body.hp-side-collapsed .as-brand-tx { display: flex; } + body.hp-side-collapsed .as-link { justify-content: flex-start; padding: 9px 10px; } + body.hp-side-collapsed .as-sec { display: block; } + .fieldset-card { scroll-margin-top: 66px; } +} + +/* ---------- 风控管理页:筛选 / 翻页局部刷新时的轻量加载态 ---------- */ +.vis-loading { opacity: .5; pointer-events: none; transition: opacity .15s ease; } diff --git a/assets/js/common.js b/assets/js/common.js index 012388c..4f915d0 100644 --- a/assets/js/common.js +++ b/assets/js/common.js @@ -426,38 +426,242 @@ } } - /* ---------- 返回顶部(右下角按钮,滚动后出现;兼容内滚容器页面) ---------- */ + /* ---------- 返回顶部(右下角按钮;滚动后出现,回到顶部即隐藏) ---------- */ function initToTop() { var btn = document.querySelector('.js-to-top'); if (!btn) return; - var scrollables = []; var doc = document.scrollingElement || document.documentElement; - if (doc) scrollables.push(doc); - Array.prototype.forEach.call(document.querySelectorAll('*'), function (el) { - if (el === doc || el === btn) return; - if (el.scrollHeight <= el.clientHeight + 40) return; - var cs; - try { cs = window.getComputedStyle(el); } catch (e) { cs = null; } - if (!cs) return; - if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') scrollables.push(el); - }); - function measure() { - var v = 0; - for (var i = 0; i < scrollables.length; i++) { - if (scrollables[i].scrollTop > v) v = scrollables[i].scrollTop; + var containers = []; + var ticking = false; + + function currentY() { + var y = window.pageYOffset || (doc && doc.scrollTop) || 0; + for (var i = 0; i < containers.length; i++) { + var t = containers[i].scrollTop || 0; + if (t > y) y = t; } - btn.classList.toggle('show', v > 260); + return y; } - for (var s = 0; s < scrollables.length; s++) { - scrollables[s].addEventListener('scroll', measure, { passive: true }); + function apply() { + ticking = false; + btn.classList.toggle('show', currentY() > 180); } - window.addEventListener('resize', measure); + function schedule() { + if (ticking) return; + ticking = true; + if (window.requestAnimationFrame) window.requestAnimationFrame(apply); + else setTimeout(apply, 30); + } + // 捕获阶段监听:主文档与任意内滚容器的滚动都能收到,避免漏触发 + document.addEventListener('scroll', schedule, true); + window.addEventListener('resize', schedule, { passive: true }); + + // 采集内滚容器:DOM 就绪与资源加载后各测一次(布局完成后才能正确判断是否溢出) + function collect() { + var list = document.querySelectorAll('main, section, article, div, ul, ol'); + Array.prototype.forEach.call(list, function (el) { + if (el === btn || containers.indexOf(el) >= 0) return; + if (el.closest('.admin-side, .hp-toasts, .art-topbar')) return; // 侧栏/弹窗/顶栏自身滚动不应触发 + if (el.scrollHeight <= el.clientHeight + 40) return; + var cs; + try { cs = window.getComputedStyle(el); } catch (e) { return; } + if (cs.position === 'fixed' || cs.position === 'sticky') return; + if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') containers.push(el); + }); + apply(); + } + collect(); + if (document.readyState !== 'complete') window.addEventListener('load', collect); + btn.addEventListener('click', function () { - var root = doc || document.documentElement; - if (root && root.scrollTop > 0) root.scrollTo({ top: 0, behavior: 'smooth' }); - else window.scrollTo({ top: 0, behavior: 'smooth' }); + btn.classList.remove('show'); // 乐观隐藏,避免到顶后仍残留 + for (var i = 0; i < containers.length; i++) { + if (containers[i].scrollTop > 0) containers[i].scrollTop = 0; + } + if (doc && doc.scrollTop > 0) { + try { doc.scrollTo({ top: 0, behavior: 'smooth' }); } catch (e) { doc.scrollTop = 0; } + } else { + try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (e) { window.scrollTo(0, 0); } + } + schedule(); }); - measure(); + schedule(); + } + + /* ---------- 后台侧边栏:收缩 / 移动端抽屉 / 本页功能锚点 ---------- */ + function initAdminSide() { + var side = document.querySelector('.admin-side'); + if (!side) return; + var body = document.body; + var mq = window.matchMedia ? window.matchMedia('(max-width: 900px)') : null; + function isMobile() { return mq ? mq.matches : window.innerWidth <= 900; } + + // 收缩(桌面)/ 抽屉开关(小屏) + Array.prototype.forEach.call(document.querySelectorAll('.js-admin-side-toggle'), function (btn) { + btn.addEventListener('click', function () { + if (isMobile()) { + body.classList.toggle('hp-side-open'); + } else { + var collapsed = body.classList.toggle('hp-side-collapsed'); + try { localStorage.setItem('hp-admin-side-collapsed', collapsed ? '1' : '0'); } catch (e) { /* ignore */ } + } + }); + }); + var mask = document.getElementById('adminSideMask'); + if (mask) mask.addEventListener('click', function () { body.classList.remove('hp-side-open'); }); + window.addEventListener('resize', function () { if (!isMobile()) body.classList.remove('hp-side-open'); }); + Array.prototype.forEach.call(side.querySelectorAll('a'), function (a) { + a.addEventListener('click', function () { if (isMobile()) body.classList.remove('hp-side-open'); }); + }); + + // 采集当前页“本页功能”:fieldset-card 的标题 + var secWrap = document.getElementById('adminSideSections'); + var secList = document.getElementById('adminSideSectionList'); + if (!secWrap || !secList) return; + var items = []; + Array.prototype.forEach.call(document.querySelectorAll('.fieldset-card'), function (card) { + var t = card.querySelector('.fs-title'); + if (!t) return; + var clone = t.cloneNode(true); + Array.prototype.forEach.call(clone.querySelectorAll('.tip'), function (n) { if (n.parentNode) n.parentNode.removeChild(n); }); + var text = (clone.textContent || '').replace(/\s+/g, ' ').trim(); + if (text === '') return; + items.push({ el: card, text: text }); + }); + if (items.length < 1) return; + var idx = 0; + items.forEach(function (it) { + idx++; + if (!it.el.id) it.el.id = 'hp-sec-' + idx; + var a = document.createElement('a'); + a.className = 'as-sub'; + a.href = '#' + it.el.id; + a.textContent = it.text; + a.title = it.text; + a.addEventListener('click', function (ev) { + ev.preventDefault(); + try { it.el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } + catch (e) { it.el.scrollIntoView(); } + }); + secList.appendChild(a); + }); + secWrap.hidden = false; + + // 高亮当前可视区块 + var subs = secList.querySelectorAll('.as-sub'); + function spy() { + var best = -1, bestTop = -Infinity; + for (var i = 0; i < items.length; i++) { + var top = items[i].el.getBoundingClientRect().top - 120; + if (top <= 0 && top > bestTop) { bestTop = top; best = i; } + } + for (var j = 0; j < subs.length; j++) subs[j].classList.toggle('active', j === best); + } + var st = false; + function onScroll() { + if (st) return; + st = true; + if (window.requestAnimationFrame) window.requestAnimationFrame(function () { st = false; spy(); }); + else { st = false; spy(); } + } + document.addEventListener('scroll', onScroll, true); + window.addEventListener('resize', onScroll); + spy(); + } + + /* ---------- 登录页:采集设备信息(系统 / 机型 / 屏幕等,能拿到什么就带什么)随表单一并提交 ---------- */ + function initLoginDevice() { + var field = document.getElementById('devField'); + if (!field) return; + function baseInfo() { + var info = {}; + try { + info.ua = navigator.userAgent || ''; + info.platform = navigator.platform || ''; + info.lang = navigator.language || ''; + info.screen = (window.screen && screen.width && screen.height) ? (screen.width + '×' + screen.height + '@' + (window.devicePixelRatio || 1)) : ''; + info.touch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); + } catch (e) { /* ignore */ } + return info; + } + function fill() { + try { field.value = JSON.stringify(baseInfo()); } catch (e) { /* ignore */ } + } + fill(); + // Chromium 系 UA-CH:可拿到机型(model)、系统版本、架构等高熵值 + try { + if (navigator.userAgentData && navigator.userAgentData.getHighEntropyValues) { + navigator.userAgentData.getHighEntropyValues(['platform', 'platformVersion', 'architecture', 'model', 'uaFullVersion']) + .then(function (h) { + var info = baseInfo(); + info.uch = h || {}; + field.value = JSON.stringify(info); + }) + .catch(function () { /* ignore */ }); + } + } catch (e) { /* ignore */ } + } + + /* ---------- 风控管理页:筛选 / 翻页局部刷新(不整页跳转、不回到顶部) ---------- */ + function initVisitsAjax() { + var boxes = { + access: document.getElementById('visResults'), + loginfail: document.getElementById('lfResults') + }; + if (!boxes.access && !boxes.loginfail) return; + + function serialize(form) { + var parts = []; + var els = form.elements; + for (var i = 0; i < els.length; i++) { + var el = els[i]; + if (!el.name || el.disabled) continue; + var t = (el.type || '').toLowerCase(); + if ((t === 'checkbox' || t === 'radio') && !el.checked) continue; + parts.push(encodeURIComponent(el.name) + '=' + encodeURIComponent(el.value)); + } + return parts.join('&'); + } + function load(section, query, pushUrl) { + var box = boxes[section]; + if (!box) return; + box.classList.add('vis-loading'); + var xhr = new XMLHttpRequest(); + try { + xhr.open('GET', 'visits_data.php?section=' + encodeURIComponent(section) + (query ? ('&' + query) : ''), true); + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + xhr.onreadystatechange = function () { + if (xhr.readyState !== 4) return; + box.classList.remove('vis-loading'); + if (xhr.status === 200) { + box.innerHTML = xhr.responseText; + if (pushUrl && history.replaceState) { + history.replaceState(null, '', 'visits.php' + (query ? ('?' + query) : '')); + } + } else { + box.innerHTML = '
加载失败(可能登录状态已失效),请刷新页面后重试。
'; + } + }; + xhr.send(); + } catch (e) { + box.classList.remove('vis-loading'); + } + } + // 筛选表单(事件委托,局部刷新后依然有效) + document.addEventListener('submit', function (ev) { + var form = ev.target; + if (!form || !form.getAttribute || !form.hasAttribute('data-vis-form')) return; + if (!/get/i.test(form.method || '')) return; + ev.preventDefault(); + load(form.getAttribute('data-vis-form'), serialize(form), true); + }, true); + // 分页链接(委托到 document,避开局部刷新后监听丢失) + document.addEventListener('click', function (ev) { + var a = (ev.target && ev.target.closest) ? ev.target.closest('a[data-vis-page]') : null; + if (!a) return; + ev.preventDefault(); + load(a.getAttribute('data-vis-section') || 'access', a.getAttribute('data-vis-page') || '', true); + }, true); } /* ---------- 功能区静态工具页:库卡片 ?from=nav 进入时,返回按钮改为“返回上一页” ---------- */ @@ -564,6 +768,9 @@ initCopyButtons(); bindConfirmForms(); initVisitorInfo(); + initLoginDevice(); + initVisitsAjax(); + initAdminSide(); initToTop(); initFuncBack(); hpShowPageToast(); diff --git a/assets/js/navicat.js b/assets/js/navicat.js new file mode 100644 index 0000000..cf728ac --- /dev/null +++ b/assets/js/navicat.js @@ -0,0 +1,347 @@ +/* navicat.js —— Navicat 数据库连接密码 本地解密 / 加密(纯前端,不上传服务器) + * 支持两种算法(依据 HyperSine《how-does-navicat-encrypt-password》实现): + * 版本 11 及以前:Blowfish/ECB/NoPadding + 自定义链式 + * 密钥 = SHA1("3DC5CA39"),IV = E(FFFFFFFFFFFFFFFF) = d9c7c3c8870d64bd(无填充) + * 版本 12 及以后:AES-128-CBC/PKCS7 + * 密钥 = "libcckeylibcckey",IV = "libcciv libcciv " + * 组件依赖(优先本地 assets/js/,缺失回退公共 CDN;仅加载代码,不含你的内容): + * crypto-js 4.2.0 —— 版本 12 的 AES-128-CBC + * egoroof-blowfish 2.2.2 —— 版本 11 的 Blowfish/ECB 单块 + */ +(function (global) { + 'use strict'; + + var AES_KEY = 'libcckeylibcckey'; + var AES_IV = 'libcciv libcciv '; + var BLOW_KEY_HEX = '42ceb271a5e458b74aea93947922354391873340'; // SHA1("3DC5CA39") 的 20 字节密钥 + var BLOW_IV_HEX = 'd9c7c3c8870d64bd'; // Blowfish E(FFFFFFFFFFFFFFFF) + + var LIBS = { + cryptojs: { + key: 'CryptoJS', + urls: [ + '../assets/js/crypto-js.min.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' + ] + }, + blowfish: { + key: 'Blowfish', + urls: [ + '../assets/js/blowfish.js', + 'https://cdn.jsdelivr.net/npm/egoroof-blowfish@2.2.2/dist/blowfish.js', + 'https://unpkg.com/egoroof-blowfish@2.2.2/dist/blowfish.js' + ] + } + }; + var loadCache = {}; + + /* 懒加载指定组件(首个可用地址即可,返回 Promise<库对象|null>) */ + function loadLib(name) { + var spec = LIBS[name]; + if (global[spec.key]) return Promise.resolve(global[spec.key]); + if (loadCache[name]) return loadCache[name]; + loadCache[name] = new Promise(function (resolve) { + var go = function (idx) { + if (global[spec.key]) { resolve(global[spec.key]); return; } + if (idx >= spec.urls.length) { resolve(null); return; } + var s = document.createElement('script'); + s.src = spec.urls[idx]; + s.async = true; + s.onload = function () { resolve(global[spec.key] || null); }; + s.onerror = function () { go(idx + 1); }; + document.head.appendChild(s); + }; + go(0); + }); + return loadCache[name]; + } + + /* ================= 字节 / 编码工具 ================= */ + function cleanHex(s) { return String(s == null ? '' : s).replace(/[^0-9a-fA-F]/g, ''); } + + function hexToBytes(hex) { + var h = cleanHex(hex); + if (h.length % 2 !== 0) throw new Error('十六进制长度必须为偶数'); + var out = new Uint8Array(h.length / 2); + for (var i = 0; i < out.length; i++) out[i] = parseInt(h.substr(i * 2, 2), 16); + return out; + } + + function bytesToHex(bytes) { + var s = ''; + for (var i = 0; i < bytes.length; i++) { + var b = bytes[i] & 0xff; + s += (b < 16 ? '0' : '') + b.toString(16); + } + return s; + } + + function utf8Bytes(str) { + str = String(str); + if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(str); + var out = [], i, c; + for (i = 0; i < str.length; i++) { + c = str.charCodeAt(i); + if (c < 0x80) out.push(c); + else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); + else if (c < 0xd800 || c >= 0xe000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + else { + var ch = (c - 0xd800) * 0x400 + (str.charCodeAt(++i) - 0xdc00) + 0x10000; + out.push(0xf0 | (ch >> 18), 0x80 | ((ch >> 12) & 0x3f), 0x80 | ((ch >> 6) & 0x3f), 0x80 | (ch & 0x3f)); + } + } + return new Uint8Array(out); + } + + function utf8Str(bytes) { + if (typeof TextDecoder !== 'undefined') return new TextDecoder('utf-8').decode(bytes); + var out = '', i = 0, b1, b2, b3, b4, c; + while (i < bytes.length) { + b1 = bytes[i++]; + if (b1 < 0x80) out += String.fromCharCode(b1); + else if (b1 < 0xe0) { b2 = bytes[i++]; out += String.fromCharCode(((b1 & 0x1f) << 6) | (b2 & 0x3f)); } + else if (b1 < 0xf0) { + b2 = bytes[i++]; b3 = bytes[i++]; + out += String.fromCharCode(((b1 & 0x0f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f)); + } else { + b2 = bytes[i++]; b3 = bytes[i++]; b4 = bytes[i++]; + c = (((b1 & 0x07) << 18) | ((b2 & 0x3f) << 12) | ((b3 & 0x3f) << 6) | (b4 & 0x3f)) - 0x10000; + out += String.fromCharCode(0xd800 + (c >> 10), 0xdc00 + (c & 0x3ff)); + } + } + return out; + } + + function xorBytes(a, b) { + var n = Math.min(a.length, b.length), out = new Uint8Array(n); + for (var i = 0; i < n; i++) out[i] = a[i] ^ b[i]; + return out; + } + + function concatBytes(list) { + var len = 0, i; + for (i = 0; i < list.length; i++) len += list[i].length; + var out = new Uint8Array(len), off = 0; + for (i = 0; i < list.length; i++) { out.set(list[i], off); off += list[i].length; } + return out; + } + + function ab(u8) { return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength); } + + function asU8(x) { + if (x instanceof Uint8Array) return x; + if (typeof ArrayBuffer !== 'undefined' && x instanceof ArrayBuffer) return new Uint8Array(x); + if (Array.isArray(x)) return new Uint8Array(x); + if (x && typeof x.length === 'number') return new Uint8Array(x); + throw new Error('无法识别的字节数据'); + } + + /* ================= 版本 11:Blowfish 自定义链式(无填充) ================= */ + function newBlowfish(Blowfish) { + return new Blowfish(ab(hexToBytes(BLOW_KEY_HEX)), Blowfish.MODE.ECB, Blowfish.PADDING.NULL); + } + function bfEnc(bf, block) { return asU8(bf.encode(ab(block))).slice(0, 8); } + function bfDec(bf, block, Blowfish) { return asU8(bf.decode(ab(block), Blowfish.TYPE.UINT8_ARRAY)).slice(0, 8); } + + function encrypt11(Blowfish, text) { + var bf = newBlowfish(Blowfish); + var v = hexToBytes(BLOW_IV_HEX); + var data = utf8Bytes(text); + var rounds = Math.floor(data.length / 8); + var left = data.length % 8; + var out = ''; + for (var i = 0; i < rounds; i++) { + var block = data.slice(i * 8, i * 8 + 8); + var temp = bfEnc(bf, xorBytes(block, v)); + v = xorBytes(v, temp); + out += bytesToHex(temp).toUpperCase(); + } + if (left > 0) { + v = bfEnc(bf, v); + out += bytesToHex(xorBytes(data.slice(rounds * 8), v)).toUpperCase(); + } + return out; + } + + function decrypt11(Blowfish, hex) { + var bf = newBlowfish(Blowfish); + var v = hexToBytes(BLOW_IV_HEX); + var data = hexToBytes(hex); + var rounds = Math.floor(data.length / 8); + var left = data.length % 8; + var parts = []; + for (var i = 0; i < rounds; i++) { + var block = data.slice(i * 8, i * 8 + 8); + parts.push(xorBytes(bfDec(bf, block, Blowfish), v)); + v = xorBytes(v, block); + } + if (left > 0) { + v = bfEnc(bf, v); + parts.push(xorBytes(data.slice(rounds * 8), v)); + } + return utf8Str(concatBytes(parts)); + } + + /* ================= 版本 12:AES-128-CBC / PKCS7 ================= */ + function pkcs7Pad(u8, block) { + var pad = block - (u8.length % block); + var out = new Uint8Array(u8.length + pad); + out.set(u8); + for (var i = 0; i < pad; i++) out[u8.length + i] = pad; + return out; + } + function pkcs7Unpad(u8, block) { + if (!u8.length || u8.length % block) return u8; + var pad = u8[u8.length - 1]; + if (pad < 1 || pad > block || pad > u8.length) return u8; // 非合法填充:按原文返回 + for (var i = u8.length - pad; i < u8.length; i++) { + if (u8[i] !== pad) return u8; + } + return u8.slice(0, u8.length - pad); + } + + function u8ToWa(CryptoJS, u8) { + var words = []; + for (var i = 0; i < u8.length; i++) words[i >>> 2] = (words[i >>> 2] || 0) | (u8[i] << (24 - (i % 4) * 8)); + return CryptoJS.lib.WordArray.create(words, u8.length); + } + function waToU8(wa) { + var words = wa.words, n = wa.sigBytes, out = new Uint8Array(n); + for (var i = 0; i < n; i++) out[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; + return out; + } + + function encrypt12(CryptoJS, text) { + var key = CryptoJS.enc.Utf8.parse(AES_KEY); + var iv = CryptoJS.enc.Utf8.parse(AES_IV); + var data = u8ToWa(CryptoJS, pkcs7Pad(utf8Bytes(text), 16)); + var enc = CryptoJS.AES.encrypt(data, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.NoPadding }); + return enc.ciphertext.toString(CryptoJS.enc.Hex).toUpperCase(); + } + + function decrypt12(CryptoJS, hex) { + var key = CryptoJS.enc.Utf8.parse(AES_KEY); + var iv = CryptoJS.enc.Utf8.parse(AES_IV); + var ct = CryptoJS.enc.Hex.parse(cleanHex(hex).toLowerCase()); + var dec = CryptoJS.AES.decrypt({ ciphertext: ct }, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.NoPadding }); + return utf8Str(pkcs7Unpad(waToU8(dec), 16)); + } + + /* ================= .ncx 连接文件解析 ================= */ + function decodeEntities(s) { + return String(s).replace(/&(amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);/gi, function (m, g) { + var k = g.toLowerCase(); + if (k === 'amp') return '&'; + if (k === 'lt') return '<'; + if (k === 'gt') return '>'; + if (k === 'quot') return '"'; + if (k === 'apos') return "'"; + if (g.charAt(0) === '#') { + var code = g.charAt(1).toLowerCase() === 'x' ? parseInt(g.substr(2), 16) : parseInt(g.substr(1), 10); + return isNaN(code) ? m : String.fromCharCode(code); + } + return m; + }); + } + + /* 解析 .ncx(Navicat 导出的 XML 连接文件),返回连接数组 */ + function parseNcx(xmlText) { + var text = String(xmlText == null ? '' : xmlText); + if (!/\S/.test(text)) throw new Error('文件内容为空'); + var connRe = /]*?\/?>/gi; + var attrRe = /([A-Za-z_][\w:.-]*)\s*=\s*"([^"]*)"/g; + var out = [], m; + while ((m = connRe.exec(text))) { + var attrs = {}, a; + attrRe.lastIndex = 0; + while ((a = attrRe.exec(m[0]))) attrs[a[1]] = decodeEntities(a[2]); + out.push({ + name: attrs.ConnectionName || '', + type: attrs.ConnType || '', + host: attrs.Host || '', + port: attrs.Port || '', + user: attrs.UserName || '', + password: attrs.Password || attrs.Pwd || '', + attrs: attrs + }); + } + if (!out.length) throw new Error('未找到 节点,请确认是 Navicat 导出的 .ncx 文件'); + return out; + } + + function formatReport(rows, version) { + var lines = ['共解析 ' + rows.length + ' 个连接(密码按 Navicat ' + version + ' 解密)']; + for (var i = 0; i < rows.length; i++) { + var r = rows[i]; + lines.push(''); + lines.push('【' + (i + 1) + '】' + (r.name || '(未命名)') + (r.type ? '(' + r.type + ')' : '')); + lines.push(' 主机:' + (r.host || '(空)')); + lines.push(' 端口:' + (r.port || '(空)')); + lines.push(' 用户名:' + (r.user || '(空)')); + lines.push(' 密码:' + (r.plain ? r.plain : '(空)') + (r.plainNote || '')); + if (r.password && !/^null$/i.test(r.password)) lines.push(' 密码密文:' + r.password); + } + return lines.join('\n') + '\n'; + } + + /* 解析文件并解密每个连接密码,返回规范格式文本(Promise) */ + function buildConnectionReport(xmlText, version) { + var ver = normalizeVersion(version); + var rows; + try { rows = parseNcx(xmlText); } catch (e) { return Promise.reject(e); } + return Promise.all(rows.map(function (c) { + var pwd = c.password; + var make = function (plain, note) { + return { + name: c.name, type: c.type, host: c.host, port: c.port, user: c.user, + password: pwd, plain: plain, plainNote: note || '' + }; + }; + if (!pwd || /^null$/i.test(pwd)) return Promise.resolve(make('', '')); + if (!/^[0-9a-fA-F]+$/.test(pwd) || pwd.length % 2 !== 0) { + return Promise.resolve(make(pwd, '(非十六进制密文,按原文显示)')); + } + return decrypt(pwd, ver).then(function (t) { + return make(t, t.indexOf('\uFFFD') >= 0 ? '(解密结果含乱码,请核对版本)' : ''); + }).catch(function (e) { + return make(pwd, '(解密失败:' + (e && e.message ? e.message : e) + ')'); + }); + })).then(function (list) { + return formatReport(list, ver); + }); + } + + /* ================= 对外接口 ================= */ + function normalizeVersion(v) { return String(v) === '11' ? 11 : 12; } + + function decrypt(cipher, version) { + var ver = normalizeVersion(version); + if (!cleanHex(cipher).length) return Promise.reject(new Error('请输入十六进制密文')); + return loadLib(ver === 11 ? 'blowfish' : 'cryptojs').then(function (lib) { + if (!lib) throw new Error('加密组件加载失败,请检查网络后重试'); + return ver === 11 ? decrypt11(lib, cipher) : decrypt12(lib, cipher); + }); + } + + function encrypt(text, version) { + var ver = normalizeVersion(version); + if (text === '' || text == null) return Promise.reject(new Error('请输入明文')); + return loadLib(ver === 11 ? 'blowfish' : 'cryptojs').then(function (lib) { + if (!lib) throw new Error('加密组件加载失败,请检查网络后重试'); + return ver === 11 ? encrypt11(lib, text) : encrypt12(lib, text); + }); + } + + global.NavicatCipher = { + decrypt: decrypt, + encrypt: encrypt, + buildConnectionReport: buildConnectionReport, + _core: { + encrypt11: encrypt11, decrypt11: decrypt11, encrypt12: encrypt12, decrypt12: decrypt12, + parseNcx: parseNcx, formatReport: formatReport, + hexToBytes: hexToBytes, bytesToHex: bytesToHex, utf8Bytes: utf8Bytes, utf8Str: utf8Str, + pkcs7Pad: pkcs7Pad, pkcs7Unpad: pkcs7Unpad + } + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/func/index.php b/func/index.php index c488dfa..e243c81 100644 --- a/func/index.php +++ b/func/index.php @@ -1,6 +1,6 @@ ← 返回首页

功能区

-

工具统一收纳于此;新工具的入口可在后台“功能区管理”中登记

+

工具统一收纳于此;新工具的入口可在后台“内部功能”中登记

-
功能区还没有可用的工具,请到后台“功能区管理”中添加
+
功能区还没有可用的工具,请到后台“内部功能”中添加
@@ -46,4 +47,5 @@ layout_head('功能区'); - + + \ No newline at end of file diff --git a/func/navicat.html b/func/navicat.html new file mode 100644 index 0000000..c6a788b --- /dev/null +++ b/func/navicat.html @@ -0,0 +1,194 @@ + + + + + + + +Navicat 密码解密 + + + + +
+ ← 返回功能区首页 + Navicat 密码解密 + 首页 +
+ +
+
+
+

🔒 全部运算在本机浏览器完成,密文 / 明文不会上传服务器,可放心处理。

+

打开Navicat,点击左上角文件,点击导出连接选项,必须选择导出密码,然后上传ncx或者自行筛选password即可

+ +
+ + +
+ + +
+ + + +
+
+
+ +
+

解析 .ncx 连接文件

+

选择 Navicat 导出的 .ncx 连接文件,本工具在本地解析其中全部连接并按规范格式输出;连接密码会用上方所选 Navicat 版本自动解密。文件不会上传服务器。

+
+ + + 选择后自动解析;切换版本后可点“解析文件”重新解密 +
+
+
+ +
+
+
+ 输入区(密文 / 明文,可编辑) + +
+ +
+
+
+ 输出区(可编辑) + +
+ +
+
+
+
+ + + + + + + + diff --git a/includes/auth.php b/includes/auth.php index 37a061a..d6a2891 100644 --- a/includes/auth.php +++ b/includes/auth.php @@ -42,14 +42,22 @@ function hp_verify_user(string $username, string $plain): bool return hash_equals($row['password_hash'], hp_password_hash($plain)); } -/** 执行登录(成功返回 true) */ -function hp_login(string $username, string $plain): bool +/** 执行登录(成功返回 true;成功/失败均写入登录日志与设备信息) */ +function hp_login(string $username, string $plain, string $deviceClient = ''): bool { + $uaRaw = (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''); if (hp_verify_user($username, $plain)) { session_regenerate_id(true); $_SESSION['admin'] = $username; + // 记录本次登录信息(供后台“登录信息”卡片展示) + $_SESSION['login_at'] = date('Y-m-d H:i:s'); + $_SESSION['login_ip'] = client_ip(); + $_SESSION['login_ua'] = login_ua_brief($uaRaw); + $_SESSION['login_device'] = login_device_brief($deviceClient, $uaRaw); + login_log_write($username, 'ok', $deviceClient); return true; } + login_log_write($username, 'fail', $deviceClient); return false; } @@ -59,14 +67,14 @@ function hp_change_password(string $oldPlain, string $newPlain): array if (!is_logged_in()) { return [false, '未登录']; } - if (!hp_verify_user((string)$_SESSION['admin'], $oldPlain)) { + if (!hp_verify_user((string) $_SESSION['admin'], $oldPlain)) { return [false, '原密码不正确']; } if (strlen($newPlain) < 6) { return [false, '新密码至少 6 位']; } $st = db()->prepare('UPDATE users SET password_hash = ? WHERE username = ?'); - $st->execute([hp_password_hash($newPlain), (string)$_SESSION['admin']]); + $st->execute([hp_password_hash($newPlain), (string) $_SESSION['admin']]); return [true, '密码修改成功']; } @@ -87,7 +95,7 @@ function csrf_token(): string if (empty($_SESSION['csrf'])) { $_SESSION['csrf'] = bin2hex(random_bytes(16)); } - return (string)$_SESSION['csrf']; + return (string) $_SESSION['csrf']; } function csrf_field(): string diff --git a/includes/db.php b/includes/db.php index a6c65c4..b820c81 100644 --- a/includes/db.php +++ b/includes/db.php @@ -35,7 +35,7 @@ function db_ensure_column(PDO $pdo, string $table, string $column, string $defin { $cols = $pdo->query('PRAGMA table_info(' . $table . ')')->fetchAll(); foreach ($cols as $col) { - if (strtolower((string)$col['name']) === strtolower($column)) { + if (strtolower((string) $col['name']) === strtolower($column)) { return; } } @@ -138,6 +138,21 @@ function db_init(PDO $pdo): void updated_at TEXT NOT NULL DEFAULT \'\' )'); + // ---------- 登录日志(成功 / 失败均记录:时间 / 用户名 / IP / UA / 设备) ---------- + $pdo->exec('CREATE TABLE IF NOT EXISTS login_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT DEFAULT \'\', + ip TEXT DEFAULT \'\', + ua TEXT DEFAULT \'\', + device TEXT DEFAULT \'\', + status TEXT NOT NULL DEFAULT \'ok\', + created_at TEXT NOT NULL DEFAULT \'\' + )'); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_login_log_status ON login_log (status, created_at)'); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_login_log_ip ON login_log (ip)'); + // 兼容旧库:登录日志补 device 列 + db_ensure_column($pdo, 'login_log', 'device', "TEXT 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 ''"); @@ -151,7 +166,7 @@ function db_init(PDO $pdo): void db_ensure_column($pdo, 'ext_links', 'grp', "TEXT DEFAULT ''"); // ---------- 默认分类种子 ---------- - $count = (int)$pdo->query('SELECT COUNT(*) FROM categories')->fetchColumn(); + $count = (int) $pdo->query('SELECT COUNT(*) FROM categories')->fetchColumn(); if ($count === 0) { $seedCats = [ ['popular', '科普库', '📖', 1, '面向大众与初学者的计算机科学、网络安全与前沿技术科普,帮你建立体系化的安全认知。'], @@ -166,30 +181,30 @@ function db_init(PDO $pdo): void } // ---------- 文章库板块分类(第五大板块,旧库缺失时追加) ---------- - $hasArticleCat = (int)$pdo->query("SELECT COUNT(*) FROM categories WHERE key = 'article'")->fetchColumn(); + $hasArticleCat = (int) $pdo->query("SELECT COUNT(*) FROM categories WHERE key = 'article'")->fetchColumn(); if ($hasArticleCat === 0) { $stC = $pdo->prepare('INSERT INTO categories (key, name, icon, description, sort) VALUES (?,?,?,?,?)'); $stC->execute(['article', '文章库', '📚', '站长原创与整理收录的图文与 Markdown 文章合集。', 5]); } // ---------- 默认设置种子 ---------- - $hasSettings = (int)$pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn(); + $hasSettings = (int) $pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn(); if ($hasSettings === 0) { $defaults = [ - 'site_name' => '知识导航站', - 'site_slogan' => '汇聚科普、攻防与效率工具的实用首页', - 'site_logo' => '', // 空 = 使用默认 assets/img/logo.svg - 'icp_no' => '京ICP备00000000号-1', - 'gongan_no' => '京公网安备11000000000000号', - 'gongan_link' => '', // 公安备案号链接(可空则不显示为链接) - 'copyright' => 'Copyright © 2026 知识导航站 版权所有。本站内容仅供学习研究使用。', - 'footer_text' => '', - 'search_engines'=> '', // 搜索引擎列表 JSON;空 = 使用默认 4 个 - 'last_updated' => '', - 'hero_bg' => '', // 首页顶部标识栏背景(纯色 / CSS 渐变),空 = 主题默认 - 'hero_mode' => 'weather', // 首页顶部组件模式:weather=天气(默认) / info=访客信息 / blank=空白精简 - 'cdn_ranges' => '', // CDN 段库文本;空 = 使用默认内置列表 - 'article_note' => '', // 文章区首页顶部注要(Markdown 风格,纯文本) + 'site_name' => '知识导航站', + 'site_slogan' => '汇聚科普、攻防与效率工具的实用首页', + 'site_logo' => '', // 空 = 使用默认 assets/img/logo.svg + 'icp_no' => '京ICP备00000000号-1', + 'gongan_no' => '京公网安备11000000000000号', + 'gongan_link' => '', // 公安备案号链接(可空则不显示为链接) + 'copyright' => 'Copyright © 2026 知识导航站 版权所有。本站内容仅供学习研究使用。', + 'footer_text' => '', + 'search_engines' => '', // 搜索引擎列表 JSON;空 = 使用默认 4 个 + 'last_updated' => '', + 'hero_bg' => '', // 首页顶部标识栏背景(纯色 / CSS 渐变),空 = 主题默认 + 'hero_mode' => 'weather', // 首页顶部组件模式:weather=天气(默认) / info=访客信息 / blank=空白精简 + 'cdn_ranges' => '', // CDN 段库文本;空 = 使用默认内置列表 + 'article_note' => '', // 文章区首页顶部注要(Markdown 风格,纯文本) ]; $st = $pdo->prepare('INSERT INTO settings (key, value) VALUES (?,?)'); foreach ($defaults as $k => $v) { @@ -198,7 +213,7 @@ function db_init(PDO $pdo): void } // ---------- 默认管理员 ---------- - $userCount = (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn(); + $userCount = (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn(); if ($userCount === 0) { // 密码算法:先 base64 再 md5(按原始需求实现) $st = $pdo->prepare('INSERT INTO users (username, password_hash) VALUES (?,?)'); @@ -206,7 +221,7 @@ function db_init(PDO $pdo): void } // ---------- 功能区工具默认种子 ---------- - $toolCount = (int)$pdo->query('SELECT COUNT(*) FROM func_tools')->fetchColumn(); + $toolCount = (int) $pdo->query('SELECT COUNT(*) FROM func_tools')->fetchColumn(); if ($toolCount === 0) { $seedTools = [ ['编码 / 加解密', '🔐', 'Base64 / Base32 / URL / Unicode / MD5 / SHA 系列', 'codec.html', 0], @@ -226,19 +241,20 @@ function db_init(PDO $pdo): void ['国密加解密', '🔏', 'SM2 非对称加解密 / SM3 摘要 / SM4 对称加解密(SM1 为不公开硬件算法)', 'gmcodec.html'], ['IP 地址处理', '🌐', '提取日志中的全部 IPv4 并统计出现次数,再按内网 / CDN / 公网区分展示', 'ip.html'], ['辅助维权', '🛠️', '反弹 Shell 构建 / Windows SAM·mimikatz·票据提取 / Linux 自启服务(systemd 与 init.d)', 'persist.html'], + ['Navicat 密码解密', '🔓', 'Navicat 数据库连接密码本地解密 / 加密(11 及以前 Blowfish / 12 及以后 AES-128-CBC)', 'navicat.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,?)'); foreach ($extraTools as $t) { $chkUrl->execute([$t[3]]); - if ((int)$chkUrl->fetchColumn() === 0) { - $mx = (int)$pdo->query('SELECT COALESCE(MAX(sort), 0) FROM func_tools')->fetchColumn(); + if ((int) $chkUrl->fetchColumn() === 0) { + $mx = (int) $pdo->query('SELECT COALESCE(MAX(sort), 0) FROM func_tools')->fetchColumn(); $insTool->execute([$t[0], $t[1], $t[2], $t[3], $mx + 1]); } } // ---------- 库首页“外部工具区”默认种子(cat_keys 为空=展示于所有库首页;grp=分区/栏目,留空不分) ---------- - $extCount = (int)$pdo->query('SELECT COUNT(*) FROM ext_links')->fetchColumn(); + $extCount = (int) $pdo->query('SELECT COUNT(*) FROM ext_links')->fetchColumn(); if ($extCount === 0) { $seedExt = [ // [分区/栏目, 名称, 网址, 简介] @@ -257,7 +273,7 @@ function db_init(PDO $pdo): void } // ---------- 文章库示例文章(首次建库时提供一篇使用说明) ---------- - $artCount = (int)$pdo->query('SELECT COUNT(*) FROM articles')->fetchColumn(); + $artCount = (int) $pdo->query('SELECT COUNT(*) FROM articles')->fetchColumn(); if ($artCount === 0) { $sampleMd = << 'codec.html', + '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', + '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) { @@ -322,7 +338,7 @@ function setting_get(string $key, string $default = ''): string if ($cache === null) { $cache = []; foreach (db()->query('SELECT key, value FROM settings') as $row) { - $cache[$row['key']] = (string)$row['value']; + $cache[$row['key']] = (string) $row['value']; } } return array_key_exists($key, $cache) ? $cache[$key] : $default; @@ -342,7 +358,7 @@ function client_ip(): string if ($cached !== null) { return $cached; } - $ip = trim((string)($_SERVER['REMOTE_ADDR'] ?? '')); + $ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? '')); if (!filter_var($ip, FILTER_VALIDATE_IP)) { $ip = ''; } @@ -376,7 +392,7 @@ function blacklist_parse_rule(string $raw): array } // CIDR 网段 if (preg_match('#^(\d{1,3}(?:\.\d{1,3}){3})/(\d{1,2})$#', $rule, $m)) { - $prefix = (int)$m[2]; + $prefix = (int) $m[2]; if ($prefix > 32 || !filter_var($m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return [false, 0, 0, '网段格式不正确,掩码需在 0-32 之间。']; } @@ -400,10 +416,10 @@ function blacklist_parse_rule(string $raw): array } continue; } - if ($seen || $p === '' || !ctype_digit($p) || (int)$p > 255) { + if ($seen || $p === '' || !ctype_digit($p) || (int) $p > 255) { return [false, 0, 0, '通配段格式不正确,* 只能连续出现在末尾。']; } - $bytes[$i] = (int)$p; + $bytes[$i] = (int) $p; } if (!$seen) { return [false, 0, 0, '规则格式不正确。']; @@ -424,17 +440,17 @@ function blacklist_rules(bool $refresh = false): array $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']); + [$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, + '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) { @@ -473,12 +489,12 @@ function access_log_flush(): void if ($ip === '') { return; } - $method = (string)($_SERVER['REQUEST_METHOD'] ?? ''); + $method = (string) ($_SERVER['REQUEST_METHOD'] ?? ''); $method = $method !== '' ? strtoupper($method) : 'GET'; - $uri = (string)($_SERVER['REQUEST_URI'] ?? '/'); + $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); + $uri = substr((string) $uri, 0, 600); $code = http_response_code(); if (!is_int($code) || $code < 100 || $code > 599) { $code = 200; @@ -487,7 +503,7 @@ function access_log_flush(): void $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; + $isAdmin = strpos((string) ($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0; if (!$isAdmin && $code === 404) { risk_count_not_found($ip); } @@ -496,7 +512,7 @@ function access_log_flush(): void } } -/** 读取访问日志文件,解析为记录数组(新→旧排序),供后台“访问信息”页使用 */ +/** 读取访问日志文件,解析为记录数组(新→旧排序),供后台“风控管理”页使用 */ function visitors_entries(): array { $out = []; @@ -517,11 +533,11 @@ function visitors_entries(): array continue; } $out[] = [ - 'created_at' => (string)$p[0], - 'ip' => (string)$p[1], - 'method' => (string)$p[2], - 'status' => (int)$p[3], - 'url' => (string)$p[4], + 'created_at' => (string) $p[0], + 'ip' => (string) $p[1], + 'method' => (string) $p[2], + 'status' => (int) $p[3], + 'url' => (string) $p[4], ]; } fclose($fh); @@ -539,7 +555,7 @@ function access_boot(): void if ($ip === '') { return; } - $isAdmin = strpos((string)($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0; + $isAdmin = strpos((string) ($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0; // 1) 已在黑名单(手动 / 自动):前台与后台一致拦截,统一返回风控提示页 if (blacklist_ip_hit($ip) !== null) { risk_block_page(); @@ -548,15 +564,15 @@ function access_boot(): void 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'] . ' 次)'); + 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'] . ' 次)'); + if ((int) $row['not_found'] >= (int) $cfg['not_found']['n']) { + risk_ban($ip, '404 累计次数达阈值(' . (int) $cfg['not_found']['n'] . ' 次)'); risk_block_page(); } } @@ -573,7 +589,7 @@ function risk_settings_map(): array $map = []; try { foreach (db()->query('SELECT key, value FROM settings') as $r) { - $map[(string)$r['key']] = (string)$r['value']; + $map[(string) $r['key']] = (string) $r['value']; } } catch (Throwable $e) { $map = []; @@ -588,10 +604,10 @@ function risk_settings_map(): array function risk_block_tip(): array { $map = risk_settings_map(); - $rawMain = trim((string)($map['risk_tip_main'] ?? '')); - $rawSub = trim((string)($map['risk_tip_sub'] ?? '')); + $rawMain = trim((string) ($map['risk_tip_main'] ?? '')); + $rawSub = trim((string) ($map['risk_tip_sub'] ?? '')); $main = $rawMain !== '' ? $rawMain : '您触发了本站风控,请稍后再访问'; - $sub = $rawSub !== '' ? $rawSub : '如为正常访问,请稍后重试;若频繁误判,请联系站点管理员处理。'; + $sub = $rawSub !== '' ? $rawSub : '如为正常访问,请稍后重试;若频繁误判,请联系站点管理员处理。'; return ['main' => $main, 'sub' => $sub, 'raw_main' => $rawMain, 'raw_sub' => $rawSub]; } @@ -602,9 +618,9 @@ function risk_block_tip(): array function risk_rule_cfg(): array { $defs = [ - 'login_fail' => [1, 10, '后台登录失败累计达到阈值后,该 IP 被自动加入黑名单(永久封禁)。'], - 'not_found' => [1, 100, '前台访问返回 404 累计达到阈值后,该 IP 被自动加入黑名单(永久封禁)。'], - 'rate' => [0, 120, '60 秒内请求次数超过阈值后,该 IP 被自动加入黑名单(永久封禁);多人共享同一出口 IP 时建议关闭。'], + 'login_fail' => [1, 10, '后台登录失败累计达到阈值后,该 IP 被自动加入黑名单(永久封禁)。'], + 'not_found' => [1, 100, '前台访问返回 404 累计达到阈值后,该 IP 被自动加入黑名单(永久封禁)。'], + 'rate' => [0, 120, '60 秒内请求次数超过阈值后,该 IP 被自动加入黑名单(永久封禁);多人共享同一出口 IP 时建议关闭。'], ]; $map = risk_settings_map(); $out = []; @@ -613,18 +629,18 @@ function risk_rule_cfg(): array 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]))), + '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'], + 'main' => $tip['main'], + 'sub' => $tip['sub'], 'raw_main' => $tip['raw_main'], - 'raw_sub' => $tip['raw_sub'], + 'raw_sub' => $tip['raw_sub'], ]; return $out; } @@ -690,9 +706,9 @@ function risk_login_fail(string $ip): array } $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']); + $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 . ' 次)'); @@ -706,10 +722,10 @@ 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); + $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; } @@ -721,10 +737,10 @@ function risk_count_not_found(string $ip): void } $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'] . ' 次)'); + $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'] . ' 次)'); } } @@ -767,8 +783,8 @@ function risk_block_page(): void // ignore } $tip = risk_block_tip(); - $s = htmlspecialchars($site, ENT_QUOTES, 'UTF-8'); - $m = htmlspecialchars($tip['main'], ENT_QUOTES, 'UTF-8'); + $s = htmlspecialchars($site, ENT_QUOTES, 'UTF-8'); + $m = htmlspecialchars($tip['main'], ENT_QUOTES, 'UTF-8'); $sb = htmlspecialchars($tip['sub'], ENT_QUOTES, 'UTF-8'); echo '' . "\n" . '' @@ -791,4 +807,201 @@ function risk_block_page(): void exit; } +/** 由 UA 串解析浏览器与操作系统(内部复用):返回 [浏览器, 操作系统] */ +function ua_parts(string $ua): array +{ + $ua = trim($ua); + if ($ua === '') { + return ['未知浏览器', '']; + } + if (strpos($ua, 'MicroMessenger') !== false) { + $browser = '微信内置浏览器'; + } elseif (preg_match('/Edg\/([\d.]+)/', $ua, $m)) { + $browser = 'Edge ' . $m[1]; + } elseif (preg_match('/OPR\/([\d.]+)/', $ua, $m)) { + $browser = 'Opera ' . $m[1]; + } elseif (preg_match('/Firefox\/([\d.]+)/', $ua, $m)) { + $browser = 'Firefox ' . $m[1]; + } elseif (preg_match('/(?:Chrome|CriOS)\/([\d.]+)/', $ua, $m)) { + $browser = 'Chrome ' . $m[1]; + } elseif (preg_match('/Version\/([\d.]+).*Safari/', $ua, $m)) { + $browser = 'Safari ' . $m[1]; + } elseif (stripos($ua, 'curl') !== false) { + $browser = 'curl'; + } elseif (preg_match('/python|wget|Go-http|Java|okhttp/i', $ua)) { + $browser = '脚本客户端'; + } else { + $browser = '未知浏览器'; + } + 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.1') !== false) { + $os = 'Windows 7'; + } elseif (stripos($ua, 'Windows') !== false) { + $os = 'Windows'; + } elseif (strpos($ua, 'Android') !== false) { + $os = 'Android'; + } elseif (preg_match('/iPhone|iPad|iPod/', $ua)) { + $os = 'iOS'; + } elseif (strpos($ua, 'Mac OS X') !== false) { + $os = 'macOS'; + } elseif (strpos($ua, 'Linux') !== false) { + $os = 'Linux'; + } else { + $os = ''; + } + return [$browser, $os]; +} + +/** 由 UA 串解析“浏览器 / 操作系统”(简洁展示;容错返回“未知设备”) */ +function login_ua_brief(string $ua): string +{ + if (trim($ua) === '') { + return '未知设备'; + } + list($browser, $os) = ua_parts($ua); + return $os !== '' ? ($browser . ' / ' . $os) : $browser; +} + +/** + * 设备信息描述(手机型号 / 操作系统 / 浏览器 / 设备类型 + 屏幕、语言等): + * 客户端采集 JSON($clientJson,见 common.js collectLoginDevice)优先,服务端 UA 回退;能获取到什么就写什么。 + */ +function login_device_brief(string $clientJson, string $ua): string +{ + $ua = trim($ua); + $ci = json_decode($clientJson, true); + if (!is_array($ci)) { + $ci = []; + } + $uch = (isset($ci['uch']) && is_array($ci['uch'])) ? $ci['uch'] : []; + list($browser, $os) = ua_parts($ua); + // 系统:客户端高熵值优先(可拿到 Windows/Android 具体版本) + if (!empty($uch['platform'])) { + $os2 = trim((string) $uch['platform']); + if (!empty($uch['platformVersion'])) { + $os2 .= ' ' . trim((string) $uch['platformVersion']); + } + if ($os2 !== '') { + $os = $os2; + } + } + // 型号:客户端 model 优先,其次从 Android UA 提取,iOS 只能得到机型系列 + $model = ''; + if (!empty($uch['model'])) { + $model = trim((string) $uch['model']); + } elseif (preg_match('/Android[\s\d.]+;\s*([^;)]+?)(?:\s+Build|\))/i', $ua, $m)) { + $model = trim($m[1]); + } elseif (preg_match('/(iPhone|iPad|iPod)/i', $ua, $m)) { + $model = 'Apple ' . $m[1]; + } + $mobile = (!empty($ci['touch'])) || (stripos($ua, 'Mobile') !== false) || (isset($uch['mobile']) && $uch['mobile']); + $seg = []; + if ($model !== '') { + $seg[] = $model; + } + if ($os !== '') { + $seg[] = $os; + } + if ($browser !== '') { + $seg[] = $browser; + } + $seg[] = $mobile ? '移动设备' : '桌面设备'; + $extra = []; + if (!empty($uch['architecture'])) { + $extra[] = '架构 ' . trim((string) $uch['architecture']); + } + if (!empty($ci['screen'])) { + $extra[] = '屏幕 ' . trim((string) $ci['screen']); + } + if (!empty($ci['lang'])) { + $extra[] = trim((string) $ci['lang']); + } + $text = implode(' · ', $seg); + if ($extra) { + $text .= '(' . implode(',', $extra) . ')'; + } + return function_exists('mb_substr') ? mb_substr($text, 0, 300, 'UTF-8') : substr($text, 0, 300); +} + +/** 写入一条登录日志(status:ok=成功 / fail=失败);同时保存客户端设备信息($deviceClient 为前端采集 JSON) */ +function login_log_write(string $username, string $status, string $deviceClient = ''): void +{ + $status = ($status === 'fail') ? 'fail' : 'ok'; + $uaRaw = (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''); + $ua = function_exists('mb_substr') ? mb_substr($uaRaw, 0, 300, 'UTF-8') : substr($uaRaw, 0, 300); + $device = login_device_brief(substr($deviceClient, 0, 2000), $uaRaw); + try { + $st = db()->prepare('INSERT INTO login_log (username, ip, ua, device, status, created_at) VALUES (?,?,?,?,?,?)'); + $st->execute([$username, client_ip(), $ua, $device, $status, date('Y-m-d H:i:s')]); + } catch (Throwable $e) { + // 登录日志写入失败不影响登录流程 + } +} + +/** 最近的成功登录记录(新→旧) */ +function login_log_recent_ok(int $limit = 15): array +{ + $limit = max(1, min(200, $limit)); + try { + $st = db()->prepare("SELECT username, ip, ua, device, created_at FROM login_log WHERE status = 'ok' ORDER BY id DESC LIMIT ?"); + $st->bindValue(1, $limit, PDO::PARAM_INT); + $st->execute(); + return $st->fetchAll(); + } catch (Throwable $e) { + return []; + } +} + +/** 登录失败日志:按「IP × 日期」聚合(次数 / 最近时间 / 最近设备),支持 IP 模糊筛选 */ +function login_fail_daily_stats(string $ipFilter = '', int $limit = 200): array +{ + $limit = max(1, min(1000, $limit)); + try { + $sql = "SELECT ip, substr(created_at,1,10) AS day, COUNT(*) AS cnt, MAX(created_at) AS last_at, + (SELECT ua FROM login_log l2 WHERE l2.ip = l1.ip AND l2.status = 'fail' ORDER BY l2.id DESC LIMIT 1) AS ua, + (SELECT device FROM login_log l3 WHERE l3.ip = l1.ip AND l3.status = 'fail' ORDER BY l3.id DESC LIMIT 1) AS device + FROM login_log l1 WHERE status = 'fail'"; + $args = []; + if ($ipFilter !== '') { + $sql .= ' AND ip LIKE ?'; + $args[] = '%' . $ipFilter . '%'; + } + $sql .= ' GROUP BY ip, day ORDER BY last_at DESC LIMIT ' . $limit; + $st = db()->prepare($sql); + $st->execute($args); + return $st->fetchAll(); + } catch (Throwable $e) { + return []; + } +} + +/** 登录日志统计:今日成功 / 今日失败 / 失败涉及 IP 数 / 总记录数 */ +function login_log_stat(): array +{ + $out = ['ok_today' => 0, 'fail_today' => 0, 'fail_ips' => 0, 'total' => 0]; + try { + $today = db()->quote(date('Y-m-d')); + $out['ok_today'] = (int) db()->query("SELECT COUNT(*) FROM login_log WHERE status = 'ok' AND substr(created_at,1,10) = " . $today)->fetchColumn(); + $out['fail_today'] = (int) db()->query("SELECT COUNT(*) FROM login_log WHERE status = 'fail' AND substr(created_at,1,10) = " . $today)->fetchColumn(); + $out['fail_ips'] = (int) db()->query("SELECT COUNT(DISTINCT ip) FROM login_log WHERE status = 'fail'")->fetchColumn(); + $out['total'] = (int) db()->query('SELECT COUNT(*) FROM login_log')->fetchColumn(); + } catch (Throwable $e) { + // ignore + } + return $out; +} + +/** 清空登录日志(成功 + 失败) */ +function login_log_clear(): void +{ + try { + db()->exec('DELETE FROM login_log'); + } catch (Throwable $e) { + // ignore + } +} + access_boot(); diff --git a/nav.php b/nav.php index c301ec9..9a5f827 100644 --- a/nav.php +++ b/nav.php @@ -7,25 +7,26 @@ * 说明:库间切换仅在首页板块完成,库内不提供切换入口;板块说明文案常显于首页板块卡。 * 数据来源: * 导航区 = nav_blocks / nav_items(按当前库); - * 内部功能区 = func_tools(后台“功能区管理”中 board_cats 控制按库展示,空=通用); + * 内部功能区 = func_tools(后台“内部功能”中 board_cats 控制按库展示,空=通用); * 文章区 = articles(后台“文章管理”中 board_cats 勾选分发到库,未分发不显示); * 外部工具区 = ext_links(后台“外部工具”维护,cat_keys 控制范围,空=通用)。 */ $P = ''; require_once __DIR__ . '/includes/layout.php'; -$catKey = isset($_GET['cat']) ? (string)$_GET['cat'] : ''; +$catKey = isset($_GET['cat']) ? (string) $_GET['cat'] : ''; $allowed = ['popular', 'red', 'blue']; if (!in_array($catKey, $allowed, true)) { - $catKey = 'popular'; + $catKey = 'popular'; } /** 范围列是否包含当前库:空字符串视为“通用(所有库)” */ function board_cats_has(string $csv, string $key): bool { - $csv = trim($csv); - if ($csv === '') return true; - return in_array($key, array_map('trim', explode(',', $csv)), true); + $csv = trim($csv); + if ($csv === '') + return true; + return in_array($key, array_map('trim', explode(',', $csv)), true); } $pdo = db(); @@ -33,89 +34,99 @@ $st = $pdo->prepare('SELECT * FROM categories WHERE key = ? LIMIT 1'); $st->execute([$catKey]); $cat = $st->fetch(); if (!$cat) { - http_response_code(404); - layout_head('导航页不存在'); - echo '
导航分类不存在,请返回首页。
'; - layout_theme_fab(); - layout_footer(); - echo ''; - exit; + http_response_code(404); + layout_head('导航页不存在'); + echo '
导航分类不存在,请返回首页。
'; + layout_theme_fab(); + layout_footer(); + echo ''; + exit; } // —— 导航区数据 —— $st = $pdo->prepare('SELECT * FROM nav_blocks WHERE cat_id = ? ORDER BY sort ASC, id ASC'); -$st->execute([(int)$cat['id']]); +$st->execute([(int) $cat['id']]); $blocks = $st->fetchAll(); $itemsByBlock = []; if ($blocks) { - $ids = array_map(function ($b) { return (int)$b['id']; }, $blocks); - $in = implode(',', array_fill(0, count($ids), '?')); - $st = $pdo->prepare("SELECT * FROM nav_items WHERE block_id IN ($in) ORDER BY sort ASC, id ASC"); - $st->execute($ids); - foreach ($st->fetchAll() as $item) { - $itemsByBlock[(int)$item['block_id']][] = $item; - } + $ids = array_map(function ($b) { + return (int) $b['id']; + }, $blocks); + $in = implode(',', array_fill(0, count($ids), '?')); + $st = $pdo->prepare("SELECT * FROM nav_items WHERE block_id IN ($in) ORDER BY sort ASC, id ASC"); + $st->execute($ids); + foreach ($st->fetchAll() as $item) { + $itemsByBlock[(int) $item['block_id']][] = $item; + } } $totalNav = 0; -foreach ($itemsByBlock as $list) { $totalNav += count($list); } +foreach ($itemsByBlock as $list) { + $totalNav += count($list); +} $navCols = [[], [], []]; $idx = 0; -foreach ($blocks as $b) { $navCols[$idx % 3][] = $b; $idx++; } +foreach ($blocks as $b) { + $navCols[$idx % 3][] = $b; + $idx++; +} // —— 内部功能区数据(通用或包含当前库) —— $libTools = []; foreach ($pdo->query('SELECT * FROM func_tools WHERE enabled = 1 ORDER BY sort ASC, id ASC')->fetchAll() as $t) { - if (board_cats_has((string)($t['board_cats'] ?? ''), $catKey)) { - $libTools[] = $t; - } + if (board_cats_has((string) ($t['board_cats'] ?? ''), $catKey)) { + $libTools[] = $t; + } } // —— 文章区数据(仅分发到当前库;按分类分组,分类内按最后更新时间倒序) —— $libArts = []; foreach ($pdo->query('SELECT id, part, title, summary, updated_at, board_cats FROM articles WHERE enabled = 1 ORDER BY updated_at DESC, id DESC')->fetchAll() as $a) { - if (in_array($catKey, array_map('trim', explode(',', trim((string)($a['board_cats'] ?? '')))), true)) { - $libArts[] = $a; - } + if (in_array($catKey, array_map('trim', explode(',', trim((string) ($a['board_cats'] ?? '')))), true)) { + $libArts[] = $a; + } } // 按分类(part)分组;分类顺序:后台拖出的分区顺序优先,否则按各组最新更新时间降序 $artGroups = []; foreach ($libArts as $a) { - $p = trim((string)$a['part']); - if ($p === '') $p = '未分类'; - if (!isset($artGroups[$p])) { - $artGroups[$p] = ['ts' => (string)$a['updated_at'], 'items' => []]; - } - $artGroups[$p]['items'][] = $a; - if (strcmp((string)$a['updated_at'], $artGroups[$p]['ts']) > 0) { - $artGroups[$p]['ts'] = (string)$a['updated_at']; - } + $p = trim((string) $a['part']); + if ($p === '') + $p = '未分类'; + if (!isset($artGroups[$p])) { + $artGroups[$p] = ['ts' => (string) $a['updated_at'], 'items' => []]; + } + $artGroups[$p]['items'][] = $a; + if (strcmp((string) $a['updated_at'], $artGroups[$p]['ts']) > 0) { + $artGroups[$p]['ts'] = (string) $a['updated_at']; + } } $poIdx = []; -$poCfg = trim((string)setting_get('article_part_order', '')); +$poCfg = trim((string) setting_get('article_part_order', '')); if ($poCfg !== '') { - foreach (explode(',', $poCfg) as $po) { - $po = trim($po); - if ($po !== '' && isset($artGroups[$po])) $poIdx[$po] = count($poIdx); - } + foreach (explode(',', $poCfg) as $po) { + $po = trim($po); + if ($po !== '' && isset($artGroups[$po])) + $poIdx[$po] = count($poIdx); + } } uksort($artGroups, static function ($a, $b) use ($poIdx, $artGroups) { - $ia = $poIdx[$a] ?? PHP_INT_MAX; - $ib = $poIdx[$b] ?? PHP_INT_MAX; - if ($ia !== $ib) return $ia <=> $ib; - $d = strcmp($artGroups[$b]['ts'], $artGroups[$a]['ts']); - return $d !== 0 ? $d : strcmp($a, $b); + $ia = $poIdx[$a] ?? PHP_INT_MAX; + $ib = $poIdx[$b] ?? PHP_INT_MAX; + if ($ia !== $ib) + return $ia <=> $ib; + $d = strcmp($artGroups[$b]['ts'], $artGroups[$a]['ts']); + return $d !== 0 ? $d : strcmp($a, $b); }); $artGroupOrder = []; foreach ($artGroups as $p => $g) { - $artGroupOrder[] = ['p' => $p, 'items' => $g['items']]; + $artGroupOrder[] = ['p' => $p, 'items' => $g['items']]; } // —— 外部工具区数据(通用或包含当前库) —— $extLinks = []; foreach ($pdo->query('SELECT * FROM ext_links WHERE enabled = 1 ORDER BY sort ASC, id ASC')->fetchAll() as $e) { - if (board_cats_has((string)($e['cat_keys'] ?? ''), $catKey)) { - $extLinks[] = $e; - } + if (board_cats_has((string) ($e['cat_keys'] ?? ''), $catKey)) { + $extLinks[] = $e; + } } $siteName = setting_get('site_name', '知识导航站'); @@ -123,80 +134,293 @@ $engines = search_engines(); $defaultEngine = $engines[0]['key'] ?? 'bing'; $enginesJson = json_encode($engines, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $domainOf = static function (string $u): string { - $h = (string)parse_url($u, PHP_URL_HOST); - return $h !== '' ? preg_replace('/^www\./i', '', $h) : $u; + $h = (string) parse_url($u, PHP_URL_HOST); + return $h !== '' ? preg_replace('/^www\./i', '', $h) : $u; }; -$catName = (string)$cat['name']; -$catIcon = trim((string)$cat['icon']); +$catName = (string) $cat['name']; +$catIcon = trim((string) $cat['icon']); layout_head($catName); ?>