795 lines
33 KiB
PHP
795 lines
33 KiB
PHP
<?php
|
||
/**
|
||
* db.php —— SQLite 连接与首次初始化
|
||
* 提供 db() 单例;首次运行时自动建库建表并写入默认分类与设置。
|
||
*/
|
||
defined('ROOT_DIR') || define('ROOT_DIR', dirname(__DIR__));
|
||
defined('DATA_DIR') || define('DATA_DIR', ROOT_DIR . '/data');
|
||
|
||
if (!is_dir(DATA_DIR)) {
|
||
@mkdir(DATA_DIR, 0777, true);
|
||
}
|
||
|
||
function db(): PDO
|
||
{
|
||
static $pdo = null;
|
||
if ($pdo instanceof PDO) {
|
||
return $pdo;
|
||
}
|
||
$file = DATA_DIR . '/homepage.db';
|
||
try {
|
||
$pdo = new PDO('sqlite:' . $file);
|
||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||
$pdo->exec('PRAGMA journal_mode=WAL;');
|
||
db_init($pdo);
|
||
} catch (Throwable $e) {
|
||
http_response_code(500);
|
||
die('数据库初始化失败:' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'));
|
||
}
|
||
return $pdo;
|
||
}
|
||
|
||
/** 若旧表缺少某列则补充(SQLite 兼容迁移) */
|
||
function db_ensure_column(PDO $pdo, string $table, string $column, string $definition): void
|
||
{
|
||
$cols = $pdo->query('PRAGMA table_info(' . $table . ')')->fetchAll();
|
||
foreach ($cols as $col) {
|
||
if (strtolower((string)$col['name']) === strtolower($column)) {
|
||
return;
|
||
}
|
||
}
|
||
$pdo->exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $column . ' ' . $definition);
|
||
}
|
||
|
||
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
|
||
)');
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS settings (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT
|
||
)');
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS categories (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
key TEXT UNIQUE NOT NULL,
|
||
name TEXT NOT NULL,
|
||
icon TEXT DEFAULT \'\',
|
||
description TEXT DEFAULT \'\',
|
||
sort INTEGER DEFAULT 0
|
||
)');
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS nav_blocks (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
cat_id INTEGER NOT NULL,
|
||
title TEXT NOT NULL,
|
||
sort INTEGER DEFAULT 0
|
||
)');
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS nav_items (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
block_id INTEGER NOT NULL,
|
||
label TEXT NOT NULL,
|
||
url TEXT NOT NULL,
|
||
note TEXT DEFAULT \'\',
|
||
sort INTEGER DEFAULT 0
|
||
)');
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS func_tools (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL,
|
||
icon TEXT DEFAULT \'\',
|
||
note TEXT DEFAULT \'\',
|
||
description TEXT DEFAULT \'\',
|
||
url TEXT NOT NULL DEFAULT \'\',
|
||
is_external INTEGER NOT NULL DEFAULT 0,
|
||
enabled INTEGER NOT NULL DEFAULT 1,
|
||
sort INTEGER NOT NULL DEFAULT 0
|
||
)');
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS quick_links (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL,
|
||
url TEXT NOT NULL DEFAULT \'\',
|
||
icon TEXT DEFAULT \'\',
|
||
note TEXT DEFAULT \'\',
|
||
enabled INTEGER NOT NULL DEFAULT 1,
|
||
sort INTEGER NOT NULL DEFAULT 0
|
||
)');
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS ext_links (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL,
|
||
icon TEXT DEFAULT \'\',
|
||
url TEXT NOT NULL DEFAULT \'\',
|
||
description TEXT DEFAULT \'\',
|
||
grp TEXT DEFAULT \'\',
|
||
cat_keys TEXT DEFAULT \'\',
|
||
enabled INTEGER NOT NULL DEFAULT 1,
|
||
sort INTEGER NOT NULL DEFAULT 0
|
||
)');
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS articles (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
part TEXT NOT NULL DEFAULT \'未分类\',
|
||
title TEXT NOT NULL,
|
||
summary TEXT DEFAULT \'\',
|
||
markdown TEXT DEFAULT \'\',
|
||
pinned INTEGER NOT NULL DEFAULT 0,
|
||
enabled INTEGER NOT NULL DEFAULT 1,
|
||
sort INTEGER NOT NULL DEFAULT 0,
|
||
board_cats TEXT DEFAULT \'\',
|
||
created_at TEXT DEFAULT \'\',
|
||
updated_at TEXT DEFAULT \'\'
|
||
)');
|
||
// ---------- IP 黑名单 ----------
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS ip_blacklist (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
rule TEXT NOT NULL,
|
||
note TEXT DEFAULT \'\',
|
||
created_at TEXT NOT NULL DEFAULT \'\',
|
||
source TEXT NOT NULL DEFAULT \'manual\'
|
||
)');
|
||
|
||
// ---------- 自动风控计数(登录失败 / 404 次数 / 每分钟请求频率) ----------
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS ip_risk (
|
||
ip TEXT PRIMARY KEY,
|
||
login_fail INTEGER NOT NULL DEFAULT 0,
|
||
not_found INTEGER NOT NULL DEFAULT 0,
|
||
rate_ts INTEGER NOT NULL DEFAULT 0,
|
||
rate_count INTEGER NOT NULL DEFAULT 0,
|
||
updated_at TEXT NOT NULL DEFAULT \'\'
|
||
)');
|
||
|
||
// 兼容旧库:ip_blacklist 补 source 列;categories 补充 icon 列
|
||
db_ensure_column($pdo, 'ip_blacklist', 'source', "TEXT NOT NULL DEFAULT 'manual'");
|
||
db_ensure_column($pdo, 'categories', 'icon', "TEXT DEFAULT ''");
|
||
// 兼容旧库:quick_links / func_tools 若缺列一并补齐
|
||
db_ensure_column($pdo, 'quick_links', 'note', "TEXT DEFAULT ''");
|
||
db_ensure_column($pdo, 'func_tools', 'note', "TEXT DEFAULT ''");
|
||
// 兼容旧库:工具与文章的“库首页分发范围”(board_cats,逗号分隔库 key;空=通用)
|
||
db_ensure_column($pdo, 'func_tools', 'board_cats', "TEXT DEFAULT ''");
|
||
db_ensure_column($pdo, 'articles', 'board_cats', "TEXT DEFAULT ''");
|
||
// 兼容旧库:外部工具的“分区/栏目”(grp,空=不分组平铺)
|
||
db_ensure_column($pdo, 'ext_links', 'grp', "TEXT DEFAULT ''");
|
||
|
||
// ---------- 默认分类种子 ----------
|
||
$count = (int)$pdo->query('SELECT COUNT(*) FROM categories')->fetchColumn();
|
||
if ($count === 0) {
|
||
$seedCats = [
|
||
['popular', '科普库', '📖', 1, '面向大众与初学者的计算机科学、网络安全与前沿技术科普,帮你建立体系化的安全认知。'],
|
||
['red', '红队库', '⚔️', 2, '面向渗透测试与攻防演练的资源集,汇聚漏洞利用、武器化、情报收集与红队基础设施相关的高质量工具与文档。'],
|
||
['blue', '蓝队库', '🛡️', 3, '面向防御侧的资源集,汇集应急响应、威胁狩猎、流量分析与取证溯源相关的工具与知识。'],
|
||
['tool', '工具库', '🧰', 4, '日常效率工具与安全实用小工具都在这里,点击直达功能区。'],
|
||
];
|
||
$st = $pdo->prepare('INSERT INTO categories (key, name, icon, sort, description) VALUES (?,?,?,?,?)');
|
||
foreach ($seedCats as $c) {
|
||
$st->execute($c);
|
||
}
|
||
}
|
||
|
||
// ---------- 文章库板块分类(第五大板块,旧库缺失时追加) ----------
|
||
$hasArticleCat = (int)$pdo->query("SELECT COUNT(*) FROM categories WHERE key = 'article'")->fetchColumn();
|
||
if ($hasArticleCat === 0) {
|
||
$stC = $pdo->prepare('INSERT INTO categories (key, name, icon, description, sort) VALUES (?,?,?,?,?)');
|
||
$stC->execute(['article', '文章库', '📚', '站长原创与整理收录的图文与 Markdown 文章合集。', 5]);
|
||
}
|
||
|
||
// ---------- 默认设置种子 ----------
|
||
$hasSettings = (int)$pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn();
|
||
if ($hasSettings === 0) {
|
||
$defaults = [
|
||
'site_name' => '知识导航站',
|
||
'site_slogan' => '汇聚科普、攻防与效率工具的实用首页',
|
||
'site_logo' => '', // 空 = 使用默认 assets/img/logo.svg
|
||
'icp_no' => '京ICP备00000000号-1',
|
||
'gongan_no' => '京公网安备11000000000000号',
|
||
'gongan_link' => '', // 公安备案号链接(可空则不显示为链接)
|
||
'copyright' => 'Copyright © 2026 知识导航站 版权所有。本站内容仅供学习研究使用。',
|
||
'footer_text' => '',
|
||
'search_engines'=> '', // 搜索引擎列表 JSON;空 = 使用默认 4 个
|
||
'last_updated' => '',
|
||
'hero_bg' => '', // 首页顶部标识栏背景(纯色 / CSS 渐变),空 = 主题默认
|
||
'hero_mode' => 'weather', // 首页顶部组件模式:weather=天气(默认) / info=访客信息 / blank=空白精简
|
||
'cdn_ranges' => '', // CDN 段库文本;空 = 使用默认内置列表
|
||
'article_note' => '', // 文章区首页顶部注要(Markdown 风格,纯文本)
|
||
];
|
||
$st = $pdo->prepare('INSERT INTO settings (key, value) VALUES (?,?)');
|
||
foreach ($defaults as $k => $v) {
|
||
$st->execute([$k, $v]);
|
||
}
|
||
}
|
||
|
||
// ---------- 默认管理员 ----------
|
||
$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'))]);
|
||
}
|
||
|
||
// ---------- 功能区工具默认种子 ----------
|
||
$toolCount = (int)$pdo->query('SELECT COUNT(*) FROM func_tools')->fetchColumn();
|
||
if ($toolCount === 0) {
|
||
$seedTools = [
|
||
['编码 / 加解密', '🔐', 'Base64 / Base32 / URL / Unicode / MD5 / SHA 系列', 'codec.html', 0],
|
||
['随机密码生成', '🔑', '自定义字符集与长度,一次生成多组', 'password.html', 0],
|
||
['二维码生成器', '▦', '任意内容生成二维码,可放大并下载 PNG', 'qrcode.html', 0],
|
||
['杀软识别', '🛡', '粘贴 tasklist 输出,匹配进程对应的安全软件', 'av.html', 0],
|
||
];
|
||
$st = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, sort) VALUES (?,?,?,?,?,1,?)');
|
||
$sort = 0;
|
||
foreach ($seedTools as $t) {
|
||
$st->execute([$t[0], $t[1], $t[2], $t[3], $t[4], ++$sort]);
|
||
}
|
||
}
|
||
|
||
// ---------- 后期新增功能页默认登记(按 url 去重,允许后台再停用/删除) ----------
|
||
$extraTools = [
|
||
['国密加解密', '🔏', 'SM2 非对称加解密 / SM3 摘要 / SM4 对称加解密(SM1 为不公开硬件算法)', 'gmcodec.html'],
|
||
['IP 地址处理', '🌐', '提取日志中的全部 IPv4 并统计出现次数,再按内网 / CDN / 公网区分展示', 'ip.html'],
|
||
['辅助维权', '🛠️', '反弹 Shell 构建 / Windows SAM·mimikatz·票据提取 / Linux 自启服务(systemd 与 init.d)', 'persist.html'],
|
||
];
|
||
$chkUrl = $pdo->prepare('SELECT COUNT(*) FROM func_tools WHERE url = ?');
|
||
$insTool = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, sort) VALUES (?,?,?,?,0,1,?)');
|
||
foreach ($extraTools as $t) {
|
||
$chkUrl->execute([$t[3]]);
|
||
if ((int)$chkUrl->fetchColumn() === 0) {
|
||
$mx = (int)$pdo->query('SELECT COALESCE(MAX(sort), 0) FROM func_tools')->fetchColumn();
|
||
$insTool->execute([$t[0], $t[1], $t[2], $t[3], $mx + 1]);
|
||
}
|
||
}
|
||
|
||
// ---------- 库首页“外部工具区”默认种子(cat_keys 为空=展示于所有库首页;grp=分区/栏目,留空不分) ----------
|
||
$extCount = (int)$pdo->query('SELECT COUNT(*) FROM ext_links')->fetchColumn();
|
||
if ($extCount === 0) {
|
||
$seedExt = [
|
||
// [分区/栏目, 名称, 网址, 简介]
|
||
['在线检测', 'VirusTotal', 'https://www.virustotal.com', '多引擎文件与 URL 检测'],
|
||
['在线检测', 'Shodan', 'https://www.shodan.io', '互联网设备与端口测绘'],
|
||
['在线检测', 'URLScan', 'https://urlscan.io', '网站访问行为分析'],
|
||
['情报与数据', 'CyberChef', 'https://gchq.github.io/CyberChef/', '数据加工与编解码台'],
|
||
['情报与数据', 'Exploit-DB', 'https://www.exploit-db.com', '公开漏洞利用库'],
|
||
['情报与数据', 'Have I Been Pwned', 'https://haveibeenpwned.com', '泄露账户与密码查询'],
|
||
];
|
||
$stExt = $pdo->prepare('INSERT INTO ext_links (name, url, description, grp, cat_keys, enabled, sort) VALUES (?,?,?,?,?,1,?)');
|
||
$extSort = 0;
|
||
foreach ($seedExt as $e) {
|
||
$stExt->execute([$e[1], $e[2], $e[3], $e[0], '', ++$extSort]);
|
||
}
|
||
}
|
||
|
||
// ---------- 文章库示例文章(首次建库时提供一篇使用说明) ----------
|
||
$artCount = (int)$pdo->query('SELECT COUNT(*) FROM articles')->fetchColumn();
|
||
if ($artCount === 0) {
|
||
$sampleMd = <<<MD
|
||
## 分区与文章
|
||
|
||
后台「文章管理」页可新增、编辑、删除文章,正文使用标准 Markdown 书写,支持分区与置顶。
|
||
|
||
## 三栏阅读
|
||
|
||
- 左列:全站文章标题栏,按分区展示,可折叠;
|
||
- 中列:当前文章的段落目录,点击可快速定位;
|
||
- 右列:文章正文(Markdown 渲染)。
|
||
|
||
### 图片引用
|
||
|
||
图片文件上传到 data/articles/img 目录,正文中写作:
|
||
|
||

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