Files
MovieLog_Server/lib/settings.php
T
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

364 lines
12 KiB
PHP
Raw 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/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',
];
}