Files
laoyang 673a8abf78 feat: 云端图片存储(OSS/COS/七牛)+ 服务端代理读取,并补全文档
新增存储抽象层:图片可存本地或云对象存储,切换存储方式不影响已有图片。

存储层
- lib/storage.php 存储门面:本地优先读取、URL 生成、上传/删除、批量迁移、
  服务端读取(storageRead)与访问方式判定(storageAccessMode)
- lib/storage/{local,oss,cos,qiniu}.php 四种后端驱动;http.php 共用 cURL 客户端;
  meta.php 实例元数据鉴权(ECS RAM 角色 / CVM CAM 角色,免密钥)
- lib/settings.php 设置读写;云厂商密钥以 enc:v1:gcm 密文落库(db/.settings.key)
- 文件名改为内容指纹(md5 前 12 位_6 位随机[_角色].ext),内容不变则名不变,
  因此可下发 immutable 长缓存

两种图片访问方式
- 直连存储:storageUrl() 产出对象存储地址(桶需允许匿名读)
- 服务端代理:media.php 鉴权后由服务器签名读取并同源回吐,桶可保持私有
  · 未登录返回 403 纯文本(刻意不用 302,否则 <img> 会拿到一段 HTML)
  · 对象键白名单防目录穿越;Content-Type 按扩展名白名单决定 + nosniff(防同源 XSS)
  · 透传 If-None-Match / Range,上游 304 直接短路;private + immutable 长缓存
  · 鉴权后立即 session_write_close(),避免排他会话锁把并行图片请求串行化

内网 endpoint 判定(修「开了代理后慢到不可用」)
- 内网地址只在与桶同地域的 ECS 上可路由;跨地域时 DNS 能解析但 TCP 连不通,
  无脑「内网优先」会让每次读取先白等满一次连接超时(默认 10 秒)
- 改为先比本机 ECS 地域与桶地域(零成本、结论确定)→ 不同即跳过;
  结论落缓存;内网只给 2 秒连接超时,传输层失败立刻标记且短期内不再重试
- 「测试连通性」升级为 PUT → GET 回读 → DELETE,并显式报出内网判定结论

界面与页面
- settings.php 系统设置页:驱动选择、各云配置、图片访问方式、连接测试、迁移进度
- storage_api.php 提供 stat / migrate / test 三个 JSON 接口
- detail / edit / annual_report / persons 原先直接拼本地路径(file_exists / '/'.$path),
  迁云后会整块不渲染或 404,统一改为走存储门面
- js/main.js 增加破图占位兜底;config.php 统一下发 Referrer-Policy: same-origin

文档
- README 新增《图片存储与对象存储》整章:设计约定、两种访问方式取舍表、
  直连存储逐项配置说明(桶 ACL / 防盗链白名单 / 跨域 CORS 与 crossorigin /
  强制下载 / endpoint 地域 / 自定义域名)与上线检查清单、服务端代理实现要点、
  迁移步骤、12 条踩坑记录、可直接复制的排障命令手册
2026-09-21 22:06:02 +08:00

134 lines
5.7 KiB
PHP
Raw Permalink 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
// ============================================
// 文件: 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;