Files
SimpleNav/install.php
T

391 lines
17 KiB
PHP
Raw Normal View History

<?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>