323 lines
13 KiB
PHP
323 lines
13 KiB
PHP
<?php
|
||
/**
|
||
* layout.php —— 页面公共输出组件
|
||
* 约定:每个页面在引入本文件前需定义全局 $P(资源前缀,根目录为 '',func/、admin/ 子目录为 '../')。
|
||
*/
|
||
require_once __DIR__ . '/db.php';
|
||
|
||
if (!isset($P)) {
|
||
$P = '';
|
||
}
|
||
|
||
/** HTML 转义 */
|
||
function he($s): string
|
||
{
|
||
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 的站点相对路径(后台配置优先,空则默认) */
|
||
function site_logo_rel(): string
|
||
{
|
||
$logo = setting_get('site_logo', '');
|
||
return $logo !== '' ? $logo : 'assets/img/logo.svg';
|
||
}
|
||
|
||
/** 输出页面 <head>:标题/favicon/主题防闪烁/公共样式 */
|
||
function layout_head(string $title = ''): void
|
||
{
|
||
global $P;
|
||
$site = setting_get('site_name', 'SecHome');
|
||
$logo = site_logo_rel();
|
||
$fullTitle = ($title !== '' ? $title . ' - ' : '') . $site;
|
||
$prefix = $P;
|
||
echo '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">';
|
||
echo '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
|
||
echo '<meta name="renderer" content="webkit">';
|
||
// 主题防闪烁:在任何样式加载前应用
|
||
echo '<script>(function(){var t;try{t=localStorage.getItem("hp-theme")}catch(e){}';
|
||
echo 'if(!t){t=(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)?"dark":"light"}';
|
||
echo 'document.documentElement.setAttribute("data-theme",t);})();</script>';
|
||
echo '<title>' . he($fullTitle) . '</title>';
|
||
echo '<link rel="icon" href="' . he($prefix . $logo) . '">';
|
||
echo '<link rel="stylesheet" href="' . he($prefix) . 'assets/css/common.css">';
|
||
// 站长提示开关(后台可关):关闭时统一隐藏带 st-tip 标记的辅助文案
|
||
if ((setting_get('show_st_tips', '1')) !== '1') {
|
||
echo '<style>.st-tip{display:none!important}</style>';
|
||
}
|
||
echo '</head><body>' . "\n";
|
||
}
|
||
|
||
/** 昼夜切换浮动按钮 + 返回顶部按钮(全站页面右下角) */
|
||
function layout_theme_fab(): void
|
||
{
|
||
echo '<button type="button" class="theme-fab js-theme-btn" title="切换昼夜模式" aria-label="切换昼夜模式">';
|
||
echo '<span class="js-theme-icon"></span></button>' . "\n";
|
||
echo '<button type="button" class="theme-fab to-top js-to-top" title="返回顶部" aria-label="返回顶部">↑</button>' . "\n";
|
||
}
|
||
|
||
/** 站内 logo 图片(内容区使用,同 favicon) */
|
||
function layout_logo_img(string $cls = 'logo', string $alt = 'logo'): void
|
||
{
|
||
global $P;
|
||
echo '<img class="' . he($cls) . '" src="' . he($P . site_logo_rel()) . '" alt="' . he($alt) . '">';
|
||
}
|
||
|
||
/** 全站页脚 */
|
||
function layout_footer(): void
|
||
{
|
||
global $P;
|
||
$site = setting_get('site_name', 'SecHome');
|
||
$icp = setting_get('icp_no', '');
|
||
$gongan = setting_get('gongan_no', '');
|
||
$gonganLink = setting_get('gongan_link', '');
|
||
$copyright = setting_get('copyright', '');
|
||
$extra = setting_get('footer_text', '');
|
||
echo '<footer class="footer"><div class="wrap footer-inner">';
|
||
if ($icp !== '') {
|
||
echo '<div><a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" style="color:inherit">' . he($icp) . '</a></div>';
|
||
}
|
||
if ($gongan !== '') {
|
||
if ($gonganLink !== '') {
|
||
echo '<div><a href="' . he($gonganLink) . '" target="_blank" rel="noopener noreferrer" style="color:inherit">' . he($gongan) . '</a></div>';
|
||
} else {
|
||
echo '<div>' . he($gongan) . '</div>';
|
||
}
|
||
}
|
||
if ($copyright !== '') {
|
||
echo '<div>' . he(str_replace('{site_name}', $site, $copyright)) . '</div>';
|
||
}
|
||
if ($extra !== '') {
|
||
echo '<div>' . he($extra) . '</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";
|
||
}
|
||
|
||
/** 首页访客信息(hero 信息模式):IP 与浏览器/系统/内核由服务端解析 UA 提供,屏幕/语言由前端补齐 */
|
||
function visitor_brief(): array
|
||
{
|
||
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||
if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) {
|
||
$ip = '未知';
|
||
}
|
||
$ua = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
|
||
$browser = '未知';
|
||
$kernel = '未知';
|
||
$os = '未知';
|
||
if ($ua !== '') {
|
||
// 浏览器 / 内核
|
||
if (strpos($ua, 'MicroMessenger') !== false) {
|
||
$browser = '微信内置浏览器';
|
||
$kernel = 'WebKit';
|
||
} elseif (preg_match('/Edg\/([\d.]+)/', $ua, $m)) {
|
||
$browser = 'Edge ' . $m[1];
|
||
$kernel = 'Blink';
|
||
} elseif (preg_match('/OPR\/([\d.]+)/', $ua, $m)) {
|
||
$browser = 'Opera ' . $m[1];
|
||
$kernel = 'Blink';
|
||
} elseif (strpos($ua, 'Firefox/') !== false) {
|
||
$browser = 'Firefox';
|
||
$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';
|
||
} elseif (strpos($ua, 'Safari/') !== false) {
|
||
$browser = 'Safari';
|
||
$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, 'Windows NT 10.0') !== false) {
|
||
$os = 'Windows 10 / 11';
|
||
} elseif (strpos($ua, 'Windows NT 6.3') !== false) {
|
||
$os = 'Windows 8.1';
|
||
} elseif (strpos($ua, 'Windows NT 6.2') !== false) {
|
||
$os = 'Windows 8';
|
||
} elseif (strpos($ua, 'Windows NT 6.1') !== false) {
|
||
$os = 'Windows 7';
|
||
} elseif (strpos($ua, 'Windows NT 5.1') !== false) {
|
||
$os = 'Windows XP';
|
||
} elseif (strpos($ua, 'Windows Phone') !== false) {
|
||
$os = 'Windows Phone';
|
||
} elseif (strpos($ua, 'Android') !== false) {
|
||
$os = 'Android';
|
||
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]);
|
||
} elseif (strpos($ua, 'Mac OS X') !== false) {
|
||
$os = 'macOS';
|
||
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) {
|
||
$os = 'Linux';
|
||
}
|
||
}
|
||
return ['ip' => $ip, 'browser' => $browser, 'os' => $os, 'kernel' => $kernel];
|
||
}
|
||
|
||
/** 图标值是否属于图片(dataURL / http / 常见图片扩展 / 相对路径) */
|
||
function icon_is_image(string $raw): bool
|
||
{
|
||
$v = trim($raw);
|
||
if ($v === '') {
|
||
return false;
|
||
}
|
||
if (preg_match('/^data:image\//i', $v) || preg_match('#^https?://#i', $v)) {
|
||
return true;
|
||
}
|
||
if (preg_match('/\.(png|jpe?g|gif|svg|webp)(\?|#|$)/i', $v)) {
|
||
return true;
|
||
}
|
||
return $v[0] === '/' || strpos($v, './') === 0 || strpos($v, '../') === 0;
|
||
}
|
||
|
||
/** 渲染图标:图片返回 <img>,否则返回转义文本(供前台/后台预览复用) */
|
||
function site_icon(?string $raw, string $alt = 'icon'): string
|
||
{
|
||
$v = trim((string) $raw);
|
||
if ($v === '') {
|
||
return '';
|
||
}
|
||
if (icon_is_image($v)) {
|
||
return '<img class="icon-img" src="' . he($v) . '" alt="' . he($alt) . '" loading="lazy">';
|
||
}
|
||
return he($v);
|
||
}
|
||
|
||
/** 默认 CDN IPv4 段库(每行:CIDR + 空格 + 厂商名;# 开头为注释,由后台可覆盖) */
|
||
function default_cdn_ranges(): string
|
||
{
|
||
return implode("\n", [
|
||
'# 内置默认:Cloudflare 官方 IPv4 段(可在后台“CDN IP 段库”补充其它厂商)',
|
||
'103.21.244.0/22 Cloudflare',
|
||
'103.22.200.0/22 Cloudflare',
|
||
'103.31.4.0/22 Cloudflare',
|
||
'104.16.0.0/13 Cloudflare',
|
||
'104.24.0.0/14 Cloudflare',
|
||
'108.162.192.0/18 Cloudflare',
|
||
'131.0.72.0/22 Cloudflare',
|
||
'141.101.64.0/18 Cloudflare',
|
||
'162.158.0.0/15 Cloudflare',
|
||
'172.64.0.0/13 Cloudflare',
|
||
'173.245.48.0/20 Cloudflare',
|
||
'188.114.96.0/20 Cloudflare',
|
||
'190.93.240.0/20 Cloudflare',
|
||
'197.234.240.0/22 Cloudflare',
|
||
'198.41.128.0/17 Cloudflare',
|
||
]);
|
||
}
|
||
|
||
/** 当前生效的 CDN 段库文本(后台配置优先,为空回退默认内置列表) */
|
||
function cdn_ranges_text(): string
|
||
{
|
||
$v = setting_get('cdn_ranges', '');
|
||
return $v !== '' ? $v : default_cdn_ranges();
|
||
}
|
||
|
||
/** 默认搜索引擎列表(后台未配置时的兜底) */
|
||
function default_search_engines(): array
|
||
{
|
||
return [
|
||
['key' => 'bing', 'name' => 'Bing', 'icon' => 'B', 'url' => 'https://www.bing.com/search?q={kw}'],
|
||
['key' => 'baidu', 'name' => '百度', 'icon' => '度', 'url' => 'https://www.baidu.com/s?wd={kw}'],
|
||
['key' => 'github', 'name' => 'GitHub', 'icon' => 'G', 'url' => 'https://github.com/search?q={kw}'],
|
||
['key' => 'google', 'name' => 'Google', 'icon' => 'G', 'url' => 'https://www.google.com/search?q={kw}'],
|
||
];
|
||
}
|
||
|
||
/** 读取当前生效的搜索引擎列表(优先后台配置,无效或为空回退默认) */
|
||
function search_engines(): array
|
||
{
|
||
$raw = setting_get('search_engines', '');
|
||
if ($raw === '') {
|
||
return default_search_engines();
|
||
}
|
||
$arr = json_decode($raw, true);
|
||
if (!is_array($arr) || count($arr) === 0) {
|
||
return default_search_engines();
|
||
}
|
||
$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 ($key === '') {
|
||
$key = 'e' . ((int) $i + 1);
|
||
}
|
||
$out[] = ['key' => $key, 'name' => $name, 'icon' => ($icon !== '' ? $icon : 'S'), 'url' => $url];
|
||
}
|
||
return $out !== [] ? $out : default_search_engines();
|
||
}
|