Files
FeedLite/install.php
T

371 lines
18 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' => '中文字数统计(可选)'];
$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.0~8.3)', $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) {
$has = extension_loaded($ext);
$rows[] = ['PHP 扩展:' . $ext, $has ? '已启用' : '未启用(不影响安装)', true, $why, ''];
}
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'), '/');
$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 ($setPw && fb_len($pw) < 8) {
$err = '管理员口令至少 8 个字符。';
} elseif ($setPw && $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;
if ($setPw && 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=7">
</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']); ?>
<section class="narrow">
<h1>安装完成 🎉</h1>
<p class="muted">已生成 <code>config.php</code>、建好数据库表。下面是接下来要做的两件事:</p>
<div class="card-form" style="margin:14px 0">
<div style="display:flex;flex-direction:column;gap:8px">
<span><b>① 删除安装文件</b> —— 请把这个 <code>install.php</code> 从服务器上删掉(已安装态下它会拒绝重装,但删掉更干净)。</span>
<span><b>② 设置口令</b> ——
<?php if ($pwSet): ?>
管理员口令已设成你刚才填的那个,并以 <b>bcrypt 哈希</b>存进数据库;<code>config.php</code> 里没有任何明文口令。
<?php else: ?>
你这次没设口令。<b>在设好之前后台无法登录</b>:请进入 <a href="admin.php">管理后台</a>,在「站点设置 → 登录口令」里设定(或重新运行本向导)。
<?php endif; ?>
</span>
</div>
</div>
<?php if ($err !== ''): ?><?php fb_flash_box($err, 'error'); ?><?php endif; ?>
<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()); ?>">
<label class="field"><span>站点标题 <b class="req">*</b></span>
<input type="text" name="site_name" maxlength="80" required
value="<?php echo e(isset($_POST['site_name']) ? (string)$_POST['site_name'] : (string)fb_cfg('site_name', '轻反馈')); ?>"
placeholder="轻反馈">
<i class="hint">显示在浏览器标签、页面顶栏与页脚。用来说明「本系统为哪个软件提供反馈服务」,如「XX 软件 · 问题反馈」。</i></label>
<label class="field"><span>站点描述</span>
<textarea name="site_desc" rows="3" maxlength="300"
placeholder="欢迎在这里反馈使用问题、提出功能建议。"><?php echo e(isset($_POST['site_desc']) ? (string)$_POST['site_desc'] : (string)fb_cfg('site_desc', '')); ?></textarea>
<i class="hint">显示在首页标题下方,一句话说明这里可以反馈什么。</i></label>
<div class="grid2">
<label class="field"><span>官方邮箱</span>
<input type="email" name="admin_email" value="<?php echo e(isset($_POST['admin_email']) ? (string)$_POST['admin_email'] : (string)fb_cfg('admin_email', '')); ?>"
placeholder="support@example.com">
<i class="hint">对外展示用的联系邮箱,可留空。</i></label>
<label class="field"><span>站点根地址</span>
<input type="text" name="base_url" value="<?php echo e(isset($_POST['base_url']) ? (string)$_POST['base_url'] : (string)fb_cfg('base_url', '')); ?>"
placeholder="https://你的域名/feedback">
<i class="hint">用于邮件正文里的链接。留空则按访问域名自动探测。</i></label>
</div>
<h2 style="margin:22px 0 10px">三、设置管理员口令</h2>
<p class="muted small" style="margin:-4px 0 10px">
口令用来登录管理后台、以及以「官方」身份回复。<b>它以 bcrypt 单向哈希写入数据库,明文不会保存在 config.php 或任何文件里。</b>
留空则暂不设定,装完后可在后台补设(补设之前后台不可登录)。
</p>
<div class="grid2">
<label class="field"><span>管理员口令(至少 8 位)</span>
<input type="password" name="admin_password" autocomplete="new-password"></label>
<label class="field"><span>再输入一次</span>
<input type="password" name="admin_password2" autocomplete="new-password"></label>
</div>
<label class="check"><input type="checkbox" name="set_password" value="1" checked> 现在设定口令(推荐)</label>
<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)都在安装完成后、在后台「站点设置」里配置,这里不需要填。</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>