diff --git a/config.php b/config.php index 1d2a25f..f095331 100644 --- a/config.php +++ b/config.php @@ -6,8 +6,25 @@ // 所有页面统一通过 require_once 'config.php' 引入本文件。 // ============================================ +// 错误报告策略。 +// +// 关键:PHP 的警告/弃用提示是**直接写进响应体**的。对返回 JSON 的接口 +// (fetch_movie.php、storage_api.php 等)来说,一条 Warning 插在 JSON 前面 +// 就会让前端 JSON.parse 失败,表现为「功能静默失灵」——联想下拉空着、 +// 存储测试报连接失败,都可能是这个原因,而接口本身其实是好的。 +// +// 本项目踩过:PHP 8.5 把 curl_close() 标为 deprecated,一行提示就把 +// 「电影名联想」整个搞挂了(接口返回的 JSON 完全正确,只是前面多了警告)。 +// +// 因此: +// - 永远不把错误显示给浏览器(display_errors=0); +// - 仍然记录到日志(log_errors=1),排查时看 PHP 错误日志; +// - 需要临时调试时,设环境变量 MOVIELOG_DEBUG=1 即可把错误显示打开。 +$__movielog_debug = getenv('MOVIELOG_DEBUG') === '1'; error_reporting(E_ALL); -ini_set('display_errors', 1); +ini_set('display_errors', $__movielog_debug ? '1' : '0'); +ini_set('log_errors', '1'); +unset($__movielog_debug); session_start(); @@ -38,6 +55,27 @@ foreach ([POSTER_DIR, TICKET_DIR, PHOTO_DIR, AVATAR_DIR] as $dir) { } } +// ===== 安装检查(必须在加载任何业务模块、建库之前)===== +// +// 未安装时:把请求引到 install.php,并立刻退出 —— 绝不能继续往下走到 initDB()。 +// 判据只看锁文件 db/.installed,由 install.php 装完时写入。 +// +// 为什么不能「先 initDB() 再判断」: +// initDB() 会建库建表,并在 admins 为空时插入 admin/123456。 +// 那样「打开任意页面」这个动作就等于完成了一半安装,还会把弱口令暴露出去 +// (FeedLite 踩过同一个坑:访问动作本身建库 → 「未安装」永远判不出来)。 +// +// 例外:install.php 自身、以及 css/js 等静态资源不受此检查约束 +// (install.php 不加载本文件;静态资源由 Web 服务器直接返回,不经过 PHP)。 +if (!is_file(ROOT_DIR . '/db/.installed')) { + // 当前脚本名(兼容子目录部署) + $self = basename($_SERVER['SCRIPT_NAME'] ?? ''); + if ($self !== 'install.php' && PHP_SAPI !== 'cli') { + header('Location: install.php'); + exit; + } +} + // ===== 功能模块 ===== require_once ROOT_DIR . '/lib/db.php'; // 数据库连接与初始化 require_once ROOT_DIR . '/lib/settings.php'; // 系统设置读写 @@ -50,4 +88,6 @@ require_once ROOT_DIR . '/lib/reviews.php'; // 评价与评价下的讨论 require_once ROOT_DIR . '/lib/stats.php'; // 统计与年度报告 // 初始化数据库 -initDB(); +// 走到这里说明已装过(上面的检查已拦截未安装的情况), +// 因此 seedDefaultAdmin 传 false:老库本来就有人,空库也不该被塞一个 admin/123456。 +initDB(false); diff --git a/fetch_movie.php b/fetch_movie.php index efaf7ad..602385e 100644 --- a/fetch_movie.php +++ b/fetch_movie.php @@ -79,7 +79,9 @@ function httpGet($url, $referer = '', $accept = '') { $body = curl_exec($ch); $http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); - curl_close($ch); + // 不调用 curl_close():PHP 8.0 起句柄由 GC 回收,该函数自 8.5 起被标记 deprecated。 + // 一旦调用,8.5 会往输出里插一行 Deprecated 警告,把本接口的 JSON 响应污染成 + // 「警告 + JSON」,前端 JSON.parse 直接失败 —— 联想功能就是因此挂掉的。 if ($body === false) { return ['ok' => false, 'http' => $http, 'body' => '', 'err' => $err ?: '请求失败']; diff --git a/install.php b/install.php new file mode 100644 index 0000000..a7b14dc --- /dev/null +++ b/install.php @@ -0,0 +1,506 @@ +prepare("SELECT COUNT(*) FROM settings WHERE name = ?"); + $stmt->execute([$name]); + if ((int)$stmt->fetchColumn() > 0) { + $up = $db->prepare("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?"); + return $up->execute([$value, $name]); + } + $ins = $db->prepare("INSERT INTO settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)"); + return $ins->execute([$name, $value]); +} + +// 已安装?——只认锁文件。锁在 = 装过了。 +// 注意:不拿「库文件存在」当判据 —— 库可能是空壳(建库失败、被人为清空), +// 那种情况应该允许重新安装,而不是把用户挡在门外。 +function alreadyInstalled() { + return is_file(INSTALL_LOCK); +} + +function abort404() { + http_response_code(404); + header('Content-Type: text/html; charset=utf-8'); + echo '' + . '404 Not Found' + . '

404 Not Found

页面不存在。

'; + exit; +} + +// ---------- 环境体检 ---------- +// 每项:['name'=>显示名, 'ok'=>bool, 'need'=>是否硬性要求, 'hint'=>不通过时的补救说明] +function runChecks() { + $checks = []; + + // PHP 版本:代码用了 PASSWORD_DEFAULT、箭头函数等,7.4 起可跑 + $checks[] = [ + 'name' => 'PHP 版本 ≥ 7.4', + 'ok' => version_compare(PHP_VERSION, '7.4.0', '>='), + 'need' => true, + 'hint' => '当前 ' . PHP_VERSION . ',请升级 PHP。', + ]; + + // 扩展:pdo_sqlite 是硬要求;openssl 用于设置项加密(缺了仍能跑,只是云密钥无法加密); + // fileinfo 用于本机存储模式的图片内容校验;mbstring 处理中文更稳。 + $exts = [ + 'pdo_sqlite' => ['PDO SQLite 驱动', true, '数据库必需。'], + 'openssl' => ['OpenSSL', true, '用于加密保存云存储密钥。'], + 'fileinfo' => ['Fileinfo', false, '本机存储模式下按内容校验图片类型;缺失则退化为扩展名判断。'], + 'mbstring' => ['Mbstring', false, '中文截断/编码处理;缺失一般不影响主流程。'], + ]; + foreach ($exts as $ext => $cfg) { + $checks[] = [ + 'name' => '扩展 ' . $cfg[0] . '(' . $ext . ')', + 'ok' => extension_loaded($ext), + 'need' => $cfg[1], + 'hint' => $cfg[2], + ]; + } + + // 目录可写:db/(建库+密钥)、uploads/ 四个子目录(存图) + $dirs = [ + ROOT_DIR . '/db' => '数据库目录 db/', + POSTER_DIR => '海报目录 uploads/posters/', + TICKET_DIR => '票根目录 uploads/tickets/', + PHOTO_DIR => '照片目录 uploads/photos/', + AVATAR_DIR => '头像目录 uploads/avatars/', + ]; + foreach ($dirs as $dir => $label) { + $exists = is_dir($dir); + $writable = $exists && is_writable($dir); + $checks[] = [ + 'name' => $label . ' 可写', + 'ok' => $writable, + 'need' => true, + 'hint' => $exists + ? '目录存在但不可写,请给 Web 用户写权限(属主 www,或 chmod 775)。' + : '目录不存在且无法自动创建,请手动建好并授权。', + ]; + } + + return $checks; +} + +// 体检是否放行(硬性项全过) +function checksPass(array $checks) { + foreach ($checks as $c) { + if ($c['need'] && !$c['ok']) return false; + } + return true; +} + +// 尽力创建运行所需目录 +function ensureDirs() { + $dirs = [ROOT_DIR . '/db', POSTER_DIR, TICKET_DIR, PHOTO_DIR, AVATAR_DIR]; + $failed = []; + foreach ($dirs as $d) { + if (!is_dir($d)) { + if (!@mkdir($d, 0755, true) && !is_dir($d)) { + $failed[] = $d; + } + } + } + return $failed; +} + +// ---------- 入口 ---------- +if (alreadyInstalled()) { + abort404(); // 装过了就当它不存在 +} + +$createdDirs = ensureDirs(); +$checks = runChecks(); +$canInstall = checksPass($checks); + +$errors = []; // 全局错误 +$fieldErrors = []; // 字段级错误 +$old = ['site_name' => '', 'username' => '']; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $old['site_name'] = trim((string)($_POST['site_name'] ?? '')); + $old['username'] = trim((string)($_POST['username'] ?? '')); + $password = (string)($_POST['password'] ?? ''); + $password2 = (string)($_POST['password2'] ?? ''); + + if (!$canInstall) { + $errors[] = '环境检查未通过,请先解决下面标红的问题再安装。'; + } + + // 站点名:存进 settings,用于页面标题(可留空,留空用默认「电影日志」) + if ($old['site_name'] !== '' && charLen($old['site_name']) > 40) { + $fieldErrors['site_name'] = '站点名称最多 40 个字。'; + } + + // 管理员账号 + if ($old['username'] === '') { + $fieldErrors['username'] = '请填写管理员账号。'; + } elseif (!preg_match('/^[A-Za-z0-9_\x{4e00}-\x{9fa5}]{2,20}$/u', $old['username'])) { + $fieldErrors['username'] = '账号 2–20 位,可用中文、字母、数字、下划线。'; + } elseif (in_array(strtolower($old['username']), ['admin', 'administrator', 'root'], true) + && $password === '123456') { + // 明确拦住「admin + 123456」这个最容易被扫的弱口令组合 + $fieldErrors['username'] = '请勿使用 admin/123456 这类默认弱口令,换一个账号或改密码。'; + } + + // 密码 + if ($password === '') { + $fieldErrors['password'] = '请设置密码。'; + } elseif (charLen($password) < 6) { + $fieldErrors['password'] = '密码至少 6 位。'; + } elseif ($password !== $password2) { + $fieldErrors['password2'] = '两次输入的密码不一致。'; + } + + if (!$errors && !$fieldErrors) { + try { + // 建表:require lib/db.php 只定义函数,不执行任何副作用。 + // initDB(false) 表示「不要种默认管理员」——管理员由本页按用户输入创建。 + require_once ROOT_DIR . '/lib/db.php'; + initDB(false); + + $db = getDB(); + $db->beginTransaction(); + + // 双保险:库若已有管理员(例如重复安装到旧库),不再新增,直接报错让人处理 + $cnt = (int)$db->query("SELECT COUNT(*) FROM admins")->fetchColumn(); + if ($cnt > 0) { + $db->rollBack(); + throw new RuntimeException('这个数据库里已经有管理员账号了。为避免覆盖,安装已中止。' + . '若确实要重装,请先备份并删除 db/movies.db。'); + } + + $hashed = password_hash($password, PASSWORD_DEFAULT); + $stmt = $db->prepare("INSERT INTO admins (username, password) VALUES (?, ?)"); + $stmt->execute([$old['username'], $hashed]); + $adminId = (int)$db->lastInsertId(); + + // 站点名写入 settings(表由 initDB 建好) + if ($old['site_name'] !== '') { + putSetting($db, 'site_name', $old['site_name']); + } + + // 存储方式默认 local(与 settings.php 的缺省一致) + putSetting($db, 'storage_type', 'local'); + + // 安装完成标记 + putSetting($db, 'installed_at', date('Y-m-d H:i:s')); + + $db->commit(); + + // 写锁文件(先写,再尝试自删;顺序反了会留下「已自删但没上锁」的窗口) + $lockOk = @file_put_contents( + INSTALL_LOCK, + "installed_at=" . date('c') . "\nadmin=" . $old['username'] . "\n" + ) !== false; + if ($lockOk) @chmod(INSTALL_LOCK, 0600); + + // 自删:成功则以后连文件都不存在;失败也无妨,上面的锁已生效 + $selfDeleted = @unlink(__FILE__); + + // 直接登入,省得用户再输一次 + if (session_status() === PHP_SESSION_NONE) { + @session_start(); + } + $_SESSION['admin_id'] = $adminId; + $_SESSION['admin_username'] = $old['username']; + $_SESSION['admin_theme'] = ''; + + // 安装成功页 + renderDone($old['site_name'], $old['username'], $lockOk, $selfDeleted); + exit; + + } catch (Throwable $e) { + $errors[] = '安装失败:' . $e->getMessage(); + } + } +} + +// ---------- 渲染 ---------- +function pageHead($title) { + ?> + + + + + + +<?= esc($title) ?> + + + + +
+ +
+ + + +
+
🎉
+

安装完成

+

已经可以开始记录观影了

+ +
+
站点名称
+
管理员
+
数据文件db/movies.db
+
安全锁
+
安装入口
+
+ + 进入系统 → + +

+ 接下来建议先去「系统设置」确认图片来源存储方式(默认存本机)。 +

+
+ + +
+ + + +
', array_map('esc', $errors)) ?>
+ + +
+

① 运行环境

+
+ +
+ + + + + +
+ +
+ +

② 站点

+
+ + + +
+ +
显示在登录页与导航栏,可以后面再改。
+ +
+ +

③ 管理员账号

+
+ + + +
+ +
2–20 位,可用中文、字母、数字、下划线。
+ +
+ +
+ + + +
+ +
+ +
+ + + +
+ +
+ +
+ +
+
+ +

+ 装好后本页面会自动删除,再次访问返回 404。
+ 数据库与图片都在站点目录内,迁移时记得一起带走。 +

+
+prepare("UPDATE viewers SET person_id = (SELECT id FROM persons WHERE persons.name = viewers.name) WHERE person_id IS NULL AND name IS NOT NULL"); $update->execute(); - // 默认管理员 - $stmt = $db->prepare("SELECT COUNT(*) FROM admins"); - $stmt->execute(); - if ($stmt->fetchColumn() == 0) { - $hashed = password_hash('123456', PASSWORD_DEFAULT); - $stmt = $db->prepare("INSERT INTO admins (username, password) VALUES (?, ?)"); - $stmt->execute(['admin', $hashed]); + // 默认管理员(仅当调用方允许时;安装向导会传 false,由用户自己设账号) + if ($seedDefaultAdmin) { + $stmt = $db->prepare("SELECT COUNT(*) FROM admins"); + $stmt->execute(); + if ($stmt->fetchColumn() == 0) { + $hashed = password_hash('123456', PASSWORD_DEFAULT); + $stmt = $db->prepare("INSERT INTO admins (username, password) VALUES (?, ?)"); + $stmt->execute(['admin', $hashed]); + } } } + +// 系统是否已完成安装。 +// 判据是锁文件 db/.installed(由 install.php 写入),**不看库文件是否存在** —— +// 空库、半成品库都该允许重新安装,用「库在不在」当判据会把用户永久挡在门外。 +function isInstalled() { + return is_file(ROOT_DIR . '/db/.installed'); +} diff --git a/lib/settings.php b/lib/settings.php index 66839b5..ffc6a82 100644 --- a/lib/settings.php +++ b/lib/settings.php @@ -244,6 +244,14 @@ function getSetting($name, $default = null) { 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); diff --git a/lib/storage/http.php b/lib/storage/http.php index b091ef6..c3ee086 100644 --- a/lib/storage/http.php +++ b/lib/storage/http.php @@ -104,7 +104,8 @@ function storageHttp($method, $url, array $headers = [], $body = null, $timeout $err = curl_error($ch); $errno = curl_errno($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); - curl_close($ch); + // 不调用 curl_close():PHP 8.0 起句柄由 GC 回收;8.5 起该函数被标记 deprecated, + // 调用会在输出里插入警告,污染本模块返回给前端的 JSON。 if ($out === false) { return [ diff --git a/login.php b/login.php index 4365ecd..a2772e2 100644 --- a/login.php +++ b/login.php @@ -44,7 +44,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { - 电影全纪录 - 登录 + <?= h(siteName()) ?> - 登录