Files
FeedLite/install.php
T

489 lines
23 KiB
PHP
Raw Normal View History

<?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 为准,不经过本扩展',
],
];
// 只有 data/ 与 uploads/ 是硬要求(数据库和附件必须能写)。
// 站点根不必可写:config.php 写不进去时,设置会落到数据库 settings 表,
// 站点照常运行。反过来,把站点根设成只读是更安全的做法,不该被判为错误。
$dirs = [
FB_ROOT . '/data' => '数据库目录 data/',
FB_ROOT . '/uploads' => '本机附件目录 uploads/(本机存储模式用)',
];
$rows = [];
$ok = true;
$verOk = version_compare(PHP_VERSION, '7.4.0', '>=');
2026-10-23 08:23:55 +08:00
$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); }
// 目录一建好就立刻补防护文件,不留"目录存在但无防护"的空窗期。
// data/ 用 fb_db_ensure_guard(),uploads/ 用 fb_local_ensure_dir(),
// 两者都在被保护目录里写 .htaccess + index.html。
if (basename($d) === 'data' && function_exists('fb_db_ensure_guard')) {
fb_db_ensure_guard($d);
} elseif (basename($d) === 'uploads' && function_exists('fb_local_ensure_dir')) {
fb_local_ensure_dir();
}
$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 时,站点设置会全部落到数据库 settings 表,功能不受影响。
$rootW = is_writable(FB_ROOT);
$rows[] = [
'目录可写(站点根)',
$rootW ? '可写' : '只读(不影响使用)',
true, // 不参与 $ok 判定
'用于生成 config.php;只读时设置改存数据库,功能一样',
$rootW ? '' : '站点根只读是可接受的(更安全)。若希望生成 config.php,再给它写权限。',
];
// config.php 若已存在(例如从模板复制过),只读也不影响运行,只影响能否就地更新
if (is_file(FB_ROOT . '/config.php')) {
$w = is_writable(FB_ROOT . '/config.php');
$rows[] = ['config.php 可写', $w ? '可写' : '只读(不影响使用)', true,
'需要就地更新配置时才有用',
$w ? '' : '只读可接受:后台改的设置会存数据库,不写这个文件。'];
} 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;
/* ---- 装完自动删除本文件 ----
* 安装向导本身是可写文件,留着等于长期暴露一个"重装入口"。
* 虽然已安装态会拒绝重装,但最干净的做法是装完即自毁。
*
* 删除失败不算安装失败:站点已经装好了,只是清理动作没成。
* 失败原因通常是文件属主不是 Web 用户(部署账号上传的文件常属 btops),
* 这时把实际情况记到 $GLOBALS 里,成功页如实告知,不假装删掉了。
*/
$GLOBALS['__fb_install_selfdel'] = fb_install_self_delete();
}
}
/**
* 安装完成后删除自身。
* @return string '' = 已删除;非空 = 没删掉的原因(给成功页显示)
*/
function fb_install_self_delete() {
$self = __FILE__;
if (!is_file($self)) { return ''; } // 已经不在了
/* ① 直接尝试删除。
*
* 🔴 这里绝不能先 chmod 目录!曾经写成 @chmod(dirname($self), 0755) 再删,
* 等于程序自己把"站点根只读"这个安全设置拆掉了 —— 用户有意设成只读的目录
* 被 PHP 改回可写,然后文件就被删掉了。既违反管理员意图,也让"删不掉"
* 这个真实场景永远测不出来。
* 删不掉就如实报告,不要硬删。
*/
if (@unlink($self)) { return ''; }
// ② 兜底:只有在目录可写时才试 rename(目录不可写时 rename 同样会失败,
// 硬试只会在站点里留下 .deleted.xxxx 垃圾文件)
if (is_writable(dirname($self))) {
$tmp = $self . '.deleted.' . substr(md5((string)time() . random_int(1000, 9999)), 0, 8);
if (@rename($self, $tmp)) {
if (@unlink($tmp)) { return ''; }
// 改名成功但删不掉:还原,别留垃圾
@rename($tmp, $self);
}
}
// ③ 都没成,给出可操作的原因
$dir = dirname($self);
$uid = function_exists('posix_getuid') ? posix_getuid() : -1;
$own = @fileowner($self);
$msg = '自动删除失败';
if (!is_writable($dir)) {
$msg .= ':' . $dir . ' 目录对 Web 用户不可写';
}
if ($uid >= 0 && $own !== false && $own !== $uid) {
$msg .= ':文件属主(uid ' . (int)$own . ')与运行用户(uid ' . $uid . ')不同';
}
return $msg;
}
?>
<!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">
<?php
$selfDel = isset($GLOBALS['__fb_install_selfdel']) ? (string)$GLOBALS['__fb_install_selfdel'] : '';
?>
<?php if ($selfDel === ''): ?>
<p class="muted small" style="margin:0">
<b>✅ <code>install.php</code> 已自动删除</b>,不会再有重复安装的入口。
</p>
<?php else: ?>
<p class="muted small" style="margin:0">
<b>⚠️ 安装已完成,但 <code>install.php</code> 没能自动删除。</b><br>
原因:<?php echo e($selfDel); ?>。<br>
它在已安装状态下会拒绝重装,但建议手工删掉,或在服务器上执行:<br>
<code>sudo chown www:www <?php echo e(__DIR__); ?>/install.php &amp;&amp; sudo rm <?php echo e(__DIR__); ?>/install.php</code>
</p>
<?php endif; ?>
</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" data-validate>
<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>
<?php
// 首次进入必须空着:config.example.php 里有出厂名「轻反馈」,
// 直接回显会让用户以为那是要保留的数据,得先删一遍。
// 只有表单校验失败重来时,才回填用户刚填的内容。
$nameVal = isset($_POST['site_name']) ? (string)$_POST['site_name'] : '';
?>
<input type="text" name="site_name" maxlength="80" required autofocus
value="<?php echo e($nameVal); ?>"
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'] : ''); ?>"
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>