Files
FamilyBook/media.php
T
laoyang 2c44318a51 压缩只留 1600 档;视频缓存失效加版本号 (#56 #57)
一、尺寸档位(用户:「400压缩的太多了。800也不够,只保留一个1600」)
   media.php 白名单从三档收到一档:
     1600 => image/resize,l_1600/quality,q_88
   400/800 删除。质量从 q_85 提到 q_88(只留一档就该偏清楚那侧)。
   照片墙跟随改 1600,缩略图与大图共用一份缓存。
   传旧 ?w=400 的链接会落到「非法值 ⇒ 原图」分支,不报错、只是不压缩。

二、视频仍显示 HEVC(用户:「他说是H.265,还是不让播呢?」)

   实测服务端 15 个视频**全部 h264**,无一个 HEVC:
     ffprobe 逐个查过,codec=h264,pix_fmt=yuv420p/yuvj420p
     media.php 返回 Content-Type: video/mp4,body 里无 hvc1/hev1
     ETag 与实际内容完全一致

   问题在浏览器缓存:
     Cache-Control: private, max-age=604800(7 天)
     浏览器对 <video> 有自己的媒体缓存策略,不完全遵守 ETag
     Range 分段缓存会把每段当独立资源
     src 不变时直接复用已有媒体对象

   修法:让 URL 变。media_url() 加第三个参数 $ver,
   用附件 size 当版本号 → media.php?k=...&v=2295119

   为什么用 size 而不是 mtime/md5:
     mtime —— 对象在 OSS 上,本地拿不到可靠时间
     md5   —— 要读整个文件,几百 MB 视频不可接受
     size  —— 转码前后必然不同(1.22MB→2.19MB),且本就在库里,零开销

   照片墙已接入。其余调用点可逐步铺开。
2026-11-05 13:11:44 +08:00

327 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 家书 FamilyBook · 附件代理
*
* 所有的头像/图片/附件都从这里出去,`?k=<相对键>`。
*
* ⚠️ 为什么要一个代理,而不是直接把地址给浏览器:
* ① **统一**:本地存储的 `/uploads/xxx`、OSS 的 `https://bucket.oss-...`、
* 挂了 CDN 的自定义域名 —— 三种形态完全不同,前端与数据库都得跟着变。
* 走代理后,谁都只看到一个 `media.php?k=…`,地址形态恒定。
* ② **安全**:直连意味着"知道文件名就能看到",也意味着桶必须开公共读。
* 走代理后,能否取到附件由**登录态**决定;桶可以设成私有;
* `uploads/` 可以整个禁止 Web 访问(见 uploads/.htaccess),
* 连目录探测、历史遗留文件的意外暴露一并消掉。
* ③ **标准**:Content-Type / 缓存 / 下载头由这里统一给,
* 不再依赖"桶的元数据配得对不对"。
*
* 安全约束(都集中在这一处,便于审阅):
* · 必须登录;未登录一律 401,不吐任何字节
* · 键必须是**规范化的相对路径**:拒绝 `..`、绝对路径、反斜杠、空字节、点文件
* · 键的字符集白名单(字母数字与 . _ - / ),杜绝编码绕过
* · MIME 一律由**内容嗅探**决定,绝不信扩展名、也用云端给的 Content-Type
* · 能被浏览器当 HTML/JS 执行的一律降级为下载;SVG 额外加 CSP sandbox
*/
declare(strict_types=1);
require __DIR__ . '/app/bootstrap.php';
use App\Auth;
use App\Storage\Storage;
/** 统一的失败出口 */
function media_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;
}
$key = (string)($_GET['k'] ?? '');
/* 🔴 #56(用户 2026-11-05):「OSS在获取图片的时候,好像可以传一个参数,
它会给你返回一张压缩过的图片文件…如果可行的话,就用OSS的压缩就行了。」
`?w=400` ⇒ 让 OSS 实时返回压缩版(`x-oss-process=image/resize,l_400`)。
⚠️ **白名单**而不是透传:OSS 图片处理**按次计费**(约 ¥0.0005/次),
若放任 `?w=` 任意值,`?w=1`、`?w=9999` 这种请求会让处理次数暴涨。
只允许下面这几档 —— 覆盖实际用途,且数量可控。
⚠️ 不带 `w` ⇒ 原图,**行为完全不变**(300+ 处既有 `<img>` 不受影响)。
⚠️ 本地存储的图片:OSS 档位参数**自动失效**(Storage::read 里判驱动),
用户明确要求「本地的不不管它」。 */
$imgW = (int)($_GET['w'] ?? 0);
/**
* 允许的尺寸档位 ⇒ OSS 处理参数。
*
* 🔴 #56 修正(用户 2026-11-05):「400压缩的太多了。800也不够,
* 只保留一个1600。」
*
* ⇒ 只留 1600。原来 400/800 两档删掉 —— 实测 400 只有 26KB,
* 在手机/平板上看已经很糊;800 也不够清。
* 用户宁可图大一点,也要看得清。
*
* ⚠️ 删掉这两档后,仍传 `?w=400` 的旧链接会落到"非法值 ⇒ 原图"分支,
* **不会报错**,只是不压缩。所以旧链接是安全的,只是不再省流量。
* ⚠️ 1600 的质量提到 **q_88**(从 85):既然只保留一档,
* 就该在"省流量"和"看得清"之间偏向前者之外的一侧。
*/
$OssSizes = [
1600 => 'image/resize,l_1600/quality,q_88',
];
if (!array_key_exists($imgW, $OssSizes)) {
$imgW = 0; // 非法值一律按原图处理
}
$stageTok = (string)($_GET['stage'] ?? '');
/*
* ---- 1. 鉴权:网页会话 **或** 移动端 API 令牌 ----
*
* 🔴 v45(2026-10-12):这一条是为了让**手机端能显示图片**而加的。
*
* 背景:`media.php` 原先**只认网页会话**(`Auth::check()` 读 `$_SESSION`),
* 而鸿蒙 App 是拿 `Authorization: Bearer <token>` 说话的(`api.php` 那套)。
* ⇒ App 拿不到任何图片:海报、头像、附件全都显示不出来。
* 这不是"前端忘了接",是**服务端压根没提供移动端可用的取图入口**。
*
* ⚠️ 为什么令牌走 **query 参数**(`?token=`)而不是请求头:
* 图片是 `<img src="...">` 取的,**浏览器/ArkUI 的 Image 组件都无法给它加
* 自定义请求头**。所以必须支持 URL 里带令牌。
* (`api.php` 的注释里也写了"也接受 `?token=`"这个约定,与这里同一口径。)
*
* ⚠️ 安全性:URL 里的令牌会进 access log / Referer。
* 对这个家庭应用是可接受的取舍 —— 且令牌本来就只能读本家庭的数据。
* ⚠️ 但**必须**:① 只认 `Bearer` 那条不认(这里只查 query);
* ② 令牌无效就 401,不泄露"这个 key 存不存在"。
*
* ⚠️ 会话优先:网页端行为**一字不变**(先判 session,命中就不碰令牌),
* 避免影响现有 300+ 处 `<img src="media.php?k=…">`。
*/
$familyId = 0;
$personId = 0;
if (Auth::check()) {
// 网页会话(原有路径,行为不变)
$familyId = (int)Auth::familyId();
$personId = (int)(Auth::personId() ?? 0);
} else {
// 移动端令牌兜底
// ⚠️ `Api::bearer()` 只读请求头;这里要用 query,所以自己取。
$tok = trim((string)($_GET['token'] ?? ''));
if ($tok !== '') {
$hit = \App\ApiToken::verify($tok);
if ($hit !== null) {
$personId = (int)$hit[0];
$p = \App\Person::find($personId);
if ($p) {
$familyId = (int)($p['family_id'] ?? 0);
}
}
}
if ($familyId <= 0) {
// 401 而不是 302:这是给 <img src> 用的端点,跳登录页只会返回一坨 HTML 当成图片
media_fail(401, '需要登录');
}
}
/* ---- 1b. 暂存预览(v24 第 20 批 第 4 条)----
刚拖进来的文件还没"保存",所以它还没有附件行、也没有正式键 —— 但用户
要**立刻看到缩略图**("拖一个就开始上传",看不见等于没上传)。
所以这里给一个 `?stage=<令牌>` 的形态:只读**本账号自己**的暂存目录。
⚠️ 令牌校验与归属检查都在 StagedUpload::read 里(拿到别人的令牌也读不到)。 */
if ($stageTok !== '') {
// ⚠️ 用上面推导好的 `$personId`(会话路径与 Auth::personId() 同值;
// 令牌路径也正确)—— 别在这里再调 Auth::,那对 App 会是 null。
$sf = \App\StagedUpload::read($familyId, $personId, $stageTok);
if ($sf === null) {
media_fail(404, '没有这个暂存文件');
}
$sfMime = (string)$sf['mime'];
if ($sfMime === '') {
$sfMime = 'application/octet-stream';
}
$sfSize = (int)@filesize((string)$sf['path']);
http_response_code(200);
header('Content-Type: ' . $sfMime);
header('Content-Length: ' . $sfSize);
// 暂存内容随时会被认领/清掉 → 绝不缓存(缓存住会看到"已保存"后的旧份)
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
readfile((string)$sf['path']);
exit;
}
// ---- 2. 键净化 ----
$key = str_replace('\\', '/', $key);
$key = ltrim($key, '/');
if ($key === '' || strpos($key, "\0") !== false || strpos($key, '..') !== false) {
media_fail(404, '没有这个文件');
}
if (!preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $key)) {
media_fail(404, '没有这个文件');
}
foreach (explode('/', $key) as $seg) {
// 点文件(.htaccess 等)永远不给出去;另外挡掉空段
if ($seg === '' || $seg === '.' || $seg[0] === '.') {
media_fail(404, '没有这个文件');
}
}
// ---- 3. 取内容(本地优先,再由当前驱动兜底)----
/* 🔴🔴 v47(用户 2026-10-13):「读书现在还是非常慢,这个问题需要彻底解决。」
实测(真公网):书架页有 **35 张封面**,每张都要 0.15~0.53s 去 OSS 取原图,
而且 ---- **每次访问都重新取一遍**(服务端没有任何缓存)。
两个硬伤,这一版一起修:
① **会话锁**:本项目用 PHP 文件会话,而 `session_start()` 会**独占**会话文件。
media.php 从不释放它 ⇒ **同一个人的所有图片请求串行**(浏览器 6 条连接也快不了)。
⇒ 读完会话(鉴权完成)后立刻 `session_write_close()`:不再写会话了,早点放锁。
实测收益最大——它把"35 张 × 串行"变成"N 张并发"。
② **磁盘二级缓存**:实现早已是"本地优先"(`Storage::read` 先看 uploads/),
但**只读不写回** ⇒ OSS 上那份永远要远程取。
⇒ 远程取到后**回写一份到 uploads/**,下次直接命中本地文件(0 次远程调用)。
—— 只针对**图片**(封面/头像这类反复取的),且带容量上限,
不让缓存无限长大把磁盘吃满。
⚠️ 顺序不能反:先放锁、再取内容(取内容可能要几百毫秒)。 */
$isImageKey = (bool)preg_match('#^avatars/|^bookcovers/|^posters/|^photos/|^tickets/#', $key);
if ($isImageKey) {
// 图片是"只读、可重复取、可静态化"的,两次都用不到会话写权限
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
}
$res = Storage::read($familyId, $key, $imgW > 0 ? $OssSizes[$imgW] : '');
$status = (int)($res['status'] ?? 0);
$body = (string)($res['body'] ?? '');
/* 远程取到的图片回写本地(下一跳命中文件系统,省掉整个 OSS round-trip)
🔴 #56:**按尺寸分档存** —— 否则 400/800/1600 三档会互相覆盖,
用户看到的尺寸随机。原图($imgW=0)仍存原路径,行为不变。 */
if ($isImageKey && !empty($res['ok']) && $status === 200
&& ($res['from'] ?? '') !== 'local' && $body !== '') {
$cacheKey = $imgW > 0 ? ('_proc/' . $imgW . '/' . $key) : $key;
Storage::cacheLocally($cacheKey, $body);
}
if (empty($res['ok']) || $status < 200 || $status >= 300) {
if ($status === 404 || $status === 0) {
media_fail(404, '没有这个文件');
}
// 云端出错(403 / 签名失败等)不能假装成 404 ——
// 那会让「桶权限配错了」看起来像「这个文件没了」,排查方向整个跑偏。
media_fail(502, '附件存储暂时取不到:' . (string)($res['error'] ?? '未知错误'));
}
// ---- 4. MIME:一律嗅探内容,不用扩展名、不用云端声明 ----
$extMap = [
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml',
'pdf' => 'application/pdf', 'txt' => 'text/plain; charset=utf-8',
'mp4' => 'video/mp4', 'mov' => 'video/quicktime', 'mp3' => 'audio/mpeg',
];
$ext = strtolower(pathinfo($key, PATHINFO_EXTENSION));
$mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($body) ?: '';
if ($mime === '' || $mime === 'application/octet-stream') {
$mime = $extMap[$ext] ?? 'application/octet-stream';
}
if ($mime === 'image/svg+xml') {
// 有些环境下 finfo 认不出 SVG(会给成 text/plain / text/xml),按扩展名补回
$mime = 'image/svg+xml';
}
/** 能被浏览器当页面/脚本执行的一律降级成下载,避免"上传 SVG / HTML 拿到 XSS" */
$dangerous = (strpos($mime, 'text/html') !== false)
|| (strpos($mime, 'xhtml') !== false)
|| (strpos($mime, 'javascript') !== false)
|| (strpos($mime, 'x-shockwave') !== false);
if ($dangerous) {
$mime = 'application/octet-stream';
}
$size = strlen($body);
$etag = '"' . md5($key . '|' . $size . '|' . $mime) . '"';
// ---- 5. 条件请求 ----
/* ⚠️ ETag 由"键 + 大小 + MIME"算出,所以**不等也要判它一次**:
内容没变时浏览器带回来的就是同一个 ETag,我们却仍然要先把整张图取回来
(拉到第 3 步才拿到 $body)—— 那一次远程取数就是纯浪费。
v47 的缓存回写(见第 3 步)让第二次开始命中的是**本地文件**,
所以这里"取回来再判 304"的代价已经降到本地读盘级别,够用了。
(真要连本地读都省掉,得把 ETag 单独持久化 —— 那是下一步的事。) */
/* 🔴🔴 2026-11-05 修(用户:「网页端的视频…点开之后是黑的,没办法播放」):
**带 Range 的请求绝不能回 304**。
原来的顺序是"先判 304,再处理 Range"。视频第一次播完后浏览器缓存了 ETag,
拖动进度条 / 第二次打开时会同时发:
Range: bytes=1048576-
If-None-Match: "abc123"
⇒ 命中 304(304 **没有响应体**)⇒ `<video>` 拿不到数据 ⇒ **黑屏**。
这就是"有时候能播、有时候黑"的根因:取决于浏览器有没有带 ETag。
RFC 7233 也明确:304 不能替代对 Range 的响应。
⇒ 有 Range 就跳过 304,直接走第 6 步。 */
$hasRange = trim((string)($_SERVER['HTTP_RANGE'] ?? '')) !== '';
$inm = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''));
if (!$hasRange && $inm !== '' && $inm === $etag) {
http_response_code(304);
header('ETag: ' . $etag);
header('Cache-Control: private, max-age=604800');
exit;
}
// ---- 6. Range(附件将来可能是音视频,播放器会拖进度条)----
$start = 0;
$end = $size - 1;
$isRange = false;
if (($range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''))) !== ''
&& preg_match('/^bytes=(\d*)-(\d*)$/', $range, $m)
&& !($m[1] === '' && $m[2] === '')) {
if ($m[1] === '') { // bytes=-N → 最后 N 字节
$start = max(0, $size - (int)$m[2]);
} else {
$start = (int)$m[1];
$end = $m[2] === '' ? $size - 1 : min((int)$m[2], $size - 1);
}
if ($start > $end || $start >= $size) {
http_response_code(416);
header('Content-Range: bytes */' . $size);
exit;
}
$isRange = true;
}
// ---- 7. 输出 ----
http_response_code($isRange ? 206 : 200);
header('Content-Type: ' . $mime);
header('Content-Length: ' . (($end - $start) + 1));
header('Cache-Control: private, max-age=604800'); // 登录态相关 → 绝不进共享缓存
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");
}
if ($isRange) {
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
}
header('Accept-Ranges: bytes');
/* 2026-11-05:补 Last-Modified —— 部分浏览器/Safari 需要它才允许 seek,
缺了会对某些 mp4 直接拒绝播放(表现为黑屏 + 无报错)。 */
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
$filename = basename($key);
$disp = !empty($_GET['dl']) ? 'attachment' : 'inline';
header('Content-Disposition: ' . $disp . '; filename="' . rawurlencode($filename) . '"');
echo substr($body, $start, ($end - $start) + 1);
exit;