新增存储抽象层:图片可存本地或云对象存储,切换存储方式不影响已有图片。
存储层
- 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 条踩坑记录、可直接复制的排障命令手册
428 lines
18 KiB
PHP
428 lines
18 KiB
PHP
<?php
|
||
// ============================================
|
||
// 文件: lib/storage/oss.php
|
||
// 说明: 阿里云 OSS 驱动
|
||
// 签名算法:OSS 签名 V1(HMAC-SHA1)
|
||
// 鉴权方式:AccessKey 或 ECS RAM 角色(实例元数据,免密钥)
|
||
// ============================================
|
||
|
||
class StorageOss {
|
||
|
||
private $cfg;
|
||
private $cred = null;
|
||
|
||
public function __construct(array $cfg) {
|
||
$this->cfg = $cfg;
|
||
}
|
||
|
||
public function id() { return 'oss'; }
|
||
|
||
public function label() { return '阿里云 OSS'; }
|
||
|
||
// ---------- 凭据 ----------
|
||
|
||
private function credentials() {
|
||
if ($this->cred !== null) return $this->cred;
|
||
|
||
if (($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role') {
|
||
$c = aliyunEcsRamCredentials($this->cfg['ram_role_name'] ?? '');
|
||
if (empty($c['ok'])) return $this->cred = ['ok' => false, 'error' => $c['error']];
|
||
return $this->cred = $c;
|
||
}
|
||
|
||
if (trim((string)($this->cfg['access_key_id'] ?? '')) === ''
|
||
|| trim((string)($this->cfg['access_key_secret'] ?? '')) === '') {
|
||
return $this->cred = ['ok' => false, 'error' => '未填写 AccessKey ID / AccessKey Secret'];
|
||
}
|
||
return $this->cred = [
|
||
'ok' => true,
|
||
'id' => trim($this->cfg['access_key_id']),
|
||
'secret' => trim($this->cfg['access_key_secret']),
|
||
'token' => '',
|
||
'error' => '',
|
||
];
|
||
}
|
||
|
||
// ---------- 寻址 ----------
|
||
|
||
private function useHttps() {
|
||
return ($this->cfg['https'] ?? '1') === '1';
|
||
}
|
||
|
||
private function scheme() {
|
||
return $this->useHttps() ? 'https' : 'http';
|
||
}
|
||
|
||
private function endpoint() {
|
||
return preg_replace('#^https?://#i', '', trim((string)($this->cfg['endpoint'] ?? '')));
|
||
}
|
||
|
||
/**
|
||
* 服务端读取时优先使用的内网 endpoint(ECS ↔ OSS 同地域内网流量免费、更快)。
|
||
* 只把标准的 `<region>.aliyuncs.com` 推导为 `<region>-internal.aliyuncs.com`;
|
||
* 自定义域名 / VPC 域名 / 已是内网域名的一律不猜,返回空串表示"不适用"。
|
||
*/
|
||
private function internalEndpoint() {
|
||
if (($this->cfg['internal_endpoint'] ?? '1') === '0') return '';
|
||
$ep = $this->endpoint();
|
||
if ($ep === '' || strpos($ep, '-internal.') !== false) return '';
|
||
if (preg_match('#^(oss-[a-z0-9-]+)\.aliyuncs\.com$#i', $ep, $m)) {
|
||
return $m[1] . '-internal.aliyuncs.com';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// 给对象键加上「对象前缀」(桶内目录)。库中始终存不带前缀的相对键,
|
||
// 前缀只在驱动层拼接,因此改前缀不影响数据库里的历史记录。
|
||
private function objectKey($key) {
|
||
$key = ltrim((string)$key, '/');
|
||
$prefix = trim((string)($this->cfg['prefix'] ?? ''), '/');
|
||
return $prefix === '' ? $key : $prefix . '/' . $key;
|
||
}
|
||
|
||
private function host() {
|
||
return trim((string)$this->cfg['bucket']) . '.' . $this->endpoint();
|
||
}
|
||
|
||
private function objectUrl($key) {
|
||
return $this->scheme() . '://' . $this->host() . '/' . storageEncodeKey($key);
|
||
}
|
||
|
||
// 对外访问地址(优先自定义域名)
|
||
public function url($key) {
|
||
$key = $this->objectKey($key);
|
||
$custom = trim((string)($this->cfg['custom_domain'] ?? ''));
|
||
if ($custom !== '') {
|
||
return storageHostOf($custom, $this->useHttps()) . '/' . storageEncodeKey($key);
|
||
}
|
||
return $this->scheme() . '://' . $this->host() . '/' . storageEncodeKey($key);
|
||
}
|
||
|
||
// ---------- 签名 ----------
|
||
|
||
private function authorization($method, $key, $contentType, $date, $cred) {
|
||
$canonicalizedHeaders = '';
|
||
if (($cred['token'] ?? '') !== '') {
|
||
$canonicalizedHeaders = "x-oss-security-token:{$cred['token']}\n";
|
||
}
|
||
$canonicalizedResource = '/' . trim((string)$this->cfg['bucket']) . '/' . ltrim((string)$key, '/');
|
||
$stringToSign = $method . "\n" // VERB
|
||
. "\n" // Content-MD5(未使用)
|
||
. $contentType . "\n" // Content-Type
|
||
. $date . "\n" // Date
|
||
. $canonicalizedHeaders
|
||
. $canonicalizedResource;
|
||
$signature = base64_encode(hash_hmac('sha1', $stringToSign, $cred['secret'], true));
|
||
return 'OSS ' . $cred['id'] . ':' . $signature;
|
||
}
|
||
|
||
private function buildHeaders($method, $key, $contentType, $cred) {
|
||
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||
$headers = [
|
||
'Date: ' . $date,
|
||
'Content-Type: ' . $contentType,
|
||
'Authorization: ' . $this->authorization($method, $key, $contentType, $date, $cred),
|
||
];
|
||
if (($cred['token'] ?? '') !== '') {
|
||
$headers[] = 'x-oss-security-token: ' . $cred['token'];
|
||
}
|
||
return $headers;
|
||
}
|
||
|
||
/**
|
||
* GET/HEAD 的请求头。故意**不发送** Content-Type 头(签名串里的 Content-Type 用空串),
|
||
* 这是 OSS 签名 V1 对读取类请求的标准做法。
|
||
* $extra 里放条件/断点头(If-None-Match、If-Modified-Since、Range)——
|
||
* 它们不属于 CanonicalizedOSSHeaders(只有 x-oss-* 才计入),因此不影响签名。
|
||
*/
|
||
private function readHeaders($key, $cred, array $extra = [], $method = 'GET') {
|
||
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||
$headers = [
|
||
'Date: ' . $date,
|
||
'Authorization: ' . $this->authorization($method, $key, '', $date, $cred),
|
||
];
|
||
if (($cred['token'] ?? '') !== '') {
|
||
$headers[] = 'x-oss-security-token: ' . $cred['token'];
|
||
}
|
||
foreach ($extra as $h) {
|
||
$headers[] = $h;
|
||
}
|
||
return $headers;
|
||
}
|
||
|
||
// ---------- 内网 endpoint 可用性 ----------
|
||
//
|
||
// 内网 endpoint(oss-<地域>-internal.aliyuncs.com)只在**与桶同地域**的 ECS 上
|
||
// 可路由。跨地域时 DNS 会照常解析出一个 A 记录(解析耗时 ~1ms,看不出异常),
|
||
// 但 TCP 连接永远建不起来,只能等满连接超时。若不做判定就无脑"内网优先",
|
||
// 每读一张图都要先白等一次超时再回退公网——图片多的页面直接慢到不可用。
|
||
// 所以这里先比地域(零成本、结论确定),拿不准才真的探一次,并把结论缓存下来。
|
||
|
||
/** 桶所在地域(从标准 endpoint 解析);自定义/VPC 域名返回空串。 */
|
||
private function bucketRegion() {
|
||
if (preg_match('#^oss-([a-z0-9-]+?)(?:-internal)?\.aliyuncs\.com$#i', $this->endpoint(), $m)) {
|
||
return strtolower($m[1]);
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* 探测内网 endpoint 是否可用,并把结论写入缓存。
|
||
* @param bool $force 忽略缓存结论重新探测(设置页"测试连通性"用)
|
||
* @return array ['ok'=>bool,'reason'=>string]
|
||
*/
|
||
private function probeInternal($force = false) {
|
||
$internal = $this->internalEndpoint();
|
||
$out = ['ok' => false, 'reason' => ''];
|
||
|
||
if ($internal === '') {
|
||
return ['ok' => false, 'reason' => '未启用,或 endpoint 不是标准 OSS 域名'];
|
||
}
|
||
|
||
$bucketRegion = $this->bucketRegion();
|
||
$ecsRegion = function_exists('aliyunEcsRegion') ? aliyunEcsRegion($force) : '';
|
||
|
||
if ($bucketRegion !== '' && $ecsRegion !== '' && $bucketRegion !== $ecsRegion) {
|
||
// 跨地域:不必探测,内网一定不通
|
||
$out['reason'] = "本机在 {$ecsRegion}、桶在 {$bucketRegion},不是同一地域";
|
||
} else {
|
||
$cred = $this->credentials();
|
||
if (empty($cred['ok'])) {
|
||
$out['reason'] = '凭据不可用,无法探测';
|
||
} else {
|
||
// 对桶根发一个签名 HEAD:只要拿到任何 HTTP 状态码就说明内网可路由
|
||
// (没有 ListBucket 权限会回 403,但这已经证明网络通)。
|
||
$url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $internal . '/';
|
||
$res = storageHttp('HEAD', $url, $this->readHeaders('/', $cred, [], 'HEAD'),
|
||
null, 3, false, 2);
|
||
$out['ok'] = $res['status'] > 0;
|
||
$out['reason'] = $out['ok']
|
||
? "内网可达(HTTP {$res['status']})"
|
||
: '内网 endpoint 连接失败:' . ($res['error'] ?? '');
|
||
}
|
||
}
|
||
|
||
setSettingJson('cache_oss_internal', [
|
||
'endpoint' => $internal,
|
||
'ok' => $out['ok'] ? 1 : 0,
|
||
'reason' => $out['reason'],
|
||
'expires' => time() + ($out['ok'] ? 3600 : 1800),
|
||
]);
|
||
return $out;
|
||
}
|
||
|
||
/** 内网 endpoint 现在能不能用(结论走缓存,不会每个请求都探测)。 */
|
||
private function internalUsable() {
|
||
$internal = $this->internalEndpoint();
|
||
if ($internal === '') return false;
|
||
|
||
$cache = getSettingJson('cache_oss_internal', []);
|
||
if (!empty($cache['expires']) && $cache['expires'] > time()
|
||
&& ($cache['endpoint'] ?? '') === $internal) {
|
||
return !empty($cache['ok']);
|
||
}
|
||
return !empty($this->probeInternal()['ok']);
|
||
}
|
||
|
||
/** 内网这次真的连不上 → 立刻记下,半小时内不再重试(避免每张图都白等)。 */
|
||
private function markInternalDown($reason) {
|
||
if ($this->internalEndpoint() === '') return;
|
||
setSettingJson('cache_oss_internal', [
|
||
'endpoint' => $this->internalEndpoint(),
|
||
'ok' => 0,
|
||
'reason' => $reason,
|
||
'expires' => time() + 1800,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 供设置页展示:内网 endpoint 的推导结果与可用性判定。
|
||
* @param bool $probe 强制重新探测
|
||
* @return array ['endpoint'=>string,'ok'=>bool,'reason'=>string]
|
||
*/
|
||
public function internalStatus($probe = false) {
|
||
$internal = $this->internalEndpoint();
|
||
if ($internal === '') return ['endpoint' => '', 'ok' => false, 'reason' => ''];
|
||
|
||
$cache = getSettingJson('cache_oss_internal', []);
|
||
$cached = !empty($cache['expires']) && $cache['expires'] > time()
|
||
&& ($cache['endpoint'] ?? '') === $internal;
|
||
|
||
if ($probe || !$cached) {
|
||
$r = $this->probeInternal($probe);
|
||
return ['endpoint' => $internal, 'ok' => !empty($r['ok']), 'reason' => (string)$r['reason']];
|
||
}
|
||
return [
|
||
'endpoint' => $internal,
|
||
'ok' => !empty($cache['ok']),
|
||
'reason' => (string)($cache['reason'] ?? ''),
|
||
];
|
||
}
|
||
|
||
private function validateConfig() {
|
||
foreach (['bucket' => 'Bucket 名称', 'endpoint' => 'Endpoint'] as $f => $label) {
|
||
if (trim((string)($this->cfg[$f] ?? '')) === '') {
|
||
return "未填写{$label}";
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// ---------- 操作 ----------
|
||
|
||
public function putFile($srcPath, $key, $mime = null) {
|
||
return $this->putContents(file_get_contents($srcPath), $key, $mime ?: 'application/octet-stream');
|
||
}
|
||
|
||
public function putContents($binary, $key, $mime = null) {
|
||
if ($err = $this->validateConfig()) return ['ok' => false, 'error' => $err];
|
||
$cred = $this->credentials();
|
||
if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']];
|
||
|
||
$key = $this->objectKey($key);
|
||
$mime = $mime ?: 'application/octet-stream';
|
||
$res = storageHttp('PUT', $this->objectUrl($key),
|
||
$this->buildHeaders('PUT', $key, $mime, $cred), $binary, 60);
|
||
|
||
if (!$res['ok']) {
|
||
return ['ok' => false, 'error' => '上传到 OSS 失败:' . $this->describe($res)];
|
||
}
|
||
return ['ok' => true, 'error' => ''];
|
||
}
|
||
|
||
public function delete($key) {
|
||
$cred = $this->credentials();
|
||
if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']];
|
||
|
||
$key = $this->objectKey($key);
|
||
$res = storageHttp('DELETE', $this->objectUrl($key),
|
||
$this->buildHeaders('DELETE', $key, '', $cred), null, 30);
|
||
|
||
// 对象本就不存在时视为成功
|
||
if (!$res['ok'] && $res['status'] !== 404) {
|
||
return ['ok' => false, 'error' => '删除 OSS 对象失败:' . $this->describe($res)];
|
||
}
|
||
return ['ok' => true, 'error' => ''];
|
||
}
|
||
|
||
public function exists($key) {
|
||
return true; // 远端不做逐次 HEAD 探测,交由页面兜底
|
||
}
|
||
|
||
/**
|
||
* 服务端读取对象(供 media.php 代理回吐)。
|
||
* 私有桶同样可读——用的是实例角色 / AccessKey 签名,与桶的公共读权限无关。
|
||
* $extra 用于透传条件头(If-None-Match / If-Modified-Since)与 Range,实现 304 短路与断点。
|
||
* 返回结构与 storageHttp() 同构:['ok','status','headers','body','error']
|
||
*/
|
||
public function get($key, array $extra = []) {
|
||
if ($err = $this->validateConfig()) {
|
||
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $err];
|
||
}
|
||
$cred = $this->credentials();
|
||
if (empty($cred['ok'])) {
|
||
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $cred['error']];
|
||
}
|
||
|
||
$key = $this->objectKey($key);
|
||
$headers = $this->readHeaders($key, $cred, $extra);
|
||
|
||
// 候选主机:内网优先(同地域内网流量免费、延迟更低),传输层失败再回退公网。
|
||
// 内网是否**真的可用**先判定(结论有缓存)——跨地域时内网不可路由,
|
||
// 无脑尝试会让每张图先白等一次连接超时。
|
||
// Authorization 的 CanonicalizedResource 只含 /bucket/key,与主机无关,故可复用同一组头。
|
||
$hosts = [];
|
||
$internal = $this->internalEndpoint();
|
||
if ($internal !== '' && $this->internalUsable()) $hosts[] = $internal;
|
||
$hosts[] = $this->endpoint();
|
||
|
||
$res = null;
|
||
foreach ($hosts as $i => $host) {
|
||
$isInternal = ($internal !== '' && $host === $internal);
|
||
$url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $host
|
||
. '/' . storageEncodeKey($key);
|
||
// 内网只给 2 秒连接超时:万一判定失效,也只损失 2 秒而不是默认的 10 秒
|
||
$res = storageHttp('GET', $url, $headers, null, 60, false, $isInternal ? 2 : null);
|
||
if ($res['status'] === 0) {
|
||
if ($isInternal) {
|
||
$this->markInternalDown('连接失败:' . ($res['error'] ?? ''));
|
||
}
|
||
if (isset($hosts[$i + 1])) continue; // 连不上 → 换下一个主机
|
||
}
|
||
break;
|
||
}
|
||
return $res ?: ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => '无法连接 OSS'];
|
||
}
|
||
|
||
public function test() {
|
||
if ($err = $this->validateConfig()) return ['ok' => false, 'error' => $err];
|
||
$cred = $this->credentials();
|
||
if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']];
|
||
|
||
// 探测对象也走前缀,顺便验证前缀目录的写权限
|
||
$raw = '.movielog-probe/' . date('YmdHis') . '-' . bin2hex(random_bytes(4)) . '.txt';
|
||
$key = $this->objectKey($raw);
|
||
$binary = 'movielog storage probe ' . date('c');
|
||
|
||
$res = storageHttp('PUT', $this->objectUrl($key),
|
||
$this->buildHeaders('PUT', $key, 'text/plain', $cred), $binary, 20);
|
||
if (!$res['ok']) {
|
||
return ['ok' => false, 'error' => '写入测试失败:' . $this->describe($res)];
|
||
}
|
||
|
||
// 回读验证:「服务端代理」模式(media.php)完全依赖这条读取链路,
|
||
// 所以测试连通性必须把 GET 也走一遍——写入成功不等于读得回来。
|
||
$read = $this->get($raw);
|
||
$readErr = '';
|
||
if (!$read['ok']) {
|
||
$readErr = $this->describe($read);
|
||
} elseif (trim((string)$read['body']) !== trim($binary)) {
|
||
$readErr = '回读到的内容与写入不一致';
|
||
}
|
||
|
||
$del = $this->delete($raw); // delete() 内部会自行加前缀,故传原始键
|
||
$mode = ($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role'
|
||
? 'ECS RAM 角色(免密钥)' : 'AccessKey';
|
||
$prefix = trim((string)($this->cfg['prefix'] ?? ''), '/');
|
||
|
||
// 内网 endpoint 的结论要明说:跨地域时它是**静默拖慢**的元凶,
|
||
// 不报出来用户根本无从察觉。
|
||
$internalNote = '';
|
||
if ($this->internalEndpoint() !== '') {
|
||
$probe = $this->probeInternal(true);
|
||
$internalNote = $probe['ok']
|
||
? "服务端读取走内网 {$probe['endpoint']}({$probe['reason']})。"
|
||
: '内网 endpoint 已自动跳过(原因:' . $probe['reason'] . '),'
|
||
. '读取改走公网 ' . $this->endpoint() . ',功能不受影响。';
|
||
}
|
||
|
||
return [
|
||
'ok' => true,
|
||
'read_ok' => $readErr === '',
|
||
'error' => '',
|
||
'message' => "连接成功({$mode})。桶 {$this->cfg['bucket']} 可读写,"
|
||
. ($prefix !== '' ? "前缀 {$prefix}/," : '')
|
||
. ($readErr === ''
|
||
? '服务端读取已验证——可安全启用「服务端代理」访问方式。'
|
||
: "但服务端读取失败:{$readErr}(请先排查读取权限,暂不要启用「服务端代理」)。")
|
||
. $internalNote
|
||
. '测试对象已清理。' . ($del['ok'] ? '' : '(注意:测试对象删除失败,请检查删除权限)'),
|
||
];
|
||
}
|
||
|
||
// 把 HTTP 响应压成一句可读的错误
|
||
private function describe($res) {
|
||
$body = trim((string)$res['body']);
|
||
if ($body !== '') {
|
||
if (preg_match('#<Message>(.*?)</Message>#s', $body, $m)) {
|
||
$code = preg_match('#<Code>(.*?)</Code>#s', $body, $c) ? $c[1] : '';
|
||
return trim("{$code} {$m[1]}");
|
||
}
|
||
if (preg_match('#<Code>(.*?)</Code>#s', $body, $c)) {
|
||
return $c[1];
|
||
}
|
||
}
|
||
return $res['error'];
|
||
}
|
||
}
|