Files
SimpleNav/install.php
T
laoyang 286a01beb8 安装向导 install.php + data/ 防护文件入库
install.php(新增):
- 环境自检(PHP 版本 / PDO SQLite / mbstring / GD / 网络抓取 / fileinfo
  / data 与 uploads 可写性),缺 GD 只警告不阻断
- 输入校验:用户名格式、密码 ≥6 位、两次一致
- 建库 + 建管理员 + 写 data/install.lock
- 已存在管理员时拒绝安装(防删锁重装夺权);锁文件删掉也不认
- 装完展示数据库信息(类型/路径/大小/表内条目数)

data/ 防护文件入库(关键安全修复):
- 原 .gitignore 整目录排除 /data/,导致 data/.htaccess 永远进不了仓库,
  部署后 data/ 没有禁止 Web 访问的规则 —— nav.db 可被公网直接下载,
  里面是管理员密码哈希和全部站点数据
- 改为 /data/* + 白名单放行 .htaccess、index.html
2026-11-03 21:14:40 +08:00

311 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';
$dbExists = is_file(DB_PATH);
$hasAdmin = false;
if ($dbExists) {
try {
$hasAdmin = (int)db()->query('SELECT COUNT(*) FROM users')->fetchColumn() > 0;
} catch (Throwable $e) {
// 库文件存在但读不出来(损坏 / 不可写)—— 环境检查会报,这里不重复喊
}
}
$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>