新装一套 PHP(尤其宝塔里的 8.5)时 fileinfo 常常默认没开,此时 上传的类型校验只能按「扩展名 + 浏览器声明的 MIME」核对。代码本身 有回落链、不会报错,但用户无从得知这层弱化,因此在安装体检里显式 列出,并说明开启后的收益。 - install.php:可选扩展支持 [说明, 缺失文案, 补救提示] 三元组 - README:扩展清单补 fileinfo,并提示换 PHP 版本后要复查一次
387 lines
19 KiB
PHP
387 lines
19 KiB
PHP
<?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' => [
|
||
'上传文件的真实类型识别(推荐开启)',
|
||
'未启用(仍可用,但上传类型只按扩展名核对)',
|
||
'建议在 PHP 设置里开启 fileinfo,上传校验会更严格',
|
||
],
|
||
];
|
||
|
||
$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'), '/');
|
||
$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>
|