Files
FeedLite/install.php
T
laoyang 6135e7b890 兼容 PHP 8.5:消除全部弃用调用
PHP 8.5 把 curl_close()/finfo_close() 这类"空操作函数"标为弃用,
并把预定义局部变量 http_response_header 标为弃用(编译期判定,@ 与三元短路都压不住)。

- 句柄关闭收进 fb_curl_close()/fb_finfo_close():仅 PHP < 8.0 真正调用,
  新版本直接跳过(8.0 起句柄随引用计数释放),保留 7.4 兼容
- 响应头读取改用 http_get_last_response_headers()(8.4+);
  老版本经 get_defined_vars() 从作用域变量表取,源码不再出现已弃用标识符
- 新 API 是全局状态,用「请求前基线」比对,避免把上一次的残留头当成本次结果
- 安装体检与 README 的推荐版本区间更新为 8.1~8.5

验证:PHP 8.5.12 下五套测试全绿(25/25、22/22、60/60、7/7、反向 4 组全被抓住),
error_log 零条弃用记录;PHP 8.4.25 同样全绿。
2026-10-23 08:23:55 +08:00

371 lines
18 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' => '中文字数统计(可选)'];
$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) {
$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>