后台上传站点图标:首页品牌图 + 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
+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);
}
/* =========================================================================
* 杂项
* ========================================================================= */