后台上传站点图标:首页品牌图 + 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:
+7
-1
@@ -1,11 +1,17 @@
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 运行时数据(绝不入库)
|
# 运行时数据(绝不入库)
|
||||||
# data/ —— SQLite 数据库等敏感数据
|
# data/ —— SQLite 数据库等敏感数据
|
||||||
# uploads/icons/* —— 抓取/上传的网站图标
|
# uploads/icons/* —— 抓取/上传的网站图标(每个目标网站一份)
|
||||||
|
# uploads/site/* —— 管理员上传的**本站**图标整套尺寸
|
||||||
|
# ⚠️ 与 uploads/icons 是两件事,别合并成一条通配:
|
||||||
|
# 它们是不同维度(本站一个 vs 每站一个),
|
||||||
|
# 分开写才能各自加白名单、也便于日后单独清理。
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
/data/
|
/data/
|
||||||
/uploads/icons/*
|
/uploads/icons/*
|
||||||
!/uploads/icons/.gitkeep
|
!/uploads/icons/.gitkeep
|
||||||
|
/uploads/site/*
|
||||||
|
!/uploads/site/.gitkeep
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 部署脚本(与具体服务器、账号、路径绑定)
|
# 部署脚本(与具体服务器、账号、路径绑定)
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@ function admin_layout(string $active, string $title, string $body): void
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= e($title) ?> · <?= e(site_name()) ?> 后台</title>
|
<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/style.css')) ?>">
|
||||||
<link rel="stylesheet" href="<?= e(asset('assets/css/admin.css')) ?>"><?php if (setting('primary_color', '#2F6BF7') !== '#2F6BF7'): ?>
|
<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; ?>
|
<style>:root{--primary:<?= e(setting('primary_color', '#2F6BF7')) ?>}</style><?php endif; ?>
|
||||||
|
|||||||
@@ -207,6 +207,58 @@ try {
|
|||||||
break;
|
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': {
|
case 'settings_save': {
|
||||||
$srcRaw = (string)($_POST['suggest_source'] ?? 'baidu');
|
$srcRaw = (string)($_POST['suggest_source'] ?? 'baidu');
|
||||||
|
|||||||
@@ -367,6 +367,63 @@ else: // settings ?>
|
|||||||
<div class="span2 form-actions"><button class="btn primary">保存设置</button></div>
|
<div class="span2 form-actions"><button class="btn primary">保存设置</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</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">
|
<section class="card">
|
||||||
<div class="card-head"><h2>修改管理员密码</h2></div>
|
<div class="card-head"><h2>修改管理员密码</h2></div>
|
||||||
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="grid-form">
|
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="grid-form">
|
||||||
|
|||||||
@@ -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 img { width: 100%; height: 100%; object-fit: contain; background: #fff; }
|
||||||
.icon-preview .ph { color: #bbb; font-size: 12px; }
|
.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-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-title { font-size: 13px; color: #555; font-weight: 600; }
|
||||||
.icon-field-body input[type=text] {
|
.icon-field-body input[type=text] {
|
||||||
|
|||||||
+35
-3
@@ -18,6 +18,17 @@ define('ICON_PATH', APP_PATH . '/uploads/icons'); // 抓取的网站图标(可
|
|||||||
define('ICON_URL', 'uploads/icons'); // 相对站点根
|
define('ICON_URL', 'uploads/icons'); // 相对站点根
|
||||||
define('ICON_STORE', 128); // 图标统一存成 128×128(高清屏下 45~64px 显示仍锐利)
|
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。
|
* 是否「由 PHP 再发一颗」绕开服务器侧页面缓存的标记 cookie。
|
||||||
*
|
*
|
||||||
@@ -60,11 +71,19 @@ function site_name(): string
|
|||||||
return setting('site_name', APP_NAME);
|
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;
|
static $cache = null;
|
||||||
if ($cache === null) {
|
if ($cache === null || $reload) {
|
||||||
$cache = [];
|
$cache = [];
|
||||||
try {
|
try {
|
||||||
foreach (db()->query('SELECT k, v FROM settings') as $row) {
|
foreach (db()->query('SELECT k, v FROM settings') as $row) {
|
||||||
@@ -74,5 +93,18 @@ function setting(string $key, string $default = ''): string
|
|||||||
$cache = [];
|
$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;
|
return array_key_exists($key, $cache) && $cache[$key] !== '' ? $cache[$key] : $default;
|
||||||
}
|
}
|
||||||
|
|||||||
+379
@@ -616,6 +616,385 @@ function save_custom_icon(string $binary, string $key): string
|
|||||||
return $file;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
* 杂项
|
* 杂项
|
||||||
* ========================================================================= */
|
* ========================================================================= */
|
||||||
|
|||||||
@@ -89,11 +89,9 @@ function site_tile(array $s): void
|
|||||||
<title><?= e(site_name()) ?> · <?= e(setting('subtitle', '个人网站导航')) ?></title>
|
<title><?= e(site_name()) ?> · <?= e(setting('subtitle', '个人网站导航')) ?></title>
|
||||||
<meta name="description" content="<?= e(site_name()) ?> —— 个人网站导航">
|
<meta name="description" content="<?= e(site_name()) ?> —— 个人网站导航">
|
||||||
<meta name="theme-color" content="<?= e($primary) ?>">
|
<meta name="theme-color" content="<?= e($primary) ?>">
|
||||||
<link rel="icon" href="<?= e(asset('favicon.ico')) ?>" sizes="any">
|
<!-- 站点图标(favicon / apple-touch / PWA manifest):有自定义就用自定义,
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="<?= e(asset('assets/icons/icon-32.png')) ?>">
|
没有就完全退回内置资源。三处页面共用 site_icon_head(),避免半生效。 -->
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="<?= e(asset('assets/icons/icon-16.png')) ?>">
|
<?= site_icon_head() ?>
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="<?= e(asset('apple-touch-icon.png')) ?>">
|
|
||||||
<link rel="manifest" href="<?= e(asset('site.webmanifest')) ?>">
|
|
||||||
<link rel="stylesheet" href="<?= e(asset('assets/css/style.css')) ?>">
|
<link rel="stylesheet" href="<?= e(asset('assets/css/style.css')) ?>">
|
||||||
<?php if ($primary !== '#2F6BF7'): ?>
|
<?php if ($primary !== '#2F6BF7'): ?>
|
||||||
<style>:root { --primary: <?= e($primary) ?>; }</style>
|
<style>:root { --primary: <?= e($primary) ?>; }</style>
|
||||||
@@ -103,8 +101,8 @@ function site_tile(array $s): void
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
<!-- 顶部品牌区 -->
|
<!-- 顶部品牌区 -->
|
||||||
<header class="hero">
|
<header class="hero" data-site-icon="<?= e(site_icon_mode()) ?>">
|
||||||
<img class="logo" src="<?= e(asset('assets/img/logo.svg')) ?>" alt="<?= e(site_name()) ?>">
|
<img class="logo" src="<?= e(site_icon_url('logo', 512) ?: asset('assets/img/logo.svg')) ?>" alt="<?= e(site_name()) ?>">
|
||||||
<h1><?= e(site_name()) ?></h1>
|
<h1><?= e(site_name()) ?></h1>
|
||||||
<p><?= e(setting('subtitle', '个人网站导航')) ?></p>
|
<p><?= e(setting('subtitle', '个人网站导航')) ?></p>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ $title = $hasAdmin ? '登录' : '初始化管理员';
|
|||||||
<title><?= e($title) ?> · <?= e(site_name()) ?></title>
|
<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/style.css')) ?>">
|
||||||
<link rel="stylesheet" href="<?= e(asset('assets/css/admin.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>
|
</head>
|
||||||
<body class="admin">
|
<body class="admin">
|
||||||
<div class="login-wrap">
|
<div class="login-wrap">
|
||||||
<form class="login-card" method="post" action="">
|
<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>
|
<h1><?= e(site_name()) ?></h1>
|
||||||
<p class="sub"><?= $hasAdmin ? '管理员登录' : '首次使用,请创建管理员账号' ?></p>
|
<p class="sub"><?= $hasAdmin ? '管理员登录' : '首次使用,请创建管理员账号' ?></p>
|
||||||
<?php if ($err): ?><div class="alert"><?= e($err) ?></div><?php endif; ?>
|
<?php if ($err): ?><div class="alert"><?= e($err) ?></div><?php endif; ?>
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user