Files

106 lines
4.6 KiB
PHP
Raw Permalink Normal View History

<?php
/**
* 家书 FamilyBook · 导航图标**公开**出口(v24 第 25 批 用户第 9 条新增)
*
* 为什么需要它(两个约束撞在一起,谁都绕不开):
* ① 家书的 `uploads/` 整个目录是 `<Require all denied>`(见 uploads/.htaccess)——
* 所有附件一律经 `media.php` 出去,而 `media.php` **要求登录态**(未登录 401)。
* 线上实测:直接取 `uploads/nav-icons/xxx.png` 得 **403**。
* ② 但**导航展示页 `/nav/?<短语>` 是全站唯一免登录页**(用户明确要求),
* 而它的图标请求也来自浏览器、同样不带登录态。
* ⇒ 于是"要登录才给图"和"公开页面要显示图"直接冲突:图标一个都显示不出来。
* 这正是用户报的「图标也没有」在服务端的那一半原因。
*
* 做法:仿 media.php 开一个**窄得多的**公开出口 ——
* 只服务 `Nav::ICON_DIR` 这一个目录、且文件名必须严格符合
* `md5串.<扩展名>`。别的一律 404(连目录都不列)。
*
* ⚠️ 安全性说明(为什么这里敢免登录):
* · 图标是**公开网站自己的 favicon**,不含任何用户隐私;
* · 文件名是 `md5(网址)`,且这些网址本来就列在**同样是公开的**展示页上 ——
* 这里的暴露面不比展示页本身更大;
* · 只能取到这一层目录里的"名字长得像图标"的文件,路径穿越 / 目录遍历都不可能;
* · 目录写权限在部署账号手里(见部署脚本),Web 侧只读。
*
* 用法:`/nav_icon.php?f=<文件名>&v=<mtime>`
* ⚠️ `v` 只用于缓存击穿,不参与鉴权(值随便传也不会取到别的文件)。
*/
declare(strict_types=1);
require __DIR__ . '/app/bootstrap.php';
use App\Nav;
/** 统一的失败出口(与 media.php 同一套做法:不吐任何多余信息) */
function nav_icon_fail(int $code, string $message): void
{
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo $message;
exit;
}
$file = (string)($_GET['f'] ?? '');
// ---- ① 只认文件名(杜绝任何路径成分)----
//
// ⚠️ 用 basename 而不是"检查有没有 `..`":前者把 `a/../../x.png` 直接截成 `x.png`,
// 连"要判断什么"都不需要判断;后者总有没想到的编码绕过(%2e%2e、双重编码…)。
$file = basename(str_replace('\\', '/', $file));
// ---- ② 严格白名单:必须是「32 位十六进制 + 图片扩展名」----
//
// ⚠️ 这一条是**真正的门**:没有它,`f=.htaccess` 之类就能被读出去
// (哪怕 basename 已经挡住了路径,文件名本身仍然可以是敏感文件)。
if (!preg_match('/^[a-f0-9]{32}\.(png|jpe?g|gif|webp|svg|ico)$/i', $file)) {
nav_icon_fail(404, '没有这个图标');
}
$abs = FB_ROOT . '/' . Nav::ICON_DIR . '/' . $file;
if (!is_file($abs)) {
nav_icon_fail(404, '没有这个图标');
}
// ---- ③ MIME:由**内容**嗅探,绝不信扩展名 ----
//
// (与 media.php 同一口径。SVG 会被 finfo 认成 text/plain,按扩展名补回。)
$body = (string)@file_get_contents($abs);
$mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($body) ?: '';
if ($mime === '' || $mime === 'application/octet-stream') {
$mime = 'image/png';
}
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if ($ext === 'svg' || $mime === 'image/svg+xml') {
$mime = 'image/svg+xml';
}
// 能被浏览器当页面/脚本执行的一律降级(与 media.php 同一条防线)
if (strpos($mime, 'text/html') !== false || strpos($mime, 'javascript') !== false
|| strpos($mime, 'xhtml') !== false) {
$mime = 'application/octet-stream';
}
$etag = '"' . md5($file . '|' . strlen($body)) . '"';
$inm = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''));
if ($inm !== '' && $inm === $etag) {
http_response_code(304);
header('ETag: ' . $etag);
header('Cache-Control: public, max-age=31536000, immutable');
exit;
}
http_response_code(200);
header('Content-Type: ' . $mime);
header('Content-Length: ' . strlen($body));
// ⚠️ 长缓存 + immutable 是安全的:文件名里含 `md5(网址)`,换图必换名;
// 调用方还会再挂一个 `?v=<mtime>`(见 Nav::iconUrl),双保险。
header('Cache-Control: public, max-age=31536000, immutable');
header('ETag: ' . $etag);
header('X-Content-Type-Options: nosniff');
if ($mime === 'image/svg+xml') {
// SVG 能带脚本 → 即便要显示,也禁止执行脚本、禁止加载外部资源
header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox");
}
echo $body;
exit;