'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', '>='); $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 时,站点设置会全部落到数据库 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 = " 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; } ?> 安装 · 轻反馈 FeedLite
轻反馈 FeedLite
安装向导

已经装好了

检测到数据库已存在,无需重复安装。

进入问题列表 管理后台

如果你确实想重装(例如换了数据库位置),请先手动移走 data/feedback.db 再刷新本页。本向导不会替你删除任何数据。

安装完成

已建好数据库、写入站点设置。2 秒后自动进入首页(也可点下方按钮)。

网站名称: 管理员用户名: 登录入口:admin.php(用户名 + 口令) 口令存放:bcrypt 哈希,存于数据库;config.php 里没有任何明文口令。

✅ install.php 已自动删除,不会再有重复安装的入口。

⚠️ 安装已完成,但 install.php 没能自动删除。
原因:。
它在已安装状态下会拒绝重装,但建议手工删掉,或在服务器上执行:
sudo chown www:www /install.php && sudo rm /install.php

进入首页 管理后台

安装向导

数据库目录 data/ 下还没有数据,先完成这几步设置,装完即可使用。

一、环境体检

: ()
↳

二、初始化站点

管理员账号

用来登录管理后台、以「官方」身份回复。口令以 bcrypt 单向哈希存进数据库, 明文不会写进 config.php 或任何文件。

环境体检未通过前无法提交,请先处理上方标红项。

其余设置都已自动处理:站点地址按访问域名自动探测,密钥与令牌已自动生成。
邮件通知(SMTP)与附件上传(OSS)在装好后到后台「站点设置」里配,这里不用填。
装完请删掉 install.php(它本身也会拒绝重复安装)。