Files
SimpleNav/install.php
T
laoyang ae75058de1 未安装时自动跳转安装向导(修「打开首页不跳转」)
现象:全新部署后打开域名,看到的是一个空导航站(没有任何提示),
必须自己猜 /install.php 这个网址才能安装。

原因:index.php 一进来就 db() 建库并渲染前台,全站没有任何
「还没装」的引导逻辑;install.php 只能靠手输网址触达。

改动:
- functions.php 新增 app_installed():全站唯一的「已安装」判断源
  (有管理员账号 = 已安装),内部 try/catch 兜住建库失败
- index.php 开头:未安装且 install.php 存在 → 302 到安装页
  · 只在 install.php 真存在时跳,用户装完删掉后自动失效,不会 404
- install.php 复用 app_installed(),删掉自己那套 is_file(DB_PATH) 推断,
  避免「首页说没装、install 说装了」的口径分裂

测试(都在 _tooling/):
- _sn_install_redirect_e2e.py 19 项:未安装跳转 / 已安装不跳 /
  install.php 删了不跳 / 已装拒重装,四态全覆盖
- _sn_install_redirect_falsify.py 6 变体全精确命中(含「无条件跳转→死循环」)
- _sn_install_e2e.py 41 项回归全绿(顺手修 6 条因新逻辑过时的断言)
2026-11-03 22:07:22 +08:00

304 lines
13 KiB
PHP
Executable File
Raw 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
/**
* 简航 SimpleNav —— 安装向导
*
* 用途:全新部署时一次性完成「环境自检 → 建库 → 创建管理员」。
* 装完会在 data/install.lock 写一个锁文件,之后任何人再访问本页都只会看到
* 「已安装」提示,不会再执行任何写操作。
*
* ⚠️ 安全须知:
* ① 本文件装完**建议直接从服务器删掉**(install.lock 只是第二道保险);
* ② data/ 目录必须禁止 Web 访问(data/.htaccess 已拦,根 .htaccess 也拦了 ^data/);
* ③ 已存在管理员账号时,本页拒绝执行安装(防止有人删了 lock 就重装夺权)。
*/
declare(strict_types=1);
require_once __DIR__ . '/functions.php';
/* =========================================================================
* 环境自检
* ========================================================================= */
/**
* 判断目录能否创建文件(目录本身不存在时,看最近的已存在父目录是否可写)。
* 只做 check,不留下任何测试文件。
*/
function sn_dir_writable(string $dir): bool
{
if (is_dir($dir)) {
return is_writable($dir);
}
$p = dirname($dir);
while (!is_dir($p) && $p !== dirname($p)) {
$p = dirname($p);
}
return is_dir($p) && is_writable($p);
}
/** @return array<int,array{name:string,ok:bool,detail:string,required:bool}> */
function sn_env_checks(): array
{
$c = [];
$add = static function (string $name, bool $ok, string $detail, bool $required = true) use (&$c): void {
$c[] = ['name' => $name, 'ok' => $ok, 'detail' => $detail, 'required' => $required];
};
$add('PHP 版本 ≥ 8.0', PHP_VERSION_ID >= 80000, '当前 ' . PHP_VERSION . '(' . PHP_SAPI . ')');
$add('PDO SQLite 扩展', extension_loaded('pdo_sqlite'),
extension_loaded('pdo_sqlite') ? '已启用' : '未启用,无法建库(必需)');
$add('mbstring 扩展', extension_loaded('mbstring'),
extension_loaded('mbstring') ? '已启用' : '未启用,中文长度校验会失效(必需)');
$add('GD 扩展', extension_loaded('gd'),
extension_loaded('gd') ? '已启用' : '未启用,无法抓取/缩放网站图标;仍可安装,但图标只能显示首字母块', false);
$add('网络抓取能力', function_exists('curl_init') || (bool)ini_get('allow_url_fopen'),
function_exists('curl_init') ? 'curl 可用' : ((bool)ini_get('allow_url_fopen') ? '仅 allow_url_fopen' : '都没有,无法自动抓取网站名称/图标(建议开启)'));
$add('fileinfo 扩展', extension_loaded('fileinfo'),
extension_loaded('fileinfo') ? '已启用' : '未启用,自定义图标的类型识别会退化');
$dirs = [
DATA_PATH => '数据目录 data/(放 SQLite 数据库)',
ICON_PATH => '网站图标目录 uploads/icons/',
SITE_ICON_PATH => '站点图标目录 uploads/site/',
];
foreach ($dirs as $dir => $label) {
$rel = ltrim(str_replace(APP_PATH, '', $dir), '/\\');
$add($label, sn_dir_writable($dir), sn_dir_writable($dir) ? ($rel . ' 可写') : ($rel . ' 不可写,请给 Web 用户写权限'));
}
// 这两项只提醒,不阻断安装:即使漏了也能装,但数据文件会被公网下载
$dataHt = is_file(DATA_PATH . '/.htaccess');
$add('data/ 已禁止 Web 访问', $dataHt,
$dataHt ? 'data/.htaccess 存在' : '缺少 data/.htaccess,数据库可能被直接下载(强烈建议补上)', false);
return $c;
}
$checks = sn_env_checks();
$envOk = true;
$warnOnly = [];
foreach ($checks as $ck) {
if (!$ck['ok']) {
if ($ck['required']) {
$envOk = false;
} else {
$warnOnly[] = $ck['name'];
}
}
}
/* =========================================================================
* 安装状态
* ========================================================================= */
$lockFile = DATA_PATH . '/install.lock';
// 与前台共用同一个判断源(functions.php 的 app_installed),避免两边口径不一致
$hasAdmin = app_installed();
$installed = is_file($lockFile) || $hasAdmin;
$err = '';
$done = false;
/* =========================================================================
* 执行安装
* ========================================================================= */
if (!$installed && $_SERVER['REQUEST_METHOD'] === 'POST') {
$siteName = trim((string)($_POST['site_name'] ?? ''));
$u = trim((string)($_POST['username'] ?? ''));
$p = (string)($_POST['password'] ?? '');
$p2 = (string)($_POST['password2'] ?? '');
if (!$envOk) {
$err = '环境检查未全部通过,请先解决上面标红的问题';
} elseif ($u === '' || $p === '') {
$err = '用户名和密码都不能为空';
} elseif (!preg_match('/^[A-Za-z0-9_.\-]{2,32}$/', $u)) {
$err = '用户名只能用字母、数字、下划线、点、连字符,长度 2~32';
} elseif (mb_strlen($p) < 6) {
$err = '密码至少 6 位(建议 8 位以上,混合大小写和数字)';
} elseif ($p !== $p2) {
$err = '两次输入的密码不一致';
} else {
try {
$pdo = db(); // 触发建表 + 写入默认设置/搜索引擎/示例板块
// 双保险:并发下第二个请求别重复创建
if ((int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn() > 0) {
throw new RuntimeException('已有管理员账号,安装已中止。请直接登录。');
}
$st = $pdo->prepare('INSERT INTO users (username, password_hash, created_at) VALUES (?,?,?)');
$st->execute([$u, password_hash($p, PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
if ($siteName !== '') {
$pdo->prepare('INSERT INTO settings (k,v) VALUES (?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v')
->execute(['site_name', $siteName]);
}
// 锁文件:写不进去不算致命(已有管理员账号时本页本来就拒绝执行)
@file_put_contents($lockFile, date('c') . " installed\n");
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$_SESSION['uid'] = (int)$pdo->lastInsertId();
$done = true;
} catch (Throwable $e) {
$err = '安装失败:' . $e->getMessage();
}
}
}
/* 装成功后统计一下库的信息(给用户看,也证明库真的建好了) */
$dbInfo = null;
if ($done || $installed) {
try {
$pdo = db();
$cnt = static function (string $t) use ($pdo): int {
return (int)$pdo->query("SELECT COUNT(*) FROM {$t}")->fetchColumn();
};
$dbInfo = [
'path' => DB_PATH,
'size' => is_file(DB_PATH) ? (int)filesize(DB_PATH) : 0,
'groups' => $cnt('groups'),
'sites' => $cnt('sites'),
'engines' => $cnt('engines'),
'users' => $cnt('users'),
];
} catch (Throwable $e) {
$dbInfo = null;
}
}
$title = $installed ? '已安装' : '安装向导';
?><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($title) ?> · <?= e(APP_NAME) ?></title>
<link rel="stylesheet" href="<?= e(asset('assets/css/style.css')) ?>">
<link rel="stylesheet" href="<?= e(asset('assets/css/admin.css')) ?>">
<style>
.install-card{max-width:760px}
.install-card h1{font-size:22px;margin:0 0 4px}
.install-card .sub{margin:0 0 18px;color:var(--muted,#888);font-size:13px}
.env{width:100%;border-collapse:collapse;margin:0 0 18px;font-size:13px;text-align:left}
.env td{padding:7px 8px;border-bottom:1px solid rgba(128,128,128,.18);vertical-align:top}
.env td:first-child{width:26px;text-align:center;font-weight:700}
.env td:last-child{color:var(--muted,#777)}
.env .ok td:first-child{color:#128a3c}
.env .no td:first-child{color:#c62828}
.env .warn td:first-child{color:#b26a00}
.env .no td:last-child{color:#c62828}
.sec{margin:20px 0 10px;font-size:13px;font-weight:700;letter-spacing:.02em;opacity:.75}
.dbinfo{background:rgba(128,128,128,.09);border-radius:8px;padding:12px 14px;font-size:13px;margin:0 0 16px}
.dbinfo code{font-size:12px;word-break:break-all}
.dbinfo dl{margin:6px 0 0;display:grid;grid-template-columns:auto 1fr;gap:4px 14px}
.dbinfo dt{opacity:.7}
.dbinfo dd{margin:0;font-variant-numeric:tabular-nums}
.tip{font-size:12px;line-height:1.7;color:var(--muted,#777);margin:14px 0 0}
.tip strong{color:#c62828}
</style>
</head>
<body class="admin">
<div class="login-wrap">
<div class="login-card install-card">
<?php if ($installed): ?>
<h1><?= e(APP_NAME) ?> 已安装</h1>
<p class="sub">本系统已完成初始化,安装向导不会再执行任何写操作。</p>
<?php if ($dbInfo): ?>
<div class="dbinfo">
<strong>数据库信息</strong>
<dl>
<dt>类型</dt><dd>SQLite 3</dd>
<dt>文件</dt><dd><code><?= e($dbInfo['path']) ?></code></dd>
<dt>大小</dt><dd><?= number_format($dbInfo['size']) ?> 字节</dd>
<dt>内容</dt><dd><?= (int)$dbInfo['groups'] ?> 个板块 · <?= (int)$dbInfo['sites'] ?> 个网站 · <?= (int)$dbInfo['engines'] ?> 个搜索引擎 · <?= (int)$dbInfo['users'] ?> 个账号</dd>
</dl>
</div>
<?php endif; ?>
<a class="btn primary block" href="<?= e(url($hasAdmin ? 'login.php' : '')) ?>"><?= $hasAdmin ? '去登录' : '去首页' ?></a>
<p class="tip">
安全提示:<strong>请把服务器上的 install.php 删掉</strong>。<br>
虽然已有管理员账号时本页会拒绝执行安装,但删掉它能少一个入口。
</p>
<?php elseif ($done): ?>
<h1>安装完成</h1>
<p class="sub">数据库已创建,管理员账号已就绪。</p>
<?php if ($dbInfo): ?>
<div class="dbinfo">
<strong>数据库信息</strong>
<dl>
<dt>类型</dt><dd>SQLite 3(文件型,无需数据库账号密码)</dd>
<dt>文件</dt><dd><code><?= e($dbInfo['path']) ?></code></dd>
<dt>大小</dt><dd><?= number_format($dbInfo['size']) ?> 字节</dd>
<dt>内容</dt><dd><?= (int)$dbInfo['groups'] ?> 个板块 · <?= (int)$dbInfo['sites'] ?> 个网站 · <?= (int)$dbInfo['engines'] ?> 个搜索引擎 · <?= (int)$dbInfo['users'] ?> 个账号</dd>
</dl>
</div>
<?php endif; ?>
<a class="btn primary block" href="<?= e(url('admin/')) ?>">进入后台</a>
<p class="tip">
<strong>下一步(重要):把服务器上的 install.php 删掉。</strong><br>
后台默认已有一份示例板块和示例网站,进去之后可以随意删改、添加自己的链接。
</p>
<?php else: ?>
<h1><?= e(APP_NAME) ?> 安装向导</h1>
<p class="sub">本系统使用 <strong>SQLite</strong>:数据库是一个文件,不需要你填写数据库地址/账号/密码,向导会自动创建。</p>
<?php if ($err): ?><div class="alert"><?= e($err) ?></div><?php endif; ?>
<div class="sec">一、环境检查</div>
<table class="env">
<?php foreach ($checks as $ck): ?>
<tr class="<?= $ck['ok'] ? 'ok' : ($ck['required'] ? 'no' : 'warn') ?>">
<td><?= $ck['ok'] ? '✓' : ($ck['required'] ? '✕' : '!') ?></td>
<td><?= e($ck['name']) ?><?= $ck['required'] ? '' : '(建议)' ?></td>
<td><?= e($ck['detail']) ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php if (!$envOk): ?>
<div class="alert">标 ✕ 的项必须先解决才能安装。若目录不可写,请用 root 执行:<br>
<code>chown -R www:www <?= e(APP_PATH) ?>/data <?= e(APP_PATH) ?>/uploads</code></div>
<?php else: ?>
<div class="sec">二、数据库</div>
<div class="dbinfo">
<dl>
<dt>类型</dt><dd>SQLite 3(PHP 内置,无需外部数据库服务)</dd>
<dt>文件</dt><dd><code><?= e(DB_PATH) ?></code></dd>
<dt>状态</dt><dd><?= is_file(DB_PATH) ? '文件已存在,将沿用并在其基础上初始化' : '尚不存在,安装时自动创建' ?></dd>
</dl>
</div>
<div class="sec">三、站点与管理员</div>
<form method="post" action="">
<input type="text" name="site_name" placeholder="站点名称(留空用默认「简航」)" maxlength="40" autocomplete="off">
<input type="text" name="username" placeholder="管理员用户名" required maxlength="32" autocomplete="username">
<input type="password" name="password" placeholder="管理员密码(至少 6 位)" required autocomplete="new-password">
<input type="password" name="password2" placeholder="再输一次密码" required autocomplete="new-password">
<button type="submit" class="btn primary block">开始安装</button>
</form>
<p class="tip">
安装会创建数据表,并写入一份<strong>示例板块与示例网站</strong>作为起点(可在后台随意删改)。<br>
装完请<strong>立刻删除服务器上的 install.php</strong>。
</p>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
</body>
</html>