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

381 lines
13 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.php
// 说明: 对象存储门面层。对上暴露一组过程式函数,对下按设置选择驱动。
//
// 设计要点:
// 1. 数据库里始终存"相对键"(如 uploads/posters/xxx.jpg),不存完整 URL,
// 换存储或换域名时无需改动历史数据。
// 2. 生成 URL 时"本地优先":本地存在同名文件就按本地路径输出。
// 这样从本地切换到云端后,历史图片自动继续可用,无需先迁移。
// 3. 云端上传失败自动回退本地保存,绝不因为云配置问题导致用户丢图。
// ============================================
require_once __DIR__ . '/storage/http.php';
require_once __DIR__ . '/storage/meta.php';
require_once __DIR__ . '/storage/local.php';
require_once __DIR__ . '/storage/oss.php';
require_once __DIR__ . '/storage/cos.php';
require_once __DIR__ . '/storage/qiniu.php';
// ---------- 驱动实例 ----------
// 按驱动标识 + 配置构造驱动对象
function storageDriverInstance($driver, array $cfg) {
switch ($driver) {
case 'oss': return new StorageOss($cfg);
case 'cos': return new StorageCos($cfg);
case 'qiniu': return new StorageQiniu($cfg);
default: return new StorageLocal();
}
}
// 当前生效的驱动实例(每请求缓存)
function storage() {
static $driver = null;
static $currentId = null;
$id = currentStorageDriver();
// 配置不完整时退回本地驱动,否则会出现
// "文件传到了云端、URL 却按本地算" 的错配,图片直接裂开。
if ($id !== 'local' && !storageConfigComplete($id)) {
$id = 'local';
}
if ($driver === null || $currentId !== $id) {
$driver = storageDriverInstance($id, $id === 'local' ? [] : storageConfigFor($id));
$currentId = $id;
}
return $driver;
}
// 当前是否使用远端存储(配置不完整时按本地处理,避免产生无效 URL)
function storageIsRemote() {
$id = currentStorageDriver();
if ($id === 'local') return false;
return storageConfigComplete($id);
}
// 判断某个驱动的配置是否已填齐
function storageConfigComplete($driver) {
if ($driver === 'local') return true;
$c = storageConfigFor($driver);
switch ($driver) {
case 'oss':
$auth = ($c['auth_mode'] === 'ecs_role')
|| (trim($c['access_key_id']) !== '' && trim($c['access_key_secret']) !== '');
return $auth && trim($c['bucket']) !== '' && trim($c['endpoint']) !== '';
case 'cos':
$auth = ($c['auth_mode'] === 'cvm_role')
|| (trim($c['secret_id']) !== '' && trim($c['secret_key']) !== '');
return $auth && trim($c['bucket']) !== '' && trim($c['region']) !== '';
case 'qiniu':
// 七牛必须绑定域名才能对外访问。若只填了密钥和空间名,
// 上传会成功但生成不出可访问 URL(页面会指向本地 404),
// 因此这里必须一并要求 custom_domain。
return trim($c['access_key']) !== '' && trim($c['secret_key']) !== ''
&& trim($c['bucket']) !== ''
&& trim($c['custom_domain']) !== '';
}
return false;
}
// ---------- 错误记录(供设置页展示)----------
function storageRecordError($message) {
setSettingJson('storage_last_error', [
'time' => date('Y-m-d H:i:s'),
'message' => storageShortError($message),
'driver' => currentStorageDriver(),
]);
}
function storageLastError() {
return getSettingJson('storage_last_error', []);
}
function storageClearError() {
deleteSetting('storage_last_error');
}
// ---------- 写入 ----------
/**
* 上传一个本地文件到当前存储。云端失败时自动回退到本地保存。
* @return bool 是否成功(含回退成功的场景)
*/
function storagePutFile($srcPath, $key, $mime = null) {
$key = ltrim((string)$key, '/');
if ($key === '' || !is_file($srcPath)) {
storageRecordError('上传失败:源文件不存在或目标键为空');
return false;
}
$mime = $mime ?: storageMimeOfFile($srcPath);
$driver = storage();
if ($driver->id() === 'local') {
$r = $driver->putFile($srcPath, $key, $mime);
if (empty($r['ok'])) storageRecordError($r['error']);
return !empty($r['ok']);
}
$r = $driver->putFile($srcPath, $key, $mime);
if (!empty($r['ok'])) return true;
// 云端失败 → 回退本地,避免丢图
$fallback = new StorageLocal();
$l = $fallback->putFile($srcPath, $key, $mime);
storageRecordError('云端上传失败,已自动回退为本地保存。原因:' . $r['error']);
return !empty($l['ok']);
}
// 上传一段二进制内容(用于 base64 裁剪结果)
function storagePutContents($binary, $key, $mime = null) {
$key = ltrim((string)$key, '/');
if ($key === '' || $binary === '' || $binary === null) {
storageRecordError('上传失败:内容为空或目标键为空');
return false;
}
$mime = $mime ?: 'application/octet-stream';
$driver = storage();
if ($driver->id() === 'local') {
$r = $driver->putContents($binary, $key, $mime);
if (empty($r['ok'])) storageRecordError($r['error']);
return !empty($r['ok']);
}
$r = $driver->putContents($binary, $key, $mime);
if (!empty($r['ok'])) return true;
$fallback = new StorageLocal();
$l = $fallback->putContents($binary, $key, $mime);
storageRecordError('云端上传失败,已自动回退为本地保存。原因:' . $r['error']);
return !empty($l['ok']);
}
// ---------- 删除 ----------
/**
* 删除一个对象:本地有就删本地;当前是远端存储时同时尝试删除云端对象。
*/
function storageDelete($key) {
if ($key === null || trim((string)$key) === '') return false;
$key = ltrim((string)$key, '/');
if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $key)) return false; // 绝对 URL 无法定位对象
$ok = false;
$local = new StorageLocal();
if ($local->exists($key)) {
$ok = (bool)$local->delete($key);
}
if (storageIsRemote()) {
$r = storage()->delete($key);
if (empty($r['ok'])) {
storageRecordError($r['error']);
} else {
$ok = true;
}
}
return $ok;
}
// ---------- 读取 / URL ----------
// 是否为绝对地址(http/https/协议相对)
function storageIsAbsoluteUrl($path) {
return (bool)preg_match('#^(https?:)?//#i', (string)$path);
}
// 本地是否已存在该文件
function storageHasLocalFile($key) {
$key = ltrim((string)$key, '/');
return $key !== '' && is_file(ROOT_DIR . '/' . $key);
}
// ---------- 图片访问方式 ----------
// direct:图片 URL 直接指向存储(要求桶可被匿名读取,或由防盗链/签名兜底)
// proxy :图片 URL 指向本站 media.php,由服务端凭实例角色/密钥读取后回吐
// —— 桶可以设为「私有」,且因同源交付,Canvas 读像素(裁剪)也不再受限
function storageAccessMode() {
return ((string)getSetting('storage_access_mode', 'direct')) === 'proxy' ? 'proxy' : 'direct';
}
// 指向本站代理的图片地址。
// 逐段编码、保留斜杠(与 storageEncodeKey 同样的策略):URL 可读、nginx 日志好看。
// 目录穿越由 media.php 侧的白名单负责,不依赖这里的编码。
function storageProxyUrl($key) {
$parts = array_map('rawurlencode', explode('/', ltrim((string)$key, '/')));
return '/media.php?k=' . implode('/', $parts);
}
/**
* 生成可访问的 URL。规则:
* 1. 已经是绝对地址 → 原样返回
* 2. 本地存在同名文件 → 走本地路径(同源,无需绕代理)
* 3. 代理模式 → 指向本站 media.php
* 4. 否则交给当前驱动生成
*/
function storageUrl($key) {
if ($key === null || trim((string)$key) === '') return null;
$key = (string)$key;
if (storageIsAbsoluteUrl($key)) return $key;
$trimmed = ltrim($key, '/');
if (storageHasLocalFile($trimmed)) return '/' . $trimmed;
if (storageAccessMode() === 'proxy') return storageProxyUrl($trimmed);
return storage()->url($trimmed);
}
/**
* 服务端读取对象内容(供 media.php 回吐)。
* 本地优先:本地有文件就直接读盘,省一次外网往返;否则交给驱动。
* $conditional 为透传给上游的条件头(如 ['If-None-Match: "abc"'])。
*/
function storageRead($key, array $conditional = []) {
$key = ltrim((string)$key, '/');
if ($key === '') {
return ['ok' => false, 'status' => 400, 'headers' => [], 'body' => '', 'error' => '缺少对象键'];
}
if (storageHasLocalFile($key)) {
$abs = ROOT_DIR . '/' . $key;
$body = @file_get_contents($abs);
if ($body !== false) {
return [
'ok' => true,
'status' => 200,
'headers' => [
'content-type' => storageMimeOfFile($abs),
'content-length' => (string)strlen($body),
'etag' => '"' . md5($body) . '"',
'last-modified' => gmdate('D, d M Y H:i:s \G\M\T', (int)@filemtime($abs)),
],
'body' => $body,
'error' => '',
];
}
}
$drv = storage();
if (!method_exists($drv, 'get')) {
return [
'ok' => false, 'status' => 501, 'headers' => [], 'body' => '',
'error' => '当前存储驱动(' . $drv->label() . ')暂不支持服务端读取',
];
}
return $drv->get($key, $conditional);
}
/**
* 判断图片是否"应当被视为存在"。
* 本地:真实检查文件;云端:键非空即视为存在(远端不做逐次 HEAD 探测)。
*/
function storageExists($key) {
if ($key === null || trim((string)$key) === '') return false;
if (storageIsAbsoluteUrl($key)) return true;
if (storageHasLocalFile($key)) return true;
return storageIsRemote();
}
// ---------- MIME ----------
function storageMimeOfFile($path) {
if (function_exists('finfo_open') && is_file($path)) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$mime = finfo_file($finfo, $path);
finfo_close($finfo);
if ($mime) return $mime;
}
}
return storageMimeFromName($path);
}
function storageMimeFromContents($binary) {
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$mime = finfo_buffer($finfo, $binary);
finfo_close($finfo);
if ($mime) return $mime;
}
}
return 'application/octet-stream';
}
function storageMimeFromName($name) {
$map = [
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp',
'txt' => 'text/plain',
];
$ext = strtolower(pathinfo((string)$name, PATHINFO_EXTENSION));
return $map[$ext] ?? 'application/octet-stream';
}
// ---------- 诊断(设置页用)----------
// 探测当前服务器是否位于某朵云上(仅做可达性判断,不拉取凭据)
function storageDetectCloudHost() {
$hosts = ['aliyun' => 'ECS(阿里云)', 'tencent' => 'CVM(腾讯云)'];
$found = [];
foreach ($hosts as $key => $label) {
$cred = $key === 'aliyun'
? aliyunEcsRamCredentials('', true)
: tencentCvmCamCredentials('', true);
if (!empty($cred['ok'])) {
$found[$key] = ['label' => $label, 'role' => $cred['role'] ?? '', 'expires' => $cred['expires'] ?? 0];
}
}
return $found;
}
// ---------- 迁移改名:改写数据库里的图片引用 ----------
/**
* 数据库里所有存放「图片相对键」的字段(表, 字段)。
*
* 迁移时若把文件按指纹改名,必须一并改写这些引用,
* 否则库里仍指向旧名,图片会裂开——特别是勾选了"迁移后删除本地文件"时无法回退。
*/
function storageImageRefFields() {
return [
['movies', 'poster_path'],
['movies', 'ticket_path'],
['movies', 'photo_path'], // 遗留字段,仍可能有历史数据
['movie_photos', 'photo_path'],
['persons', 'avatar'],
['admins', 'avatar'],
];
}
/**
* 把所有等于 $oldKey 的图片引用改写为 $newKey。
* 同一条旧键被多行引用时会一次全部改掉。
*
* @return int 受影响行数合计
*/
function storageRewriteImageRef($oldKey, $newKey) {
$oldKey = (string)$oldKey;
$newKey = (string)$newKey;
if ($oldKey === '' || $newKey === '' || $oldKey === $newKey) return 0;
try {
$db = getDB();
} catch (Throwable $e) {
return 0;
}
$affected = 0;
foreach (storageImageRefFields() as $pair) {
list($table, $column) = $pair;
// 表名/字段名来自上面的硬编码清单,非用户输入
try {
$stmt = $db->prepare("UPDATE {$table} SET {$column} = ? WHERE {$column} = ?");
$stmt->execute([$newKey, $oldKey]);
$affected += $stmt->rowCount();
} catch (PDOException $e) {
// 表或字段不存在(旧库)时跳过,不阻断迁移
}
}
return $affected;
}