请查看CHANGELOG.md获取更新信息
This commit is contained in:
parent
bf772046cc
commit
9bb6e6a41e
89
CHANGELOG.md
Normal file
89
CHANGELOG.md
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# SecHome · 安全屋 —— 更新日志
|
||||||
|
|
||||||
|
本文件记录项目的主要功能新增与调整,最新在上、按日期归档。
|
||||||
|
升级/迁移前请务必备份整个 `data/` 目录(数据库、访问日志、上传文件、文章图片)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-12
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **Navicat 数据库连接密码解密工具**(`func/navicat.html`)
|
||||||
|
- 支持 Navicat 11 及以前(Blowfish / ECB)与 12 及以后(AES-128-CBC)两代算法
|
||||||
|
- 纯前端本地计算,密文不上传服务器;支持解密与加密双向操作
|
||||||
|
- **Navicat 连接文件解析**:可直接上传 Navicat 导出的 `.ncx` 文件,规范化输出各连接的名称、主机、端口、用户名与解密后的密码
|
||||||
|
- **「更新公告」功能**
|
||||||
|
- 前台页脚新增「更新公告」入口,后台新增「更新公告管理」(`admin/notice.php`)
|
||||||
|
- 支持多条公告、**保留历史**、任意一条均可再次编辑;可置顶与隐藏
|
||||||
|
- 每条公告展示**发布时间**与**最后修改时间**(未修改时标注)
|
||||||
|
- 内容支持超链接:`[文字](链接)` 写法,站内相对路径与外链均可跳转;裸网址自动识别
|
||||||
|
- **风控管理页:登录审计**
|
||||||
|
- 新增「登录信息」卡片:当前用户、本次登录时间 / 登录 IP / 登录设备,以及历史成功登录记录(最近 15 条)
|
||||||
|
- 新增「登录失败日志」卡片:按「IP × 日期」聚合失败次数与最近设备信息,支持按 IP 模糊筛选、一键清空
|
||||||
|
- 登录成功与失败均记录到 `login_log` 表(旧库自动补列,无需手工迁移)
|
||||||
|
- **登录设备信息**由浏览器采集(操作系统及版本、手机型号、CPU 架构、屏幕分辨率、语言、是否触屏,能获取到什么就记录什么)并结合服务端 UA 解析
|
||||||
|
- **访问风控体系**
|
||||||
|
- IP 黑名单支持单 IP / CIDR 网段 / 星号通配,前台与后台(含登录页)**统一拦截**,命中返回 403 风控提示页(主提示与次行说明可在后台自定义)
|
||||||
|
- 自动风控规则:后台登录失败、前台 404、请求频率三条,均可独立启停并配置阈值,达到阈值自动永久封禁
|
||||||
|
- 黑名单支持一键移除「风控自动」、勾选批量移除与清空;解封单 IP 时同步清除其风险计数
|
||||||
|
- **工具页站点 logo 统一**:新增 `api/site.php`,静态工具页 favicon 自动跟随后台「站点 logo」设置(未设置或本地直开时回退默认图标)
|
||||||
|
|
||||||
|
### 优化
|
||||||
|
|
||||||
|
- 后台顶栏改为**左侧可收缩侧边栏**:整合全部管理入口,并自动汇总当前页「本页功能」锚点(点击直达设置区块、随滚动高亮);折叠状态本地记忆,小屏自动切换为抽屉
|
||||||
|
- 侧栏底部显示「当前用户(用户名)」
|
||||||
|
- **风控管理页筛选 / 翻页改为 AJAX 局部刷新**(新增 `admin/visits_data.php` 查询接口):不再整页跳转,也不会跳回页面顶部
|
||||||
|
- 修复全站「返回顶部」按钮不灵敏 / 到顶仍显示的问题(改为统一捕获滚动监听,并排除侧栏等容器误触发)
|
||||||
|
- 首次登录强制修改默认口令
|
||||||
|
|
||||||
|
## 2026-09-10
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 新增项目说明文档 `README.md` 与操作指南 `操作文档.md`
|
||||||
|
|
||||||
|
### 优化
|
||||||
|
|
||||||
|
- 完善网页逻辑;功能区工具由 PHP 外壳迁移为**静态 HTML 页**,旧入口自动 302 迁移,无需手工调整
|
||||||
|
- 访问日志改为文件存储(`data/vistors.log`,完整保留、不设上限),后台读取文件做统计与筛选
|
||||||
|
- 后台操作提示统一改为**右下角 Toast 弹窗**,不再占据页面顶部
|
||||||
|
- 同页表单提交后保持滚动位置,避免刷新后跳回页面顶部
|
||||||
|
|
||||||
|
## 2026-09-09
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 外部工具区支持**分区 / 栏目**,库首页按分组展示
|
||||||
|
|
||||||
|
### 优化
|
||||||
|
|
||||||
|
- 更新网页架构与样式;操作逻辑若干改进
|
||||||
|
|
||||||
|
## 2026-09-08
|
||||||
|
|
||||||
|
### 优化
|
||||||
|
|
||||||
|
- 更新网页架构,新增并完善外部工具区
|
||||||
|
|
||||||
|
## 2026-09-06
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 新增**文章库**(Markdown 写作与三栏阅读,分区管理、置顶排序)
|
||||||
|
- 新增**辅助维权工具**(反弹 Shell、凭证提取、持久化参考;仅供授权测试学习)
|
||||||
|
|
||||||
|
### 优化
|
||||||
|
|
||||||
|
- 项目更名;完善整体功能
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
|
||||||
|
- 初始化仓库,创建项目
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 维护约定
|
||||||
|
|
||||||
|
- 每次发布新功能或重要调整,请在**顶部新增对应日期的条目**,按「新增 / 优化 / 修复 / 其他」归类。
|
||||||
|
- 涉及数据结构的变更,请同步在 `README.md` / `操作文档.md` 中说明。
|
||||||
16
README.md
16
README.md
@ -40,10 +40,8 @@ Markdown 写作与三栏阅读(标题栏 / 段落目录 / 正文),分区
|
|||||||
|
|
||||||
要求:PHP 7.4+(启用 `pdo_sqlite`)、现代浏览器。
|
要求:PHP 7.4+(启用 `pdo_sqlite`)、现代浏览器。
|
||||||
|
|
||||||
```bash
|
直接将文件放置在网站路径下即可,打开网站时会自动创建数据库文件;
|
||||||
cd SecHome
|
迁移时仅需确保/data/文件夹不变即可。
|
||||||
php -S 0.0.0.0:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
- 前台:http://localhost:8000
|
- 前台:http://localhost:8000
|
||||||
- 后台:http://localhost:8000/admin
|
- 后台:http://localhost:8000/admin
|
||||||
@ -76,3 +74,13 @@ php -S 0.0.0.0:8000
|
|||||||
- 请立即修改默认口令,并视需要更换密码散列方案
|
- 请立即修改默认口令,并视需要更换密码散列方案
|
||||||
- 自动风控可能误封**共享出口 IP**:请谨慎开启「请求频率」规则或调高阈值
|
- 自动风控可能误封**共享出口 IP**:请谨慎开启「请求频率」规则或调高阈值
|
||||||
- 黑名单为全站拦截、无后台逃生通道;误封时请换网络登录后台移除,或直接编辑 `data/homepage.db` 中 `ip_blacklist` 表
|
- 黑名单为全站拦截、无后台逃生通道;误封时请换网络登录后台移除,或直接编辑 `data/homepage.db` 中 `ip_blacklist` 表
|
||||||
|
|
||||||
|
## 网站展示
|
||||||
|
|
||||||
|
前端UI展示:
|
||||||
|

|
||||||
|
|
||||||
|
后端展示:
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|||||||
@ -7,11 +7,20 @@ require_once dirname(__DIR__) . '/includes/auth.php';
|
|||||||
require_once dirname(__DIR__) . '/includes/layout.php';
|
require_once dirname(__DIR__) . '/includes/layout.php';
|
||||||
require_login('login.php');
|
require_login('login.php');
|
||||||
|
|
||||||
|
// 未修改初始/重置密码前,禁止进入其它后台页面(避免绕过强制改密)
|
||||||
|
if (hp_password_needs_change()) {
|
||||||
|
$self = basename((string) ($_SERVER['SCRIPT_NAME'] ?? ''));
|
||||||
|
if ($self !== 'force_password.php') {
|
||||||
|
header('Location: force_password.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 后台侧边栏(原顶部分区栏改为可收缩侧栏;含“本页功能”锚点容器) */
|
/** 后台侧边栏(原顶部分区栏改为可收缩侧栏;含“本页功能”锚点容器) */
|
||||||
function admin_topbar(string $active = ''): void
|
function admin_topbar(string $active = ''): void
|
||||||
{
|
{
|
||||||
global $P;
|
global $P;
|
||||||
$site = setting_get('site_name', '知识导航站');
|
$site = setting_get('site_name', 'SecHome');
|
||||||
$admin = (string) ($_SESSION['admin'] ?? '');
|
$admin = (string) ($_SESSION['admin'] ?? '');
|
||||||
// 立即执行:标记 body 供侧栏布局使用,并恢复折叠状态(避免首帧闪动)
|
// 立即执行:标记 body 供侧栏布局使用,并恢复折叠状态(避免首帧闪动)
|
||||||
echo '<script>(function(){var b=document.body;if(!b)return;b.classList.add("hp-admin-side");try{if(localStorage.getItem("hp-admin-side-collapsed")==="1")b.classList.add("hp-side-collapsed");}catch(e){}})();</script>' . "\n";
|
echo '<script>(function(){var b=document.body;if(!b)return;b.classList.add("hp-admin-side");try{if(localStorage.getItem("hp-admin-side-collapsed")==="1")b.classList.add("hp-side-collapsed");}catch(e){}})();</script>' . "\n";
|
||||||
@ -40,6 +49,7 @@ function admin_topbar(string $active = ''): void
|
|||||||
'articles' => ['articles.php', '文章管理', '📄'],
|
'articles' => ['articles.php', '文章管理', '📄'],
|
||||||
'exttools' => ['exttools.php', '外部工具', '🔗'],
|
'exttools' => ['exttools.php', '外部工具', '🔗'],
|
||||||
'content' => ['content.php', '网站管理', '⚙'],
|
'content' => ['content.php', '网站管理', '⚙'],
|
||||||
|
'notice' => ['notice.php', '更新公告', '📢'],
|
||||||
'visits' => ['visits.php', '风控管理', '🛡'],
|
'visits' => ['visits.php', '风控管理', '🛡'],
|
||||||
];
|
];
|
||||||
echo '<nav class="as-nav">';
|
echo '<nav class="as-nav">';
|
||||||
|
|||||||
@ -46,7 +46,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$act = (string) ($_POST['act'] ?? '');
|
$act = (string) ($_POST['act'] ?? '');
|
||||||
switch ($act) {
|
switch ($act) {
|
||||||
case 'site':
|
case 'site':
|
||||||
settings_set('site_name', trim((string) ($_POST['site_name'] ?? '知识导航站')));
|
settings_set('site_name', trim((string) ($_POST['site_name'] ?? 'SecHome')));
|
||||||
settings_set('site_slogan', trim((string) ($_POST['site_slogan'] ?? '')));
|
settings_set('site_slogan', trim((string) ($_POST['site_slogan'] ?? '')));
|
||||||
touch_last_updated();
|
touch_last_updated();
|
||||||
$msgKind = 'ok';
|
$msgKind = 'ok';
|
||||||
@ -344,7 +344,7 @@ $all = [];
|
|||||||
foreach ($pdo->query('SELECT key, value FROM settings') as $r) {
|
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'] ?? '知识导航站';
|
$site = $all['site_name'] ?? 'SecHome';
|
||||||
|
|
||||||
layout_head('网站管理');
|
layout_head('网站管理');
|
||||||
admin_topbar('content');
|
admin_topbar('content');
|
||||||
|
|||||||
73
admin/force_password.php
Normal file
73
admin/force_password.php
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* admin/force_password.php —— 初始 / 重置密码未修改前的强制改密页
|
||||||
|
* 说明:默认管理员(或标记了 must_change_pwd 的账号)登录后,未改密前不得进入其它后台页面。
|
||||||
|
*/
|
||||||
|
$P = '../';
|
||||||
|
require_once dirname(__DIR__) . '/includes/auth.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/layout.php';
|
||||||
|
|
||||||
|
if (!is_logged_in()) {
|
||||||
|
header('Location: login.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
// 已完成改密的账号无需停留在本页
|
||||||
|
if (!hp_password_needs_change()) {
|
||||||
|
header('Location: nav.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$err = '';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!csrf_verify()) {
|
||||||
|
$err = '安全校验失败,请刷新页面重试。';
|
||||||
|
} else {
|
||||||
|
$np = (string) ($_POST['new_pwd'] ?? '');
|
||||||
|
$np2 = (string) ($_POST['new_pwd2'] ?? '');
|
||||||
|
if ($np !== $np2) {
|
||||||
|
$err = '两次输入的新密码不一致。';
|
||||||
|
} else {
|
||||||
|
[$ok, $text] = hp_change_password((string) ($_POST['old_pwd'] ?? ''), $np);
|
||||||
|
if ($ok) {
|
||||||
|
header('Location: nav.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$err = $text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$site = setting_get('site_name', 'SecHome');
|
||||||
|
layout_head('首次登录修改密码');
|
||||||
|
?>
|
||||||
|
<main class="page-main">
|
||||||
|
<div class="wrap page-body" style="max-width:520px;margin:40px auto">
|
||||||
|
<div class="fieldset-card">
|
||||||
|
<div class="fs-title">安全提醒:请先修改初始密码</div>
|
||||||
|
<p class="tip" style="margin:6px 0 14px">
|
||||||
|
检测到当前账号仍在使用初始密码。为防止后台被未授权访问,必须修改密码后方可进入其它管理页面。
|
||||||
|
</p>
|
||||||
|
<?php if ($err !== ''): ?>
|
||||||
|
<div class="err-msg"><?= he($err) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="post" autocomplete="off">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<label class="fl">当前密码(初始为 admin123)</label>
|
||||||
|
<input type="password" name="old_pwd" required autocomplete="off" autofocus>
|
||||||
|
<label class="fl">新密码(至少 6 位)</label>
|
||||||
|
<input type="password" name="new_pwd" required minlength="6" autocomplete="off">
|
||||||
|
<label class="fl">确认新密码</label>
|
||||||
|
<input type="password" name="new_pwd2" required minlength="6" autocomplete="off">
|
||||||
|
<div class="field-row" style="margin-top:14px">
|
||||||
|
<button type="submit" class="btn btn-primary">修改并继续</button>
|
||||||
|
<a class="btn" href="logout.php">退出登录</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<?php layout_theme_fab(); ?>
|
||||||
|
<script src="../assets/js/common.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@ -18,7 +18,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
} elseif (hp_login($username, $password, (string) ($_POST['dev'] ?? ''))) {
|
} elseif (hp_login($username, $password, (string) ($_POST['dev'] ?? ''))) {
|
||||||
// 正常登录成功:清零本 IP 风控计数(避免携带历史失败次数,防止管理员自误封)
|
// 正常登录成功:清零本 IP 风控计数(避免携带历史失败次数,防止管理员自误封)
|
||||||
risk_clear_for_ip($curIp);
|
risk_clear_for_ip($curIp);
|
||||||
|
// 初始密码未修改:强制进入改密页
|
||||||
|
if (hp_password_needs_change($username)) {
|
||||||
|
header('Location: force_password.php');
|
||||||
|
} else {
|
||||||
header('Location: nav.php');
|
header('Location: nav.php');
|
||||||
|
}
|
||||||
exit;
|
exit;
|
||||||
} else {
|
} else {
|
||||||
// 轻量防爆破延时(命中黑名单的 IP 在访问本页前即被 access_boot 拦截)
|
// 轻量防爆破延时(命中黑名单的 IP 在访问本页前即被 access_boot 拦截)
|
||||||
@ -33,7 +38,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$site = setting_get('site_name', '知识导航站');
|
$site = setting_get('site_name', 'SecHome');
|
||||||
layout_head('后台登录');
|
layout_head('后台登录');
|
||||||
?>
|
?>
|
||||||
<main class="login-page">
|
<main class="login-page">
|
||||||
|
|||||||
372
admin/notice.php
Normal file
372
admin/notice.php
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* admin/notice.php —— 更新公告管理
|
||||||
|
* 多条公告的增删改:保留历史、任意一条均可再次编辑、可置顶 / 隐藏;
|
||||||
|
* 内容支持超链接([文字](链接) 或裸 http(s) 网址)。发布日期与修改时间均由系统记录。
|
||||||
|
*/
|
||||||
|
$P = '../';
|
||||||
|
require_once __DIR__ . '/_guard.php';
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
function msg_block(string $kind, string $text): string
|
||||||
|
{
|
||||||
|
return '<script>window.hpToastMsg = ' . json_encode(['kind' => $kind === 'ok' ? 'ok' : 'err', 'text' => $text], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) . ';</script>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 公告内容摘要(用于列表,压缩空白并截断) */
|
||||||
|
function notice_excerpt(string $text, int $len = 60): string
|
||||||
|
{
|
||||||
|
$t = trim(preg_replace('/\s+/u', ' ', $text));
|
||||||
|
if (function_exists('mb_substr') && function_exists('mb_strlen')) {
|
||||||
|
return mb_strlen($t, 'UTF-8') > $len ? mb_substr($t, 0, $len, 'UTF-8') . '…' : $t;
|
||||||
|
}
|
||||||
|
return strlen($t) > $len * 2 ? substr($t, 0, $len * 2) . '…' : $t;
|
||||||
|
}
|
||||||
|
|
||||||
|
$msgKind = '';
|
||||||
|
$msgText = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!csrf_verify()) {
|
||||||
|
$msgKind = 'err';
|
||||||
|
$msgText = '安全校验失败,请刷新页面重试。';
|
||||||
|
} else {
|
||||||
|
$act = (string) ($_POST['act'] ?? '');
|
||||||
|
switch ($act) {
|
||||||
|
case 'save':
|
||||||
|
$id = max(0, (int) ($_POST['id'] ?? 0));
|
||||||
|
$title = trim((string) ($_POST['title'] ?? ''));
|
||||||
|
$content = trim((string) ($_POST['content'] ?? ''));
|
||||||
|
$pinned = (isset($_POST['pinned']) && $_POST['pinned'] === '1') ? 1 : 0;
|
||||||
|
$enabled = (isset($_POST['enabled']) && $_POST['enabled'] === '1') ? 1 : 0;
|
||||||
|
if ($title === '' && $content === '') {
|
||||||
|
$msgKind = 'err';
|
||||||
|
$msgText = '公告标题与内容不能同时为空。';
|
||||||
|
} elseif ($id > 0) {
|
||||||
|
$st = $pdo->prepare('UPDATE notices SET title=?, content=?, pinned=?, enabled=?, updated_at=? WHERE id=?');
|
||||||
|
$st->execute([$title, $content, $pinned, $enabled, date('Y-m-d H:i:s'), $id]);
|
||||||
|
touch_last_updated();
|
||||||
|
header('Location: notice.php?edit=' . $id . '&ok=1');
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$st = $pdo->prepare('INSERT INTO notices (title, content, pinned, enabled, created_at, updated_at) VALUES (?,?,?,?,?,?)');
|
||||||
|
$st->execute([$title, $content, $pinned, $enabled, $now, $now]);
|
||||||
|
$id = (int) $pdo->lastInsertId();
|
||||||
|
touch_last_updated();
|
||||||
|
header('Location: notice.php?edit=' . $id . '&ok=1');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'del':
|
||||||
|
$id = (int) ($_POST['id'] ?? 0);
|
||||||
|
if ($id > 0) {
|
||||||
|
$pdo->prepare('DELETE FROM notices WHERE id = ?')->execute([$id]);
|
||||||
|
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 notices WHERE id = ' . $id)->fetchColumn();
|
||||||
|
$pdo->prepare('UPDATE notices SET ' . $field . ' = ? WHERE id = ?')->execute([$cur ? 0 : 1, $id]);
|
||||||
|
touch_last_updated();
|
||||||
|
$msgKind = 'ok';
|
||||||
|
$msgText = $field === 'pinned' ? '置顶状态已更新。' : '可见状态已更新。';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$msgKind = 'err';
|
||||||
|
$msgText = '未知操作。';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$savedFlag = isset($_GET['ok']);
|
||||||
|
$list = $pdo->query('SELECT * FROM notices ORDER BY pinned DESC, created_at DESC, id DESC')->fetchAll();
|
||||||
|
|
||||||
|
$editing = false;
|
||||||
|
$row = null;
|
||||||
|
$editId = (int) ($_GET['edit'] ?? 0);
|
||||||
|
if ($editId > 0) {
|
||||||
|
$st = $pdo->prepare('SELECT * FROM notices WHERE id = ?');
|
||||||
|
$st->execute([$editId]);
|
||||||
|
$row = $st->fetch();
|
||||||
|
if ($row) {
|
||||||
|
$editing = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layout_head('更新公告管理');
|
||||||
|
admin_topbar('notice');
|
||||||
|
?>
|
||||||
|
<main class="page-main">
|
||||||
|
<div class="wrap page-body">
|
||||||
|
<h2 style="margin-bottom:6px">更新公告管理</h2>
|
||||||
|
<p class="tip">
|
||||||
|
前台页脚「更新公告」页展示内容:可发布多条公告并<strong>保留历史</strong>,任意一条均可再次编辑。
|
||||||
|
内容支持超链接,写法 <code>[文字](链接)</code>;站内用相对路径(如 <code>/index.php</code>、<code>/func/index.php</code>),
|
||||||
|
外链用 <code>https://…</code>,也可直接粘贴网址自动识别。换行会被保留。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php if ($savedFlag): ?>
|
||||||
|
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '公告已保存,前台已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($msgText !== ''):
|
||||||
|
echo msg_block($msgKind, $msgText);
|
||||||
|
endif; ?>
|
||||||
|
|
||||||
|
<?php if ($editing || isset($_GET['new'])): ?>
|
||||||
|
<?php
|
||||||
|
$cur = $row ? [
|
||||||
|
'id' => (int) $row['id'],
|
||||||
|
'title' => (string) $row['title'],
|
||||||
|
'content' => (string) $row['content'],
|
||||||
|
'pinned' => (int) $row['pinned'],
|
||||||
|
'enabled' => (int) $row['enabled'],
|
||||||
|
'created_at' => (string) $row['created_at'],
|
||||||
|
'updated_at' => (string) $row['updated_at'],
|
||||||
|
] : ['id' => 0, 'title' => '', 'content' => '', 'pinned' => 0, 'enabled' => 1, 'created_at' => '', 'updated_at' => ''];
|
||||||
|
?>
|
||||||
|
<div class="fieldset-card">
|
||||||
|
<div class="fs-title"><?= $cur['id'] ? '编辑公告 #' . $cur['id'] : '发布新公告' ?></div>
|
||||||
|
<form method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="act" value="save">
|
||||||
|
<input type="hidden" name="id" value="<?= (int) $cur['id'] ?>">
|
||||||
|
|
||||||
|
<label class="fl">公告标题(可选)</label>
|
||||||
|
<input type="text" name="title" id="ntTitle" value="<?= he($cur['title']) ?>"
|
||||||
|
placeholder="例如:2026-09-12 新增 Navicat 密码解密工具">
|
||||||
|
|
||||||
|
<label class="fl" style="margin-top:12px">公告内容</label>
|
||||||
|
<textarea name="content" id="ntContent" rows="10"
|
||||||
|
style="font-size:14px;line-height:1.7"><?= he($cur['content']) ?></textarea>
|
||||||
|
|
||||||
|
<div class="field-row" style="margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||||
|
<button type="button" class="btn btn-sm" id="ntLinkBtn">插入链接</button>
|
||||||
|
<button type="button" class="btn btn-sm" id="ntPreviewBtn">预览渲染</button>
|
||||||
|
<span class="tip">链接写法:<code>[文字](链接)</code>;也能直接粘贴 http(s) 网址。发布日期自动记录,保存时自动更新「修改时间」。</span>
|
||||||
|
</div>
|
||||||
|
<div class="notice-live" id="ntPreview" style="display:none"></div>
|
||||||
|
|
||||||
|
<div class="field-row" style="gap:18px;margin:12px 0">
|
||||||
|
<label class="seg-check"><input type="checkbox" name="pinned" value="1" <?= $cur['pinned'] ? ' checked' : '' ?>>
|
||||||
|
置顶(前台排在最前)</label>
|
||||||
|
<label class="seg-check"><input type="checkbox" name="enabled" value="1" <?= $cur['enabled'] ? ' checked' : '' ?>>
|
||||||
|
前台可见</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row" style="margin-top:8px;align-items:center">
|
||||||
|
<button type="submit" class="btn btn-primary">保存公告</button>
|
||||||
|
<a class="btn" href="notice.php">取消 / 返回列表</a>
|
||||||
|
<?php if ($cur['created_at'] !== ''): ?>
|
||||||
|
<span class="tip" style="align-self:center">发布:<?= he($cur['created_at']) ?> ·
|
||||||
|
上次修改:<?= he($cur['updated_at']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- 公告列表:全部保留,可重新编辑 -->
|
||||||
|
<div class="fieldset-card">
|
||||||
|
<div class="field-row" style="align-items:center;justify-content:space-between">
|
||||||
|
<span class="fs-title" style="margin:0;border:none;padding:0">公告列表(共 <?= count($list) ?> 条)</span>
|
||||||
|
<span style="display:inline-flex;gap:8px;align-items:center">
|
||||||
|
<a class="btn btn-sm" href="../notice.php" target="_blank" rel="noopener noreferrer">预览前台页面</a>
|
||||||
|
<a class="btn btn-primary btn-sm" href="notice.php?new=1">+ 发布公告</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<?php if (!$list): ?>
|
||||||
|
<p class="tip" style="margin-top:10px">还没有公告,点击右上角「发布公告」开始编辑。</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table class="tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>标题 / 内容摘要</th>
|
||||||
|
<th>发布时间</th>
|
||||||
|
<th>最后修改</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($list as $n): ?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="notice.php?edit=<?= (int) $n['id'] ?>"><?= $n['title'] !== '' ? he((string) $n['title']) : '(无标题公告 #' . (int) $n['id'] . ')' ?></a>
|
||||||
|
<div class="tip"><?= he(notice_excerpt((string) $n['content'])) ?></div>
|
||||||
|
</td>
|
||||||
|
<td style="white-space:nowrap"><?= he((string) $n['created_at']) ?></td>
|
||||||
|
<td style="white-space:nowrap"><?= he((string) $n['updated_at']) ?></td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<?php if ((int) $n['pinned'] === 1): ?><span
|
||||||
|
class="badge badge-ok">置顶</span><?php endif; ?>
|
||||||
|
<?php if ((int) $n['enabled'] === 1): ?><span
|
||||||
|
class="badge badge-ok">可见</span><?php else: ?><span
|
||||||
|
class="badge badge-no">隐藏</span><?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<a class="btn btn-sm" href="notice.php?edit=<?= (int) $n['id'] ?>">编辑</a>
|
||||||
|
<form method="post" style="display:inline">
|
||||||
|
<?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input
|
||||||
|
type="hidden" name="field" value="pinned"><input type="hidden" name="id"
|
||||||
|
value="<?= (int) $n['id'] ?>">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-sm"><?= (int) $n['pinned'] === 1 ? '取消置顶' : '置顶' ?></button>
|
||||||
|
</form>
|
||||||
|
<form method="post" style="display:inline">
|
||||||
|
<?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input
|
||||||
|
type="hidden" name="field" value="enabled"><input type="hidden" name="id"
|
||||||
|
value="<?= (int) $n['id'] ?>">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-sm"><?= (int) $n['enabled'] === 1 ? '隐藏' : '显示' ?></button>
|
||||||
|
</form>
|
||||||
|
<form method="post" style="display:inline" data-confirm="确认删除该公告?删除后不可恢复。">
|
||||||
|
<?= csrf_field() ?><input type="hidden" name="act" value="del"><input type="hidden"
|
||||||
|
name="id" value="<?= (int) $n['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger" title="删除">✕</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<?php layout_theme_fab(); ?>
|
||||||
|
<script src="../assets/js/common.js"></script>
|
||||||
|
<script>
|
||||||
|
// 插入链接(站内风格弹窗)+ 本地预览渲染(与前台 notice_html 逻辑一致)
|
||||||
|
(function () {
|
||||||
|
var ta = document.getElementById('ntContent');
|
||||||
|
if (!ta) return;
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析链接:http(s) / 协议相对 / 站内相对 / 裸域名(自动补 https://);无法识别则保留原写法
|
||||||
|
function resolveLink(label, url) {
|
||||||
|
var raw = url.replace(/&/g, '&').trim();
|
||||||
|
if (/^(?:https?:)?\/\//i.test(raw)) return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
|
||||||
|
if (raw.charAt(0) === '/') return '<a href="' + url + '">' + label + '</a>';
|
||||||
|
if (/^[A-Za-z]:/.test(raw)) return '[' + label + '](' + url + ')';
|
||||||
|
if (/^[^\s]+\.[A-Za-z]{2,}(?:[/?#].*)?$/.test(raw)) return '<a href="https://' + url + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
|
||||||
|
if (/^[\w\-.]+[/][\w\-./?%#=&]*$/.test(raw)) return '<a href="' + url + '">' + label + '</a>';
|
||||||
|
return '[' + label + '](' + url + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(text) {
|
||||||
|
var out = esc(text);
|
||||||
|
// 1) [文字](链接)
|
||||||
|
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, function (m, label, url) {
|
||||||
|
return resolveLink(label, url);
|
||||||
|
});
|
||||||
|
// 2) 裸链接(用回调判断前一个字符,避免使用后行断言)
|
||||||
|
out = out.replace(/https?:\/\/[^\s<"')]+/g, function (m, offset) {
|
||||||
|
var prev = offset > 0 ? out.charAt(offset - 1) : '';
|
||||||
|
if (prev === '"' || prev === '=' || prev === "'" || prev === '/' || prev === '>') return m;
|
||||||
|
var url = m.replace(/[.,;:!?]+$/, '');
|
||||||
|
var tail = m.slice(url.length);
|
||||||
|
return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + url + '</a>' + tail;
|
||||||
|
});
|
||||||
|
return out.replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 站内风格「插入链接」弹窗(替代原生 prompt)
|
||||||
|
function insertLinkDialog(defaultText) {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var overlay = document.createElement('div');
|
||||||
|
overlay.className = 'sc-overlay';
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div class="sc-card nt-dlg" role="dialog" aria-modal="true">' +
|
||||||
|
'<div class="sc-head">插入链接</div>' +
|
||||||
|
'<div class="sc-body">' +
|
||||||
|
'<label class="fl" for="ntLiText">链接文字</label>' +
|
||||||
|
'<input type="text" id="ntLiText" class="nt-li-text" placeholder="显示给访客的文字,例如:点击查看">' +
|
||||||
|
'<label class="fl" for="ntLiUrl">链接地址</label>' +
|
||||||
|
'<input type="text" id="ntLiUrl" class="nt-li-url" placeholder="如 /index.php 或 https://example.com">' +
|
||||||
|
'<div class="tip" style="margin-top:8px">站内用 <code>/index.php</code> 这类相对路径;外部填 <code>https://…</code>;只填 <code>www.example.com</code> 会自动补上 https://。</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="sc-foot">' +
|
||||||
|
'<button type="button" class="btn btn-sm nt-li-cancel">取消</button>' +
|
||||||
|
'<button type="button" class="btn btn-sm btn-primary nt-li-ok">插入</button>' +
|
||||||
|
'</div></div>';
|
||||||
|
var textIn = overlay.querySelector('.nt-li-text');
|
||||||
|
var urlIn = overlay.querySelector('.nt-li-url');
|
||||||
|
textIn.value = defaultText || '';
|
||||||
|
var finished = false;
|
||||||
|
function close(val) {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
document.removeEventListener('keydown', onKey, true);
|
||||||
|
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||||
|
resolve(val);
|
||||||
|
}
|
||||||
|
function submit() {
|
||||||
|
var url = urlIn.value.trim();
|
||||||
|
if (url === '') { urlIn.focus(); return; }
|
||||||
|
var text = textIn.value.trim();
|
||||||
|
if (text === '') text = url;
|
||||||
|
close({ text: text, url: url });
|
||||||
|
}
|
||||||
|
function onKey(ev) {
|
||||||
|
if (ev.key === 'Escape') { ev.preventDefault(); close(null); }
|
||||||
|
else if (ev.key === 'Enter') { ev.preventDefault(); submit(); }
|
||||||
|
}
|
||||||
|
overlay.querySelector('.nt-li-ok').addEventListener('click', submit);
|
||||||
|
overlay.querySelector('.nt-li-cancel').addEventListener('click', function () { close(null); });
|
||||||
|
overlay.addEventListener('mousedown', function (ev) { if (ev.target === overlay) close(null); });
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
(defaultText ? urlIn : textIn).focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var linkBtn = document.getElementById('ntLinkBtn');
|
||||||
|
if (linkBtn) {
|
||||||
|
linkBtn.addEventListener('click', function () {
|
||||||
|
var s = ta.selectionStart, e = ta.selectionEnd;
|
||||||
|
var sel = ta.value.slice(s, e).trim();
|
||||||
|
insertLinkDialog(sel).then(function (r) {
|
||||||
|
if (!r) return;
|
||||||
|
var snippet = '[' + r.text + '](' + r.url + ')';
|
||||||
|
ta.value = ta.value.slice(0, s) + snippet + ta.value.slice(e);
|
||||||
|
ta.focus();
|
||||||
|
ta.selectionStart = ta.selectionEnd = s + snippet.length;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var pvBtn = document.getElementById('ntPreviewBtn');
|
||||||
|
var pv = document.getElementById('ntPreview');
|
||||||
|
if (pvBtn && pv) {
|
||||||
|
pvBtn.addEventListener('click', function () {
|
||||||
|
if (pv.style.display === 'none') {
|
||||||
|
pv.innerHTML = '<div class="notice-body">' + render(ta.value) + '</div>';
|
||||||
|
pv.style.display = '';
|
||||||
|
pvBtn.textContent = '收起预览';
|
||||||
|
} else {
|
||||||
|
pv.style.display = 'none';
|
||||||
|
pvBtn.textContent = '预览渲染';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@ -412,6 +412,15 @@ a.nav-btn[data-note]:hover::after { opacity: 1; transform: translateX(-50%) tran
|
|||||||
.panel-card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 20px; margin-bottom: 20px; box-shadow: var(--shadow-sm); }
|
.panel-card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 20px; margin-bottom: 20px; box-shadow: var(--shadow-sm); }
|
||||||
.panel-card > h2 { font-size: 17px; margin-top: 0; }
|
.panel-card > h2 { font-size: 17px; margin-top: 0; }
|
||||||
|
|
||||||
|
/* 更新公告(前台展示 / 后台管理共用) */
|
||||||
|
.notice-item { border: 1px solid var(--line); border-radius: 12px; padding: 16px 18px; margin-bottom: 16px; background: var(--card); box-shadow: var(--shadow-sm); }
|
||||||
|
.notice-item:last-child { margin-bottom: 0; }
|
||||||
|
.notice-title { display: flex; align-items: center; gap: 8px; font-size: 17px; margin: 0 0 6px; }
|
||||||
|
.notice-meta { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12.5px; color: var(--text-3); margin-bottom: 10px; }
|
||||||
|
.notice-body { line-height: 1.9; font-size: 15px; word-break: break-word; }
|
||||||
|
.notice-body a { color: var(--brand); text-decoration: underline; word-break: break-all; }
|
||||||
|
.notice-live { border: 1px dashed var(--line-2); border-radius: 10px; padding: 14px 16px; margin-top: 12px; min-height: 24px; }
|
||||||
|
|
||||||
/* IO 双文本区布局 */
|
/* IO 双文本区布局 */
|
||||||
.io-area { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
|
.io-area { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
|
||||||
.io-box { display: flex; flex-direction: column; }
|
.io-box { display: flex; flex-direction: column; }
|
||||||
@ -594,6 +603,12 @@ a.nav-btn[data-note]:hover::after { opacity: 1; transform: translateX(-50%) tran
|
|||||||
@keyframes scFade { from { opacity: 0; } to { opacity: 1; } }
|
@keyframes scFade { from { opacity: 0; } to { opacity: 1; } }
|
||||||
@keyframes scPop { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
|
@keyframes scPop { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
|
||||||
|
|
||||||
|
/* 插入链接弹窗(复用站点确认弹窗外观) */
|
||||||
|
.nt-dlg .sc-body { padding-top: 12px; }
|
||||||
|
.nt-dlg .sc-body .fl { display: block; margin: 10px 0 6px; }
|
||||||
|
.nt-dlg .sc-body .fl:first-child { margin-top: 0; }
|
||||||
|
.nt-dlg .sc-body input[type=text] { width: 100%; }
|
||||||
|
|
||||||
/* 登录页 */
|
/* 登录页 */
|
||||||
.login-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg-2); padding: 20px; }
|
.login-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg-2); padding: 20px; }
|
||||||
.login-card { width: 100%; max-width: 380px; background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 30px; box-shadow: var(--shadow); }
|
.login-card { width: 100%; max-width: 380px; background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 30px; box-shadow: var(--shadow); }
|
||||||
|
|||||||
BIN
img/back-1.png
Normal file
BIN
img/back-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 759 KiB |
BIN
img/back-2.png
Normal file
BIN
img/back-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 580 KiB |
BIN
img/front.png
Normal file
BIN
img/front.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@ -24,22 +24,58 @@ function require_login(string $loginPage = 'login.php'): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 计算需求规定的密码散列:先 base64 再 md5 */
|
/** 密码散列:使用 PHP 内置强散列(bcrypt/argon2),自动加盐与迭代 */
|
||||||
function hp_password_hash(string $plain): string
|
function hp_password_hash(string $plain): string
|
||||||
|
{
|
||||||
|
return password_hash($plain, PASSWORD_DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旧版散列(md5(base64()))——仅用于兼容迁移,禁止用于新写入 */
|
||||||
|
function hp_password_hash_legacy(string $plain): string
|
||||||
{
|
{
|
||||||
return md5(base64_encode($plain));
|
return md5(base64_encode($plain));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 验证用户 */
|
/** 验证用户(旧版散列验证通过后自动升级为强散列) */
|
||||||
function hp_verify_user(string $username, string $plain): bool
|
function hp_verify_user(string $username, string $plain): bool
|
||||||
{
|
{
|
||||||
$st = db()->prepare('SELECT password_hash FROM users WHERE username = ? LIMIT 1');
|
$st = db()->prepare('SELECT password_hash FROM users WHERE username = ? LIMIT 1');
|
||||||
$st->execute([$username]);
|
$st->execute([$username]);
|
||||||
$row = $st->fetch();
|
$row = $st->fetch();
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
|
// 用户不存在也执行一次等价开销,削弱用户名枚举的时序差异
|
||||||
|
password_verify($plain, '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$stored = (string) $row['password_hash'];
|
||||||
|
|
||||||
|
// 旧库:32 位十六进制 md5 → 校验通过后透明升级
|
||||||
|
if (preg_match('/^[0-9a-f]{32}$/i', $stored)) {
|
||||||
|
if (!hash_equals($stored, hp_password_hash_legacy($plain))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$upd = db()->prepare('UPDATE users SET password_hash = ? WHERE username = ?');
|
||||||
|
$upd->execute([hp_password_hash($plain), $username]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return password_verify($plain, $stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指定用户(默认当前登录用户)是否必须修改密码(初始/重置密码未改时为 true) */
|
||||||
|
function hp_password_needs_change(?string $username = null): bool
|
||||||
|
{
|
||||||
|
$username = ($username !== null) ? $username : (string) ($_SESSION['admin'] ?? '');
|
||||||
|
if ($username === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$st = db()->prepare('SELECT must_change_pwd FROM users WHERE username = ? LIMIT 1');
|
||||||
|
$st->execute([$username]);
|
||||||
|
return ((int) $st->fetchColumn()) === 1;
|
||||||
|
} catch (Throwable $e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return hash_equals($row['password_hash'], hp_password_hash($plain));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 执行登录(成功返回 true;成功/失败均写入登录日志与设备信息) */
|
/** 执行登录(成功返回 true;成功/失败均写入登录日志与设备信息) */
|
||||||
@ -73,7 +109,7 @@ function hp_change_password(string $oldPlain, string $newPlain): array
|
|||||||
if (strlen($newPlain) < 6) {
|
if (strlen($newPlain) < 6) {
|
||||||
return [false, '新密码至少 6 位'];
|
return [false, '新密码至少 6 位'];
|
||||||
}
|
}
|
||||||
$st = db()->prepare('UPDATE users SET password_hash = ? WHERE username = ?');
|
$st = db()->prepare('UPDATE users SET password_hash = ?, must_change_pwd = 0 WHERE username = ?');
|
||||||
$st->execute([hp_password_hash($newPlain), (string) $_SESSION['admin']]);
|
$st->execute([hp_password_hash($newPlain), (string) $_SESSION['admin']]);
|
||||||
return [true, '密码修改成功'];
|
return [true, '密码修改成功'];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,7 +47,8 @@ function db_init(PDO $pdo): void
|
|||||||
$pdo->exec('CREATE TABLE IF NOT EXISTS users (
|
$pdo->exec('CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password_hash TEXT NOT NULL
|
password_hash TEXT NOT NULL,
|
||||||
|
must_change_pwd INTEGER NOT NULL DEFAULT 0
|
||||||
)');
|
)');
|
||||||
$pdo->exec('CREATE TABLE IF NOT EXISTS settings (
|
$pdo->exec('CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@ -119,6 +120,16 @@ function db_init(PDO $pdo): void
|
|||||||
created_at TEXT DEFAULT \'\',
|
created_at TEXT DEFAULT \'\',
|
||||||
updated_at TEXT DEFAULT \'\'
|
updated_at TEXT DEFAULT \'\'
|
||||||
)');
|
)');
|
||||||
|
// ---------- 更新公告(多条记录:保留历史、可编辑、支持超链接) ----------
|
||||||
|
$pdo->exec('CREATE TABLE IF NOT EXISTS notices (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT DEFAULT \'\',
|
||||||
|
content TEXT DEFAULT \'\',
|
||||||
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT DEFAULT \'\',
|
||||||
|
updated_at TEXT DEFAULT \'\'
|
||||||
|
)');
|
||||||
// ---------- IP 黑名单 ----------
|
// ---------- IP 黑名单 ----------
|
||||||
$pdo->exec('CREATE TABLE IF NOT EXISTS ip_blacklist (
|
$pdo->exec('CREATE TABLE IF NOT EXISTS ip_blacklist (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@ -152,6 +163,8 @@ function db_init(PDO $pdo): void
|
|||||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_login_log_ip ON login_log (ip)');
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_login_log_ip ON login_log (ip)');
|
||||||
// 兼容旧库:登录日志补 device 列
|
// 兼容旧库:登录日志补 device 列
|
||||||
db_ensure_column($pdo, 'login_log', 'device', "TEXT DEFAULT ''");
|
db_ensure_column($pdo, 'login_log', 'device', "TEXT DEFAULT ''");
|
||||||
|
// 兼容旧库:users 补 must_change_pwd 列(初始/重置密码强制修改标志)
|
||||||
|
db_ensure_column($pdo, 'users', 'must_change_pwd', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
|
||||||
// 兼容旧库:ip_blacklist 补 source 列;categories 补充 icon 列
|
// 兼容旧库:ip_blacklist 补 source 列;categories 补充 icon 列
|
||||||
db_ensure_column($pdo, 'ip_blacklist', 'source', "TEXT NOT NULL DEFAULT 'manual'");
|
db_ensure_column($pdo, 'ip_blacklist', 'source', "TEXT NOT NULL DEFAULT 'manual'");
|
||||||
@ -191,13 +204,13 @@ function db_init(PDO $pdo): void
|
|||||||
$hasSettings = (int) $pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn();
|
$hasSettings = (int) $pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn();
|
||||||
if ($hasSettings === 0) {
|
if ($hasSettings === 0) {
|
||||||
$defaults = [
|
$defaults = [
|
||||||
'site_name' => '知识导航站',
|
'site_name' => 'SecHome',
|
||||||
'site_slogan' => '汇聚科普、攻防与效率工具的实用首页',
|
'site_slogan' => '汇聚科普、攻防与效率工具的实用首页',
|
||||||
'site_logo' => '', // 空 = 使用默认 assets/img/logo.svg
|
'site_logo' => '', // 空 = 使用默认 assets/img/logo.svg
|
||||||
'icp_no' => '京ICP备00000000号-1',
|
'icp_no' => '京ICP备00000000号-1',
|
||||||
'gongan_no' => '京公网安备11000000000000号',
|
'gongan_no' => '京公网安备11000000000000号',
|
||||||
'gongan_link' => '', // 公安备案号链接(可空则不显示为链接)
|
'gongan_link' => '', // 公安备案号链接(可空则不显示为链接)
|
||||||
'copyright' => 'Copyright © 2026 知识导航站 版权所有。本站内容仅供学习研究使用。',
|
'copyright' => 'Copyright © 2026 SecHome 版权所有。本站内容仅供学习研究使用。',
|
||||||
'footer_text' => '',
|
'footer_text' => '',
|
||||||
'search_engines' => '', // 搜索引擎列表 JSON;空 = 使用默认 4 个
|
'search_engines' => '', // 搜索引擎列表 JSON;空 = 使用默认 4 个
|
||||||
'last_updated' => '',
|
'last_updated' => '',
|
||||||
@ -215,9 +228,9 @@ 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) {
|
if ($userCount === 0) {
|
||||||
// 密码算法:先 base64 再 md5(按原始需求实现)
|
// 密码算法:PHP 内置 password_hash(bcrypt/argon2);默认管理员首次登录强制改密
|
||||||
$st = $pdo->prepare('INSERT INTO users (username, password_hash) VALUES (?,?)');
|
$st = $pdo->prepare('INSERT INTO users (username, password_hash, must_change_pwd) VALUES (?,?,1)');
|
||||||
$st->execute(['admin', md5(base64_encode('admin123'))]);
|
$st->execute(['admin', password_hash('admin123', PASSWORD_DEFAULT)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 功能区工具默认种子 ----------
|
// ---------- 功能区工具默认种子 ----------
|
||||||
@ -317,6 +330,17 @@ MD;
|
|||||||
foreach ($toolHtmlMap as $oldUrl => $newUrl) {
|
foreach ($toolHtmlMap as $oldUrl => $newUrl) {
|
||||||
$updUrl->execute([$newUrl, $oldUrl]);
|
$updUrl->execute([$newUrl, $oldUrl]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 公告数据迁移:旧版单条公告(settings.update_notice)→ notices 表首条 ----------
|
||||||
|
$noticeCount = (int) $pdo->query('SELECT COUNT(*) FROM notices')->fetchColumn();
|
||||||
|
if ($noticeCount === 0) {
|
||||||
|
$legacy = trim((string) $pdo->query("SELECT value FROM settings WHERE key = 'update_notice'")->fetchColumn());
|
||||||
|
if ($legacy !== '') {
|
||||||
|
$nowN = date('Y-m-d H:i:s');
|
||||||
|
$stN = $pdo->prepare('INSERT INTO notices (title, content, pinned, enabled, created_at, updated_at) VALUES (?,?,0,1,?,?)');
|
||||||
|
$stN->execute(['', $legacy, $nowN, $nowN]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 标记内容已更新(首页展示"上次更新时间") */
|
/** 标记内容已更新(首页展示"上次更新时间") */
|
||||||
@ -778,7 +802,7 @@ function risk_block_page(): void
|
|||||||
}
|
}
|
||||||
$site = '本站';
|
$site = '本站';
|
||||||
try {
|
try {
|
||||||
$site = setting_get('site_name', '知识导航站');
|
$site = setting_get('site_name', 'SecHome');
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,57 @@ if (!isset($P)) {
|
|||||||
/** HTML 转义 */
|
/** HTML 转义 */
|
||||||
function he($s): string
|
function he($s): string
|
||||||
{
|
{
|
||||||
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成一个公告链接:允许 http(s) 外链、协议相对 //、站内相对路径,以及裸域名(自动补 https://);
|
||||||
|
* 本地磁盘路径或其它无法识别的内容原样保留。$label / $url 均为已 HTML 转义后的文本。
|
||||||
|
*/
|
||||||
|
function notice_link(string $label, string $url): string
|
||||||
|
{
|
||||||
|
$raw = trim(html_entity_decode($url, ENT_QUOTES, 'UTF-8'));
|
||||||
|
$href = $url; // 默认沿用(已转义)的原文
|
||||||
|
$external = false; // 是否新窗口打开
|
||||||
|
if (preg_match('#^(?:https?:)?//#i', $raw)) {
|
||||||
|
$external = true; // http(s):// 或 //example.com
|
||||||
|
} elseif ($raw !== '' && $raw[0] === '/') {
|
||||||
|
// 站内相对路径:保持原样
|
||||||
|
} elseif (preg_match('#^[A-Za-z]:#', $raw)) {
|
||||||
|
return '[' . $label . '](' . $url . ')'; // Windows 盘符路径等,非链接
|
||||||
|
} elseif (preg_match('#^[^\s]+\.[A-Za-z]{2,}(?:[/?\#].*)?$#', $raw)) {
|
||||||
|
$href = 'https://' . $url; // 裸域名,自动补 https://
|
||||||
|
$external = true;
|
||||||
|
} elseif (preg_match('#^[\w\-.]+/[\w\-./?%\#=&]*$#', $raw)) {
|
||||||
|
// 形如 func/index.php 的相对路径:保持原样
|
||||||
|
} else {
|
||||||
|
return '[' . $label . '](' . $url . ')'; // 无法识别:保留原写法
|
||||||
|
}
|
||||||
|
$attr = $external ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||||
|
return '<a href="' . $href . '"' . $attr . '>' . $label . '</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公告正文渲染:先整体转义,再仅放行 http(s) / 站内相对链接,保留换行。
|
||||||
|
* 支持 [文字](链接) 显式写法,也自动识别裸 http(s) 链接。
|
||||||
|
*/
|
||||||
|
function notice_html(string $text): string
|
||||||
|
{
|
||||||
|
$esc = he($text);
|
||||||
|
// 1) Markdown 风格链接:[文字](链接)
|
||||||
|
$esc = preg_replace_callback('/\[([^\]]+)\]\(([^)\s]+)\)/u', static function (array $m): string {
|
||||||
|
return notice_link($m[1], $m[2]);
|
||||||
|
}, $esc);
|
||||||
|
// 2) 裸链接自动识别(跳过已在标签 / href="..." 内的地址)
|
||||||
|
$esc = preg_replace_callback('/(?<![="\'\/>])https?:\/\/[^\s<"\')\]]+/u', static function (array $m): string {
|
||||||
|
$url = rtrim($m[0], '.,;:!?');
|
||||||
|
if ($url === '') {
|
||||||
|
return $m[0];
|
||||||
|
}
|
||||||
|
$tail = substr($m[0], strlen($url));
|
||||||
|
return '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>' . $tail;
|
||||||
|
}, $esc);
|
||||||
|
return nl2br($esc);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前 logo 的站点相对路径(后台配置优先,空则默认) */
|
/** 当前 logo 的站点相对路径(后台配置优先,空则默认) */
|
||||||
@ -26,7 +76,7 @@ function site_logo_rel(): string
|
|||||||
function layout_head(string $title = ''): void
|
function layout_head(string $title = ''): void
|
||||||
{
|
{
|
||||||
global $P;
|
global $P;
|
||||||
$site = setting_get('site_name', '知识导航站');
|
$site = setting_get('site_name', 'SecHome');
|
||||||
$logo = site_logo_rel();
|
$logo = site_logo_rel();
|
||||||
$fullTitle = ($title !== '' ? $title . ' - ' : '') . $site;
|
$fullTitle = ($title !== '' ? $title . ' - ' : '') . $site;
|
||||||
$prefix = $P;
|
$prefix = $P;
|
||||||
@ -66,7 +116,7 @@ function layout_logo_img(string $cls = 'logo', string $alt = 'logo'): void
|
|||||||
function layout_footer(): void
|
function layout_footer(): void
|
||||||
{
|
{
|
||||||
global $P;
|
global $P;
|
||||||
$site = setting_get('site_name', '知识导航站');
|
$site = setting_get('site_name', 'SecHome');
|
||||||
$icp = setting_get('icp_no', '');
|
$icp = setting_get('icp_no', '');
|
||||||
$gongan = setting_get('gongan_no', '');
|
$gongan = setting_get('gongan_no', '');
|
||||||
$gonganLink = setting_get('gongan_link', '');
|
$gonganLink = setting_get('gongan_link', '');
|
||||||
@ -89,7 +139,10 @@ function layout_footer(): void
|
|||||||
if ($extra !== '') {
|
if ($extra !== '') {
|
||||||
echo '<div>' . he($extra) . '</div>';
|
echo '<div>' . he($extra) . '</div>';
|
||||||
}
|
}
|
||||||
echo '<div><a class="admin-link" href="' . he($P) . 'admin/login.php">后台管理</a></div>';
|
echo '<div style="display:flex;gap:16px;align-items:center;justify-content:center;flex-wrap:wrap">';
|
||||||
|
echo '<a class="admin-link" href="' . he($P) . 'notice.php">更新公告</a>';
|
||||||
|
echo '<a class="admin-link" href="' . he($P) . 'admin/login.php">后台管理</a>';
|
||||||
|
echo '</div>';
|
||||||
echo '</div></footer>' . "\n";
|
echo '</div></footer>' . "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,11 +171,11 @@ function home_quote(): string
|
|||||||
/** 首页访客信息(hero 信息模式):IP 与浏览器/系统/内核由服务端解析 UA 提供,屏幕/语言由前端补齐 */
|
/** 首页访客信息(hero 信息模式):IP 与浏览器/系统/内核由服务端解析 UA 提供,屏幕/语言由前端补齐 */
|
||||||
function visitor_brief(): array
|
function visitor_brief(): array
|
||||||
{
|
{
|
||||||
$ip = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
|
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||||
if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) {
|
if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||||
$ip = '未知';
|
$ip = '未知';
|
||||||
}
|
}
|
||||||
$ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
|
$ua = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
|
||||||
$browser = '未知';
|
$browser = '未知';
|
||||||
$kernel = '未知';
|
$kernel = '未知';
|
||||||
$os = '未知';
|
$os = '未知';
|
||||||
@ -140,18 +193,22 @@ function visitor_brief(): array
|
|||||||
} elseif (strpos($ua, 'Firefox/') !== false) {
|
} elseif (strpos($ua, 'Firefox/') !== false) {
|
||||||
$browser = 'Firefox';
|
$browser = 'Firefox';
|
||||||
$kernel = 'Gecko';
|
$kernel = 'Gecko';
|
||||||
if (preg_match('/Firefox\/([\d.]+)/', $ua, $m)) $browser .= ' ' . $m[1];
|
if (preg_match('/Firefox\/([\d.]+)/', $ua, $m))
|
||||||
|
$browser .= ' ' . $m[1];
|
||||||
} elseif (preg_match('/(?:Chrome|CriOS)\/([\d.]+)/', $ua, $m)) {
|
} elseif (preg_match('/(?:Chrome|CriOS)\/([\d.]+)/', $ua, $m)) {
|
||||||
$browser = 'Chrome ' . $m[1];
|
$browser = 'Chrome ' . $m[1];
|
||||||
$kernel = 'Blink';
|
$kernel = 'Blink';
|
||||||
} elseif (strpos($ua, 'Safari/') !== false) {
|
} elseif (strpos($ua, 'Safari/') !== false) {
|
||||||
$browser = 'Safari';
|
$browser = 'Safari';
|
||||||
$kernel = 'WebKit';
|
$kernel = 'WebKit';
|
||||||
if (preg_match('/Version\/([\d.]+)/', $ua, $m)) $browser .= ' ' . $m[1];
|
if (preg_match('/Version\/([\d.]+)/', $ua, $m))
|
||||||
|
$browser .= ' ' . $m[1];
|
||||||
}
|
}
|
||||||
if ($kernel === '未知') {
|
if ($kernel === '未知') {
|
||||||
if (strpos($ua, 'Gecko/') !== false) $kernel = 'Gecko';
|
if (strpos($ua, 'Gecko/') !== false)
|
||||||
elseif (strpos($ua, 'AppleWebKit/') !== false) $kernel = 'WebKit';
|
$kernel = 'Gecko';
|
||||||
|
elseif (strpos($ua, 'AppleWebKit/') !== false)
|
||||||
|
$kernel = 'WebKit';
|
||||||
}
|
}
|
||||||
// 操作系统
|
// 操作系统
|
||||||
if (strpos($ua, 'Windows NT 10.0') !== false) {
|
if (strpos($ua, 'Windows NT 10.0') !== false) {
|
||||||
@ -168,13 +225,16 @@ function visitor_brief(): array
|
|||||||
$os = 'Windows Phone';
|
$os = 'Windows Phone';
|
||||||
} elseif (strpos($ua, 'Android') !== false) {
|
} elseif (strpos($ua, 'Android') !== false) {
|
||||||
$os = 'Android';
|
$os = 'Android';
|
||||||
if (preg_match('/Android ([\d.]+)/', $ua, $m)) $os .= ' ' . $m[1];
|
if (preg_match('/Android ([\d.]+)/', $ua, $m))
|
||||||
|
$os .= ' ' . $m[1];
|
||||||
} elseif (strpos($ua, 'iPad') !== false || strpos($ua, 'iPhone') !== false || strpos($ua, 'iPod') !== false) {
|
} elseif (strpos($ua, 'iPad') !== false || strpos($ua, 'iPhone') !== false || strpos($ua, 'iPod') !== false) {
|
||||||
$os = 'iOS';
|
$os = 'iOS';
|
||||||
if (preg_match('/OS ([\d_]+)/', $ua, $m)) $os .= ' ' . str_replace('_', '.', $m[1]);
|
if (preg_match('/OS ([\d_]+)/', $ua, $m))
|
||||||
|
$os .= ' ' . str_replace('_', '.', $m[1]);
|
||||||
} elseif (strpos($ua, 'Mac OS X') !== false) {
|
} elseif (strpos($ua, 'Mac OS X') !== false) {
|
||||||
$os = 'macOS';
|
$os = 'macOS';
|
||||||
if (preg_match('/Mac OS X ([\d_]+)/', $ua, $m)) $os .= ' ' . str_replace('_', '.', $m[1]);
|
if (preg_match('/Mac OS X ([\d_]+)/', $ua, $m))
|
||||||
|
$os .= ' ' . str_replace('_', '.', $m[1]);
|
||||||
} elseif (strpos($ua, 'CrOS') !== false) {
|
} elseif (strpos($ua, 'CrOS') !== false) {
|
||||||
$os = 'ChromeOS';
|
$os = 'ChromeOS';
|
||||||
} elseif (strpos($ua, 'Linux') !== false) {
|
} elseif (strpos($ua, 'Linux') !== false) {
|
||||||
@ -203,7 +263,7 @@ function icon_is_image(string $raw): bool
|
|||||||
/** 渲染图标:图片返回 <img>,否则返回转义文本(供前台/后台预览复用) */
|
/** 渲染图标:图片返回 <img>,否则返回转义文本(供前台/后台预览复用) */
|
||||||
function site_icon(?string $raw, string $alt = 'icon'): string
|
function site_icon(?string $raw, string $alt = 'icon'): string
|
||||||
{
|
{
|
||||||
$v = trim((string)$raw);
|
$v = trim((string) $raw);
|
||||||
if ($v === '') {
|
if ($v === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@ -267,14 +327,16 @@ function search_engines(): array
|
|||||||
}
|
}
|
||||||
$out = [];
|
$out = [];
|
||||||
foreach ($arr as $i => $e) {
|
foreach ($arr as $i => $e) {
|
||||||
if (!is_array($e)) continue;
|
if (!is_array($e))
|
||||||
$name = trim((string)($e['name'] ?? ''));
|
continue;
|
||||||
$url = trim((string)($e['url'] ?? ''));
|
$name = trim((string) ($e['name'] ?? ''));
|
||||||
$icon = trim((string)($e['icon'] ?? ''));
|
$url = trim((string) ($e['url'] ?? ''));
|
||||||
if ($name === '' || $url === '' || strpos($url, '{kw}') === false) continue;
|
$icon = trim((string) ($e['icon'] ?? ''));
|
||||||
$key = trim((string)($e['key'] ?? ''));
|
if ($name === '' || $url === '' || strpos($url, '{kw}') === false)
|
||||||
|
continue;
|
||||||
|
$key = trim((string) ($e['key'] ?? ''));
|
||||||
if ($key === '') {
|
if ($key === '') {
|
||||||
$key = 'e' . ((int)$i + 1);
|
$key = 'e' . ((int) $i + 1);
|
||||||
}
|
}
|
||||||
$out[] = ['key' => $key, 'name' => $name, 'icon' => ($icon !== '' ? $icon : 'S'), 'url' => $url];
|
$out[] = ['key' => $key, 'name' => $name, 'icon' => ($icon !== '' ? $icon : 'S'), 'url' => $url];
|
||||||
}
|
}
|
||||||
|
|||||||
32
index.php
32
index.php
@ -6,7 +6,7 @@
|
|||||||
$P = '';
|
$P = '';
|
||||||
require_once __DIR__ . '/includes/layout.php';
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
|
|
||||||
$siteName = setting_get('site_name', '知识导航站');
|
$siteName = setting_get('site_name', 'SecHome');
|
||||||
$siteSlogan = setting_get('site_slogan', '');
|
$siteSlogan = setting_get('site_slogan', '');
|
||||||
$heroBg = setting_get('hero_bg', ''); // 后台自定义顶部背景(纯色 / CSS 渐变)
|
$heroBg = setting_get('hero_bg', ''); // 后台自定义顶部背景(纯色 / CSS 渐变)
|
||||||
// 首页顶部组件模式:weather=天气(默认) / info=访客信息 / blank=空白精简
|
// 首页顶部组件模式:weather=天气(默认) / info=访客信息 / blank=空白精简
|
||||||
@ -69,7 +69,8 @@ $catHint = [
|
|||||||
layout_head('');
|
layout_head('');
|
||||||
?>
|
?>
|
||||||
<!-- Hero 顶部展示栏 -->
|
<!-- Hero 顶部展示栏 -->
|
||||||
<header class="hero<?= $heroMode === 'blank' ? ' hero-slim' : '' ?>"<?php if ($heroBg !== ''): ?> style="background:<?= he($heroBg) ?>"<?php endif; ?>>
|
<header class="hero<?= $heroMode === 'blank' ? ' hero-slim' : '' ?>" <?php if ($heroBg !== ''): ?>
|
||||||
|
style="background:<?= he($heroBg) ?>" <?php endif; ?>>
|
||||||
<div class="wrap hero-inner">
|
<div class="wrap hero-inner">
|
||||||
<div class="hero-top">
|
<div class="hero-top">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
@ -89,16 +90,17 @@ layout_head('');
|
|||||||
<?php if ($heroMode === 'weather'): ?>
|
<?php if ($heroMode === 'weather'): ?>
|
||||||
<!-- 天气模式(默认):第三方天气条 -->
|
<!-- 天气模式(默认):第三方天气条 -->
|
||||||
<div class="weather-box">
|
<div class="weather-box">
|
||||||
<iframe width="800" height="150" src="https://i.tianqi.com/?c=code&a=getcode&id=48&num=6&icon=1" frameborder="0"></iframe>
|
<iframe width="800" height="150" src="https://i.tianqi.com/?c=code&a=getcode&id=48&num=6&icon=1"
|
||||||
|
frameborder="0"></iframe>
|
||||||
</div>
|
</div>
|
||||||
<?php elseif ($heroMode === 'info'): ?>
|
<?php elseif ($heroMode === 'info'): ?>
|
||||||
<!-- 访客信息模式:IP / 浏览器 / 系统 / 内核 / 屏幕 / 语言 -->
|
<!-- 访客信息模式:IP / 浏览器 / 系统 / 内核 / 屏幕 / 语言 -->
|
||||||
<div class="visitor-box js-visitor-box">
|
<div class="visitor-box js-visitor-box">
|
||||||
<div class="vb-grid">
|
<div class="vb-grid">
|
||||||
<div class="vb-cell"><span>访问者 IP</span><b><?= he((string)($visitor['ip'] ?? '未知')) ?></b></div>
|
<div class="vb-cell"><span>访问者 IP</span><b><?= he((string) ($visitor['ip'] ?? '未知')) ?></b></div>
|
||||||
<div class="vb-cell"><span>浏览器</span><b><?= he((string)($visitor['browser'] ?? '未知')) ?></b></div>
|
<div class="vb-cell"><span>浏览器</span><b><?= he((string) ($visitor['browser'] ?? '未知')) ?></b></div>
|
||||||
<div class="vb-cell"><span>操作系统</span><b><?= he((string)($visitor['os'] ?? '未知')) ?></b></div>
|
<div class="vb-cell"><span>操作系统</span><b><?= he((string) ($visitor['os'] ?? '未知')) ?></b></div>
|
||||||
<div class="vb-cell"><span>内核 / 渲染引擎</span><b><?= he((string)($visitor['kernel'] ?? '未知')) ?></b></div>
|
<div class="vb-cell"><span>内核 / 渲染引擎</span><b><?= he((string) ($visitor['kernel'] ?? '未知')) ?></b></div>
|
||||||
<div class="vb-cell"><span>屏幕分辨率</span><b class="js-vscreen">--</b></div>
|
<div class="vb-cell"><span>屏幕分辨率</span><b class="js-vscreen">--</b></div>
|
||||||
<div class="vb-cell"><span>系统语言</span><b class="js-vlang">--</b></div>
|
<div class="vb-cell"><span>系统语言</span><b class="js-vlang">--</b></div>
|
||||||
</div>
|
</div>
|
||||||
@ -127,14 +129,15 @@ layout_head('');
|
|||||||
<?php if ($quickLinks): ?>
|
<?php if ($quickLinks): ?>
|
||||||
<div class="quick-strip">
|
<div class="quick-strip">
|
||||||
<?php foreach ($quickLinks as $ql):
|
<?php foreach ($quickLinks as $ql):
|
||||||
$qIcon = trim((string)($ql['icon'] ?? ''));
|
$qIcon = trim((string) ($ql['icon'] ?? ''));
|
||||||
$qIcon = $qIcon !== '' ? $qIcon : '🔗';
|
$qIcon = $qIcon !== '' ? $qIcon : '🔗';
|
||||||
$qNote = trim((string)($ql['note'] ?? ''));
|
$qNote = trim((string) ($ql['note'] ?? ''));
|
||||||
?>
|
?>
|
||||||
<a class="quick-tile" href="<?= he((string)$ql['url']) ?>" target="_blank" rel="noopener noreferrer" title="<?= he($qNote !== '' ? $qNote : (string)$ql['name']) ?>">
|
<a class="quick-tile" href="<?= he((string) $ql['url']) ?>" target="_blank" rel="noopener noreferrer"
|
||||||
|
title="<?= he($qNote !== '' ? $qNote : (string) $ql['name']) ?>">
|
||||||
<span class="q-tile-ic"><?= site_icon($qIcon, '') ?></span>
|
<span class="q-tile-ic"><?= site_icon($qIcon, '') ?></span>
|
||||||
<span class="q-tile-b">
|
<span class="q-tile-b">
|
||||||
<b><?= he((string)$ql['name']) ?></b>
|
<b><?= he((string) $ql['name']) ?></b>
|
||||||
<?php if ($qNote !== ''): ?><small><?= he($qNote) ?></small><?php endif; ?>
|
<?php if ($qNote !== ''): ?><small><?= he($qNote) ?></small><?php endif; ?>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
@ -143,11 +146,11 @@ layout_head('');
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="boards">
|
<div class="boards">
|
||||||
<?php foreach ($cats as $c):
|
<?php foreach ($cats as $c):
|
||||||
$k = (string)$c['key'];
|
$k = (string) $c['key'];
|
||||||
$href = $catLink[$k] ?? 'index.php';
|
$href = $catLink[$k] ?? 'index.php';
|
||||||
$bIcon = trim((string)($c['icon'] ?? ''));
|
$bIcon = trim((string) ($c['icon'] ?? ''));
|
||||||
$bIcon = $bIcon !== '' ? $bIcon : ($catIcon[$k] ?? '●');
|
$bIcon = $bIcon !== '' ? $bIcon : ($catIcon[$k] ?? '●');
|
||||||
$descTxt = trim((string)($c['description'] ?? ''));
|
$descTxt = trim((string) ($c['description'] ?? ''));
|
||||||
if ($descTxt === '') {
|
if ($descTxt === '') {
|
||||||
$descTxt = $catHint[$k] ?? '';
|
$descTxt = $catHint[$k] ?? '';
|
||||||
}
|
}
|
||||||
@ -168,4 +171,5 @@ layout_head('');
|
|||||||
|
|
||||||
<script src="assets/js/common.js"></script>
|
<script src="assets/js/common.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
2
nav.php
2
nav.php
@ -129,7 +129,7 @@ foreach ($pdo->query('SELECT * FROM ext_links WHERE enabled = 1 ORDER BY sort AS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$siteName = setting_get('site_name', '知识导航站');
|
$siteName = setting_get('site_name', 'SecHome');
|
||||||
$engines = search_engines();
|
$engines = search_engines();
|
||||||
$defaultEngine = $engines[0]['key'] ?? 'bing';
|
$defaultEngine = $engines[0]['key'] ?? 'bing';
|
||||||
$enginesJson = json_encode($engines, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
$enginesJson = json_encode($engines, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|||||||
59
notice.php
Normal file
59
notice.php
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* notice.php —— 更新公告(前台展示页)
|
||||||
|
* 数据来自 notices 表,由后台「更新公告管理」(admin/notice.php)维护;
|
||||||
|
* 展示每条公告的发布时间与最后修改时间,内容支持超链接与换行。
|
||||||
|
*/
|
||||||
|
$P = '';
|
||||||
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
// 置顶优先;其余按发布时间倒序(旧公告保留、顺次展示)
|
||||||
|
$notices = $pdo->query('SELECT * FROM notices WHERE enabled = 1 ORDER BY pinned DESC, created_at DESC, id DESC')->fetchAll();
|
||||||
|
|
||||||
|
layout_head('更新公告');
|
||||||
|
?>
|
||||||
|
<div class="func-bar">
|
||||||
|
<div class="wrap func-bar-inner">
|
||||||
|
<a class="btn btn-sm" href="index.php" title="返回首页">← 返回首页</a>
|
||||||
|
<span class="func-bar-title">更新公告</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="page-main">
|
||||||
|
<div class="wrap page-body">
|
||||||
|
<?php if (!$notices): ?>
|
||||||
|
<div class="panel-card">
|
||||||
|
<p class="tip" style="margin:0">暂无更新公告。</p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($notices as $n): ?>
|
||||||
|
<?php
|
||||||
|
$title = trim((string) $n['title']);
|
||||||
|
$created = (string) $n['created_at'];
|
||||||
|
$updated = (string) $n['updated_at'];
|
||||||
|
?>
|
||||||
|
<div class="notice-item">
|
||||||
|
<h2 class="notice-title">
|
||||||
|
<?php if ((int) $n['pinned'] === 1): ?><span class="badge badge-ok">置顶</span><?php endif; ?>
|
||||||
|
<?= $title !== '' ? he($title) : '公告' ?>
|
||||||
|
</h2>
|
||||||
|
<div class="notice-meta">
|
||||||
|
<?php if ($created !== ''): ?><span>发布时间:<?= he($created) ?></span><?php endif; ?>
|
||||||
|
<?php if ($updated !== ''): ?>
|
||||||
|
<span>最后修改:<?= he($updated) ?><?= ($updated === $created) ? '(未修改)' : '' ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="notice-body"><?= notice_html((string) $n['content']) ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<?php layout_theme_fab(); ?>
|
||||||
|
<script src="assets/js/common.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@ -26,7 +26,7 @@
|
|||||||
入口:首页「工具库」板块 → 完整功能区;或各库「内部功能区」。
|
入口:首页「工具库」板块 → 完整功能区;或各库「内部功能区」。
|
||||||
|
|
||||||
| 工具 | 常见用法 |
|
| 工具 | 常见用法 |
|
||||||
| ------------- | ------------------------------------------------------------------------------------------------------------------ |
|
| ---------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 编码 / 加解密 | Base64 / Base32 / URL / Unicode 编解码,MD5 / SHA 摘要 |
|
| 编码 / 加解密 | Base64 / Base32 / URL / Unicode 编解码,MD5 / SHA 摘要 |
|
||||||
| 随机密码生成 | 选字符集与长度,批量生成 |
|
| 随机密码生成 | 选字符集与长度,批量生成 |
|
||||||
| 二维码生成器 | 输入内容生成,可放大预览、下载 PNG |
|
| 二维码生成器 | 输入内容生成,可放大预览、下载 PNG |
|
||||||
@ -34,6 +34,7 @@
|
|||||||
| IP 地址处理 | 粘贴日志/拖入文件,自动提取 IPv4 并按 /24 段聚合统计(内网/CDN/公网分类);填“重点匹配 IP”可置顶标红;支持导出 TXT |
|
| IP 地址处理 | 粘贴日志/拖入文件,自动提取 IPv4 并按 /24 段聚合统计(内网/CDN/公网分类);填“重点匹配 IP”可置顶标红;支持导出 TXT |
|
||||||
| 辅助维权 | 反弹 Shell、凭证提取、持久化参考(仅供授权测试学习) |
|
| 辅助维权 | 反弹 Shell、凭证提取、持久化参考(仅供授权测试学习) |
|
||||||
| 国密加解密 | SM2 / SM3 / SM4(SM1 为不公开硬件算法,无法软件实现) |
|
| 国密加解密 | SM2 / SM3 / SM4(SM1 为不公开硬件算法,无法软件实现) |
|
||||||
|
| Navicat 密码解密 | Navicat 数据库连接密码本地解密 / 加密;支持 11 及以前(Blowfish)与 12 及以后(AES-128-CBC),可解析 `.ncx` 连接文件 |
|
||||||
|
|
||||||
- 所有运算**在浏览器本地完成**,内容不上传服务器;可放心处理本地大文件
|
- 所有运算**在浏览器本地完成**,内容不上传服务器;可放心处理本地大文件
|
||||||
- 离线完全可用需在 `assets/js/` 放入第三方库(见下文“常见问题”),否则在线自动回退 CDN
|
- 离线完全可用需在 `assets/js/` 放入第三方库(见下文“常见问题”),否则在线自动回退 CDN
|
||||||
@ -44,9 +45,15 @@
|
|||||||
- 顶部「☰ 标题栏 / ≡ 段落」按钮可开关左右栏
|
- 顶部「☰ 标题栏 / ≡ 段落」按钮可开关左右栏
|
||||||
- 正文插图上传后自动插入 `` 引用
|
- 正文插图上传后自动插入 `` 引用
|
||||||
|
|
||||||
|
### 5. 更新公告
|
||||||
|
|
||||||
|
- 入口:页面底部信息栏「更新公告」(位于「后台管理」旁),或直接访问 `/notice.php`
|
||||||
|
- 逐条展示公告的**发布时间**与**最后修改时间**(未修改时标注),正文中的超链接可跳转站内或外部页面
|
||||||
|
- 置顶公告排在最前;页面顶部提供「← 返回首页」按钮
|
||||||
|
|
||||||
## 二、后台管理
|
## 二、后台管理
|
||||||
|
|
||||||
入口:站点域名 `/admin`,登录后进入;顶栏依次为:
|
入口:站点域名 `/admin`,登录后进入。左侧为可收缩**侧边栏**(小屏自动切换为抽屉),整合全部管理入口,并自动汇总当前页「本页功能」锚点(点击直达对应设置区块、随滚动高亮);侧边栏底部显示「当前用户(用户名)」。管理入口依次为:
|
||||||
|
|
||||||
### 1. 导航管理(nav.php)
|
### 1. 导航管理(nav.php)
|
||||||
|
|
||||||
@ -85,7 +92,16 @@
|
|||||||
- **首页顶部组件**:天气(默认)/ 访客信息 / 空白精简
|
- **首页顶部组件**:天气(默认)/ 访客信息 / 空白精简
|
||||||
- **修改密码**:建议首次登录即修改默认 `admin/admin123`
|
- **修改密码**:建议首次登录即修改默认 `admin/admin123`
|
||||||
|
|
||||||
### 6. 风控管理(visits.php)—— 日志与安全
|
### 6. 更新公告(notice.php)
|
||||||
|
|
||||||
|
- 维护前台页脚「更新公告」页展示的内容;**支持多条公告并保留历史**,任意一条均可再次编辑
|
||||||
|
- 每条公告可填标题(可选)与正文;支持**置顶**(前台排在最前)与**隐藏**(前台不展示)
|
||||||
|
- 时间由系统自动记录:「发布时间」(首次保存)与「最后修改时间」(每次保存),前台逐条展示
|
||||||
|
- **超链接**:正文支持 `[文字](链接)` 写法——站内用相对路径(如 `/index.php`、`/func/index.php`),外链用 `https://…`;也可直接粘贴网址自动识别
|
||||||
|
- 编辑器提供「插入链接」按钮与「预览渲染」,可先看效果再保存
|
||||||
|
- 列表可按「编辑 / 置顶 / 隐藏 / 删除」管理;右上角「预览前台页面」直达 `/notice.php`
|
||||||
|
|
||||||
|
### 7. 风控管理(visits.php)—— 日志与安全
|
||||||
|
|
||||||
**访问概况**:累计访问 / 独立 IP / 今日访问 / 黑名单规则数。
|
**访问概况**:累计访问 / 独立 IP / 今日访问 / 黑名单规则数。
|
||||||
**访问记录**:
|
**访问记录**:
|
||||||
@ -139,6 +155,7 @@
|
|||||||
```
|
```
|
||||||
assets/js/qrcode.min.js qrcodejs 1.0.0
|
assets/js/qrcode.min.js qrcodejs 1.0.0
|
||||||
assets/js/crypto-js.min.js crypto-js 4.2.0
|
assets/js/crypto-js.min.js crypto-js 4.2.0
|
||||||
|
assets/js/blowfish.js egoroof-blowfish 2.2.2 (Navicat 密码解密用)
|
||||||
assets/js/sm-crypto/sm2.js|sm3.js|sm4.js sm-crypto 0.3.13
|
assets/js/sm-crypto/sm2.js|sm3.js|sm4.js sm-crypto 0.3.13
|
||||||
```
|
```
|
||||||
5. **忘记后台密码**:数据库 `users` 表密码为 `md5(base64(明文))`;可将某行改为已知口令的散列后登录,随后在后台重设。
|
5. **忘记后台密码**:数据库 `users` 表密码为 `md5(base64(明文))`;可将某行改为已知口令的散列后登录,随后在后台重设。
|
||||||
Loading…
Reference in New Issue
Block a user