Files
laoyang 5b57e6a81e 安装完成后自动删除 install.php(不再让用户手动删)
用户要求:装完由程序自己删,不该让用户记着。

三级降级(删不掉也必须安全、且如实告知):
① 目录可写 → unlink 删除
② 删不掉 → 覆写成**纯静态 HTML** 404 页(不含 <?php)
③ 都失败 → 成功页明确提示需手动删 + 原因,不谎报成功

关键实现要点:
- 自删除放在安装 try/catch **之外**(if ($done))。它是清理动作,
  不属于安装事务;若留在 try 里抛异常会被 catch 误报成「安装失败」。
- 降级**不能写成 PHP 空壳**:实测 opcache / php -S 会缓存已编译脚本,
  写成 `<?php exit;` 后请求仍返回旧的「已安装」页(2289 字节),
  等于文件还是暴露安装页的入口。纯静态 HTML 才彻底。
- 补 opcache_invalidate,让残留文件立刻按新内容生效。

测试:
- _sn_selfdelete_e2e.py 19 项:删成功 / 目录不可写降级 / 删除失败不影响安装
- _sn_selfdelete_falsify.py 6 变体全精确命中
  (含「不自删但谎报」「降级写成 PHP」「删除失败就回滚」)
- install e2e 41 项、跳转 e2e 19 项回归全绿(适配自删除后的语义变化)

顺手修 e2e 自身缺陷:install.php 会被自删除吃掉,
快照改为存放在仓库内 .install.php.e2e_snapshot 并**无条件还原**。
2026-11-03 22:32:04 +08:00

391 lines
17 KiB
PHP
Executable File
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
/**
* 简航 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'];
}
}
}
/**
* 装完**自己删除自己**。
*
* 为什么不交给用户手动删:用户会忘。留着的 install.php 即使有「已有管理员就拒绝执行」
* 的守卫,也仍是攻击面(万一哪天守卫被改坏、或数据库被清空,就能重装夺权)。
* 能自动做的事不该让人记着。
*
* 三级降级(删不掉也必须让站是安全的、且如实告知):
* ① 目录可写 → unlink 删掉,最干净。
* ② 目录不可写但文件可写 → 把文件内容**截断成无意义的空壳**;
* 访问它只会得到空响应,同样失去威胁(这是没法删时的次优解)。
* ③ 都失败 → 返回原因,页面明确提示「请手动删除」,绝不谎报成功。
*
* @return array{ok:bool, mode:string, msg:string} mode: deleted|blanked|failed
*/
function sn_self_delete(string $file): array
{
if (!is_file($file)) {
return ['ok' => true, 'mode' => 'deleted', 'msg' => '文件已不存在'];
}
// ① 正常删除(关键在**所在目录**是否可写,与文件自身权限无关)
if (@unlink($file)) {
return ['ok' => true, 'mode' => 'deleted', 'msg' => '已自动删除'];
}
// ② 删不掉 → 覆写成**纯静态 HTML**(关键:不能写成 PHP)
//
// ⚠️ 实测教训:原先写成 `<?php ... exit;` 的空壳,**运行时无效** ——
// opcache / php -S 会缓存已编译的脚本,请求照样返回旧内容(实测 2289 字节的
// 「已安装」页),文件仍是暴露安装页的入口。
// 纯静态 HTML 不经过 PHP 编译,不做任何事,也不存在被缓存成旧逻辑的问题。
$blank = "<!doctype html>\n<html lang=\"zh-CN\"><head><meta charset=\"utf-8\">"
. "<title>404</title></head><body><h1>404</h1>"
. "<p>安装已完成,此文件已失效,可安全删除。</p></body></html>\n";
if (@file_put_contents($file, $blank, LOCK_EX) !== false) {
// 清掉可能存在的编译缓存,让残留文件立刻按新内容(静态)生效
if (function_exists('opcache_invalidate')) {
@opcache_invalidate($file, true);
}
return ['ok' => true, 'mode' => 'blanked',
'msg' => '目录不可写,已覆写为无害静态页(无法执行任何代码,但仍建议手动删除)'];
}
// ③ 彻底失败,如实上报
$why = '未知原因';
if (!is_writable(dirname($file))) {
$why = '目录 ' . dirname($file) . ' 不可写';
} elseif (!is_writable($file)) {
$why = '文件本身不可写';
}
return ['ok' => false, 'mode' => 'failed', 'msg' => '无法删除:' . $why];
}
/* =========================================================================
* 安装状态
* ========================================================================= */
$lockFile = DATA_PATH . '/install.lock';
// 与前台共用同一个判断源(functions.php 的 app_installed),避免两边口径不一致
$hasAdmin = app_installed();
$installed = is_file($lockFile) || $hasAdmin;
$err = '';
$done = false;
$delResult = null; // 自删除结果(仅安装成功那一轮有值)
/* =========================================================================
* 执行安装
* ========================================================================= */
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();
}
// 自删除**刻意放在 try/catch 之外**。
//
// 为什么:它是一次「清理动作」,不属于安装事务。如果在 try 里抛异常,
// 会被上面 catch 兜住并写进 $err —— 而那时账号其实已经建好了,
// 等于把「清理失败」误报成「安装失败」。
// sn_self_delete 内部全部用 @ 抑制且返回结构化结果,本身不会抛;
// 放在外面是双重保险,语义也更清楚。
if ($done) {
$delResult = sn_self_delete(__FILE__);
}
}
}
/* 装成功后统计一下库的信息(给用户看,也证明库真的建好了) */
$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}
.ok-tip strong{color:#128a3c}
.warn-tip strong{color:#b26a00}
.warn-tip code,.ok-tip code{background:rgba(128,128,128,.15);padding:1px 5px;border-radius:4px}
</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>
<?php if ($delResult !== null): ?>
<?php if ($delResult['ok']): ?>
<p class="tip ok-tip">
<strong>✓ 安装文件已自动处理:<?= e($delResult['msg']) ?></strong><br>
你不需要再做任何清理。
<?php if ($delResult['mode'] === 'blanked'): ?>
<br>(目录不可写所以没能物理删除,内容已清空成无害空壳;若想彻底删掉,
请给站点根目录写权限后手动 <code>rm install.php</code>。)
<?php endif; ?>
</p>
<?php else: ?>
<p class="tip warn-tip">
<strong>⚠ 安装文件没能自动删除:<?= e($delResult['msg']) ?></strong><br>
站点照常可用,但请手动执行:<code>rm <?= e(APP_PATH) ?>/install.php</code><br>
原因通常是站点根目录对 PHP 进程不可写。
</p>
<?php endif; ?>
<?php endif; ?>
<p class="tip">
后台默认已有一份示例板块和示例网站,进去之后可以随意删改、添加自己的链接。
</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>