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

506 lines
21 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
// ============================================
// 文件: install.php
// 说明: 安装向导。全新部署时访问站点,会被自动引到这里;
// 填完站点名与管理员账号即完成初始化,然后进入系统。
//
// ⚠️ 为什么本文件**不 require config.php**:
// config.php 末尾无条件调用 initDB(),而 initDB() 会建库、建表、
// 并在 admins 为空时插入默认管理员 admin/123456。
// 如果安装页也走 config.php,那么「打开安装页」这个动作本身就把库建出来了,
// 「是否已安装」永远判不出来,安装向导永远进不去。
// (同一类坑在 FeedLite 上踩过:e2e 因此全 FAIL、源码目录凭空多出 db 文件。)
// 所以本文件自己定义常量、自己按需建库,绝不触发 config.php 的副作用。
//
// 安装完成后:
// 1) 写 db/.installed 锁文件(该目录已被 .gitignore 排除,不会进仓库);
// 2) 尝试自删本文件;删不掉(例如站点目录只读)也不影响安全,
// 因为锁文件已存在,再次访问一律 404。
// ============================================
// ---------- 最小引导(不加载 config.php)----------
define('ROOT_DIR', __DIR__);
define('DB_PATH', ROOT_DIR . '/db/movies.db');
define('INSTALL_LOCK', ROOT_DIR . '/db/.installed');
define('UPLOAD_DIR', ROOT_DIR . '/uploads/');
define('POSTER_DIR', UPLOAD_DIR . 'posters/');
define('TICKET_DIR', UPLOAD_DIR . 'tickets/');
define('PHOTO_DIR', UPLOAD_DIR . 'photos/');
define('AVATAR_DIR', UPLOAD_DIR . 'avatars/');
// 安装期把错误显示出来,方便诊断;但出口统一包 try/catch,不把堆栈抛给用户。
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
// ---------- 工具 ----------
function esc($s) {
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
// 字符数(不依赖 mbstring:缺 mbstring 时用正则数 UTF-8 码点,避免直接 Fatal)
function charLen($s) {
if (function_exists('mb_strlen')) {
return mb_strlen($s, 'UTF-8');
}
if (preg_match_all('/./us', $s, $m) !== false) {
return count($m[0]);
}
return strlen($s);
}
// 写一条 settings(不依赖 SQLite 版本:老库不一定支持 UPSERT 语法)
// 显式 INSERT,冲突时改 UPDATE,两条语句等效且兼容 3.20 以下。
function putSetting(PDO $db, $name, $value) {
$stmt = $db->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 '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">'
. '<title>404 Not Found</title></head><body>'
. '<h1>404 Not Found</h1><p>页面不存在。</p></body></html>';
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) {
?>
<!DOCTYPE html>
<html lang="zh-CN" data-theme="ticket">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex,nofollow">
<title><?= esc($title) ?></title>
<link rel="stylesheet" href="css/style.css">
<style>
/* 安装页自包含样式:直接复用登录页的深色玻璃语言,不依赖 themes.css
(全新部署时主题 CSS 可能还没生效,安装页必须自身完整可看)。 */
:root { --primary: #e67e22; --primary-rgb: 230,126,34; --primary-light: #f39c12; }
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; padding: 32px 16px;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: #fff;
}
.wrap { width: 100%; max-width: 560px; }
.card {
background: rgba(255,255,255,0.05);
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 24px; padding: 40px 36px;
box-shadow: 0 30px 80px rgba(0,0,0,0.5);
}
.logo { text-align: center; margin-bottom: 28px; }
.logo .icon { font-size: 48px; display: block; margin-bottom: 6px; }
.logo h1 { font-size: 26px; margin: 0; letter-spacing: 2px; font-weight: 700; }
.logo p { color: rgba(255,255,255,0.5); font-size: 14px; margin: 6px 0 0; }
h2.section {
font-size: 15px; font-weight: 600; color: rgba(255,255,255,0.85);
margin: 28px 0 12px; padding-bottom: 8px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
h2.section:first-of-type { margin-top: 0; }
.field { margin-bottom: 18px; }
.field label { display: block; font-size: 14px; font-weight: 500; color: rgba(255,255,255,0.7); margin-bottom: 6px; }
.field input {
width: 100%; padding: 13px 16px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 12px; color: #fff; font-size: 16px;
transition: all .25s ease; outline: none;
}
.field input:focus {
border-color: var(--primary);
background: rgba(255,255,255,0.12);
box-shadow: 0 0 0 4px rgba(var(--primary-rgb),0.15);
}
.field input::placeholder { color: rgba(255,255,255,0.3); }
.field .hint { font-size: 12.5px; color: rgba(255,255,255,0.4); margin-top: 6px; }
.field .err { font-size: 13px; color: #ff8a80; margin-top: 6px; }
.field.bad input { border-color: rgba(231,76,60,0.7); }
.btns { margin-top: 26px; }
.btn {
width: 100%; padding: 14px; border: none; border-radius: 12px;
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: #fff; font-size: 17px; font-weight: 600; cursor: pointer;
transition: all .25s ease; letter-spacing: 1px;
}
.btn:hover { transform: translateY(-2px); box-shadow: 0 10px 30px rgba(var(--primary-rgb),0.3); }
.btn:disabled { opacity: .45; cursor: not-allowed; transform: none; box-shadow: none; }
.btn-ghost {
display: inline-block; text-decoration: none; text-align: center;
padding: 13px 22px; border-radius: 12px; color: #fff;
background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.15);
font-weight: 600;
}
.btn-ghost:hover { background: rgba(255,255,255,0.16); }
.alert {
padding: 12px 16px; border-radius: 10px; font-size: 14px; margin-bottom: 18px;
background: rgba(231,76,60,0.18); border: 1px solid rgba(231,76,60,0.35); color: #ff8a80;
line-height: 1.6;
}
.checks { display: flex; flex-direction: column; gap: 2px; }
.check {
display: flex; align-items: flex-start; gap: 10px;
padding: 9px 12px; border-radius: 9px; font-size: 14px;
background: rgba(255,255,255,0.03);
}
.check .mark { flex: 0 0 auto; font-weight: 700; line-height: 1.5; }
.check.ok .mark { color: #4ade80; }
.check.warn .mark { color: #fbbf24; }
.check.bad .mark { color: #ff6b6b; }
.check .txt { flex: 1 1 auto; color: rgba(255,255,255,0.85); }
.check .sub { display: block; font-size: 12.5px; color: rgba(255,255,255,0.45); margin-top: 3px; }
.check.bad { background: rgba(231,76,60,0.12); }
.foot { text-align: center; margin-top: 22px; color: rgba(255,255,255,0.3); font-size: 12.5px; line-height: 1.7; }
.done-icon { text-align: center; font-size: 56px; margin-bottom: 8px; }
.done-title { text-align: center; font-size: 22px; font-weight: 700; margin: 0 0 8px; }
.done-sub { text-align: center; color: rgba(255,255,255,0.55); font-size: 14px; margin: 0 0 26px; }
.kv { font-size: 14px; background: rgba(255,255,255,0.04); border-radius: 12px; padding: 14px 18px; margin-bottom: 22px; }
.kv div { display: flex; gap: 10px; padding: 5px 0; }
.kv b { flex: 0 0 84px; color: rgba(255,255,255,0.55); font-weight: 500; }
.kv span { color: #fff; word-break: break-all; }
@media (max-width: 520px) {
.card { padding: 28px 20px; border-radius: 18px; }
.logo h1 { font-size: 22px; }
}
</style>
</head>
<body>
<div class="wrap">
<?php
}
function pageFoot() {
?>
</div>
</body>
</html>
<?php
}
function renderDone($siteName, $username, $lockOk, $selfDeleted) {
pageHead('安装完成 - 电影日志');
$title = $siteName !== '' ? $siteName : '电影日志';
?>
<div class="card">
<div class="done-icon">🎉</div>
<h1 class="done-title">安装完成</h1>
<p class="done-sub">已经可以开始记录观影了</p>
<div class="kv">
<div><b>站点名称</b><span><?= esc($title) ?></span></div>
<div><b>管理员</b><span><?= esc($username) ?></span></div>
<div><b>数据文件</b><span>db/movies.db</span></div>
<div><b>安全锁</b><span><?= $lockOk ? '已启用(db/.installed)' : '未写入,请检查 db/ 权限' ?></span></div>
<div><b>安装入口</b><span><?= $selfDeleted ? '已自动删除' : '未能自动删除(锁文件已拦截,可手动删)' ?></span></div>
</div>
<a class="btn-ghost" href="index.php" style="display:block;">进入系统 →</a>
<p class="foot">
接下来建议先去「系统设置」确认图片来源存储方式(默认存本机)。
</p>
</div>
<?php
pageFoot();
}
?>
<?php
// 正常渲染安装表单(走完上面的 POST 分支后仍在此处,说明安装未成功或首次进入)
pageHead('安装 - 电影日志');
?>
<div class="card">
<div class="logo">
<span class="icon">🎬</span>
<h1>电影日志</h1>
<p>首次使用,先做一点简单设置</p>
</div>
<?php if ($errors): ?>
<div class="alert"><?= implode('<br>', array_map('esc', $errors)) ?></div>
<?php endif; ?>
<form method="POST" action="">
<h2 class="section">① 运行环境</h2>
<div class="checks">
<?php foreach ($checks as $c):
$cls = $c['ok'] ? 'ok' : ($c['need'] ? 'bad' : 'warn');
$mark = $c['ok'] ? '✓' : ($c['need'] ? '✕' : '!');
?>
<div class="check <?= $cls ?>">
<span class="mark"><?= $mark ?></span>
<span class="txt">
<?= esc($c['name']) ?>
<?php if (!$c['ok']): ?><span class="sub"><?= esc($c['hint']) ?></span><?php endif; ?>
</span>
</div>
<?php endforeach; ?>
</div>
<h2 class="section">② 站点</h2>
<div class="field <?= isset($fieldErrors['site_name']) ? 'bad' : '' ?>">
<label>站点名称</label>
<input type="text" name="site_name" maxlength="40"
value="<?= esc($old['site_name']) ?>"
placeholder="电影日志(留空则用默认)">
<?php if (isset($fieldErrors['site_name'])): ?>
<div class="err"><?= esc($fieldErrors['site_name']) ?></div>
<?php else: ?>
<div class="hint">显示在登录页与导航栏,可以后面再改。</div>
<?php endif; ?>
</div>
<h2 class="section">③ 管理员账号</h2>
<div class="field <?= isset($fieldErrors['username']) ? 'bad' : '' ?>">
<label>账号</label>
<input type="text" name="username" autocomplete="username"
value="<?= esc($old['username']) ?>"
placeholder="例如 老杨">
<?php if (isset($fieldErrors['username'])): ?>
<div class="err"><?= esc($fieldErrors['username']) ?></div>
<?php else: ?>
<div class="hint">2–20 位,可用中文、字母、数字、下划线。</div>
<?php endif; ?>
</div>
<div class="field <?= isset($fieldErrors['password']) ? 'bad' : '' ?>">
<label>密码</label>
<input type="password" name="password" autocomplete="new-password"
placeholder="至少 6 位">
<?php if (isset($fieldErrors['password'])): ?>
<div class="err"><?= esc($fieldErrors['password']) ?></div>
<?php endif; ?>
</div>
<div class="field <?= isset($fieldErrors['password2']) ? 'bad' : '' ?>">
<label>确认密码</label>
<input type="password" name="password2" autocomplete="new-password"
placeholder="再输一次">
<?php if (isset($fieldErrors['password2'])): ?>
<div class="err"><?= esc($fieldErrors['password2']) ?></div>
<?php endif; ?>
</div>
<div class="btns">
<button type="submit" class="btn" <?= $canInstall ? '' : 'disabled' ?>>
<?= $canInstall ? '开始安装' : '请先解决环境问题' ?>
</button>
</div>
</form>
<p class="foot">
装好后本页面会自动删除,再次访问返回 404。<br>
数据库与图片都在站点目录内,迁移时记得一起带走。
</p>
</div>
<?php
pageFoot();