Files
SimpleNav/install.php
T

311 lines
13 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'];
}
}
}
/* =========================================================================
* 安装状态
* ========================================================================= */
$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>