Files
FeedLite/install.php
T
laoyang 7a578fac47 静态资源版本收敛到 FB_ASSET_V 常量 (#1)
原先 layout.php 与 install.php 各写一份 style.css?v=N,改样式时只改一处,
另一处会漂移 —— 实际就发生了:首页 v=10、安装页还是 v=7,两页样式不一致。

现在统一在 lib/bootstrap.php 定义 FB_ASSET_V,两处都引用它。
以后改 assets 下的 css/js,只改这一个数字。
2026-11-01 12:47:41 +08:00

397 lines
19 KiB
PHP
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
/**
* FeedLite · 安装向导
* ------------------------------------------------------------------
* 什么时候会用到:`data/` 下还没有数据库文件(全新部署)时,任何页面都会把
* 访问者送到这里。向导只做四件事:
* ① 体检:PHP 版本、必需扩展、目录可写性;
* ② 收信息:站点标题/描述、管理员口令、官方邮箱、站点根地址;
* ③ 写配置:生成 config.php(在 config.example.php 基础上逐项替换,
* 已有的 OSS / SMTP 段落原样保留,不覆盖你手工改过的内容);
* ④ 建库建表 + 把口令以 bcrypt 哈希写进数据库 ——
* **明文口令不落盘**,config.php 里始终保持出厂占位值。
*
* 装完之后:删除本文件与否都可以(已安装态下它会礼貌地拒绝重复安装),
* 但建议删掉,少一个入口少一分风险。
* ------------------------------------------------------------------
* 安装向导必须能在「尚未安装」的状态下运行,所以这里自己引导,
* 并显式声明跳过 bootstrap 的「未安装就跳 install」逻辑,否则会自己跳自己。
*/
define('FB_SKIP_INSTALL_REDIRECT', 1);
require __DIR__ . '/lib/bootstrap.php';
$root = FB_ROOT;
$cfgFile = $root . '/config.php';
/* ==================================================================
* 已安装 → 拒绝重复安装
* ================================================================== */
$already = fb_installed();
/* ==================================================================
* 环境体检
* ================================================================== */
function fb_install_checks() {
$need = ['pdo_sqlite' => 'SQLite 数据库(PDO)', 'openssl' => 'SMTP / OSS 加密通信'];
// 可选扩展:值是说明字符串;也可写成 [说明, 缺失时的状态文案, 缺失时的补救提示]
$opt = [
'curl' => '访问 OSS(可选,没有会退回 PHP 流式请求)',
'mbstring' => '中文字数统计(可选)',
'fileinfo' => [
'按文件内容识别附件类型(仅本机存储模式用得上)',
'未启用(不影响安装;本机存储时只按扩展名核对)',
'建议开启。注意 OSS 存储模式的类型判定以对象存储返回的 Content-Type 为准,不经过本扩展',
],
];
$dirs = [
FB_ROOT => '站点根目录(要写入 config.php)',
FB_ROOT . '/data' => '数据库目录 data/',
FB_ROOT . '/uploads' => '本机附件目录 uploads/(本机存储模式用)',
];
$rows = [];
$ok = true;
$verOk = version_compare(PHP_VERSION, '7.4.0', '>=');
$rows[] = ['PHP 版本', PHP_VERSION, $verOk, '需要 7.4 及以上(推荐 8.1~8.5,已在 8.5 实测)', $verOk ? '' : '当前版本过低,请切换站点的 PHP 版本'];
if (!$verOk) { $ok = false; }
foreach ($need as $ext => $why) {
$has = extension_loaded($ext);
if (!$has) { $ok = false; }
$rows[] = ['PHP 扩展:' . $ext, $has ? '已启用' : '未启用', $has, $why, $has ? '' : ('请在 PHP 设置里开启 ' . $ext . ' 扩展')];
}
foreach ($opt as $ext => $why) {
$note = '未启用(不影响安装)';
$hint = '';
if (is_array($why)) {
$note = isset($why[1]) ? $why[1] : $note;
$hint = isset($why[2]) ? $why[2] : '';
$why = isset($why[0]) ? $why[0] : '';
}
$has = extension_loaded($ext);
$rows[] = ['PHP 扩展:' . $ext, $has ? '已启用' : $note, true, $why, $has ? '' : $hint];
}
foreach ($dirs as $d => $why) {
if (!is_dir($d)) { @mkdir($d, 0755, true); }
$w = is_dir($d) && is_writable($d);
if (!$w) { $ok = false; }
$shown = ($d === FB_ROOT) ? '(站点根)' : str_replace(FB_ROOT, '', $d);
$rows[] = ['目录可写 ' . $shown, $w ? '可写' : '不可写', $w, $why, $w ? '' : ('请给 Web 用户写权限,例如 chown www:www ' . $d)];
}
// config.php 若已存在(例如从模板复制过),必须可写才能就地更新
if (is_file(FB_ROOT . '/config.php')) {
$w = is_writable(FB_ROOT . '/config.php');
if (!$w) { $ok = false; }
$rows[] = ['config.php 可写', $w ? '可写' : '不可写', $w, '需要就地更新配置', $w ? '' : '请给该文件写权限,例如 chown www:www config.php'];
} else {
$rows[] = ['config.php', '尚不存在(将自动生成)', true, '由安装向导创建', ''];
}
$ex = is_file(FB_ROOT . '/config.example.php');
$rows[] = ['配置模板 config.example.php', $ex ? '存在' : '缺失', $ex, '生成 config.php 的底稿', $ex ? '' : '仓库文件不完整,请重新获取源码'];
if (!$ex) { $ok = false; }
return [$ok, $rows];
}
/* ==================================================================
* 生成 config.php(在模板文本上逐项替换,保留注释与其它段落)
* ================================================================== */
function fb_install_write_config(array $v) {
$example = FB_ROOT . '/config.example.php';
$src = (string)@file_get_contents($example);
if ($src === '') { return '读不到 config.example.php。'; }
/** 把顶层键(含引号形式)整项替换成新值;找不到就返回原串由调用方兜底 */
$setTop = function ($key, $valueExpr, $txt) {
$pat = "/^([ \t]*'" . preg_quote($key, '/') . "'[ \t]*=>[ \t]*)(.*?)(,[ \t]*)(\r?\n)/m";
$rep = '$1' . $valueExpr . '$3$4';
return preg_replace($pat, $rep, $txt, 1);
};
$phpStr = function ($s) { return "'" . str_replace(["\\", "'"], ["\\\\", "\\'"], (string)$s) . "'"; };
$dbPath = str_replace('\\', '/', FB_ROOT . '/data/feedback.db');
$src = $setTop('db_path', "'" . $dbPath . "'", $src);
$src = $setTop('site_name', $phpStr($v['site_name']), $src);
$src = $setTop('site_desc', $phpStr($v['site_desc']), $src);
$src = $setTop('admin_email', $phpStr($v['admin_email']), $src);
$src = $setTop('base_url', $phpStr($v['base_url']), $src);
// 口令不写进文件:此处只把残留的旧明文重置为占位值(正常都是占位值,等价于不动)
$src = $setTop('admin_password', "'CHANGE_ME_admin'", $src);
$src = $setTop('api_token', $phpStr($v['api_token']), $src);
$src = $setTop('ip_salt', $phpStr($v['ip_salt']), $src);
// 兜底校验:生成的文本必须是可解析的 PHP。
// 注意必须带 TOKEN_PARSE —— 不加这一位 token_get_all 只做词法切分、不报语法错,等于没校验。
$syntaxOk = true;
if (defined('TOKEN_PARSE') && function_exists('token_get_all')) {
try { @token_get_all($src, TOKEN_PARSE); }
catch (Throwable $e) { $syntaxOk = false; }
}
if (!$syntaxOk) { return '生成的配置内容语法不正确,已放弃写入(请把 config.php 恢复为模板后再试)。'; }
$stamp = date('Y-m-d H:i:s');
$header = "<?php\n"
. "/**\n"
. " * FeedLite · 轻反馈 · 配置文件\n"
. " * ------------------------------------------------------------------\n"
. " * 本文件由安装向导于 " . $stamp . " 生成,已按你填写的内容更新;\n"
. " * 段落注释与 OSS / SMTP 等配置保持模板原样,可继续手工编辑。\n"
. " * 注意:管理员口令【不在这里】——它以 bcrypt 哈希存在数据库 settings 表,\n"
. " * 下行的 admin_password 只是工厂占位值(已被程序明确禁用,无法登录)。\n"
. " */\n\n";
$src = $header . preg_replace('/^<\?php\s*/', '', $src, 1);
$ok = @file_put_contents(FB_ROOT . '/config.php', $src);
if ($ok === false) { return '写入 config.php 失败(目录或文件不可写)。'; }
@chmod(FB_ROOT . '/config.php', 0640);
return '';
}
/** 随机串(口令 / 令牌 / 盐) */
function fb_install_rand($n = 32) {
try { return bin2hex(random_bytes($n)); } catch (Throwable $e) { return md5(uniqid('', true) . mt_rand()); }
}
/* ==================================================================
* 提交处理
* ================================================================== */
$err = '';
$done = false;
if (isset($_POST['do_install'])) {
list($envOk, ) = fb_install_checks();
$post = function ($k, $d = '') { return isset($_POST[$k]) ? trim((string)$_POST[$k]) : $d; };
$siteName = $post('site_name');
$siteDesc = $post('site_desc');
$admEmail = $post('admin_email');
$baseUrl = rtrim($post('base_url'), '/');
$admUser = $post('admin_user');
$pw = (string)(isset($_POST['admin_password']) ? $_POST['admin_password'] : '');
$pw2 = (string)(isset($_POST['admin_password2']) ? $_POST['admin_password2'] : '');
$setPw = !empty($_POST['set_password']);
if (!$envOk) {
$err = '环境体检未通过,请先按提示处理好上面标红的问题。';
} elseif (!fb_csrf_ok(isset($_POST['csrf']) ? (string)$_POST['csrf'] : '')) {
$err = '会话已过期,请重新提交。';
} elseif ($siteName === '' || fb_len($siteName) > 80) {
$err = '站点标题请填写 1~80 个字符。';
} elseif ($admEmail !== '' && !filter_var($admEmail, FILTER_VALIDATE_EMAIL)) {
$err = '官方邮箱格式不正确(也可以留空)。';
} elseif ($baseUrl !== '' && !preg_match('~^https?://~i', $baseUrl)) {
$err = '站点根地址需要以 http:// 或 https:// 开头(留空则自动探测)。';
} elseif ($admUser === '') {
$err = '请填写管理员用户名。';
} elseif (!preg_match('/^[A-Za-z0-9_.\-]{2,32}$/', $admUser)) {
$err = '管理员用户名只能用字母、数字、下划线、点、连字符,长度 2~32。';
} elseif (fb_len($pw) < 8) {
$err = '管理员口令至少 8 个字符。';
} elseif ($pw !== $pw2) {
$err = '两次输入的口令不一致。';
} else {
// ① 先建库建表 —— 后面写设置才有地方写
$pdo = fb_db();
// ② 写 config.php(口令不入文件)
$cfgErr = fb_install_write_config([
'site_name' => $siteName,
'site_desc' => ($siteDesc !== '' ? $siteDesc : (string)fb_cfg('site_desc', '')),
'admin_email' => $admEmail,
'base_url' => $baseUrl,
'api_token' => fb_install_rand(16),
'ip_salt' => fb_install_rand(16),
]);
if ($cfgErr === '') {
fb_cfg_flush(); // 让后续取值看到刚落盘的新 config.php
} else {
// config.php 写不进去不算致命:settings 表里的值同样生效,先把系统跑起来并如实告知
$err = '提示:' . $cfgErr . '(站内设置已写入数据库,系统可用;但建议修好后手工补齐 config.php)';
}
// ③ 站点信息入库(优先生效,且不依赖 config.php 是否可写)
fb_set_setting('site_name', $siteName);
if ($siteDesc !== '') { fb_set_setting('site_desc', $siteDesc); }
fb_set_setting('installed_at', time());
// ④ 管理员账号:用户名入库,口令只写 bcrypt 哈希,绝不落盘明文
$pwSet = false;
fb_admin_set_username($admUser);
if (fb_len($pw) >= 8 && $pw === $pw2) {
$pwSet = fb_admin_set_password($pw);
}
$done = true;
$GLOBALS['__fb_install_pw_set'] = $pwSet;
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>安装 · 轻反馈 FeedLite</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png">
<link rel="stylesheet" href="assets/style.css?v=<?php echo (int)FB_ASSET_V; ?>">
</head>
<body>
<header class="topbar">
<div class="wrap">
<span class="brand-mini">
<span class="logo-mark" role="img" aria-label="轻反馈图标"></span>
<span class="brand-text">轻反馈 FeedLite</span>
</span>
<div class="spacer"></div>
<span class="navlink hide-sm">安装向导</span>
</div>
</header>
<main class="wrap">
<?php if ($already && !$done): ?>
<section class="narrow">
<h1>已经装好了</h1>
<p class="muted">检测到数据库已存在,无需重复安装。</p>
<p>
<a class="btn primary" href="index.php">进入问题列表</a>
<a class="btn" href="admin.php">管理后台</a>
</p>
<div class="card-form" style="margin-top:18px">
<p class="muted small">如果你确实想重装(例如换了数据库位置),请先手动移走
<code>data/feedback.db</code> 再刷新本页。本向导不会替你删除任何数据。</p>
</div>
</section>
<?php elseif ($done): ?>
<?php
$pwSet = !empty($GLOBALS['__fb_install_pw_set']);
$justUser = fb_admin_username();
?>
<meta http-equiv="refresh" content="2;url=index.php">
<section class="narrow">
<h1>安装完成</h1>
<p class="muted">已建好数据库、写入站点设置。<b>2 秒后自动进入首页</b>(也可点下方按钮)。</p>
<div class="card-form" style="margin:14px 0">
<div style="display:flex;flex-direction:column;gap:8px">
<span><b>网站名称</b>:<?php echo e((string)fb_cfg('site_name', '')); ?></span>
<span><b>管理员用户名</b>:<?php echo e($justUser !== '' ? $justUser : '(未设置)'); ?></span>
<span><b>登录入口</b>:<a href="admin.php">admin.php</a>(用户名 + 口令)</span>
<span><b>口令存放</b>:bcrypt 哈希,存于数据库;<code>config.php</code> 里没有任何明文口令。</span>
</div>
</div>
<?php if ($err !== ''): ?><?php fb_flash_box($err, 'error'); ?><?php endif; ?>
<div class="card-form" style="margin:14px 0">
<p class="muted small" style="margin:0">
<b>建议:装好后把 <code>install.php</code> 从服务器删掉。</b>
它在已安装状态下会拒绝重装,但删掉更干净。
</p>
</div>
<p>
<a class="btn primary" href="index.php">进入首页</a>
<a class="btn" href="admin.php">管理后台</a>
</p>
</section>
<?php else: ?>
<?php list($envOk, $rows) = fb_install_checks(); ?>
<div class="page-head">
<div>
<h1>安装向导</h1>
<p class="muted">数据库目录 <code>data/</code> 下还没有数据,先完成这几步设置,装完即可使用。</p>
</div>
</div>
<?php if (!$envOk): ?>
<?php fb_flash_box('环境体检未通过:请先处理下方标红项,处理好后刷新本页。', 'error'); ?>
<?php endif; ?>
<h2 style="margin:22px 0 10px">一、环境体检</h2>
<div class="card-form" style="margin:0 0 18px">
<div style="display:flex;flex-direction:column;gap:6px">
<?php foreach ($rows as $r): ?>
<span>
<?php echo $r[2] ? '✅' : '❌'; ?>
<b><?php echo e($r[0]); ?></b>:<?php echo e($r[1]); ?>
<?php if (!empty($r[3])): ?><i class="hint">(<?php echo e($r[3]); ?>)</i><?php endif; ?>
<?php if (!empty($r[4])): ?><br><span class="muted small" style="margin-left:1.4em">↳ <?php echo e($r[4]); ?></span><?php endif; ?>
</span>
<?php endforeach; ?>
</div>
</div>
<h2 style="margin:22px 0 10px">二、初始化站点</h2>
<?php fb_flash_box($err, 'error'); ?>
<form method="post" action="install.php" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="site_desc" value="<?php echo e(isset($_POST['site_desc']) ? (string)$_POST['site_desc'] : ''); ?>">
<input type="hidden" name="admin_email" value="<?php echo e(isset($_POST['admin_email']) ? (string)$_POST['admin_email'] : ''); ?>">
<input type="hidden" name="base_url" value="<?php echo e(isset($_POST['base_url']) ? (string)$_POST['base_url'] : ''); ?>">
<input type="hidden" name="set_password" value="1">
<label class="field"><span>网站名称 <b class="req">*</b></span>
<input type="text" name="site_name" maxlength="80" required autofocus
value="<?php echo e(isset($_POST['site_name']) ? (string)$_POST['site_name'] : (string)fb_cfg('site_name', '')); ?>"
placeholder="例如:我的软件 · 问题反馈">
<i class="hint">显示在浏览器标签、页面顶栏和页脚。</i></label>
<h2 style="margin:20px 0 10px">管理员账号</h2>
<p class="muted small" style="margin:-4px 0 12px">
用来登录管理后台、以「官方」身份回复。<b>口令以 bcrypt 单向哈希存进数据库,
明文不会写进 config.php 或任何文件。</b>
</p>
<label class="field"><span>用户名 <b class="req">*</b></span>
<input type="text" name="admin_user" maxlength="32" required
value="<?php echo e(isset($_POST['admin_user']) ? (string)$_POST['admin_user'] : 'admin'); ?>"
placeholder="admin" autocomplete="username">
<i class="hint">字母、数字、下划线、点、连字符,2~32 位。</i></label>
<div class="grid2">
<label class="field"><span>口令 <b class="req">*</b></span>
<input type="password" name="admin_password" minlength="8" required autocomplete="new-password"></label>
<label class="field"><span>再输入一次 <b class="req">*</b></span>
<input type="password" name="admin_password2" minlength="8" required autocomplete="new-password"></label>
</div>
<div class="form-actions">
<button class="btn primary" type="submit" name="do_install" value="1"<?php echo $envOk ? '' : ' disabled'; ?>>完成安装,进入首页</button>
</div>
<?php if (!$envOk): ?>
<p class="muted small">环境体检未通过前无法提交,请先处理上方标红项。</p>
<?php endif; ?>
</form>
<div class="card-form" style="margin-top:18px">
<p class="muted small">
其余设置都已自动处理:站点地址按访问域名自动探测,密钥与令牌已自动生成。<br>
邮件通知(SMTP)与附件上传(OSS)在装好后到后台「站点设置」里配,这里不用填。<br>
<b>装完请删掉 install.php</b>(它本身也会拒绝重复安装)。
</p>
</div>
<?php endif; ?>
</main>
<footer class="foot">
<div class="wrap">
<p class="muted foot-line"><span>轻反馈 FeedLite</span><span class="dot">·</span><a href="<?php echo e(fb_credit_url_default()); ?>" target="_blank" rel="noopener">FeedLite</a></p>
</div>
</footer>
</body>
</html>