安装完成后自动删除 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 并**无条件还原**。
This commit is contained in:
2026-11-03 22:32:04 +08:00
parent ae75058de1
commit 5b57e6a81e
+88 -1
View File
@@ -86,6 +86,60 @@ foreach ($checks as $ck) {
} }
} }
/**
* 装完**自己删除自己**。
*
* 为什么不交给用户手动删:用户会忘。留着的 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];
}
/* ========================================================================= /* =========================================================================
* 安装状态 * 安装状态
* ========================================================================= */ * ========================================================================= */
@@ -97,6 +151,7 @@ $installed = is_file($lockFile) || $hasAdmin;
$err = ''; $err = '';
$done = false; $done = false;
$delResult = null; // 自删除结果(仅安装成功那一轮有值)
/* ========================================================================= /* =========================================================================
* 执行安装 * 执行安装
@@ -145,6 +200,17 @@ if (!$installed && $_SERVER['REQUEST_METHOD'] === 'POST') {
} catch (Throwable $e) { } catch (Throwable $e) {
$err = '安装失败:' . $e->getMessage(); $err = '安装失败:' . $e->getMessage();
} }
// 自删除**刻意放在 try/catch 之外**。
//
// 为什么:它是一次「清理动作」,不属于安装事务。如果在 try 里抛异常,
// 会被上面 catch 兜住并写进 $err —— 而那时账号其实已经建好了,
// 等于把「清理失败」误报成「安装失败」。
// sn_self_delete 内部全部用 @ 抑制且返回结构化结果,本身不会抛;
// 放在外面是双重保险,语义也更清楚。
if ($done) {
$delResult = sn_self_delete(__FILE__);
}
} }
} }
@@ -198,6 +264,9 @@ $title = $installed ? '已安装' : '安装向导';
.dbinfo dd{margin:0;font-variant-numeric:tabular-nums} .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{font-size:12px;line-height:1.7;color:var(--muted,#777);margin:14px 0 0}
.tip strong{color:#c62828} .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> </style>
</head> </head>
<body class="admin"> <body class="admin">
@@ -245,8 +314,26 @@ $title = $installed ? '已安装' : '安装向导';
<a class="btn primary block" href="<?= e(url('admin/')) ?>">进入后台</a> <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 class="tip">
<strong>下一步(重要):把服务器上的 install.php 删掉。</strong><br>
后台默认已有一份示例板块和示例网站,进去之后可以随意删改、添加自己的链接。 后台默认已有一份示例板块和示例网站,进去之后可以随意删改、添加自己的链接。
</p> </p>