Files
MovieLog_Server/install.php
T

506 lines
21 KiB
PHP
Raw Normal View History

2026-10-31 21:35:41 +08:00
<?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();