Files
FamilyBook/media.php
T
laoyang d6af211a8e 图片走 OSS 实时压缩:取图带 x-oss-process,本地存储不受影响 (#56)
用户 2026-11-05:「如果图片是放在本地服务器的…就不管它。
如果是传到 OSS 里面的…就用 OSS 的压缩就行了。」

查证:OSS 图片处理确实存在,取图 URL 加 x-oss-process 即可实时压缩,
原图不动、不占额外存储。

三处改动(缺一个都会出错):

1. OssDriver
   - get() 新增 $process 参数
   - authorization() 新增 $subresource:x-oss-process 属于子资源,
     必须计入 CanonicalizedResource,否则 403 SignatureDoesNotMatch
   - URL 拼接带上 ?x-oss-process=...

2. Storage::read()
   - 新增 $process,仅当驱动是 oss 时透传
   - 本地驱动忽略(用户明确要求「本地不管它」)
   - 带 process 时跳过"本地优先"缓存(那个缓存里是原图,
     拿原图当压缩版返回等于压缩没生效)

3. media.php
   - 新增 ?w= 参数,白名单三档 400/800/1600
   - 按尺寸分档缓存到 _proc/<w>/<key>,避免互相覆盖
   - 无 w 时行为完全不变(300+ 处既有 img 不受影响)

白名单而非透传的理由:OSS 图片处理按次计费,
放任 ?w= 任意值会让 ?w=1、?w=9999 这类请求把处理次数打上去。

实测(一张 6602 KB 的图):
  原图  6602 KB
  w=400    26 KB   省 99.6%
  w=800   108 KB   省 98%
  w=1600  468 KB   省 93%

应用位置(保守,先动最确定的):
  _attachments.php 照片墙缩略图 → w=400
  trips/show.php   行程题图     → w=1600
  下载链接一律不加 w(用户要原文件,不能偷偷降质)
2026-11-05 13:06:29 +08:00

315 lines
14 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 处理参数 */
$OssSizes = [
400 => 'image/resize,l_400/quality,q_80',
800 => 'image/resize,l_800/quality,q_82',
1600 => 'image/resize,l_1600/quality,q_85',
];
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;