后台上传站点图标:首页品牌图 + favicon + PWA 图标一起换

需求:后台新增设置,允许管理员上传自己的图标;上传后首页品牌图标与浏览器
标签页 favicon(以及「添加到手机主屏」图标)一起换成它。

要点:
- 一张上传图服务端居中裁剪成正方形,生成 16/32/48/64/180/192/512 +
  maskable 192/512 整套尺寸,并手工拼装多帧 favicon.ico。
- 单一来源 site_icon_url() 统一取址;site_icon_head() 被首页/登录页/后台
  三处共用;PWA manifest 改由 manifest.php 动态输出、maskable 也跟随 ——
  三处防线共同杜绝「首页换了、标签页还是旧的」这类半生效。
- 目录可写则落静态文件(最快);不可写的保守环境整套压进设置、由
  site-icon.php 按需输出,功能不因权限限制整块不可用。
- 与「网站列表里每个网站自己的图标」是两个维度:目录与设置键都分开,
  清理时互不误伤。

顺手修掉两个真问题:
- login.php / admin/_auth.php 各留了一行写死的 favicon,与新的
  site_icon_head() 并存 → head 里两条 rel="icon",是「上传了却没换」的
  温床。已删掉写死那两行。
- 自定义 favicon.ico 原先只塞 16+32 两帧,而内置是 16/32/48/64 四帧 ——
  换图标反而退步。已补齐 icon-64 并统一为 4 帧。
- setting() 的请求内缓存导致「同请求写后再读」拿到旧值(真实坑):
  settings_cache() 支持强制重载 + settings_reload(),写入后立即失效。

验证:单测 44/44(含只读目录动态回退)、HTTP 56/56、e2e 174/0、
反向验证 5 变体全部被抓住(含「写死残留」与「ico 只塞两帧」两个新哨兵)、
真机几何 360/390/768/1440 无溢出、线上直连桌面+移动端几何全绿、
线上诊断页走通「上传→前台全换→复原」。
This commit is contained in:
2026-09-28 22:28:23 +08:00
parent 2dfecef116
commit 92eb9d90be
11 changed files with 653 additions and 14 deletions
+7 -1
View File
@@ -1,11 +1,17 @@
# ---------------------------------------------------------------------------
# 运行时数据(绝不入库)
# data/ —— SQLite 数据库等敏感数据
# uploads/icons/* —— 抓取/上传的网站图标
# uploads/icons/* —— 抓取/上传的网站图标(每个目标网站一份)
# uploads/site/* —— 管理员上传的**本站**图标整套尺寸
# ⚠️ 与 uploads/icons 是两件事,别合并成一条通配:
# 它们是不同维度(本站一个 vs 每站一个),
# 分开写才能各自加白名单、也便于日后单独清理。
# ---------------------------------------------------------------------------
/data/
/uploads/icons/*
!/uploads/icons/.gitkeep
/uploads/site/*
!/uploads/site/.gitkeep
# ---------------------------------------------------------------------------
# 部署脚本(与具体服务器、账号、路径绑定)
+1 -1
View File
@@ -70,7 +70,7 @@ function admin_layout(string $active, string $title, string $body): void
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($title) ?> · <?= e(site_name()) ?> 后台</title>
<link rel="icon" href="<?= e(asset('favicon.ico')) ?>" sizes="any">
<?= site_icon_head() ?>
<link rel="stylesheet" href="<?= e(asset('assets/css/style.css')) ?>">
<link rel="stylesheet" href="<?= e(asset('assets/css/admin.css')) ?>"><?php if (setting('primary_color', '#2F6BF7') !== '#2F6BF7'): ?>
<style>:root{--primary:<?= e(setting('primary_color', '#2F6BF7')) ?>}</style><?php endif; ?>
+52
View File
@@ -207,6 +207,58 @@ try {
break;
}
/* ---------- 站点整体图标(favicon + 首页品牌图标) ---------- */
case 'site_icon_save': {
$back = 'admin/?p=settings';
$f = $_FILES['site_icon_file'] ?? null;
if (!$f || empty($f['tmp_name']) || !is_uploaded_file((string)$f['tmp_name'])) {
flash('请选择要上传的图片', 'err');
break;
}
if ((int)$f['error'] !== UPLOAD_ERR_OK) {
flash('上传失败(错误码 ' . (int)$f['error'] . ',可能是文件超过服务器上限)', 'err');
break;
}
$size = (int)($f['size'] ?? 0);
if ($size <= 0) {
flash('上传的文件是空的', 'err');
break;
}
if ($size > 5 * 1024 * 1024) {
flash('图片太大了(' . round($size / 1048576, 1) . 'MB),请压缩到 5MB 以内', 'err');
break;
}
$bin = (string)@file_get_contents((string)$f['tmp_name']);
if ($bin === '') {
flash('读取上传文件失败', 'err');
break;
}
// 只认图片:SVG 不收(矢量图当站点图标会带来 XSS 面,且栅格化各端不一致)
$im = gd_load($bin);
if ($im === null) {
flash('这不是一张能识别的图片(支持 PNG / JPG / GIF / WebP)', 'err');
break;
}
$iw = imagesx($im);
$ih = imagesy($im);
imagedestroy($im);
$err = save_site_logo($bin);
if ($err !== '') {
flash($err, 'err');
} else {
flash('站点图标已更新(' . $iw . '×' . $ih
. ' → 已生成 favicon 与手机主屏图标等一整套尺寸)');
}
break;
}
case 'site_icon_reset': {
$back = 'admin/?p=settings';
reset_site_logo();
flash('已恢复默认图标');
break;
}
/* ---------- 设置 ---------- */
case 'settings_save': {
$srcRaw = (string)($_POST['suggest_source'] ?? 'baidu');
+57
View File
@@ -367,6 +367,63 @@ else: // settings ?>
<div class="span2 form-actions"><button class="btn primary">保存设置</button></div>
</form>
</section>
<section class="card">
<div class="card-head"><h2>站点图标</h2></div>
<?php
$iconOn = site_icon_mode() === 'custom';
$iconPrev = site_icon_url('logo', 512);
$iconDirW = site_icon_dir_writable();
?>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" enctype="multipart/form-data" class="grid-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="site_icon_save">
<div class="span2 icon-field">
<div class="icon-field-preview big">
<span class="icon-preview big">
<img src="<?= e($iconPrev !== '' ? $iconPrev : url('assets/img/logo.svg')) ?>" alt="">
</span>
</div>
<div class="icon-field-body">
<div class="icon-field-title">
当前:<?= $iconOn ? '自定义图标' : '内置默认图标' ?>
<span class="pill" data-site-icon="<?= e(site_icon_mode()) ?>">
<?= $iconOn ? '自定义' : '默认' ?>
</span>
</div>
<div class="row icon-row2">
<input type="file" name="site_icon_file" accept="image/png,image/jpeg,image/gif,image/webp">
<button class="btn primary" type="submit">上传并应用</button>
</div>
<p class="hint">
上传后,<b>浏览器标签页图标(favicon)</b>与<b>首页顶部的品牌图标</b>会一起换成它,
「添加到手机主屏」的图标也一并更新。<br>
建议用<b>正方形</b>图片(至少 32×32,推荐 512×512 以上);非正方形会<b>居中裁剪</b>成正方形。
支持 PNG / JPG / GIF / WebP,单张限 5MB。
</p>
<?php if ($iconOn): ?>
<p class="hint" data-site-icon-files="<?= $iconDirW ? 'static' : 'inline' ?>">
<?= $iconDirW
? '已在 uploads/site/ 生成一整套尺寸(16/32/48/180/192/512 + PWA 自适应版)。'
: '站点目录不可写,整套尺寸已内置在本站数据里,由 site-icon.php 按需输出。' ?>
</p>
<?php endif; ?>
</div>
</div>
</form>
<?php if ($iconOn): ?>
<form method="post" action="<?= e(url('admin/actions.php')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="action" value="site_icon_reset">
<p class="hint">想换回内置图标:<button class="btn ghost" type="submit">恢复默认图标</button></p>
</form>
<?php endif; ?>
<p class="hint">
⚠️ 这与「网站列表里每个网站自己的图标」是<b>两回事</b>:这里是<b>本站自己</b>的图标(一个),
那里是导航格子里<b>每个目标网站</b>的图标(很多个)。改这里不会动到任何一个网站格子。
</p>
</section>
<section class="card">
<div class="card-head"><h2>修改管理员密码</h2></div>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="grid-form">
+8
View File
@@ -103,6 +103,14 @@ body.admin { background: #eef0f3; color: #333; font-size: 14px; }
}
.icon-preview img { width: 100%; height: 100%; object-fit: contain; background: #fff; }
.icon-preview .ph { color: #bbb; font-size: 12px; }
/* 站点整体图标:比格子里的图标大一号,看得清 */
.icon-preview.big { width: 96px; height: 96px; border-radius: 20px; }
.icon-field-preview.big { flex: 0 0 96px; }
.pill {
display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px;
font-weight: 400; background: #eef2f7; color: #667085; vertical-align: 1px;
}
.pill[data-site-icon="custom"] { background: #e8f1fe; color: #2456cc; }
.icon-field-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.icon-field-title { font-size: 13px; color: #555; font-weight: 600; }
.icon-field-body input[type=text] {
+35 -3
View File
@@ -18,6 +18,17 @@ define('ICON_PATH', APP_PATH . '/uploads/icons'); // 抓取的网站图标(可
define('ICON_URL', 'uploads/icons'); // 相对站点根
define('ICON_STORE', 128); // 图标统一存成 128×128(高清屏下 45~64px 显示仍锐利)
/**
* 「站点整体图标」(浏览器标签页 favicon + 首页品牌图标)—— 用户上传后自动生成的一整套尺寸。
*
* ⚠️ 与上面的 ICON_PATH 是**两件事**,目录也刻意分开:
* ICON_PATH = 导航格子里**某个网站**自己的小图标,一个网站一份(键 = icon_key(网址));
* SITE_ICON_PATH = **本站自己**的图标,全局唯一(键 = 随机 16 位 hex)。
* 混在一起的话,清理图标目录时两边会互相误伤。
*/
define('SITE_ICON_PATH', APP_PATH . '/uploads/site');
define('SITE_ICON_URL', 'uploads/site');
/**
* 是否「由 PHP 再发一颗」绕开服务器侧页面缓存的标记 cookie。
*
@@ -60,11 +71,19 @@ function site_name(): string
return setting('site_name', APP_NAME);
}
/** 读取设置项 */
function setting(string $key, string $default = ''): string
/**
* 设置表缓存(可失效)。
*
* ⚠️ 为什么必须能失效:这个缓存只在**首次读取**时查库,之后整进程复用。
* 后台「保存设置 → 重定向」是两次请求,所以以前没暴露问题;但只要有**同一次请求里
* 先写设置、再读回来**的路径(上传站点图标就是:写完马上要用新键拼 URL),
* 就会读到写之前的旧值 —— 表现为「上传成功但图标没变」。
* 任何写入设置的地方,写完都要调 settings_reload()。
*/
function settings_cache(bool $reload = false): array
{
static $cache = null;
if ($cache === null) {
if ($cache === null || $reload) {
$cache = [];
try {
foreach (db()->query('SELECT k, v FROM settings') as $row) {
@@ -74,5 +93,18 @@ function setting(string $key, string $default = ''): string
$cache = [];
}
}
return $cache;
}
/** 写入设置后调用,让本次请求后续的 setting() 读到新值 */
function settings_reload(): void
{
settings_cache(true);
}
/** 读取设置项 */
function setting(string $key, string $default = ''): string
{
$cache = settings_cache();
return array_key_exists($key, $cache) && $cache[$key] !== '' ? $cache[$key] : $default;
}
+379
View File
@@ -616,6 +616,385 @@ function save_custom_icon(string $binary, string $key): string
return $file;
}
/* =========================================================================
* 站点整体图标(浏览器标签页 favicon + 首页品牌图标)
*
* 用户上传一张图 → 服务端生成**一整套**尺寸,前台各处的写法都不变:
* 静态优先(有就直出文件,兼容「部署目录只读」的保守环境)
* → 没有静态再回退到动态输出(新上传立刻生效,无需给站点目录写权限)
* 「一整套」是必须的:标签页 favicon 与「添加到手机主屏」的 PWA 图标读的是不同文件,
* 只换其中一个 = 半生效(用户会看到标签页新图、主屏图标还是旧的)。
* ========================================================================= */
/** 自定义站点图标的存在性(唯一事实来源:上传成功后写的标记文件) */
function site_icon_key(): string
{
$k = setting('site_icon', '');
return preg_match('/^[a-f0-9]{16,64}$/', $k) ? $k : '';
}
/** 自定义站点图标的磁盘目录(已做白名单,键只会是 hex) */
function site_icon_dir(): string
{
$k = site_icon_key();
// ⚠️ 用 rtrim 去掉尾部斜杠再拼:否则会得到 ".../uploads/site//<键>",
// 检查文件存在时 PHP 会按原样拼路径并告警/找不到。
return $k === '' ? '' : rtrim(SITE_ICON_PATH, '/\\') . '/' . $k;
}
/**
* 站点图标地址(**单一来源**)。
*
* 前台品牌区、各页 favicon、PWA manifest 全部走这里,任何一处都不许写死 ——
* 这正是过去「半生效」(只换标签页图标、主屏图标还是旧的)的来源。
*
* @param string $name 文件基名(不带扩展名),如 'logo' / 'icon-192' / 'apple-touch-icon'
* 传 '' 表示要 favicon.ico
* @param int $size 该用途的期望尺寸,用于给静态文件挑扩展名(<32 用 .ico,其余 .png)
*/
function site_icon_url(string $name = '', int $size = 32, bool $bust = true): string
{
$dir = site_icon_dir();
if ($dir === '') {
return '';
}
if ($name === '') {
$cand = [$dir . '/favicon.ico', $dir . '/icon-32.png'];
} elseif ($size < 32) {
$cand = [$dir . '/' . $name . '.ico', $dir . '/' . $name . '.png'];
} else {
$cand = [$dir . '/' . $name . '.png'];
}
foreach ($cand as $abs) {
if (is_file($abs)) {
// 静态直出:文件名带键(每次上传都换),浏览器不可能缓存到旧图;
// 再加 mtime 只为「同键被覆盖」这种手工操作兜底。
$rel = SITE_ICON_URL . '/' . site_icon_key() . '/' . basename($abs);
return url($rel) . ($bust ? '?v=' . (string)filemtime($abs) : '');
}
}
// 静态文件没落地 → 动态输出(前提是上传时已把整套序列化进 settings 的 site_icon_files)
if (!site_icon_files_ready()) {
return '';
}
if ($name === '') {
return url('site-icon.php') . ($bust ? '?v=' . site_icon_key() : '');
}
return url('site-icon.php') . '?n=' . rawurlencode($name) . ($bust ? '&v=' . site_icon_key() : '');
}
/**
* 动态回退所需的整套文件是否已备好。
*
* 只在「上传那一刻站点目录不可写」时才会有内容;目录可写的常规情况下这里是空的
* —— 刻意如此:setting() 每次请求都会把整张设置表读进内存,把 512px 的图塞进去
* 会让每个页面都白白搬运一大串 base64。
*/
function site_icon_files_ready(): bool
{
// ⚠️ 刻意**不做** static 缓存:setting() 自己已有请求内缓存,这里再套一层
// static 就会在「同一请求内先写后读」(上传成功后立刻渲染预览)时拿到旧值
// —— 与当初 setting() 那个坑同源。代价只是一次 strlen,没必要冒这个险。
$raw = setting('site_icon_files', '');
return is_string($raw) && strlen($raw) > 64;
}
/** 站点图标目录是否可写(决定走「静态优先」还是「动态回退」) */
function site_icon_dir_writable(): bool
{
$parent = SITE_ICON_PATH;
if (!is_dir($parent)) {
@mkdir($parent, 0755, true);
}
return is_dir($parent) && is_writable($parent);
}
/* ---------- 图像处理小工具(本函数只服务于上传,不用看别处) ---------- */
/** 载入任意位图(png/jpg/gif/webp…)为 GD 资源,失败返回 null。
* ⚠️ 不要写 `?object` 返回类型:PHP 7.4 的 GD 返回的是 **resource**,加类型会直接 TypeError。 */
function gd_load(string $binary)
{
if (!function_exists('imagecreatefromstring')) {
return null;
}
$im = @imagecreatefromstring($binary);
return $im === false ? null : $im;
}
/** 把一张已载入的图居中裁剪成正方形并缩到 $size,返回 PNG 二进制(失败返回 '') */
function gd_square_png($im, int $size): string
{
$w = imagesx($im);
$h = imagesy($im);
$side = min($w, $h);
$ox = (int)(($w - $side) / 2);
$oy = (int)(($h - $side) / 2);
$canvas = imagecreatetruecolor($size, $size);
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
imagefill($canvas, 0, 0, imagecolorallocatealpha($canvas, 0, 0, 0, 127));
imagecopyresampled($canvas, $im, 0, 0, $ox, $oy, $size, $size, $side, $side);
imagealphablending($canvas, true);
ob_start();
$ok = imagepng($canvas, null, 9);
$png = (string)ob_get_clean();
imagedestroy($canvas);
return ($ok === false || $png === '') ? '' : $png;
}
/**
* 把任意图**居中裁剪成正方形**并生成整套尺寸。
*
* 为什么是「裁剪」而不是「留白」:站点图标是正方形用途(favicon / PWA 图标),
* 直接拉伸会把 logo 拉变形,留白则四周一圈透明、缩小后主体看不清。
*
* @return array<string,string> 文件基名 => 二进制(基名已含扩展名,如 'logo.png')
*/
function gd_site_icon_files(string $binary): array
{
$im = gd_load($binary);
if ($im === null) {
return [];
}
if (imagesx($im) < 8 || imagesy($im) < 8) {
imagedestroy($im);
return [];
}
$out = [];
foreach (site_icon_plan() as $name => $size) {
$png = gd_square_png($im, $size);
if ($png !== '') {
$out[$name . '.png'] = $png;
}
}
// favicon.ico 里塞 16/32/48/64 四个尺寸。
// ⚠️ 别只塞 16+32:内置 favicon.ico 本来就是 4 帧(16/32/48/64),
// 只给 2 帧会让「换自定义图标」反而**退步**(高分屏/任务栏取不到合适尺寸)。
$frames = [];
foreach ([16, 32, 48, 64] as $sz) {
if (isset($out['icon-' . $sz . '.png'])) {
$frames[$sz] = $out['icon-' . $sz . '.png'];
}
}
$ico = make_ico($frames);
if ($ico !== '') {
$out['favicon.ico'] = $ico;
}
imagedestroy($im);
return $out;
}
/** 用 PNG 二进制拼一个 .ico(微软 BMP 格式,32 位带 alpha);$pngs: 尺寸 => PNG 二进制 */
function make_ico(array $pngs): string
{
$frames = [];
foreach ($pngs as $png) {
$im = @imagecreatefromstring($png);
if ($im === false) {
continue;
}
$size = (int)imagesx($im);
if ($size < 1 || $size > 256 || imagesy($im) !== $size) {
imagedestroy($im);
continue;
}
// BITMAPINFOHEADER 40 字节:biSize biWidth biHeight biPlanes biBitCount
// biCompression biSizeImage biXPels biYPels biClrUsed biClrImportant
// (高度写 2 倍:ICO 的 DIB 把 XOR 位图与 AND 掩码叠在一起算高度)
$header = pack('VVVvvVVVVVV', 40, $size, $size * 2, 1, 32, 0, 0, 0, 0, 0, 0);
$xor = '';
for ($y = $size - 1; $y >= 0; $y--) {
for ($x = 0; $x < $size; $x++) {
$rgba = imagecolorat($im, $x, $y);
$a = ($rgba >> 24) & 0x7F;
$xor .= chr($rgba & 0xFF) . chr(($rgba >> 8) & 0xFF)
. chr(($rgba >> 16) & 0xFF) . chr((int)round(255 - $a * 2.00787));
}
}
imagedestroy($im);
$and = str_repeat("\x00", (int)($size / 8) * $size); // 1bpp 掩码,全 0 = 全部可见
$frames[$size] = $header . $xor . $and;
}
if (!$frames) {
return '';
}
ksort($frames);
$n = count($frames);
$ico = pack('vvv', 0, 1, $n);
$offset = 6 + $n * 16;
foreach ($frames as $size => $data) {
// 宽高字节:256 要写成 0
$b = ($size >= 256) ? 0 : $size;
$ico .= pack('CCCCvvVV', $b, $b, 0, 0, 1, 32, strlen($data), $offset);
$offset += strlen($data);
}
foreach ($frames as $data) {
$ico .= $data;
}
return $ico;
}
/** 整套图标清单:文件基名 => 尺寸 */
function site_icon_plan(): array
{
return [
'logo' => 512, // 首页品牌区(显示 60px,512 供高分屏)
'icon-16' => 16,
'icon-32' => 32,
'icon-48' => 48,
'icon-64' => 64, // 仅用于 favicon.ico 的第 4 帧(与内置资源集对齐)
'icon-192' => 192, // PWA 常规
'icon-512' => 512, // PWA 常规
'icon-192-maskable' => 192, // PWA 自适应(Android 会裁成圆形)
'icon-512-maskable' => 512,
'apple-touch-icon' => 180, // iOS 加到主屏
];
}
/**
* 保存用户上传的站点图标:生成整套尺寸、落盘并写好设置。
*
* 返回错误信息('' = 成功)。
*/
function save_site_logo(string $binary): string
{
if (!function_exists('imagecreatefromstring')) {
return '服务器缺少 GD 扩展,无法处理图片';
}
$im = gd_load($binary);
if ($im === null) {
return '图片无法解析,请换一张 PNG / JPG / WebP';
}
$w = imagesx($im);
$h = imagesy($im);
imagedestroy($im);
if ($w < 32 || $h < 32) {
return '图片太小了(' . $w . '×' . $h . '),请上传至少 32×32 的图';
}
$files = gd_site_icon_files($binary);
if (!isset($files['logo.png'])) {
return '图片处理失败,请换一张试试';
}
$key = bin2hex(random_bytes(8));
$saved = false;
if (site_icon_dir_writable()) {
$dir = SITE_ICON_PATH . '/' . $key;
@mkdir($dir, 0755, true);
foreach ($files as $fname => $data) {
@file_put_contents($dir . '/' . $fname, $data);
}
$saved = is_file($dir . '/logo.png');
}
// 目录不可写(保守环境:站点目录只读)→ 把整套压进 settings,改走动态输出。
// 这不是「可选的优化」:没有它,用户在有权限限制的服务器上上传会直接失败。
$store = '';
if (!$saved) {
$store = base64_encode((string)gzencode(serialize($files), 9));
if (strlen($store) < 64 || strlen($store) > 8 * 1024 * 1024) {
return '站点目录不可写,且图片较大无法内置(请给 uploads/site 写权限)';
}
}
$pdo = db();
$old = site_icon_key();
$st = $pdo->prepare('INSERT INTO settings (k,v) VALUES (?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v');
$st->execute(['site_icon', $key]);
$st->execute(['site_icon_files', $store]);
settings_reload(); // 同一次请求里后面还要用新键拼 URL(见 config.php 的说明)
if ($old !== '' && $old !== $key) {
delete_site_logo_files($old); // 换图就换键,旧目录整体清掉
}
return '';
}
/** 恢复默认图标:清掉设置与文件(前台退回内置 logo/favicon) */
function reset_site_logo(): void
{
$old = site_icon_key();
$pdo = db();
$pdo->exec("DELETE FROM settings WHERE k IN ('site_icon','site_icon_files')");
settings_reload(); // 同一次请求里后面还要判断「已回到默认」
if ($old !== '') {
delete_site_logo_files($old);
}
}
/** 删掉一套站点图标文件(只删 uploads/site/<16位hex>/ 这一层,绝不递归到外面) */
function delete_site_logo_files(string $key): void
{
if (!preg_match('/^[a-f0-9]{16,64}$/', $key)) {
return; // 白名单:键不合法就什么都不做
}
$dir = SITE_ICON_PATH . '/' . $key;
if (!is_dir($dir)) {
return;
}
foreach ((array)@scandir($dir) as $f) {
if ($f === '.' || $f === '..') {
continue;
}
$abs = $dir . '/' . $f;
if (is_file($abs)) {
@unlink($abs);
}
}
@rmdir($dir);
}
/** 取出整套图标(动态输出用);没有返回 [] */
function site_icon_all(): array
{
$raw = setting('site_icon_files', '');
if ($raw === '' || strlen($raw) < 64) {
return [];
}
$bin = @base64_decode($raw, true);
if ($bin === false) {
return [];
}
$ser = @gzdecode($bin);
if ($ser === false) {
return [];
}
$arr = @unserialize($ser, ['allowed_classes' => false]);
return is_array($arr) ? $arr : [];
}
/** 站点图标当前是「自定义」还是内置默认(供前台输出可断言标记) */
function site_icon_mode(): string
{
return site_icon_key() === '' ? 'default' : 'custom';
}
/**
* `<head>` 里的图标声明(favicon 多尺寸 / apple-touch / manifest)。
*
* **首页、登录页、后台三处共用这一个函数**:图标是全局的一件事,
* 任何一页单独写死就会出现「首页换了、登录页还是旧的」这种半生效。
* 有自定义站点图标时整套走自定义,否则完全退回内置资源(= 改造前的行为)。
*/
function site_icon_head(): string
{
$pick = static function (string $name, int $size, string $fallback): string {
$u = site_icon_url($name, $size);
return $u !== '' ? $u : asset($fallback);
};
$lines = [
'<link rel="icon" href="' . e($pick('', 32, 'favicon.ico')) . '" sizes="any">',
'<link rel="icon" type="image/png" sizes="32x32" href="' . e($pick('icon-32', 32, 'assets/icons/icon-32.png')) . '">',
'<link rel="icon" type="image/png" sizes="16x16" href="' . e($pick('icon-16', 16, 'assets/icons/icon-16.png')) . '">',
'<link rel="apple-touch-icon" sizes="180x180" href="' . e($pick('apple-touch-icon', 180, 'apple-touch-icon.png')) . '">',
'<link rel="manifest" href="' . e(url('manifest.php')) . '">',
];
return implode("\n", $lines);
}
/* =========================================================================
* 杂项
* ========================================================================= */
+5 -7
View File
@@ -89,11 +89,9 @@ function site_tile(array $s): void
<title><?= e(site_name()) ?> · <?= e(setting('subtitle', '个人网站导航')) ?></title>
<meta name="description" content="<?= e(site_name()) ?> —— 个人网站导航">
<meta name="theme-color" content="<?= e($primary) ?>">
<link rel="icon" href="<?= e(asset('favicon.ico')) ?>" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="<?= e(asset('assets/icons/icon-32.png')) ?>">
<link rel="icon" type="image/png" sizes="16x16" href="<?= e(asset('assets/icons/icon-16.png')) ?>">
<link rel="apple-touch-icon" sizes="180x180" href="<?= e(asset('apple-touch-icon.png')) ?>">
<link rel="manifest" href="<?= e(asset('site.webmanifest')) ?>">
<!-- 站点图标(favicon / apple-touch / PWA manifest):有自定义就用自定义,
没有就完全退回内置资源。三处页面共用 site_icon_head(),避免半生效。 -->
<?= site_icon_head() ?>
<link rel="stylesheet" href="<?= e(asset('assets/css/style.css')) ?>">
<?php if ($primary !== '#2F6BF7'): ?>
<style>:root { --primary: <?= e($primary) ?>; }</style>
@@ -103,8 +101,8 @@ function site_tile(array $s): void
<div class="container">
<!-- 顶部品牌区 -->
<header class="hero">
<img class="logo" src="<?= e(asset('assets/img/logo.svg')) ?>" alt="<?= e(site_name()) ?>">
<header class="hero" data-site-icon="<?= e(site_icon_mode()) ?>">
<img class="logo" src="<?= e(site_icon_url('logo', 512) ?: asset('assets/img/logo.svg')) ?>" alt="<?= e(site_name()) ?>">
<h1><?= e(site_name()) ?></h1>
<p><?= e(setting('subtitle', '个人网站导航')) ?></p>
</header>
+2 -2
View File
@@ -48,12 +48,12 @@ $title = $hasAdmin ? '登录' : '初始化管理员';
<title><?= e($title) ?> · <?= e(site_name()) ?></title>
<link rel="stylesheet" href="<?= e(asset('assets/css/style.css')) ?>">
<link rel="stylesheet" href="<?= e(asset('assets/css/admin.css')) ?>">
<link rel="icon" href="<?= e(asset('favicon.ico')) ?>" sizes="any">
<?= site_icon_head() ?>
</head>
<body class="admin">
<div class="login-wrap">
<form class="login-card" method="post" action="">
<img class="login-logo" src="<?= e(url('assets/img/logo.svg')) ?>" alt="" onerror="this.style.display='none'">
<img class="login-logo" src="<?= e(site_icon_url('logo', 512) ?: url('assets/img/logo.svg')) ?>" alt="" onerror="this.style.display='none'">
<h1><?= e(site_name()) ?></h1>
<p class="sub"><?= $hasAdmin ? '管理员登录' : '首次使用,请创建管理员账号' ?></p>
<?php if ($err): ?><div class="alert"><?= e($err) ?></div><?php endif; ?>
+56
View File
@@ -0,0 +1,56 @@
<?php
/**
* 简航 SimpleNav —— 动态输出 PWA manifest(site.webmanifest)
*
* 为什么不再用静态 site.webmanifest:
* 用户的「添加到手机主屏」读的是这里的 icons。图标一旦可自定义,静态文件就会
* 一直指向内置的那几张 → 出现半生效(标签页 favicon 换了、主屏图标还是旧的)。
* 顺带把 name / theme_color 也接上后台设置,不再与页面上显示的值打架。
*
* 静态 site.webmanifest 保留作回退(未被 Web 服务器路由到这里时仍可用)。
*/
declare(strict_types=1);
require_once __DIR__ . '/functions.php';
header('Content-Type: application/manifest+json; charset=utf-8');
// manifest 只随设置变化,短缓存即可(与图标文件的长缓存策略不同)
header('Cache-Control: no-cache, must-revalidate');
$name = site_name();
$short = mb_substr($name, 0, 12);
// 图标地址全部走 site_icon_url() 这个单一来源:有自定义就用自定义,没有就用内置。
// 内置那几张保持原样(相对路径),保证「没上传」时的行为与改造前完全一致。
$custom = site_icon_key() !== '';
$icon = static function (string $n, int $size, string $fallback) use ($custom): ?array {
if ($custom) {
$u = site_icon_url($n, $size);
if ($u !== '') {
return ['src' => $u, 'sizes' => $size . 'x' . $size, 'type' => 'image/png'];
}
}
return ['src' => $fallback, 'sizes' => $size . 'x' . $size, 'type' => 'image/png'];
};
$icons = [];
$i192 = $icon('icon-192', 192, 'assets/icons/icon-192.png');
$i512 = $icon('icon-512', 512, 'assets/icons/icon-512.png');
$m192 = $icon('icon-192-maskable', 192, 'assets/icons/icon-192-maskable.png');
$m512 = $icon('icon-512-maskable', 512, 'assets/icons/icon-512-maskable.png');
if ($i192) { $icons[] = $i192; }
if ($i512) { $icons[] = $i512; }
if ($m192) { $m192['purpose'] = 'maskable'; $icons[] = $m192; }
if ($m512) { $m512['purpose'] = 'maskable'; $icons[] = $m512; }
$out = [
'name' => $name,
'short_name' => $short,
'start_url' => './',
'scope' => './',
'display' => 'standalone',
'background_color' => '#d8d9da',
'theme_color' => setting('primary_color', '#2F6BF7'),
'icons' => $icons,
];
echo json_encode($out, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
+51
View File
@@ -0,0 +1,51 @@
<?php
/**
* 简航 SimpleNav —— 动态输出「站点整体图标」
*
* 为什么需要这个文件:
* 上传自定义图标后要生成一整套尺寸(favicon 多尺寸 + PWA 图标)。正常情况会把它们
* 写成静态文件(uploads/site/<键>/...),浏览器直接取静态文件,最快。
* 但在**站点目录不可写**的保守环境里写不了文件,此时整套图片被压进 settings,
* 由这个端点按需吐出 —— 功能不至于因为权限限制而整个不能用。
*
* 用法:
* ?n=logo → 首页品牌图标
* ?n=icon-192 → PWA 图标
* ?n=icon-192-maskable
* ?n= (不带 n) → favicon.ico(多帧)
* 任何情况下都只吐「当前设置里那一套」,取不到就 404,绝不暴露路径。
*/
declare(strict_types=1);
require_once __DIR__ . '/functions.php';
$name = isset($_GET['n']) ? (string)$_GET['n'] : '';
// 白名单:只允许清单里的名字(外加 favicon.ico),杜绝路径穿越
$plan = site_icon_plan();
$allow = [];
foreach (array_keys($plan) as $k) {
$allow[$k . '.png'] = 'image/png';
}
$allow['favicon.ico'] = 'image/x-icon';
if ($name === '') {
$file = 'favicon.ico';
} else {
$file = $name . '.png';
}
if (!isset($allow[$file])) {
http_response_code(404);
exit;
}
$all = site_icon_all();
if (!isset($all[$file]) || $all[$file] === '') {
http_response_code(404);
exit;
}
$bin = (string)$all[$file];
header('Content-Type: ' . $allow[$file]);
header('Content-Length: ' . strlen($bin));
// 图标内容只随「上传」变化,键(?v=)一变就会换 URL —— 可以放心长缓存
header('Cache-Control: public, max-age=31536000, immutable');
echo $bin;