Files

134 lines
5.7 KiB
PHP
Raw Permalink Normal View History

<?php
// ============================================
// 文件: media.php
// 说明: 图片服务端代理。
// 访问方式设为「服务端代理」时,storageUrl() 会把远端图片指向这里:
// 由服务器凭 ECS 实例角色 / AccessKey 签名读取对象后**同源回吐**。
// 好处:桶可以设为「私有」(无需公共读、无需防盗链);且因为同源交付,
// 前端裁剪(Canvas 读像素)不再受跨域限制。
// 用法: /media.php?k=uploads/posters/xxxxxxxxxxxx_xxxxxx.webp
// ============================================
require_once 'config.php';
// ---------- 未登录一律拒绝 ----------
// 这里刻意不用 checkLogin():它会 302 跳到 login.php,而 <img> 拿到 HTML 只会变成破图。
// 返回 403 纯文本,前端 js/main.js 会把它替换成占位图。
if (empty($_SESSION['admin_id'])) {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
header('X-Content-Type-Options: nosniff');
echo '未登录';
exit;
}
// ---------- 立刻释放会话锁 ----------
// PHP 的会话文件锁是**排他**的。若一直持有到脚本结束,同一个浏览器并行发起的
// 每张图片请求都会串行等待(浏览器通常同时开 6 条连接,全部卡在同一把锁上),
// 页面图片越多越慢——每张图的取回耗时被乘上图片数量。
// 本页后续逻辑(白名单 → 读存储 → 回吐)不再需要读写会话,所以这里就放开。
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
// ---------- 对象键白名单 ----------
// 只允许 uploads/ 下四个图片目录里的「纯文件名」。严格限制可同时挡住:
// · 目录穿越(../../db/movies.db、../config.php)
// · absolute path / 协议前缀 / 查询串注入
$key = isset($_GET['k']) ? (string)$_GET['k'] : '';
if (!preg_match('#^uploads/(?:posters|tickets|photos|avatars)/[A-Za-z0-9][A-Za-z0-9._-]*$#', $key)
|| strpos($key, '..') !== false) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
header('X-Content-Type-Options: nosniff');
echo '对象不存在';
exit;
}
// ---------- 条件请求头透传给上游 ----------
// 透传 If-None-Match / If-Modified-Since 后,命中缓存时 OSS 直接返回 304,
// 不必把图片字节拉回来;Range 一并透传(为将来的视频留路)。
$conditional = [];
foreach ([
'HTTP_IF_NONE_MATCH' => 'If-None-Match',
'HTTP_IF_MODIFIED_SINCE' => 'If-Modified-Since',
'HTTP_RANGE' => 'Range',
] as $srvKey => $headerName) {
if (!empty($_SERVER[$srvKey])) {
$conditional[] = $headerName . ': ' . $_SERVER[$srvKey];
}
}
$res = storageRead($key, $conditional);
// ---------- 上游 304:原样短路 ----------
if ((int)($res['status'] ?? 0) === 304) {
http_response_code(304);
if (!empty($res['headers']['etag'])) header('ETag: ' . $res['headers']['etag']);
exit;
}
// ---------- 读取失败 ----------
if (empty($res['ok'])) {
$status = (int)($res['status'] ?? 0);
if ($status === 0) {
$status = 502; // 连不上存储
} elseif ($status < 400 || $status > 599) {
$status = 404;
}
if ($status !== 404) {
// 把真实原因留到设置页的「最近错误」里,否则 403/501 会很难排查
storageRecordError('media.php 读取 ' . $key . ' 失败:'
. trim((string)($res['error'] ?? '')) . '(HTTP ' . $status . ')');
}
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
header('X-Content-Type-Options: nosniff');
echo $status === 501 ? '该存储驱动暂不支持服务端读取' : '对象不存在';
exit;
}
$body = (string)$res['body'];
$srcHeaders = $res['headers'] ?? [];
$etag = (string)($srcHeaders['etag'] ?? '');
$lastModified = (string)($srcHeaders['last-modified'] ?? '');
// ---------- 本端 304 判断(本地驱动不会有上游 304) ----------
if ($etag !== '' && isset($_SERVER['HTTP_IF_NONE_MATCH'])
&& trim((string)$_SERVER['HTTP_IF_NONE_MATCH']) === $etag) {
http_response_code(304);
header('ETag: ' . $etag);
exit;
}
// ---------- Content-Type 按扩展名白名单决定 ----------
// 绝不平铺上游返回的类型:否则桶里若被塞进 .html/.svg,就会以 text/html 在本站域下执行(XSS)。
$ext = strtolower(pathinfo($key, PATHINFO_EXTENSION));
$mime = [
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
'gif' => 'image/gif', 'webp' => 'image/webp', 'bmp' => 'image/bmp',
'avif' => 'image/avif', 'ico' => 'image/x-icon',
'mp4' => 'video/mp4', 'webm' => 'video/webm', 'mov' => 'video/quicktime',
][$ext] ?? 'application/octet-stream';
// ---------- 状态码与响应头 ----------
$contentRange = (string)($srcHeaders['content-range'] ?? '');
if ($contentRange !== '' && (int)$res['status'] === 206) {
http_response_code(206);
header('Content-Range: ' . $contentRange);
} else {
http_response_code(200);
}
header('Content-Type: ' . $mime);
header('Content-Length: ' . strlen($body));
header('X-Content-Type-Options: nosniff'); // 禁止内容嗅探
header('Content-Disposition: ' . ($mime === 'application/octet-stream' ? 'attachment' : 'inline'));
if ($etag !== '') header('ETag: ' . $etag);
if ($lastModified !== '') header('Last-Modified: ' . $lastModified);
// 文件名是内容指纹(md5 前 12 位 + 随机后缀),内容不会原地变更 → 可长缓存;
// private 表示仅当前浏览器可缓存(图片需登录才能读)。
header('Cache-Control: private, max-age=31536000, immutable');
echo $body;