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:
2026-09-21 22:06:02 +08:00
parent dd957d45bd
commit 673a8abf78
21 changed files with 3746 additions and 57 deletions
+363
View File
@@ -0,0 +1,363 @@
<?php
// ============================================
// 文件: lib/settings.php
// 说明: 系统设置的读写(settings 键值表 + 进程内缓存)
// 表结构在 lib/db.php 的 initDB() 中创建。
// 敏感设置(云厂商密钥、临时凭据)在落库前自动加密,读取时透明解密。
// ============================================
// ===== 敏感设置的加密存储 =====
//
// 设计取舍:
// - 密钥存放在 db/.settings.key(该目录已被 .gitignore 排除,且被 nginx 拒绝访问),
// 与数据库文件分离,因此"只泄露 movies.db"时无法解出凭据。
// - 但若整个站点目录(含 db/)一起泄露,密钥会随之泄露——这是自托管场景的固有限制。
// - 密钥丢失不会导致数据损失,只是需要在设置页重新填写云厂商密钥。
// 密文信封前缀(带算法标识,便于日后升级而不破坏旧数据)
const SETTINGS_ENC_PREFIX = 'enc:v1:';
// 需要加密落盘的设置项
function settingsSensitiveNames() {
return [
'storage_config', // 各云厂商的 AccessKey / SecretKey
'cache_meta_aliyun', // ECS 临时凭据(含 Secret + SecurityToken)
'cache_meta_tencent', // CVM 临时密钥
];
}
function settingsKeyFile() {
return ROOT_DIR . '/db/.settings.key';
}
// 解密失败标记(供设置页告警)
function &settingsCryptoFlags() {
static $flags = ['decrypt_failed' => false];
return $flags;
}
/**
* 惰性取得 32 字节站点密钥,首次调用时自动生成。
* @return string|false 密钥原始字节;false 表示加密不可用
*/
function settingsCryptoKey() {
static $key = null;
if ($key !== null) return $key;
if (!function_exists('openssl_encrypt')) return $key = false;
$file = settingsKeyFile();
if (is_file($file)) {
$raw = @file_get_contents($file);
if ($raw !== false) {
$decoded = base64_decode(trim($raw), true);
if ($decoded !== false && strlen($decoded) === 32) return $key = $decoded;
}
// 文件存在但内容损坏:不覆盖(覆盖会让仍可用的旧密钥彻底失效),仅报不可用
return $key = false;
}
$dir = dirname($file);
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return $key = false;
try {
$fresh = random_bytes(32);
} catch (Exception $e) {
return $key = false;
}
if (@file_put_contents($file, base64_encode($fresh) . "\n") === false) return $key = false;
@chmod($file, 0600);
return $key = $fresh;
}
// 加密能力是否可用(设置页展示用)
function settingsEncryptionAvailable() {
return settingsCryptoKey() !== false;
}
// 当前实际使用的加密算法名(设置页展示用)
function settingsCipherLabel() {
if (!settingsEncryptionAvailable()) return '';
return in_array('aes-256-gcm', openssl_get_cipher_methods(), true)
? 'AES-256-GCM'
: 'AES-256-CBC + HMAC-SHA256';
}
// 是否发生过解密失败(密钥丢失/损坏)
function settingsDecryptFailed() {
$flags = &settingsCryptoFlags();
return $flags['decrypt_failed'];
}
/**
* 加密一个值。优先 AES-256-GCM(AEAD),老环境退化为 AES-256-CBC + HMAC-SHA256。
* @return string|null null 表示加密不可用
*/
function settingsEncrypt($plain) {
$plain = (string)$plain;
if ($plain === '') return '';
$key = settingsCryptoKey();
if ($key === false) return null;
$useGcm = in_array('aes-256-gcm', openssl_get_cipher_methods(), true);
if ($useGcm) {
$iv = random_bytes(12);
$tag = '';
$ct = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
if ($ct === false) return null;
return SETTINGS_ENC_PREFIX . 'gcm:' . base64_encode($iv . $tag . $ct);
}
$iv = random_bytes(16);
$ct = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
if ($ct === false) return null;
$mac = hash_hmac('sha256', $iv . $ct, $key, true);
return SETTINGS_ENC_PREFIX . 'cbc:' . base64_encode($iv . $mac . $ct);
}
/**
* 解密一个值。
* 非密文(历史遗留的明文)原样返回,便于无损升级;
* 密文但解不开时返回 null(调用方应视为"未配置")。
*/
function settingsDecrypt($stored) {
$stored = (string)$stored;
if (strpos($stored, SETTINGS_ENC_PREFIX) !== 0) return $stored;
$key = settingsCryptoKey();
if ($key === false) {
$flags = &settingsCryptoFlags();
$flags['decrypt_failed'] = true;
return null;
}
$body = substr($stored, strlen(SETTINGS_ENC_PREFIX));
$sep = strpos($body, ':');
if ($sep === false) {
$flags = &settingsCryptoFlags();
$flags['decrypt_failed'] = true;
return null;
}
$algo = substr($body, 0, $sep);
$raw = base64_decode(substr($body, $sep + 1), true);
if ($raw === false) {
$flags = &settingsCryptoFlags();
$flags['decrypt_failed'] = true;
return null;
}
$fail = function () {
$flags = &settingsCryptoFlags();
$flags['decrypt_failed'] = true;
return null;
};
if ($algo === 'gcm') {
if (strlen($raw) < 12 + 16) return $fail();
$iv = substr($raw, 0, 12);
$tag = substr($raw, 12, 16);
$ct = substr($raw, 28);
$pt = openssl_decrypt($ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
return $pt === false ? $fail() : $pt;
}
if ($algo === 'cbc') {
if (strlen($raw) < 16 + 32 + 16) return $fail();
$iv = substr($raw, 0, 16);
$mac = substr($raw, 16, 32);
$ct = substr($raw, 48);
if (!hash_equals($mac, hash_hmac('sha256', $iv . $ct, $key, true))) return $fail();
$pt = openssl_decrypt($ct, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
return $pt === false ? $fail() : $pt;
}
return $fail();
}
// 是否为敏感设置项
function settingsIsSensitive($name) {
static $set = null;
if ($set === null) $set = array_flip(settingsSensitiveNames());
return isset($set[$name]);
}
// 直接落库(加密后写入),绕过内存缓存——仅供内部升级使用
function settingsRawPut($name, $value) {
try {
$enc = settingsEncrypt($value);
if ($enc === null) return false; // 加密不可用时宁可不动,避免写入不可解密的数据
$db = getDB();
$stmt = $db->prepare("INSERT OR REPLACE INTO settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)");
$stmt->execute([$name, $enc]);
return true;
} catch (PDOException $e) {
return false;
}
}
// 读取全部设置到静态缓存(每次请求只查一次库)
function &settingsCache() {
static $cache = null;
if ($cache === null) {
$cache = [];
try {
$db = getDB();
$rows = $db->query("SELECT name, value FROM settings");
$upgrade = [];
foreach ($rows as $row) {
$name = $row['name'];
$raw = (string)$row['value'];
if (!settingsIsSensitive($name)) {
$cache[$name] = $raw;
continue;
}
if (strpos($raw, SETTINGS_ENC_PREFIX) === 0) {
$plain = settingsDecrypt($raw);
// 解不开时按"未配置"处理:站点继续可用,只是需要重新填密钥
$cache[$name] = $plain === null ? '' : $plain;
} else {
// 历史明文:本次读到后自动升级为密文
$cache[$name] = $raw;
$upgrade[$name] = $raw;
}
}
foreach ($upgrade as $name => $plain) {
settingsRawPut($name, $plain);
}
} catch (PDOException $e) {
// 表尚未建立时退化为"全部使用默认值",不阻断页面
$cache = [];
}
}
return $cache;
}
// 读取单个设置
function getSetting($name, $default = null) {
$cache = &settingsCache();
return array_key_exists($name, $cache) ? $cache[$name] : $default;
}
// 读取布尔型设置
function getSettingBool($name, $default = false) {
$v = getSetting($name, null);
if ($v === null || $v === '') return $default;
return $v === '1' || $v === 1 || $v === true;
}
// 读取 JSON 型设置(解析失败时返回 $default)
function getSettingJson($name, $default = []) {
$raw = getSetting($name, null);
if ($raw === null || $raw === '') return $default;
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : $default;
}
// 写入单个设置(敏感项自动加密后落库,内存缓存里始终是明文)
function setSetting($name, $value) {
$value = (string)$value;
$store = $value;
if (settingsIsSensitive($name)) {
$enc = settingsEncrypt($value);
// 加密不可用时退回明文,保证功能可用(设置页会显著告警)
if ($enc !== null) $store = $enc;
}
$db = getDB();
$stmt = $db->prepare("INSERT OR REPLACE INTO settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)");
$stmt->execute([$name, $store]);
$cache = &settingsCache();
$cache[$name] = $value;
}
// 写入 JSON 型设置
function setSettingJson($name, array $value) {
setSetting($name, json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
// 删除单个设置
function deleteSetting($name) {
$db = getDB();
$stmt = $db->prepare("DELETE FROM settings WHERE name = ?");
$stmt->execute([$name]);
$cache = &settingsCache();
unset($cache[$name]);
}
// ===== 存储相关设置的便捷读取 =====
// 当前生效的存储驱动标识:local / oss / cos / qiniu
function currentStorageDriver() {
$d = getSetting('storage_driver', 'local');
return in_array($d, ['local', 'oss', 'cos', 'qiniu'], true) ? $d : 'local';
}
// 全部驱动的配置(形如 ['oss' => [...], 'cos' => [...]])
function allStorageConfigs() {
return getSettingJson('storage_config', []);
}
// 指定驱动的配置(合并默认值)
function storageConfigFor($driver) {
$defaults = [
'oss' => [
'auth_mode' => 'ak', // ak | ecs_role
'access_key_id' => '',
'access_key_secret' => '',
'bucket' => '',
'endpoint' => '', // 例:oss-cn-beijing.aliyuncs.com
'prefix' => '', // 对象前缀(桶内目录),可留空
'ram_role_name' => '', // 留空则自动发现
'custom_domain' => '', // 可选,CDN / 自定义域名
'internal_endpoint' => '1', // 服务端读取优先走内网 endpoint(同地域免流量费)
'https' => '1',
],
'cos' => [
'auth_mode' => 'ak', // ak | cvm_role
'secret_id' => '',
'secret_key' => '',
'bucket' => '', // 含 APPID,例:mybucket-1250000000
'region' => '', // 例:ap-beijing
'prefix' => '', // 对象前缀(桶内目录),可留空
'cam_role_name' => '', // 留空则自动发现
'custom_domain' => '',
'https' => '1',
],
'qiniu' => [
'access_key' => '',
'secret_key' => '',
'bucket' => '',
'region' => 'z0', // z0 华东 / z1 华北 / z2 华南 / na0 北美 / as0 亚太
'prefix' => '', // 对象前缀(桶内目录),可留空
'custom_domain' => '', // 七牛必须绑定域名才能访问
'https' => '1',
],
];
$cfg = allStorageConfigs();
$saved = isset($cfg[$driver]) && is_array($cfg[$driver]) ? $cfg[$driver] : [];
return array_merge($defaults[$driver] ?? [], $saved);
}
// 保存指定驱动的配置
function saveStorageConfig($driver, array $values) {
$cfg = allStorageConfigs();
$cfg[$driver] = $values;
setSettingJson('storage_config', $cfg);
}
// 所有驱动的显示名
function storageDriverLabels() {
return [
'local' => '本地硬盘',
'oss' => '阿里云 OSS',
'cos' => '腾讯云 COS',
'qiniu' => '七牛云 Kodo',
];
}