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,145 @@
|
||||
<?php
|
||||
// ============================================
|
||||
// 文件: lib/storage/http.php
|
||||
// 说明: 对象存储驱动共用的 HTTP 客户端与签名工具函数
|
||||
// ============================================
|
||||
|
||||
// 该路径是否允许当前请求访问。
|
||||
//
|
||||
// 宝塔等环境会给站点加 .user.ini:open_basedir=/站点目录/:/tmp/。
|
||||
// 此时对 /etc/ssl/... 这类"站外路径"调用 is_file() 会抛
|
||||
// Warning: is_file(): open_basedir restriction in effect
|
||||
// 而站点开着 display_errors,警告就会被打进响应体——对返回 JSON 的接口
|
||||
// 是致命的(前端 JSON.parse 直接失败)。所以探测前先自己判断一遍。
|
||||
function storagePathAllowed($path) {
|
||||
$path = str_replace('\\', '/', trim((string)$path));
|
||||
if ($path === '') return false;
|
||||
|
||||
$open = (string)ini_get('open_basedir');
|
||||
if ($open !== '') {
|
||||
$allowed = false;
|
||||
foreach (explode(PATH_SEPARATOR, $open) as $dir) {
|
||||
$dir = rtrim(str_replace('\\', '/', trim($dir)), '/');
|
||||
if ($dir === '') continue;
|
||||
if ($path === $dir || strpos($path, $dir . '/') === 0) {
|
||||
$allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$allowed) return false;
|
||||
}
|
||||
|
||||
// 仍在允许目录内,也压掉可能的名权限/符号链接警告
|
||||
return @is_file($path) && @is_readable($path);
|
||||
}
|
||||
|
||||
// 定位系统 CA 证书,供 SSL 校验使用(找不到时返回 null,交由 curl 自行处理)
|
||||
function storageCaBundle() {
|
||||
static $ca = false;
|
||||
if ($ca !== false) return $ca;
|
||||
$candidates = [
|
||||
ini_get('curl.cainfo'),
|
||||
ini_get('openssl.cafile'),
|
||||
'/etc/ssl/certs/ca-certificates.crt',
|
||||
'/etc/pki/tls/certs/ca-bundle.crt',
|
||||
'/etc/ssl/cert.pem',
|
||||
];
|
||||
foreach ($candidates as $path) {
|
||||
if (storagePathAllowed($path)) {
|
||||
$ca = (string)$path;
|
||||
return $ca;
|
||||
}
|
||||
}
|
||||
$ca = null;
|
||||
return $ca;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起 HTTP 请求。
|
||||
*
|
||||
* @param string $method GET / PUT / POST / DELETE / HEAD
|
||||
* @param string $url 完整 URL
|
||||
* @param array $headers 形如 ['Content-Type: image/jpeg', ...]
|
||||
* @param string|null $body 请求体
|
||||
* @param int $timeout 总超时(秒)
|
||||
* @param bool $insecure 是否跳过 SSL 证书校验(默认否)
|
||||
* @param int|null $connectTimeout 连接阶段超时(秒)。默认由总超时推算;
|
||||
* 想"快速失败"的地方(比如探测内网 endpoint)显式给一个很小的值。
|
||||
*
|
||||
* @return array ['ok'=>bool,'status'=>int,'headers'=>array,'body'=>string,'error'=>string]
|
||||
*/
|
||||
function storageHttp($method, $url, array $headers = [], $body = null, $timeout = 30, $insecure = false, $connectTimeout = null) {
|
||||
$respHeaders = [];
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => strtoupper($method),
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_CONNECTTIMEOUT => $connectTimeout === null
|
||||
? min(10, max(3, $timeout))
|
||||
: max(1, (int)$connectTimeout),
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_SSL_VERIFYPEER => !$insecure,
|
||||
CURLOPT_SSL_VERIFYHOST => $insecure ? 0 : 2,
|
||||
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$respHeaders) {
|
||||
$pos = strpos($line, ':');
|
||||
if ($pos !== false) {
|
||||
$name = strtolower(trim(substr($line, 0, $pos)));
|
||||
$respHeaders[$name] = trim(substr($line, $pos + 1));
|
||||
}
|
||||
return strlen($line);
|
||||
},
|
||||
]);
|
||||
$ca = storageCaBundle();
|
||||
if ($ca && !$insecure) {
|
||||
curl_setopt($ch, CURLOPT_CAINFO, $ca);
|
||||
}
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
$out = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($out === false) {
|
||||
return [
|
||||
'ok' => false, 'status' => 0, 'headers' => [],
|
||||
'body' => '',
|
||||
'error' => $errno === CURLE_OPERATION_TIMEDOUT
|
||||
? "请求超时({$timeout}s)"
|
||||
: "网络错误({$errno}): {$err}",
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $status >= 200 && $status < 300,
|
||||
'status' => $status,
|
||||
'headers' => $respHeaders,
|
||||
'body' => $out,
|
||||
'error' => $status >= 200 && $status < 300 ? '' : "HTTP {$status}",
|
||||
];
|
||||
}
|
||||
|
||||
// 生成对象的访问 URL(路径部分逐段编码,保留斜杠)
|
||||
function storageEncodeKey($key) {
|
||||
$key = ltrim((string)$key, '/');
|
||||
$segments = explode('/', $key);
|
||||
return implode('/', array_map('rawurlencode', $segments));
|
||||
}
|
||||
|
||||
// 解析 host 形式:bucket.endpoint 或自定义域名
|
||||
function storageHostOf($domain, $useHttps = true) {
|
||||
$domain = preg_replace('#^https?://#i', '', trim((string)$domain));
|
||||
return ($useHttps ? 'https://' : 'http://') . rtrim($domain, '/');
|
||||
}
|
||||
|
||||
// 统一把失败信息压缩成一行,便于写入设置页展示
|
||||
function storageShortError($text, $max = 200) {
|
||||
$text = preg_replace('/\s+/', ' ', trim((string)$text));
|
||||
return mb_strlen($text, 'UTF-8') > $max ? mb_substr($text, 0, $max, 'UTF-8') . '…' : $text;
|
||||
}
|
||||
Reference in New Issue
Block a user