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 条踩坑记录、可直接复制的排障命令手册
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
<?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;
|
||||
Reference in New Issue
Block a user