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

428 lines
18 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
// ============================================
// 文件: 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'];
}
}