请查看CHANGELOG.md获取更新信息

This commit is contained in:
MasonLiu 2026-09-13 00:54:16 +08:00
parent bf772046cc
commit 9bb6e6a41e
18 changed files with 879 additions and 105 deletions

89
CHANGELOG.md Normal file
View 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` 中说明。

View File

@ -40,10 +40,8 @@ Markdown 写作与三栏阅读(标题栏 / 段落目录 / 正文),分区
要求PHP 7.4+(启用 `pdo_sqlite`)、现代浏览器。
```bash
cd SecHome
php -S 0.0.0.0:8000
```
直接将文件放置在网站路径下即可,打开网站时会自动创建数据库文件;
迁移时仅需确保/data/文件夹不变即可。
- 前台http://localhost:8000
- 后台http://localhost:8000/admin
@ -76,3 +74,13 @@ php -S 0.0.0.0:8000
- 请立即修改默认口令,并视需要更换密码散列方案
- 自动风控可能误封**共享出口 IP**:请谨慎开启「请求频率」规则或调高阈值
- 黑名单为全站拦截、无后台逃生通道;误封时请换网络登录后台移除,或直接编辑 `data/homepage.db``ip_blacklist`
## 网站展示
前端UI展示
![网站前端](/img/front.png)
后端展示:
![网站管理](/img/back-1.png)
![风控管理](/img/back-2.png)

View File

@ -7,11 +7,20 @@ require_once dirname(__DIR__) . '/includes/auth.php';
require_once dirname(__DIR__) . '/includes/layout.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
{
global $P;
$site = setting_get('site_name', '知识导航站');
$site = setting_get('site_name', 'SecHome');
$admin = (string) ($_SESSION['admin'] ?? '');
// 立即执行:标记 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";
@ -40,6 +49,7 @@ function admin_topbar(string $active = ''): void
'articles' => ['articles.php', '文章管理', '📄'],
'exttools' => ['exttools.php', '外部工具', '🔗'],
'content' => ['content.php', '网站管理', '⚙'],
'notice' => ['notice.php', '更新公告', '📢'],
'visits' => ['visits.php', '风控管理', '🛡'],
];
echo '<nav class="as-nav">';

View File

@ -46,7 +46,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$act = (string) ($_POST['act'] ?? '');
switch ($act) {
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'] ?? '')));
touch_last_updated();
$msgKind = 'ok';
@ -344,7 +344,7 @@ $all = [];
foreach ($pdo->query('SELECT key, value FROM settings') as $r) {
$all[$r['key']] = (string) $r['value'];
}
$site = $all['site_name'] ?? '知识导航站';
$site = $all['site_name'] ?? 'SecHome';
layout_head('网站管理');
admin_topbar('content');

73
admin/force_password.php Normal file
View 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>

View File

@ -18,7 +18,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} elseif (hp_login($username, $password, (string) ($_POST['dev'] ?? ''))) {
// 正常登录成功:清零本 IP 风控计数(避免携带历史失败次数,防止管理员自误封)
risk_clear_for_ip($curIp);
header('Location: nav.php');
// 初始密码未修改:强制进入改密页
if (hp_password_needs_change($username)) {
header('Location: force_password.php');
} else {
header('Location: nav.php');
}
exit;
} else {
// 轻量防爆破延时(命中黑名单的 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('后台登录');
?>
<main class="login-page">

372
admin/notice.php Normal file
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
// 解析链接http(s) / 协议相对 / 站内相对 / 裸域名(自动补 https://);无法识别则保留原写法
function resolveLink(label, url) {
var raw = url.replace(/&amp;/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>

View File

@ -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 > 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-area { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
.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 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-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

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 KiB

BIN
img/back-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

BIN
img/front.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -24,22 +24,58 @@ function require_login(string $loginPage = 'login.php'): void
}
}
/** 计算需求规定的密码散列:先 base64 再 md5 */
/** 密码散列:使用 PHP 内置强散列bcrypt/argon2自动加盐与迭代 */
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));
}
/** 验证用户 */
/** 验证用户(旧版散列验证通过后自动升级为强散列) */
function hp_verify_user(string $username, string $plain): bool
{
$st = db()->prepare('SELECT password_hash FROM users WHERE username = ? LIMIT 1');
$st->execute([$username]);
$row = $st->fetch();
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 hash_equals($row['password_hash'], hp_password_hash($plain));
}
/** 执行登录(成功返回 true成功/失败均写入登录日志与设备信息) */
@ -73,7 +109,7 @@ function hp_change_password(string $oldPlain, string $newPlain): array
if (strlen($newPlain) < 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']]);
return [true, '密码修改成功'];
}

View File

@ -45,9 +45,10 @@ function db_ensure_column(PDO $pdo, string $table, string $column, string $defin
function db_init(PDO $pdo): void
{
$pdo->exec('CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
must_change_pwd INTEGER NOT NULL DEFAULT 0
)');
$pdo->exec('CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
@ -119,6 +120,16 @@ function db_init(PDO $pdo): void
created_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 黑名单 ----------
$pdo->exec('CREATE TABLE IF NOT EXISTS ip_blacklist (
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)');
// 兼容旧库:登录日志补 device 列
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 列
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();
if ($hasSettings === 0) {
$defaults = [
'site_name' => '知识导航站',
'site_name' => 'SecHome',
'site_slogan' => '汇聚科普、攻防与效率工具的实用首页',
'site_logo' => '', // 空 = 使用默认 assets/img/logo.svg
'icp_no' => '京ICP备00000000号-1',
'gongan_no' => '京公网安备11000000000000号',
'gongan_link' => '', // 公安备案号链接(可空则不显示为链接)
'copyright' => 'Copyright © 2026 知识导航站 版权所有。本站内容仅供学习研究使用。',
'copyright' => 'Copyright © 2026 SecHome 版权所有。本站内容仅供学习研究使用。',
'footer_text' => '',
'search_engines' => '', // 搜索引擎列表 JSON空 = 使用默认 4 个
'last_updated' => '',
@ -215,9 +228,9 @@ function db_init(PDO $pdo): void
// ---------- 默认管理员 ----------
$userCount = (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
if ($userCount === 0) {
// 密码算法:先 base64 再 md5按原始需求实现
$st = $pdo->prepare('INSERT INTO users (username, password_hash) VALUES (?,?)');
$st->execute(['admin', md5(base64_encode('admin123'))]);
// 密码算法:PHP 内置 password_hashbcrypt/argon2默认管理员首次登录强制改密
$st = $pdo->prepare('INSERT INTO users (username, password_hash, must_change_pwd) VALUES (?,?,1)');
$st->execute(['admin', password_hash('admin123', PASSWORD_DEFAULT)]);
}
// ---------- 功能区工具默认种子 ----------
@ -317,6 +330,17 @@ MD;
foreach ($toolHtmlMap as $oldUrl => $newUrl) {
$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 = '本站';
try {
$site = setting_get('site_name', '知识导航站');
$site = setting_get('site_name', 'SecHome');
} catch (Throwable $e) {
// ignore
}

View File

@ -12,7 +12,57 @@ if (!isset($P)) {
/** HTML 转义 */
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 的站点相对路径(后台配置优先,空则默认) */
@ -26,7 +76,7 @@ function site_logo_rel(): string
function layout_head(string $title = ''): void
{
global $P;
$site = setting_get('site_name', '知识导航站');
$site = setting_get('site_name', 'SecHome');
$logo = site_logo_rel();
$fullTitle = ($title !== '' ? $title . ' - ' : '') . $site;
$prefix = $P;
@ -66,7 +116,7 @@ function layout_logo_img(string $cls = 'logo', string $alt = 'logo'): void
function layout_footer(): void
{
global $P;
$site = setting_get('site_name', '知识导航站');
$site = setting_get('site_name', 'SecHome');
$icp = setting_get('icp_no', '');
$gongan = setting_get('gongan_no', '');
$gonganLink = setting_get('gongan_link', '');
@ -89,7 +139,10 @@ function layout_footer(): void
if ($extra !== '') {
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";
}
@ -118,40 +171,44 @@ function home_quote(): string
/** 首页访客信息hero 信息模式IP 与浏览器/系统/内核由服务端解析 UA 提供,屏幕/语言由前端补齐 */
function visitor_brief(): array
{
$ip = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) {
$ip = '未知';
}
$ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
$ua = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
$browser = '未知';
$kernel = '未知';
$os = '未知';
$kernel = '未知';
$os = '未知';
if ($ua !== '') {
// 浏览器 / 内核
if (strpos($ua, 'MicroMessenger') !== false) {
$browser = '微信内置浏览器';
$kernel = 'WebKit';
$kernel = 'WebKit';
} elseif (preg_match('/Edg\/([\d.]+)/', $ua, $m)) {
$browser = 'Edge ' . $m[1];
$kernel = 'Blink';
$kernel = 'Blink';
} elseif (preg_match('/OPR\/([\d.]+)/', $ua, $m)) {
$browser = 'Opera ' . $m[1];
$kernel = 'Blink';
$kernel = 'Blink';
} elseif (strpos($ua, 'Firefox/') !== false) {
$browser = 'Firefox';
$kernel = 'Gecko';
if (preg_match('/Firefox\/([\d.]+)/', $ua, $m)) $browser .= ' ' . $m[1];
$kernel = 'Gecko';
if (preg_match('/Firefox\/([\d.]+)/', $ua, $m))
$browser .= ' ' . $m[1];
} elseif (preg_match('/(?:Chrome|CriOS)\/([\d.]+)/', $ua, $m)) {
$browser = 'Chrome ' . $m[1];
$kernel = 'Blink';
$kernel = 'Blink';
} elseif (strpos($ua, 'Safari/') !== false) {
$browser = 'Safari';
$kernel = 'WebKit';
if (preg_match('/Version\/([\d.]+)/', $ua, $m)) $browser .= ' ' . $m[1];
$kernel = 'WebKit';
if (preg_match('/Version\/([\d.]+)/', $ua, $m))
$browser .= ' ' . $m[1];
}
if ($kernel === '未知') {
if (strpos($ua, 'Gecko/') !== false) $kernel = 'Gecko';
elseif (strpos($ua, 'AppleWebKit/') !== false) $kernel = 'WebKit';
if (strpos($ua, 'Gecko/') !== false)
$kernel = 'Gecko';
elseif (strpos($ua, 'AppleWebKit/') !== false)
$kernel = 'WebKit';
}
// 操作系统
if (strpos($ua, 'Windows NT 10.0') !== false) {
@ -168,13 +225,16 @@ function visitor_brief(): array
$os = 'Windows Phone';
} elseif (strpos($ua, 'Android') !== false) {
$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) {
$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) {
$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) {
$os = 'ChromeOS';
} elseif (strpos($ua, 'Linux') !== false) {
@ -203,7 +263,7 @@ function icon_is_image(string $raw): bool
/** 渲染图标:图片返回 <img>,否则返回转义文本(供前台/后台预览复用) */
function site_icon(?string $raw, string $alt = 'icon'): string
{
$v = trim((string)$raw);
$v = trim((string) $raw);
if ($v === '') {
return '';
}
@ -267,14 +327,16 @@ function search_engines(): array
}
$out = [];
foreach ($arr as $i => $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;
$key = trim((string)($e['key'] ?? ''));
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;
$key = trim((string) ($e['key'] ?? ''));
if ($key === '') {
$key = 'e' . ((int)$i + 1);
$key = 'e' . ((int) $i + 1);
}
$out[] = ['key' => $key, 'name' => $name, 'icon' => ($icon !== '' ? $icon : 'S'), 'url' => $url];
}

View File

@ -6,36 +6,36 @@
$P = '';
require_once __DIR__ . '/includes/layout.php';
$siteName = setting_get('site_name', '知识导航站');
$siteName = setting_get('site_name', 'SecHome');
$siteSlogan = setting_get('site_slogan', '');
$heroBg = setting_get('hero_bg', ''); // 后台自定义顶部背景(纯色 / CSS 渐变)
// 首页顶部组件模式weather=天气(默认) / info=访客信息 / blank=空白精简
$heroMode = setting_get('hero_mode', 'weather');
if (!in_array($heroMode, ['weather', 'info', 'blank'], true)) {
$heroMode = 'weather';
$heroMode = 'weather';
}
$visitor = [];
if ($heroMode === 'info') {
$visitor = visitor_brief();
$visitor = visitor_brief();
}
$lastUpdated = setting_get('last_updated', '');
if ($lastUpdated === '') {
$lastUpdated = '暂无更新记录';
$lastUpdated = '暂无更新记录';
}
// 名言:服务端随机一条 + 全量池(供点击格言本身切换)
$quoteFile = DATA_DIR . '/quota.txt';
$quotes = [];
if (is_file($quoteFile)) {
$raw = @file($quoteFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (is_array($raw)) {
foreach ($raw as $ln) {
$ln = trim($ln);
if ($ln !== '' && strpos($ln, '#') !== 0) {
$quotes[] = $ln;
}
}
$raw = @file($quoteFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (is_array($raw)) {
foreach ($raw as $ln) {
$ln = trim($ln);
if ($ln !== '' && strpos($ln, '#') !== 0) {
$quotes[] = $ln;
}
}
}
}
$quoteText = $quotes ? $quotes[array_rand($quotes)] : '知识就是力量。';
@ -45,31 +45,32 @@ $cats = db()->query('SELECT * FROM categories ORDER BY sort ASC, id ASC')->fetch
$quickLinks = db()->query('SELECT * FROM quick_links WHERE enabled = 1 ORDER BY sort ASC, id ASC')->fetchAll();
$catLink = [
'popular' => 'nav.php?cat=popular',
'red' => 'nav.php?cat=red',
'blue' => 'nav.php?cat=blue',
'tool' => 'func/index.php',
'article' => 'article/index.php',
'popular' => 'nav.php?cat=popular',
'red' => 'nav.php?cat=red',
'blue' => 'nav.php?cat=blue',
'tool' => 'func/index.php',
'article' => 'article/index.php',
];
$catIcon = [
'popular' => '📖',
'red' => '⚔️',
'blue' => '🛡️',
'tool' => '🧰',
'article' => '📚',
'popular' => '📖',
'red' => '⚔️',
'blue' => '🛡️',
'tool' => '🧰',
'article' => '📚',
];
$catHint = [
'popular' => '入门·科普·前沿',
'red' => '攻防演练资源',
'blue' => '防御·应急·溯源',
'tool' => '进入功能区',
'article' => '进入文章库',
'popular' => '入门·科普·前沿',
'red' => '攻防演练资源',
'blue' => '防御·应急·溯源',
'tool' => '进入功能区',
'article' => '进入文章库',
];
layout_head('');
?>
<!-- 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="hero-top">
<div class="brand">
@ -89,16 +90,17 @@ layout_head('');
<?php if ($heroMode === 'weather'): ?>
<!-- 天气模式(默认):第三方天气条 -->
<div class="weather-box">
<iframe width="800" height="150" src="https://i.tianqi.com/?c=code&a=getcode&id=48&num=6&icon=1" frameborder="0"></iframe>
<iframe width="800" height="150" src="https://i.tianqi.com/?c=code&a=getcode&id=48&num=6&icon=1"
frameborder="0"></iframe>
</div>
<?php elseif ($heroMode === 'info'): ?>
<!-- 访客信息模式IP / 浏览器 / 系统 / 内核 / 屏幕 / 语言 -->
<div class="visitor-box js-visitor-box">
<div class="vb-grid">
<div class="vb-cell"><span>访问者 IP</span><b><?= he((string)($visitor['ip'] ?? '未知')) ?></b></div>
<div class="vb-cell"><span>浏览器</span><b><?= he((string)($visitor['browser'] ?? '未知')) ?></b></div>
<div class="vb-cell"><span>操作系统</span><b><?= he((string)($visitor['os'] ?? '未知')) ?></b></div>
<div class="vb-cell"><span>内核 / 渲染引擎</span><b><?= he((string)($visitor['kernel'] ?? '未知')) ?></b></div>
<div class="vb-cell"><span>访问者 IP</span><b><?= he((string) ($visitor['ip'] ?? '未知')) ?></b></div>
<div class="vb-cell"><span>浏览器</span><b><?= he((string) ($visitor['browser'] ?? '未知')) ?></b></div>
<div class="vb-cell"><span>操作系统</span><b><?= he((string) ($visitor['os'] ?? '未知')) ?></b></div>
<div class="vb-cell"><span>内核 / 渲染引擎</span><b><?= he((string) ($visitor['kernel'] ?? '未知')) ?></b></div>
<div class="vb-cell"><span>屏幕分辨率</span><b class="js-vscreen">--</b></div>
<div class="vb-cell"><span>系统语言</span><b class="js-vlang">--</b></div>
</div>
@ -127,14 +129,15 @@ layout_head('');
<?php if ($quickLinks): ?>
<div class="quick-strip">
<?php foreach ($quickLinks as $ql):
$qIcon = trim((string)($ql['icon'] ?? ''));
$qIcon = trim((string) ($ql['icon'] ?? ''));
$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-b">
<b><?= he((string)$ql['name']) ?></b>
<b><?= he((string) $ql['name']) ?></b>
<?php if ($qNote !== ''): ?><small><?= he($qNote) ?></small><?php endif; ?>
</span>
</a>
@ -143,13 +146,13 @@ layout_head('');
<?php endif; ?>
<div class="boards">
<?php foreach ($cats as $c):
$k = (string)$c['key'];
$k = (string) $c['key'];
$href = $catLink[$k] ?? 'index.php';
$bIcon = trim((string)($c['icon'] ?? ''));
$bIcon = trim((string) ($c['icon'] ?? ''));
$bIcon = $bIcon !== '' ? $bIcon : ($catIcon[$k] ?? '●');
$descTxt = trim((string)($c['description'] ?? ''));
$descTxt = trim((string) ($c['description'] ?? ''));
if ($descTxt === '') {
$descTxt = $catHint[$k] ?? '';
$descTxt = $catHint[$k] ?? '';
}
?>
<a class="board-card" href="<?= he($href) ?>" title="<?= he($c['name']) ?><?= he($descTxt) ?>">
@ -168,4 +171,5 @@ layout_head('');
<script src="assets/js/common.js"></script>
</body>
</html>

View File

@ -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();
$defaultEngine = $engines[0]['key'] ?? 'bing';
$enginesJson = json_encode($engines, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

59
notice.php Normal file
View 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>

View File

@ -25,15 +25,16 @@
入口:首页「工具库」板块 → 完整功能区;或各库「内部功能区」。
| 工具 | 常见用法 |
| ------------- | ------------------------------------------------------------------------------------------------------------------ |
| 编码 / 加解密 | Base64 / Base32 / URL / Unicode 编解码MD5 / SHA 摘要 |
| 随机密码生成 | 选字符集与长度,批量生成 |
| 二维码生成器 | 输入内容生成,可放大预览、下载 PNG |
| 杀软识别 | 在 Windows 执行 `tasklist` 后粘贴输出,识别对应安全软件 |
| IP 地址处理 | 粘贴日志/拖入文件,自动提取 IPv4 并按 /24 段聚合统计(内网/CDN/公网分类);填“重点匹配 IP”可置顶标红支持导出 TXT |
| 辅助维权 | 反弹 Shell、凭证提取、持久化参考仅供授权测试学习 |
| 国密加解密 | SM2 / SM3 / SM4SM1 为不公开硬件算法,无法软件实现) |
| 工具 | 常见用法 |
| ---------------- | -------------------------------------------------------------------------------------------------------------------- |
| 编码 / 加解密 | Base64 / Base32 / URL / Unicode 编解码MD5 / SHA 摘要 |
| 随机密码生成 | 选字符集与长度,批量生成 |
| 二维码生成器 | 输入内容生成,可放大预览、下载 PNG |
| 杀软识别 | 在 Windows 执行 `tasklist` 后粘贴输出,识别对应安全软件 |
| IP 地址处理 | 粘贴日志/拖入文件,自动提取 IPv4 并按 /24 段聚合统计(内网/CDN/公网分类);填“重点匹配 IP”可置顶标红支持导出 TXT |
| 辅助维权 | 反弹 Shell、凭证提取、持久化参考仅供授权测试学习 |
| 国密加解密 | SM2 / SM3 / SM4SM1 为不公开硬件算法,无法软件实现) |
| Navicat 密码解密 | Navicat 数据库连接密码本地解密 / 加密;支持 11 及以前Blowfish与 12 及以后AES-128-CBC可解析 `.ncx` 连接文件 |
- 所有运算**在浏览器本地完成**,内容不上传服务器;可放心处理本地大文件
- 离线完全可用需在 `assets/js/` 放入第三方库(见下文“常见问题”),否则在线自动回退 CDN
@ -44,9 +45,15 @@
- 顶部「☰ 标题栏 / ≡ 段落」按钮可开关左右栏
- 正文插图上传后自动插入 `![图片说明](data/articles/img/文件名.png)` 引用
### 5. 更新公告
- 入口:页面底部信息栏「更新公告」(位于「后台管理」旁),或直接访问 `/notice.php`
- 逐条展示公告的**发布时间**与**最后修改时间**(未修改时标注),正文中的超链接可跳转站内或外部页面
- 置顶公告排在最前;页面顶部提供「← 返回首页」按钮
## 二、后台管理
入口:站点域名 `/admin`,登录后进入;顶栏依次为:
入口:站点域名 `/admin`,登录后进入。左侧为可收缩**侧边栏**(小屏自动切换为抽屉),整合全部管理入口,并自动汇总当前页「本页功能」锚点(点击直达对应设置区块、随滚动高亮);侧边栏底部显示「当前用户(用户名)」。管理入口依次为:
### 1. 导航管理nav.php
@ -85,7 +92,16 @@
- **首页顶部组件**:天气(默认)/ 访客信息 / 空白精简
- **修改密码**:建议首次登录即修改默认 `admin/admin123`
### 6. 风控管理visits.php—— 日志与安全
### 6. 更新公告notice.php
- 维护前台页脚「更新公告」页展示的内容;**支持多条公告并保留历史**,任意一条均可再次编辑
- 每条公告可填标题(可选)与正文;支持**置顶**(前台排在最前)与**隐藏**(前台不展示)
- 时间由系统自动记录:「发布时间」(首次保存)与「最后修改时间」(每次保存),前台逐条展示
- **超链接**:正文支持 `[文字](链接)` 写法——站内用相对路径(如 `/index.php`、`/func/index.php`),外链用 `https://…`;也可直接粘贴网址自动识别
- 编辑器提供「插入链接」按钮与「预览渲染」,可先看效果再保存
- 列表可按「编辑 / 置顶 / 隐藏 / 删除」管理;右上角「预览前台页面」直达 `/notice.php`
### 7. 风控管理visits.php—— 日志与安全
**访问概况**:累计访问 / 独立 IP / 今日访问 / 黑名单规则数。
**访问记录**
@ -139,6 +155,7 @@
```
assets/js/qrcode.min.js qrcodejs 1.0.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
```
5. **忘记后台密码**:数据库 `users` 表密码为 `md5(base64(明文))`;可将某行改为已知口令的散列后登录,随后在后台重设。