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