新增 install.php:全新部署时自动接管,用户只需上传文件、打开站点即进入
安装向导。安装页自己做三件事:
- 建 db/ 与 uploads/{posters,tickets,photos,avatars}/,权限调到 775
- 逐目录真写临时文件实测可写,不靠 is_writable 猜
- 建库、建管理员、写 db/.installed 锁、自删本文件
配套改动:
- config.php 把安装检查前置于建库之前,未安装一律 302 到 install.php。
原实现无条件调用 initDB(),导致「打开首页」就等于建库并种下 admin/123456。
- lib/db.php 的 initDB(\$seedDefaultAdmin = true);安装向导传 false,
管理员由用户自己设,绝不种弱口令。新增 isInstalled()。
- login.php / nav.php 接上 siteName(),站点名称不再只存不用。
- lib/settings.php 新增 siteName(),缺省回退「电影日志」。
PHP 8.5 兼容:
- 删掉 fetch_movie.php 与 lib/storage/http.php 的 curl_close()。
8.5 起该函数 deprecated,调用会往输出插一行警告,把 JSON 响应污染成
「警告 + JSON」,前端 JSON.parse 失败 → 电影名联想功能完全失灵。
- config.php 的 display_errors 由 1 改为 0(生产环境不应向浏览器吐错误),
错误仍写日志;需要调试时设 MOVIELOG_DEBUG=1 打开。
线上验证(movie.yangyq.net,PHP 8.5.12):
未安装时 / 、/login.php 均 302 → install.php;
安装页 200、体检 10/10 通过、自动建好 5 个目录;
安装后 install.php 404、管理员登录成功、首页 200 无报错;
联想接口带会话请求「流浪地球/星际穿越/霸王别姬」均返回纯 JSON(首字符 {)。
372 lines
13 KiB
PHP
372 lines
13 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;
|
||
}
|
||
|
||
// 站点名称(安装向导里填的那个)。
|
||
// 没填过 / 装的是老库(settings 里没有这一项)时,退回默认「电影日志」——
|
||
// 这样任何页面的标题都不会因为缺这一项而空掉。
|
||
function siteName() {
|
||
$v = trim((string)getSetting('site_name', ''));
|
||
return $v !== '' ? $v : '电影日志';
|
||
}
|
||
|
||
// 读取布尔型设置
|
||
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',
|
||
];
|
||
}
|