Files
laoyang 3a3c5890cc 安装向导 + PHP 8.5 兼容修复
新增 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(首字符 {)。
2026-10-31 21:35:41 +08:00

372 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/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',
];
}