页脚入口与安装向导;加固管理员口令存储

需求落实:
1. 页脚常驻「管理后台」链接,不用再手敲 admin.php。
2. 页脚「由 FeedLite 驱动」中的 FeedLite 改为超链接(指向开源仓库);
   后台新增「页脚」设置组,可关闭整句(关闭后只留管理后台链接),也可改链接地址。
3. 管理员口令存储方式在后台如实展示:数据库 settings.admin.password_hash,
   存 bcrypt 单向哈希(不可逆),明文不落盘。
4. 新增安装向导 install.php:data/ 下没有数据库时自动 302 引导;
   体检环境 -> 填站点信息与口令 -> 生成 config.php -> 建库建表 -> 完成。

顺带修掉一个真实安全隐患:
  config.php 里的出厂占位口令 CHANGE_ME_admin 原先可被当作有效口令登录后台
  (等于公开仓库里就写着线上后门口令)。现改为占位/空值一律拒绝,
  未设口令时后台明确提示去安装向导,不再含糊报「口令不正确」。

实现要点:
- 新增 fb_cfg_file():只读配置文件、不叠加 settings 表、绝不触达数据库。
  「是否已安装」以数据库文件是否存在为判据,而 fb_cfg() 读 settings 会触发
  fb_db() 就地建库 —— 那会把安装态自己造出来,所以判定必须走不碰库的通道。
- bootstrap 只在已安装时建库;未安装则 302 到 install.php(CLI 下报错退出)。
- fb_settings_flat() 在未安装时直接返回空,杜绝任何隐式建库。
- install.php 用文本级替换生成 config.php,保留全部注释与 OSS/SMTP 段落;
  生成的 PHP 用 token_get_all(TOKEN_PARSE) 校验后才落盘。
- 页脚链接只放行 http/https,后台保存时校验(防 javascript: 伪协议)。
- style.css 页脚行改 flex,版本号 v6 -> v7。

验证:
- php -l 全量通过;现有回归 25/25、附件专项 13/13 保持通过。
- 安装向导专项 60/60(含「明文口令未写入任何文件」「数据库字节里搜不到明文」)。
- 反向验证:撤销三处修复后对应断言确实失败(断言非装饰)。
- 几何检查:本机 15/15 + 页脚专项全过;线上真实 URL 15/15 + 页脚专项全过。
- 线上真机冒烟 22/22。
This commit is contained in:
2026-09-28 22:53:55 +08:00
parent 1401ad899b
commit 27187b38f1
8 changed files with 654 additions and 39 deletions
+67 -7
View File
@@ -25,6 +25,7 @@ feedback/
├─ thread.php 问题详情 + 回复(含"官方答复"与状态管理、附件展示) ├─ thread.php 问题详情 + 回复(含"官方答复"与状态管理、附件展示)
├─ new.php 发布新问题(含图片 / 视频上传) ├─ new.php 发布新问题(含图片 / 视频上传)
├─ admin.php 管理后台(口令登录:审核 / 管理全部问题 / 站点设置 / 操作日志) ├─ admin.php 管理后台(口令登录:审核 / 管理全部问题 / 站点设置 / 操作日志)
├─ install.php 安装向导(数据库不存在时自动引导进来;装完建议删除)
├─ upload.php 附件上传接口(两步:领对象键 → 收文件本体,local 落盘 / OSS 代理 PUT) ├─ upload.php 附件上传接口(两步:领对象键 → 收文件本体,local 落盘 / OSS 代理 PUT)
├─ media.php 附件代理输出(私有桶 + 内网 Endpoint 下图片/视频的唯一对外读取通道,带鉴权与 Range) ├─ media.php 附件代理输出(私有桶 + 内网 Endpoint 下图片/视频的唯一对外读取通道,带鉴权与 Range)
├─ captcha.php "换一题"用的人机验证刷新接口(返回 JSON) ├─ captcha.php "换一题"用的人机验证刷新接口(返回 JSON)
@@ -60,8 +61,9 @@ feedback/
chown -R www:www /www/wwwroot/你的域名/feedback chown -R www:www /www/wwwroot/你的域名/feedback
chmod -R 755 /www/wwwroot/你的域名/feedback/data chmod -R 755 /www/wwwroot/你的域名/feedback/data
``` ```
4. 首次访问 `https://你的域名/feedback/` 会自动建库建表。 4. 首次访问 `https://你的域名/feedback/` —— 此时 `data/` 下还没有数据库,
5. **打开 `config.php`,修改这三项**(务必!): 程序会**自动把你送到安装向导 `install.php`**(见 **第二节之二**)。按向导填完即装好。
5. 若你更愿意手工配置:从 `config.example.php` 复制一份为 `config.php`,改掉这三项后刷新页面即可建库。
- `admin_password` —— 管理员口令(回复时填入即以「官方」身份答复;也是审核后台 `admin.php` 的登录口令) - `admin_password` —— 管理员口令(回复时填入即以「官方」身份答复;也是审核后台 `admin.php` 的登录口令)
- `api_token` —— 读取接口的令牌(自己设一串随机字符) - `api_token` —— 读取接口的令牌(自己设一串随机字符)
- `ip_salt` —— 随机字符串(用于给访客 IP 做哈希) - `ip_salt` —— 随机字符串(用于给访客 IP 做哈希)
@@ -76,11 +78,50 @@ feedback/
--- ---
## 二之二、安装向导(`install.php`)
**什么时候出现**:只要 `data/` 下还没有数据库文件,访问站点的任何页面都会 302 到 `install.php`。
反过来,数据库一旦存在,向导就只显示「已经装好了」,**不会**覆盖任何数据。
向导分三步,一屏走完:
1. **环境体检** —— PHP 版本(≥7.4)、必需扩展(`pdo_sqlite` / `openssl`)、
`站点根 / data / uploads` 目录可写性、`config.example.php` 是否在。有任一项不过就拒绝提交。
2. **填写站点信息** —— 站点标题、站点描述、官方邮箱、站点根地址(后两项可留空)。
3. **设置管理员口令** —— 至少 8 位;也可以留空、装完后在后台补设(**补设之前后台登不进去**)。
提交后向导会:
- 生成 `config.php`(在 `config.example.php` 的**文本**上逐项替换,注释与 OSS / SMTP 段落原样保留);
- 自动填好 `api_token` 与 `ip_salt`(随机串,不用你操心);
- 建库建表;
- 把口令以 **bcrypt 哈希**写入数据库 `settings` 表 —— **明文不进 `config.php`、不进任何文件**。
**装完请做两件事**:
1. **删掉 `install.php`**(已安装态下它会拒绝重装,但少一个入口少一分风险)。
2. 若口令留空了,进后台「站点设置 → 登录口令」补设一个。
> 想重装(例如换了数据库位置):先手工移走 `data/feedback.db`,再刷新即可。
> 向导**不会**替你删除任何数据。
---
## 三、首页加链接 ## 三、首页加链接
已在官网 `index.html` 的**顶部导航**与**页脚「产品」列**加入「问题反馈」入口,指向 `feedback/`。 已在官网 `index.html` 的**顶部导航**与**页脚「产品」列**加入「问题反馈」入口,指向 `feedback/`。
(本地文件:`(你的站点首页)`) (本地文件:`(你的站点首页)`)
### 页脚里有「管理后台」入口
站点页脚固定显示一个「管理后台」链接(指向 `admin.php`),不需要手动在地址栏敲文件名。
旁边那句「由 FeedLite 驱动」可以关掉:
- 后台「**站点设置 → 页脚**」→ 取消勾选「在页脚显示『由 FeedLite 驱动』」
→ 页脚只剩「管理后台」链接;
- 同一处还能改「FeedLite」二字指向的地址(默认是本项目开源仓库)。
只接受 `http://` / `https://` 开头的网址。
--- ---
## 四、邮件通知(SMTP) ## 四、邮件通知(SMTP)
@@ -566,23 +607,42 @@ curl "https://你的域名/feedback/api.php?token=XXX&action=digest&status=open"
- `data/` 目录用 `.htaccess` 拒绝一切直接访问;请勿删除该文件。 - `data/` 目录用 `.htaccess` 拒绝一切直接访问;请勿删除该文件。
### 更换管理员口令 / 令牌 ### 更换管理员口令 / 令牌
直接修改 `config.php` 即可,无需改代码。
- **管理员口令**:登录后台后在「站点设置 → 登录口令」里改(至少 8 位)。改完即以 bcrypt 哈希存进数据库,`config.php` 里的 `admin_password` 只是「未设置」时的初始值通道。
- **令牌 `api_token` / 盐 `ip_salt`**:这两项没有后台界面,改 `config.php` 即可,无需改代码。
### 口令存在哪、是否加密(重要)
| 状态 | 存在哪 | 是否加密 | 怎么变成下一档 |
|---|---|---|---|
| 刚 clone 下来、没装 | `config.php` 的 `admin_password` = `CHANGE_ME_admin` | 明文,但**该值已被程序明确禁用**,无法登录 | 跑 `install.php`,或到后台设定口令 |
| 手工在 `config.php` 指定了口令 | `config.php` 的 `admin_password` | **明文**(配置文件里) | 登录一次后到后台改一次口令 → 转为数据库哈希 |
| 正常状态(推荐) | 数据库 `settings` 表的 `admin.password_hash` | **是**,bcrypt 单向哈希(`$2y$…`),不可逆 | 已是最终形态 |
- 后台「站点设置 → 登录口令」会把当前处于哪一档**如实显示出来**,Linux 下也能直接查库确认。
- 明文口令**永远不会**由安装向导或后台写进任何文件:向导只把哈希写进数据库。
- 怀疑口令泄露:到后台改掉即可。若把数据库里 `admin.password_hash` 这一行删掉,
会退回使用 `config.php` 的值(也因此回到明文档,建议删完立刻重新设定)。
--- ---
## 七之二、`config.php` 里的三个"必改"参数是什么 ## 七之二、`config.php` 里的三个"必改"参数是什么
这三项都写在 `config.php` 的「安全」段落里,出厂值都是 `CHANGE_ME_...` 占位符,**上线前必须换成自己的随机值**。 > **走安装向导的话,这一段可以跳过**:`install.php` 会替你生成 `config.php`,
> 并自动填好 `api_token` 与 `ip_salt`(都是随机串),管理员口令则直接以哈希入库、不进文件。
> 下面这些是「想手工配置」时看的说明。三项出厂值都是 `CHANGE_ME_...` 占位符。
### `admin_password` —— 登录口令(初始值) ### `admin_password` —— 登录口令(初始值,可留空)
- **干什么用**:两个地方共用同一串,所以必须一致 —— - **干什么用**:两个地方共用同一串 ——
① 详情页回复框里填它 → 以「官方」身份发言; ① 详情页回复框里填它 → 以「官方」身份发言;
② `/feedback/admin.php` 登录 → 进管理后台(审核、回复、改状态、删帖、改站点设置)。 ② `/feedback/admin.php` 登录 → 进管理后台(审核、回复、改状态、删帖、改站点设置)。
- **建议**:**登录后台后在「站点设置 → 修改登录口令」里改成你自己的一串**(至少 8 位)。 - **建议**:优先用安装向导;若手工配,**登录后台后立刻在「站点设置 → 登录口令」里改成你自己的一串**(至少 8 位)。
改完会以 `password_hash()` 存进数据库,`config.php` 里这行就只是"出厂默认值"了 —— 改完会以 `password_hash()` 存进数据库,`config.php` 里这行就只是"出厂默认值"了 ——
⚠️ 此时改 `config.php` **不会**再影响登录口令,忘了新口令就去数据库把 `settings` 表里 ⚠️ 此时改 `config.php` **不会**再影响登录口令,忘了新口令就去数据库把 `settings` 表里
`admin.password_hash` 这一行删掉,即可退回用 `config.php` 里的值。 `admin.password_hash` 这一行删掉,即可退回用 `config.php` 里的值。
- ⚠️ **占位值 `CHANGE_ME_admin` 已被程序明确禁用**(空值同样):也就是说,**clone 下来不做任何配置时,
后台任何人都登不进去** —— 这是刻意的,避免"文档里公开的默认口令"变成线上后门。
- **它不是什么**:站点面向访客没有账号体系,也没有访客登录页。它只是一串比对用的口令 —— - **它不是什么**:站点面向访客没有账号体系,也没有访客登录页。它只是一串比对用的口令 ——
表单里填的内容与库里的哈希(或 `config.php` 里的值)比对一次,一致就按官方处理。 表单里填的内容与库里的哈希(或 `config.php` 里的值)比对一次,一致就按官方处理。
+75 -12
View File
@@ -30,6 +30,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login'])) {
$loginErr = '会话已过期,请重试。'; $loginErr = '会话已过期,请重试。';
} elseif (!fb_rate_check('admin_login', 10)) { } elseif (!fb_rate_check('admin_login', 10)) {
$loginErr = '尝试次数过多,请一小时后再试。'; $loginErr = '尝试次数过多,请一小时后再试。';
} elseif (!fb_admin_password_is_set()) {
$loginErr = '本系统还没有设置登录口令,请先运行安装向导(install.php)完成设置。';
} elseif ($pw !== '' && fb_admin_verify_password($pw)) { } elseif ($pw !== '' && fb_admin_verify_password($pw)) {
fb_admin_login(); fb_admin_login();
fb_mod_log('login'); fb_mod_log('login');
@@ -46,7 +48,10 @@ if (!fb_admin_logged_in()) {
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div> <div class="crumbs"><a href="index.php">← 返回问题列表</a></div>
<section class="narrow"> <section class="narrow">
<h1>管理登录</h1> <h1>管理登录</h1>
<p class="muted">请输入管理员口令(初始口令在 <code>config.php</code> 的 <code>admin_password</code>,登录后在「站点设置」里可以改掉它)。</p> <p class="muted">请输入管理员口令<?php if (fb_admin_password_source() === 'file'): ?>(当前仍是 <code>config.php</code> 里的 <code>admin_password</code> 明文,登录后请到「站点设置」改成加密存储)<?php endif; ?>。</p>
<?php if (fb_admin_password_source() === 'none'): ?>
<div class="flash error">尚未设置登录口令,且出厂占位口令已被禁用。请运行 <a href="install.php">install.php</a> 设定一个。</div>
<?php endif; ?>
<?php fb_flash_box($loginErr, 'error'); ?> <?php fb_flash_box($loginErr, 'error'); ?>
<form method="post" action="admin.php" class="card-form"> <form method="post" action="admin.php" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>"> <input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
@@ -184,7 +189,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
$old = (string)(isset($_POST['old_password']) ? $_POST['old_password'] : ''); $old = (string)(isset($_POST['old_password']) ? $_POST['old_password'] : '');
$n1 = (string)(isset($_POST['new_password']) ? $_POST['new_password'] : ''); $n1 = (string)(isset($_POST['new_password']) ? $_POST['new_password'] : '');
$n2 = (string)(isset($_POST['new_password2']) ? $_POST['new_password2'] : ''); $n2 = (string)(isset($_POST['new_password2']) ? $_POST['new_password2'] : '');
if (!fb_admin_verify_password($old)) { // 从没设过口令(或仍是出厂占位值)时,跳过"当前口令"校验 —— 否则谁也进不来、又改不了
$firstSet = !fb_admin_password_is_set();
if (!$firstSet && !fb_admin_verify_password($old)) {
$msg = '当前口令不正确。'; $msg = '当前口令不正确。';
$msgType = 'error'; $msgType = 'error';
} elseif (fb_len($n1) < 8) { } elseif (fb_len($n1) < 8) {
@@ -195,8 +202,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
$msgType = 'error'; $msgType = 'error';
} else { } else {
fb_admin_set_password($n1); fb_admin_set_password($n1);
fb_mod_log('password'); fb_mod_log('password', null, $firstSet ? '首次设定登录口令(bcrypt 哈希存库)' : '修改登录口令');
$msg = '口令已修改,下次登录请使用新口令。'; $msg = $firstSet ? '口令已设定,下次登录请使用它。' : '口令已修改,下次登录请使用新口令。';
} }
} }
} }
@@ -223,6 +230,16 @@ function fb_admin_save_settings() {
else { fb_set_setting($k, $v); } else { fb_set_setting($k, $v); }
} }
// 页脚:是否显示「由 FeedLite 驱动」这句版权,以及链接指向的开源仓库地址
fb_set_setting('footer.show_credit', $post('footer_show_credit') ? 1 : 0);
$curl = trim((string)$post('footer_credit_url', ''));
// 只接受 http/https:这条会被直接渲染成 <a href>,不能让 javascript:/data: 混进来
if ($curl !== '' && !preg_match('~^https?://~i', $curl)) {
return '页脚链接地址需以 http:// 或 https:// 开头。';
}
if ($curl === '' || $curl === fb_credit_url_default()) { fb_unset_setting('footer.credit_url'); }
else { fb_set_setting('footer.credit_url', $curl); }
$ints = [ $ints = [
'upload.image_max_per_post' => [0, 20], 'upload.image_max_per_post' => [0, 20],
'upload.video_max_per_post' => [0, 10], 'upload.video_max_per_post' => [0, 10],
@@ -544,6 +561,14 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
placeholder="欢迎在这里反馈使用问题、提出功能建议。"><?php echo e((string)fb_cfg('site_desc', '')); ?></textarea> placeholder="欢迎在这里反馈使用问题、提出功能建议。"><?php echo e((string)fb_cfg('site_desc', '')); ?></textarea>
<i class="hint">显示在首页标题下方,一句话说明这里可以反馈什么。两项都留空则恢复为出厂默认。</i></label> <i class="hint">显示在首页标题下方,一句话说明这里可以反馈什么。两项都留空则恢复为出厂默认。</i></label>
<h2 style="margin:24px 0 10px">页脚</h2>
<label class="check"><input type="checkbox" name="footer_show_credit" value="1"<?php echo fb_cfg('footer.show_credit', true) ? ' checked' : ''; ?>> 在页脚显示「由 FeedLite 驱动」
<span class="hint" style="display:block;margin:2px 0 0 24px">取消勾选后,这句话连同链接都不再出现在页脚,只保留「管理后台」入口。</span></label>
<label class="field"><span>FeedLite 链接地址</span>
<input type="text" name="footer_credit_url" maxlength="300"
value="<?php echo e((string)fb_cfg('footer.credit_url', '') !== '' ? (string)fb_cfg('footer.credit_url') : fb_credit_url_default()); ?>">
<i class="hint">页脚「FeedLite」二字指向的地址,默认是本项目开源仓库。只接受 http:// 或 https:// 开头的网址;填回默认值即恢复。</i></label>
<h2 style="margin:24px 0 10px">附件存储</h2> <h2 style="margin:24px 0 10px">附件存储</h2>
<label class="field"> <label class="field">
<span>附件保存在哪</span> <span>附件保存在哪</span>
@@ -649,19 +674,57 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
</div> </div>
</div> </div>
<h2 style="margin:26px 0 10px">修改登录口令</h2> <h2 style="margin:26px 0 10px">登录口令</h2>
<div class="card-form" style="margin:0 0 16px">
<?php $pwSrc = fb_admin_password_source(); ?>
<div style="display:flex;flex-direction:column;gap:6px">
<span>存储位置:
<?php if ($pwSrc === 'db'): ?>
数据库 <code>settings</code> 表(键名 <code>admin.password_hash</code>)
<?php elseif ($pwSrc === 'file'): ?>
<code>config.php</code> 的 <code>admin_password</code>
<?php else: ?>
<b style="color:#b45309">尚未设置</b>
<?php endif; ?>
</span>
<span>是否加密:
<?php if ($pwSrc === 'db'): ?>
<b>是</b> —— 存的是 <code>password_hash()</code> 生成的 bcrypt 单向哈希(<code>$2y$…</code>),
不可逆、无法还原明文;校验用 <code>password_verify()</code>。
<?php elseif ($pwSrc === 'file'): ?>
<b>否</b> —— 目前是明文写在 <code>config.php</code> 里。
<a href="#pwform">在下面改一次口令</a>后即会转存为数据库中的 bcrypt 哈希。
<?php else: ?>
—— 还没有可用口令,当前后台<strong>无法登录</strong>。请用下方表单设定一个。
<?php endif; ?>
</span>
<?php if ($pwSrc === 'db'): ?>
<span class="muted small">数据库文件在 <code>data/feedback.db</code>(已由 .htaccess 拒绝外部访问),且只存哈希,明文不落盘。</span>
<?php endif; ?>
</div>
</div>
<div id="pwform">
<form method="post" action="admin.php?tab=settings" class="card-form"> <form method="post" action="admin.php?tab=settings" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>"> <input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<div class="grid2"> <?php if ($pwSrc !== 'none'): ?>
<label class="field"><span>当前口令</span><input type="password" name="old_password" autocomplete="current-password"></label> <div class="grid2">
<label class="field"><span>新口令(至少 8 位)</span><input type="password" name="new_password" autocomplete="new-password"></label> <label class="field"><span>当前口令</span><input type="password" name="old_password" autocomplete="current-password"></label>
<label class="field"><span>再输入一次新口令</span><input type="password" name="new_password2" autocomplete="new-password"></label> <label class="field"><span>新口令(至少 8 位)</span><input type="password" name="new_password" autocomplete="new-password"></label>
</div> <label class="field"><span>再输入一次新口令</span><input type="password" name="new_password2" autocomplete="new-password"></label>
</div>
<?php else: ?>
<div class="grid2">
<label class="field"><span>设定登录口令(至少 8 位)</span><input type="password" name="new_password" autocomplete="new-password"></label>
<label class="field"><span>再输入一次</span><input type="password" name="new_password2" autocomplete="new-password"></label>
</div>
<?php endif; ?>
<div class="form-actions"> <div class="form-actions">
<button class="btn" type="submit" name="act" value="password">修改口令</button> <button class="btn primary" type="submit" name="act" value="password"><?php echo $pwSrc === 'none' ? '设定口令' : '修改口令'; ?></button>
</div> </div>
<p class="muted small">新口令保存在数据库里,优先级高于 <code>config.php</code> 的 <code>admin_password</code>;改完立即生效。</p> <p class="muted small">新口令以 bcrypt 哈希写入数据库,优先级高于 <code>config.php</code> 的 <code>admin_password</code>;改完立即生效,明文不会留在任何文件里。</p>
</form> </form>
</div>
<?php else: ?> <?php else: ?>
<?php <?php
+3
View File
@@ -61,6 +61,9 @@ main.wrap{padding-top:24px; padding-bottom:40px; min-height:60vh}
.foot{border-top:1px solid var(--line); background:var(--card); padding:22px 0 34px; margin-top:40px} .foot{border-top:1px solid var(--line); background:var(--card); padding:22px 0 34px; margin-top:40px}
.foot p{margin:4px 0; text-align:center; font-size:13px; color:var(--muted)} .foot p{margin:4px 0; text-align:center; font-size:13px; color:var(--muted)}
.foot a{color:var(--muted); text-decoration:underline} .foot a{color:var(--muted); text-decoration:underline}
/* 版权句 + 管理后台入口:同一行居中,窄屏自动换行 */
.foot-line{display:flex; flex-wrap:wrap; align-items:center; justify-content:center; gap:0 6px}
.foot-line .dot{padding:0}
/* ---------- 按钮 ---------- */ /* ---------- 按钮 ---------- */
.btn{ .btn{
+19 -3
View File
@@ -23,9 +23,25 @@ return [
// 例如:'https://feedback.example.com' // 例如:'https://feedback.example.com'
'base_url' => '', 'base_url' => '',
/* ---------------- 安全 ---------------- */ /* ---------------- 页脚 ----------------
// 管理员口令:在"回复"表单里填入它,即以「官方」身份回复(高亮显示),并触发邮件通知。 * 页脚永远会显示「管理后台」入口;这里的开关只控制右侧那句版权说明。
// ★★ 请务必修改! ★★ * show_credit = false:页脚不再出现「由 FeedLite 驱动」,只留管理后台链接。
*/
'footer' => [
'show_credit' => true,
'credit_url' => 'https://code.yangyq.net/laoyang/FeedLite', // 页脚「FeedLite」二字指向的地址
],
/* ---------------- 安全 ----------------
* 管理员口令的存放规则(重要):
* · 正常安装(走 install.php,或登录后台改口令)→ 口令以 **bcrypt 单向哈希**
* 存在数据库 settings 表的 admin.password_hash,**明文不落盘**。
* · 下面这个 admin_password 只是「初始值」通道,且**出厂占位值已被程序明确禁用**:
* 值仍为 CHANGE_ME_admin 时,任何人(包括你)都无法用它登录后台。
* · 想手工指定初始口令,就把下面这行改成你自己的口令;登录一次后建议立即改掉,
* 改完即转为数据库哈希存储、这里可以再改回占位值。
*/
// ★★ 留空或保持占位值 = 未设置口令(后台不可登录,需先跑安装向导)★★
'admin_password' => 'CHANGE_ME_admin', 'admin_password' => 'CHANGE_ME_admin',
// API 令牌:供自动化脚本 / AI 读取数据用。★ 请务必修改 ★ // API 令牌:供自动化脚本 / AI 读取数据用。★ 请务必修改 ★
+371
View File
@@ -0,0 +1,371 @@
<?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>
+51 -11
View File
@@ -29,6 +29,30 @@ if (!isset($GLOBALS['__fb_cfg_stamp'])) {
} }
if (!function_exists('fb_cfg')) { if (!function_exists('fb_cfg')) {
/**
* 只读【配置文件】的值(config.php → config.example.php),
* **不叠加 settings 表、绝不触达数据库**。
*
* 为什么必须单独有这么一条通道:正常的 fb_cfg() 会去读 settings 表,
* 而读表要先 fb_db() —— 那一步会就地建出一个空数据库文件。
* 「是否已安装」的判定恰恰以「数据库文件在不在」为依据,
* 所以判定必须走这条不碰库的路径,否则第一秒就把安装态自己造出来了。
*/
function fb_cfg_file() {
static $file = null;
static $stamp = -1;
$now = isset($GLOBALS['__fb_cfg_stamp']) ? (int)$GLOBALS['__fb_cfg_stamp'] : 0;
if ($file === null || $stamp !== $now) {
$local = FB_ROOT . '/config.php';
$example = FB_ROOT . '/config.example.php';
if (is_file($local)) { $file = (array)(require $local); }
elseif (is_file($example)) { $file = (array)(require $example); }
else { $file = []; }
$stamp = $now;
}
return $file;
}
/** /**
* 读取配置(点分路径,如 fb_cfg('smtp.host')) * 读取配置(点分路径,如 fb_cfg('smtp.host'))
* 取值顺序:settings 表(后台改过的)> config.php(本地配置)> config.example.php(出厂默认)> $default * 取值顺序:settings 表(后台改过的)> config.php(本地配置)> config.example.php(出厂默认)> $default
@@ -41,15 +65,7 @@ if (!function_exists('fb_cfg')) {
static $stamp = -1; static $stamp = -1;
$now = isset($GLOBALS['__fb_cfg_stamp']) ? (int)$GLOBALS['__fb_cfg_stamp'] : 0; $now = isset($GLOBALS['__fb_cfg_stamp']) ? (int)$GLOBALS['__fb_cfg_stamp'] : 0;
if ($merged === null || $stamp !== $now) { if ($merged === null || $stamp !== $now) {
$local = FB_ROOT . '/config.php'; $cfg = fb_cfg_file();
$example = FB_ROOT . '/config.example.php';
if (is_file($local)) {
$cfg = (array)(require $local);
} elseif (is_file($example)) {
$cfg = (array)(require $example);
} else {
$cfg = [];
}
// 后台设置以嵌套数组形式叠加在文件配置之上(只覆盖同名键) // 后台设置以嵌套数组形式叠加在文件配置之上(只覆盖同名键)
$tree = function_exists('fb_setting_tree') ? fb_setting_tree() : []; $tree = function_exists('fb_setting_tree') ? fb_setting_tree() : [];
$merged = is_array($tree) && $tree ? array_replace_recursive($cfg, $tree) : $cfg; $merged = is_array($tree) && $tree ? array_replace_recursive($cfg, $tree) : $cfg;
@@ -68,6 +84,11 @@ if (!function_exists('fb_cfg')) {
} }
} }
/** 让 fb_cfg() 的进程内缓存失效(改了磁盘上的 config.php 后必须调用) */
function fb_cfg_flush() {
$GLOBALS['__fb_cfg_stamp'] = ((int)$GLOBALS['__fb_cfg_stamp']) + 1;
}
require_once FB_ROOT . '/lib/util.php'; require_once FB_ROOT . '/lib/util.php';
require_once FB_ROOT . '/lib/db.php'; require_once FB_ROOT . '/lib/db.php';
require_once FB_ROOT . '/lib/oss.php'; require_once FB_ROOT . '/lib/oss.php';
@@ -76,6 +97,20 @@ require_once FB_ROOT . '/lib/captcha.php';
require_once FB_ROOT . '/lib/mailer.php'; require_once FB_ROOT . '/lib/mailer.php';
require_once FB_ROOT . '/lib/layout.php'; require_once FB_ROOT . '/lib/layout.php';
/* ------------------------------------------------------------------
* 安装引导:数据库还没生成(data/ 下没数据)就说明是全新部署,
* 一律先送去 install.php 填配置、建库、设口令,装完才进系统。
* 判据只看「数据库文件在不在」——绝不能在这里调 fb_db(),
* 因为那会就地建出一个空库、把安装态自己"装"出来,之后就再也进不了向导。
* ------------------------------------------------------------------ */
if (!defined('FB_SKIP_INSTALL_REDIRECT') && !fb_installed()) {
if (PHP_SAPI === 'cli') {
fwrite(STDERR, "FeedLite 尚未安装:请先在浏览器中打开本目录下的 install.php 完成安装。\n");
exit(1);
}
fb_redirect('install.php');
}
// 会话(CLI 下不启用) // 会话(CLI 下不启用)
if (PHP_SAPI !== 'cli' && session_status() !== PHP_SESSION_ACTIVE) { if (PHP_SAPI !== 'cli' && session_status() !== PHP_SESSION_ACTIVE) {
$p = session_get_cookie_params(); $p = session_get_cookie_params();
@@ -91,5 +126,10 @@ if (PHP_SAPI !== 'cli' && session_status() !== PHP_SESSION_ACTIVE) {
@session_start(); @session_start();
} }
// 初始化数据库(建表) // 初始化数据库(建表)。
fb_db(); // ★ 只在【已安装】时做:未安装就建库,等于第一秒把这个状态自己抹掉,
// 安装向导(install.php)随后就会被判定成「已经装好了」而拒绝工作。
// install.php 在真正提交安装时会自己显式调用 fb_db()。
if (fb_installed()) {
fb_db();
}
+15 -3
View File
@@ -27,7 +27,7 @@ function fb_layout_head($title, array $opt = []) {
<link rel="icon" type="image/png" sizes="16x16" href="assets/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="assets/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png">
<link rel="manifest" href="assets/site.webmanifest"> <link rel="manifest" href="assets/site.webmanifest">
<link rel="stylesheet" href="assets/style.css?v=6"> <link rel="stylesheet" href="assets/style.css?v=7">
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -48,10 +48,22 @@ function fb_layout_head($title, array $opt = []) {
/** 输出页面底部 */ /** 输出页面底部 */
function fb_layout_foot() { function fb_layout_foot() {
?><footer class="foot"> // 页脚两件事:① 常驻「管理后台」入口(省得每次手敲 admin.php);② 可关闭的 FeedLite 版权句。
// 版权句里的 FeedLite 指向开源仓库,地址可后台改;关掉开关则整句不渲染,只留管理后台链接。
$showCredit = (bool)fb_cfg('footer.show_credit', true);
$creditUrl = trim((string)fb_cfg('footer.credit_url', ''));
if ($creditUrl === '') { $creditUrl = fb_credit_url_default(); }
// 只放行 http/https:防止把 javascript:/data: 之类写进页脚链接(后台设置项,同样要防)
if (!preg_match('~^https?://~i', $creditUrl)) { $creditUrl = fb_credit_url_default(); }
?>
<footer class="foot">
<div class="wrap"> <div class="wrap">
<p class="muted">本站仅保存你提交的昵称、邮箱与内容,用于问题跟进;页面不会公开展示邮箱与手机号。</p> <p class="muted">本站仅保存你提交的昵称、邮箱与内容,用于问题跟进;页面不会公开展示邮箱与手机号。</p>
<p class="muted"><?php echo e((string)fb_cfg('site_name', '轻反馈')); ?> · 由 FeedLite 驱动</p> <p class="muted foot-line">
<?php if ($showCredit): ?><span><?php echo e((string)fb_cfg('site_name', '轻反馈')); ?> · 由 <a href="<?php echo e($creditUrl); ?>" target="_blank" rel="noopener">FeedLite</a> 驱动</span><?php endif; ?>
<?php if ($showCredit): ?><span class="dot">·</span><?php endif; ?>
<a href="admin.php">管理后台</a>
</p>
</div> </div>
</footer> </footer>
<script src="assets/app.js?v=5"></script> <script src="assets/app.js?v=5"></script>
+53 -3
View File
@@ -155,6 +155,24 @@ function fb_thread_public(array $thread) {
return fb_is_admin(); return fb_is_admin();
} }
/* ---------------- 安装状态 ---------------- */
/** FeedLite 开源仓库地址(页脚版权链接的默认值,可在后台改) */
function fb_credit_url_default() {
return 'https://code.yangyq.net/laoyang/FeedLite';
}
/**
* 是否已安装:数据库文件存在且非空。
* ★ 必须用 fb_cfg_file()(纯文件配置)而不是 fb_cfg():
* 后者会去读 settings 表 -> 触发 fb_db() -> 就地建出一个空库,
* 那样第二秒就永远进不去安装向导了(安装态会被自己"装"出来)。
*/
function fb_installed() {
$p = (string)fb_cfg_file()['db_path'] ?? '';
return ($p !== '' && is_file($p) && (int)@filesize($p) > 0);
}
/* ---------------- 站点设置(数据库覆盖 config.php) ---------------- /* ---------------- 站点设置(数据库覆盖 config.php) ----------------
* 后台改的设置存在 settings 表,读取时覆盖 config.php 的同名项; * 后台改的设置存在 settings 表,读取时覆盖 config.php 的同名项;
* config.php 因此变成「出厂默认值」,部署后不必再手工改它。 * config.php 因此变成「出厂默认值」,部署后不必再手工改它。
@@ -164,6 +182,8 @@ function fb_thread_public(array $thread) {
function fb_setting_keys() { function fb_setting_keys() {
return [ return [
'site_name', 'site_desc', 'site_name', 'site_desc',
'footer.show_credit', 'footer.credit_url',
'installed_at',
'attachment.storage', 'attachment.storage',
'upload.image_max_per_post', 'upload.image_max_bytes', 'upload.image_max_per_post', 'upload.image_max_bytes',
'upload.video_max_per_post', 'upload.video_max_bytes', 'upload.video_max_per_post', 'upload.video_max_bytes',
@@ -187,6 +207,10 @@ function fb_settings_flat($reload = false) {
static $loading = false; static $loading = false;
if ($flat !== null && !$reload) { return $flat; } if ($flat !== null && !$reload) { return $flat; }
if ($loading) { return []; } if ($loading) { return []; }
/* ★ 未安装时绝不读表:读表要先 fb_db(),那会就地建出一个空数据库文件,
* 等于把「未安装」这个状态自己抹掉 —— 安装向导就再也进不去了。
* fb_installed() 走纯文件配置、不碰库,可以安全地在这里调用。 */
if (function_exists('fb_installed') && !fb_installed()) { return []; }
$loading = true; $loading = true;
$out = []; $out = [];
try { try {
@@ -280,19 +304,45 @@ function fb_admin_login() {
@session_regenerate_id(true); @session_regenerate_id(true);
} }
/** 安装向导会直接写成对的占位值;这些值一律视为「没设口令」 */
function fb_admin_password_placeholder() {
return ['', 'CHANGE_ME_admin', 'CHANGE_ME', 'change_me', 'admin', 'password'];
}
/**
* 口令当前存在哪 —— 用于在后台如实告诉管理员「口令保存在哪里、是否加密」。
* ① 'db' :数据库 settings 表的 admin.password_hash,bcrypt 单向哈希(不可逆)
* ② 'file' :config.php 的 admin_password 明文(仅安装向导之前的老部署会走到)
* ③ 'none' :尚未设置(或仍是出厂占位值)→ 后台拒绝登录,只能用安装向导设一个
*/
function fb_admin_password_source() {
if ((string)fb_setting('admin.password_hash', '') !== '') { return 'db'; }
$cfg = trim((string)fb_cfg('admin_password', ''));
if ($cfg === '' || in_array($cfg, fb_admin_password_placeholder(), true)) { return 'none'; }
return 'file';
}
/** 是否已设置过可用口令(未设置时后台不可登录,且不提示"口令不正确"而是提示去安装) */
function fb_admin_password_is_set() {
return fb_admin_password_source() !== 'none';
}
/** /**
* 校验管理员口令。 * 校验管理员口令。
* 后台改过口令后存的是 password_hash;没改过就用 config.php 的 admin_password。 * 后台改过口令后存的是 password_hash;没改过才回落到 config.php 的明文。
* ★ 出厂占位值(CHANGE_ME_admin 等)一律拒绝:否则 clone 下来没改配置的站点,
* 任何人都能用文档里公开的占位口令登进后台。
*/ */
function fb_admin_verify_password($pw) { function fb_admin_verify_password($pw) {
$pw = (string)$pw; $pw = (string)$pw;
$hash = (string)fb_setting('admin.password_hash', ''); $hash = (string)fb_setting('admin.password_hash', '');
if ($hash !== '') { return password_verify($pw, $hash); } if ($hash !== '') { return password_verify($pw, $hash); }
$cfg = (string)fb_cfg('admin_password', ''); $cfg = (string)fb_cfg('admin_password', '');
return $cfg !== '' && hash_equals($cfg, $pw); if ($cfg === '' || in_array($cfg, fb_admin_password_placeholder(), true)) { return false; }
return hash_equals($cfg, $pw);
} }
/** 修改管理员口令(存哈希到数据库,立即生效) */ /** 修改管理员口令(存 bcrypt 哈希到数据库,立即生效) */
function fb_admin_set_password($pw) { function fb_admin_set_password($pw) {
return fb_set_setting('admin.password_hash', password_hash((string)$pw, PASSWORD_DEFAULT)); return fb_set_setting('admin.password_hash', password_hash((string)$pw, PASSWORD_DEFAULT));
} }