拿到 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;