Files

531 lines
24 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
declare(strict_types=1);
/**
* ============================================================================
* TripLog Web 安装向导(独立入口,不加载框架)
* ============================================================================
*
* 流程:
* 1) 已安装检测 —— config.php 存在且 db.name 非空 → 拒绝再装(config.php 本身就是锁);
* 2) 环境检查 —— PHP 版本、扩展、目录可写;
* 3) 表单 —— 网站标题 / 家庭名称 / 数据库 / 高德 / 管理员;
* 4) 执行 —— 测库连接 → 跑 sql/schema.sql 全量建表 → src/Migrate.php 幂等迁移
* → 写入基本种子数据(家庭、管理员、节点类型、角色标签、出行方式);
* 5) 生成 config.php(临时文件 include 验证后 rename)→ 自动删除本文件。
*
* 安全设计:
* * 「已安装」以 config.php 为准,安装器重复访问无害;
* * 自删失败只提示手动删除,不阻塞使用;
* * 所有输出经 e() 转义;CSRF token;密码不明文回显;
* * config.php 写入前先写临时文件并 include 验证,失败即删,绝不留下半残配置。
*/
// ---------------------------------------------------------------------------
// 0) 已安装检测(最先做,不做任何环境假设)
// ---------------------------------------------------------------------------
$ROOT = __DIR__;
$CFG_FILE = $ROOT . '/config.php';
$installed = false;
if (is_file($CFG_FILE)) {
$cfgProbe = @include $CFG_FILE;
$installed = is_array($cfgProbe) && !empty($cfgProbe['db']['name'] ?? null);
unset($cfgProbe);
}
unset($CFG_FILE);
// ---------------------------------------------------------------------------
// 1) 会话与工具
// ---------------------------------------------------------------------------
session_start();
if (empty($_SESSION['install_csrf'])) {
$_SESSION['install_csrf'] = bin2hex(random_bytes(16));
}
/** HTML 转义 */
function e(string $s): string
{
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
/** 手机号加密 —— 必须与 src/Controllers/UserController::encrypt() 保持一致 */
function install_encrypt(string $plain): string
{
$key = getenv('APP_ENC_KEY') ?: 'family-trip-default-key-change-me';
$iv = random_bytes(16);
$cipher = openssl_encrypt($plain, 'AES-256-CBC', hash('sha256', $key, true), OPENSSL_RAW_DATA, $iv);
return base64_encode($iv . $cipher);
}
/** 环境检查:返回 [项名, 是否通过, 说明] 列表 */
function env_checks(string $root): array
{
$exts = ['pdo_mysql', 'openssl', 'gd', 'curl', 'mbstring', 'json', 'session'];
$rows = [];
$rows[] = ['PHP 版本 ≥ 7.4', version_compare(PHP_VERSION, '7.4.0', '>='), '当前 ' . PHP_VERSION];
foreach ($exts as $ext) {
$rows[] = ["扩展 {$ext}", extension_loaded($ext), extension_loaded($ext) ? '已加载' : '未安装'];
}
$rows[] = ['站点根目录可写(用于生成 config.php)', is_writable($root), is_writable($root) ? '可写' : '不可写'];
$rows[] = ['uploads/ 目录可写(用于存放照片)', is_writable($root . '/uploads'), is_writable($root . '/uploads') ? '可写' : '不可写'];
$rows[] = ['sql/schema.sql 存在', is_file($root . '/sql/schema.sql'), is_file($root . '/sql/schema.sql') ? '存在' : '缺失'];
return $rows;
}
/** 把 schema.sql 拆成可逐条执行的 SQL(剥离注释行、过滤空段) */
function schema_statements(string $root): array
{
$raw = (string)file_get_contents($root . '/sql/schema.sql');
$parts = preg_split('/;\s*[\r\n]+/', $raw) ?: [];
$out = [];
foreach ($parts as $part) {
// 去掉每段里以 -- 开头的注释行
$lines = preg_grep('/^\s*--/', explode("\n", $part), PREG_GREP_INVERT) ?: [];
$stmt = trim(implode("\n", $lines));
if ($stmt !== '' && stripos($stmt, 'CREATE TABLE') === 0) {
$out[] = $stmt . ';';
}
}
return $out;
}
// ---------------------------------------------------------------------------
// 2) 已安装:直接拒绝
// ---------------------------------------------------------------------------
if ($installed) {
?><!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TripLog 安装</title></head>
<body style="font-family:system-ui,-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;background:#f4f6fa;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;">
<div style="background:#fff;border-radius:14px;padding:40px 44px;max-width:460px;box-shadow:0 8px 30px rgba(30,50,90,.10);text-align:center;">
<div style="font-size:44px;">✅</div>
<h1 style="font-size:20px;margin:12px 0 8px;">TripLog 已安装</h1>
<p style="color:#6b7686;font-size:14px;line-height:1.7;margin:0 0 20px;">
检测到站点已完成安装(config.php 已存在)。<br>
为安全起见,安装向导已停用。<br>
如果 <code>install.php</code> 仍在站点目录中,请手动删除它。
</p>
<a href="index.php" style="display:inline-block;background:#3d6bf5;color:#fff;text-decoration:none;border-radius:8px;padding:10px 28px;font-size:14px;">进入首页</a>
</div></body></html><?php
exit;
}
// ---------------------------------------------------------------------------
// 3) 安装执行(POST)
// ---------------------------------------------------------------------------
$errors = [];
$values = [
'site_name' => '',
'family_name' => '我的家庭',
'db_host' => '127.0.0.1',
'db_port' => '3306',
'db_name' => '',
'db_user' => '',
'db_pass' => '',
'amap_key' => '',
'amap_sec' => '',
'amap_nav_src' => 'trip.example.com',
'admin_name' => '',
'admin_phone' => '',
];
$done = false;
$removed = false; // install.php 是否自删成功
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = (string)($_POST['_csrf'] ?? '');
if (!hash_equals($_SESSION['install_csrf'], $token)) {
$errors[] = '页面已过期,请刷新后重试。';
} else {
// ---- 收集(密码类不回填,单独处理)----
foreach ($values as $k => $_) {
if (isset($_POST[$k]) && is_string($_POST[$k])) {
$values[$k] = trim($_POST[$k]);
}
}
$adminPass = (string)($_POST['admin_pass'] ?? '');
$adminPass2 = (string)($_POST['admin_pass2'] ?? '');
$dbPass = $values['db_pass'];
// ---- 校验 ----
if ($values['site_name'] === '') {
$errors[] = '网站标题不能为空';
} elseif (mb_strlen($values['site_name']) > 50) {
$errors[] = '网站标题不能超过 50 个字符';
}
if ($values['family_name'] === '') {
$values['family_name'] = '我的家庭';
} elseif (mb_strlen($values['family_name']) > 30) {
$errors[] = '家庭名称不能超过 30 个字符';
}
if (!preg_match('/^\d{1,5}$/', $values['db_port']) || (int)$values['db_port'] < 1 || (int)$values['db_port'] > 65535) {
$errors[] = '数据库端口不正确';
}
if (!preg_match('/^[A-Za-z0-9_]+$/', $values['db_name'])) {
$errors[] = '数据库名只能包含字母、数字和下划线';
}
if ($values['db_user'] === '') {
$errors[] = '数据库用户名不能为空';
}
if ($values['admin_name'] === '') {
$errors[] = '管理员姓名不能为空';
}
if (!preg_match('/^1[3-9]\d{9}$/', $values['admin_phone'])) {
$errors[] = '管理员手机号格式不正确(11 位,1 开头)';
}
if (strlen($adminPass) < 6) {
$errors[] = '管理员密码至少 6 位';
}
if ($adminPass !== $adminPass2) {
$errors[] = '两次输入的管理员密码不一致';
}
if ($values['amap_nav_src'] === '') {
$values['amap_nav_src'] = 'trip.example.com';
}
// ---- 执行安装 ----
if (!$errors) {
$pdo = null;
try {
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
$values['db_host'], (int)$values['db_port'], $values['db_name']
);
$pdo = new PDO($dsn, $values['db_user'], $dbPass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
} catch (PDOException $ex) {
$code = (int)$ex->getCode();
$msg = $ex->getMessage();
if (strpos($msg, '1049') !== false || $code === 1049
|| strpos($msg, '1044') !== false || $code === 1044) {
// MySQL 8 对不存在的库报 1049,MariaDB 报 1044(Access denied)——
// 对用户来说两种情况都是「库没建好或账号不对」,合并成一条可操作的提示
$errors[] = '数据库不存在,或该用户无权访问「' . $values['db_name'] . '」:请先在 MySQL 里创建名为「'
. $values['db_name'] . '」的空数据库(CREATE DATABASE ' . $values['db_name']
. ' DEFAULT CHARACTER SET utf8mb4;)并把它的全部权限授予刚才填写的用户,再回到本页重试。';
} elseif (strpos($msg, '1045') !== false || $code === 1045) {
$errors[] = '无法连接数据库主机「' . $values['db_host'] . '」,请确认主机地址与端口。';
} else {
$errors[] = '数据库连接失败:' . $msg;
}
}
if ($pdo) {
try {
// ---- 建表(schema.sql 全量 + Migrate 增量,均幂等)----
$stmts = schema_statements($ROOT);
if (count($stmts) < 10) {
throw new RuntimeException('sql/schema.sql 内容异常(仅解析出 ' . count($stmts) . ' 条建表语句)');
}
foreach ($stmts as $stmt) {
$pdo->exec($stmt);
}
require $ROOT . '/src/Migrate.php';
\App\Migrate::run($pdo);
// ---- 种子数据(一个事务;均只在空表时插入,重复执行不重复)----
$pdo->beginTransaction();
$familyName = $values['family_name'];
$pdo->prepare('INSERT INTO families (name) VALUES (?)')->execute([$familyName]);
$familyId = (int)$pdo->lastInsertId();
$pdo->prepare('INSERT INTO users
(family_id, name, phone_encrypted, phone_hash, password_hash, role, status)
VALUES (?, ?, ?, ?, ?, ?, ?)')
->execute([
$familyId,
$values['admin_name'],
install_encrypt($values['admin_phone']),
hash('sha256', $values['admin_phone']),
password_hash($adminPass, PASSWORD_DEFAULT),
'admin',
'active',
]);
// 节点类型(内置,不可删除)
$insType = $pdo->prepare('INSERT IGNORE INTO node_types (family_id, name, icon, is_system) VALUES (?, ?, ?, 1)');
foreach ([
['游玩', '🎡'], ['用餐', '🍜'], ['住宿', '🏨'], ['赶路', '🚄'], ['休息', '☕'],
] as $t) {
$insType->execute([$familyId, $t[0], $t[1]]);
}
// 角色标签
$insTag = $pdo->prepare('INSERT IGNORE INTO role_tags (family_id, name, icon) VALUES (?, ?, ?)');
foreach ([
['司机', '🚗'], ['摄影担当', '📷'], ['向导', '🗺️'], ['财务', '💰'],
] as $t) {
$insTag->execute([$familyId, $t[0], $t[1]]);
}
// 出行方式:不含自驾两种(自驾需先在「车辆管理」里添加车辆,
// travel_modes.vehicle_id 外键指向车辆,空挂没有意义)
$insMode = $pdo->prepare('INSERT IGNORE INTO travel_modes (family_id, mode_type) VALUES (?, ?)');
foreach (['train', 'plane', 'bus', 'subway', 'taxi', 'bike', 'walk', 'ship', 'other'] as $m) {
$insMode->execute([$familyId, $m]);
}
$pdo->commit();
// ---- 生成 config.php(临时文件 → include 验证 → rename)----
$configArr = [
'db' => [
'host' => $values['db_host'],
'port' => (int)$values['db_port'],
'name' => $values['db_name'],
'user' => $values['db_user'],
'pass' => $dbPass,
'charset' => 'utf8mb4',
],
'app' => [
'name' => $values['site_name'],
'timezone' => 'Asia/Shanghai',
],
'amap' => [
'key' => $values['amap_key'],
'security_js' => $values['amap_sec'],
'nav_src' => $values['amap_nav_src'],
],
];
$php = "<?php\n"
. "/**\n"
. " * 由 TripLog 安装向导生成于 " . date('Y-m-d H:i:s') . "\n"
. " * 结构与 config.example.php 一致;手工调整后注意保持键完整。\n"
. " */\n"
. 'return ' . var_export($configArr, true) . ";\n";
$tmp = $ROOT . '/config.install-tmp.php';
if (@file_put_contents($tmp, $php) === false) {
throw new RuntimeException('无法写入配置文件(站点根目录不可写)。请把 Web 运行账号对该目录授予写权限后重试。');
}
$check = @include $tmp;
if (!is_array($check) || empty($check['db']['name']) || empty($check['app']['name'])) {
@unlink($tmp);
throw new RuntimeException('生成的配置文件校验未通过,已中止。请重试安装。');
}
if (!@rename($tmp, $ROOT . '/config.php')) {
@unlink($tmp);
throw new RuntimeException('配置文件落位失败(无法重命名为 config.php)。请检查站点根目录写权限后重试。');
}
// ---- 自删 install.php(失败不阻塞,config.php 已是锁)----
$removed = @unlink(__FILE__);
$done = true;
} catch (Throwable $ex) {
if ($pdo && $pdo->inTransaction()) {
$pdo->rollBack();
}
$errors[] = $ex instanceof PDOException
? '安装执行失败:' . $ex->getMessage()
: $ex->getMessage();
}
}
}
}
}
// ---------------------------------------------------------------------------
// 4) 结果页 / 表单页
// ---------------------------------------------------------------------------
$checks = env_checks($ROOT);
$envOk = true;
foreach ($checks as $c) {
if (!$c[1]) { $envOk = false; break; }
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>TripLog 安装向导</title>
<style>
* { box-sizing: border-box; }
body { margin:0; font-family:system-ui,-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;
background:#f4f6fa; color:#243041; font-size:14px; }
.wrap { max-width:640px; margin:0 auto; padding:36px 16px 60px; }
.head { text-align:center; margin-bottom:26px; }
.head .logo { font-size:0; }
.head .logo img { width:44px; height:44px; display:inline-block; }
.head h1 { font-size:22px; margin:8px 0 4px; }
.head p { color:#6b7686; margin:0; }
.card { background:#fff; border-radius:14px; padding:24px 26px; margin-bottom:18px;
box-shadow:0 8px 30px rgba(30,50,90,.10); }
.card h2 { font-size:15px; margin:0 0 14px; padding-bottom:10px; border-bottom:1px solid #eef1f6; }
.check-row { display:flex; align-items:center; gap:10px; padding:5px 0; }
.check-ok { color:#178a50; }
.check-bad { color:#c6362f; }
.check-note { color:#98a2b3; font-size:12px; margin-left:auto; }
.field { margin-bottom:14px; }
.field label { display:block; font-weight:600; margin-bottom:5px; }
.field label .req { color:#c6362f; }
.field input { width:100%; border:1px solid #d8dfe9; border-radius:8px; padding:9px 12px;
font-size:14px; background:#fbfcfe; }
.field input:focus { outline:none; border-color:#3d6bf5; background:#fff; }
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:0 14px; }
.hint { color:#8a94a6; font-size:12px; margin-top:4px; line-height:1.6; }
.btn { display:block; width:100%; background:#3d6bf5; color:#fff; border:0; border-radius:9px;
padding:12px; font-size:15px; font-weight:600; cursor:pointer; }
.btn:disabled { background:#b9c6dd; cursor:not-allowed; }
.btn-go { display:inline-block; background:#3d6bf5; color:#fff; text-decoration:none;
border-radius:8px; padding:11px 34px; font-size:15px; font-weight:600; }
.errors { background:#fdf1f0; border:1px solid #f3c2be; color:#a1322b; border-radius:9px;
padding:12px 16px; margin-bottom:18px; line-height:1.8; }
.okbox { text-align:center; padding:14px 0 4px; }
.okbox .big { font-size:48px; }
.okbox h1 { font-size:21px; margin:10px 0 6px; }
.okbox p { color:#6b7686; line-height:1.8; margin:0 0 22px; }
code { background:#eef2f8; border-radius:4px; padding:1px 6px; font-size:12.5px; }
</style>
</head>
<body>
<div class="wrap">
<div class="head">
<div class="logo"><img src="assets/icons/trip-log.svg" alt="" width="44" height="44"></div>
<h1>TripLog 安装向导</h1>
<p>家庭旅行日记 · 首次部署配置</p>
</div>
<?php if ($done): ?>
<div class="card">
<div class="okbox">
<div class="big">🎉</div>
<h1>安装完成!</h1>
<p>
数据表已创建,基本数据已就绪(节点类型、角色标签、出行方式)。<br>
请使用刚才设置的管理员手机号和密码登录。<br>
<?php if (!$removed): ?>
<span style="color:#c6362f;">⚠️ 安装文件 install.php 未能自动删除,请手动删除它。</span><br>
<?php endif; ?>
</p>
<a class="btn-go" href="index.php">开始使用</a>
</div>
</div>
<?php else: ?>
<?php if ($errors): ?>
<div class="errors">
<strong>安装未完成,请修正以下问题:</strong><br>
<?php foreach ($errors as $err): ?>· <?= e($err) ?><br><?php endforeach; ?>
</div>
<?php endif; ?>
<div class="card">
<h2>环境检查</h2>
<?php foreach ($checks as [$label, $ok, $note]): ?>
<div class="check-row">
<span class="<?= $ok ? 'check-ok' : 'check-bad' ?>"><?= $ok ? '✓' : '✗' ?></span>
<span><?= e($label) ?></span>
<span class="check-note"><?= e($note) ?></span>
</div>
<?php endforeach; ?>
</div>
<form method="post" autocomplete="off">
<input type="hidden" name="_csrf" value="<?= e($_SESSION['install_csrf']) ?>">
<div class="card">
<h2>站点信息</h2>
<div class="field">
<label>网站标题 <span class="req">*</span></label>
<input type="text" name="site_name" maxlength="50" required
placeholder="例如:我们一家的旅行日记"
value="<?= e($values['site_name']) ?>">
</div>
<div class="field">
<label>家庭名称</label>
<input type="text" name="family_name" maxlength="30"
placeholder="我的家庭" value="<?= e($values['family_name']) ?>">
<div class="hint">所有成员、行程、花销都归属这个家庭;之后还可以在后台调整。</div>
</div>
</div>
<div class="card">
<h2>数据库(MySQL)</h2>
<div class="hint" style="margin:0 0 12px;">
请先在 MySQL 中创建一个<strong>空的</strong>数据库,表结构由安装向导自动生成。
</div>
<div class="grid2">
<div class="field">
<label>主机 <span class="req">*</span></label>
<input type="text" name="db_host" required value="<?= e($values['db_host']) ?>">
</div>
<div class="field">
<label>端口 <span class="req">*</span></label>
<input type="text" name="db_port" required value="<?= e($values['db_port']) ?>">
</div>
</div>
<div class="field">
<label>数据库名 <span class="req">*</span></label>
<input type="text" name="db_name" required placeholder="trip"
value="<?= e($values['db_name']) ?>">
</div>
<div class="grid2">
<div class="field">
<label>用户名 <span class="req">*</span></label>
<input type="text" name="db_user" required value="<?= e($values['db_user']) ?>">
</div>
<div class="field">
<label>密码 <span class="req">*</span></label>
<input type="password" name="db_pass" autocomplete="new-password">
</div>
</div>
</div>
<div class="card">
<h2>高德开放平台(可稍后在 config.php 中补充)</h2>
<div class="hint" style="margin:0 0 12px;">
用于地图展示与地点搜索。<a href="https://lbs.amap.com/" target="_blank" rel="noopener">lbs.amap.com</a>
→ 控制台 → 应用管理 → 创建应用(Web端(JS API) + Web服务)后填写。
</div>
<div class="field">
<label>Key(Web端 JS API)</label>
<input type="text" name="amap_key" value="<?= e($values['amap_key']) ?>">
</div>
<div class="field">
<label>安全密钥(securityJsCode)</label>
<input type="text" name="amap_sec" value="<?= e($values['amap_sec']) ?>">
</div>
<div class="field">
<label>导航来源标识(src)</label>
<input type="text" name="amap_nav_src" value="<?= e($values['amap_nav_src']) ?>">
<div class="hint">仅用于高德侧的调用来源统计,填任意英数字串均可,通常填你的域名。</div>
</div>
</div>
<div class="card">
<h2>管理员账号</h2>
<div class="field">
<label>姓名 <span class="req">*</span></label>
<input type="text" name="admin_name" maxlength="50" required
value="<?= e($values['admin_name']) ?>">
</div>
<div class="field">
<label>手机号 <span class="req">*</span></label>
<input type="text" name="admin_phone" maxlength="11" required placeholder="11 位手机号(用于登录)"
value="<?= e($values['admin_phone']) ?>">
</div>
<div class="grid2">
<div class="field">
<label>密码 <span class="req">*</span></label>
<input type="password" name="admin_pass" minlength="6" required autocomplete="new-password">
</div>
<div class="field">
<label>确认密码 <span class="req">*</span></label>
<input type="password" name="admin_pass2" minlength="6" required autocomplete="new-password">
</div>
</div>
</div>
<button class="btn" type="submit" <?= $envOk ? '' : 'disabled' ?>>
<?= $envOk ? '开始安装' : '环境检查未通过,无法安装' ?>
</button>
</form>
<?php endif; ?>
</div>
</body>
</html>