134 lines
5.7 KiB
PHP
134 lines
5.7 KiB
PHP
<?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;
|