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
311 lines
13 KiB
PHP
Executable File
311 lines
13 KiB
PHP
Executable File
<?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>
|