Files
FeedLite/README.md
T
laoyang cede838b5e FeedLite 初始提交:通用轻量级反馈系统(PHP + SQLite,零依赖)
- 前台:问题列表 / 详情 / 发布与回复(趣味问答验证码、蜜罐、IP 限流)
- 后台:先审后显、站点设置(站点标题/描述、附件存储、OSS 参数)、操作日志
- 附件:关闭 / 本机硬盘 / 阿里云 OSS 浏览器直传(ECS 实例 RAM 角色)三种模式
- 品牌资源:全套图标(favicon.ico / SVG / PNG 各尺寸 / manifest)
- 安全:CSRF、session 加固、.htaccess 屏蔽敏感文件、口令仅存哈希
- 配置:config.example.php 为模板,config.php 不入库
2026-09-28 20:34:37 +08:00

675 lines
38 KiB
Markdown
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.
# 轻反馈 FeedLite(PHP + SQLite)
> **Lightweight feedback, made simple.** / 让每一条反馈都被听见。
一个**通用、轻量、自包含**的反馈系统:可以挂到任意软件、系统或开源项目的站点下,
让用户直接提交反馈与建议。任何人**无需注册**即可**发帖 / 回复**,只需昵称与邮箱(手机号选填);
页面**不展示**邮箱与手机号;有人回复时给发帖人**发邮件通知**。
新提交的反馈**先审后显**(默认不公开,管理员在 `admin.php` 决定公开或关闭并邮件告知,见 4.2 节)。
- 纯 PHP + SQLite,**无任何第三方依赖**(不需要 Composer、不需要 MySQL)
- 自带**趣味问答**人机验证(防机器人)+ 蜜罐字段 + 提交耗时校验 + 按 IP 频率限制
- 支持**图片 / 视频附件**:浏览器直传阿里云 OSS,**不占用服务器磁盘与带宽**(不配置也可正常运行,只是不显示上传入口)
- 自带只读 **API / Markdown 摘要**接口,方便自动化脚本或 AI 读取问题并分析
- 上手即用:clone 下来直接部署,无需建库、无需装依赖(含深色模式与响应式布局)
**适用场景**:某款软件 / 系统 / 开源项目的用户反馈区;团队内部的问题收集箱;独立开发者的建议与 Bug 收集。
---
## 一、目录结构
```
feedback/
├─ index.php 问题列表(分类 / 状态 / 搜索 / 分页 / 缩略图)
├─ thread.php 问题详情 + 回复(含"官方答复"与状态管理、附件展示)
├─ new.php 发布新问题(含图片 / 视频上传)
├─ admin.php 管理后台(口令登录:审核 / 管理全部问题 / 站点设置 / 操作日志)
├─ upload.php 附件上传接口(OSS 直传下发凭证;本机模式下接收文件本体)
├─ captcha.php "换一题"用的人机验证刷新接口(返回 JSON)
├─ api.php 只读数据接口(需令牌),供自动化 / AI 读取
├─ cron.php 附件过期清理 + 通知邮件补发(配计划任务)
├─ config.example.php ★ 配置模板:口令、令牌、SMTP、OSS、上传限额、分类等(= 出厂默认值)
├─ config.php 本地实际配置(**不参与版本管理**,从 config.example.php 复制而来;缺失时自动以模板为准)
├─ lib/ 内部库(bootstrap / db / util / oss / attach / captcha / mailer / layout)
├─ assets/ style.css / app.js / 图标(favicon、各尺寸 PNG、site.webmanifest)
├─ data/ SQLite 数据库目录(已用 .htaccess 拒绝访问)
├─ uploads/ 本机附件目录(仅"本机硬盘"模式下使用;PHP 按需自动创建)
├─ .htaccess 禁止目录列表、禁止下载 .db/.md
└─ README.md 本文件
```
> **`settings` 表 vs `config.php`**:后台改的设置存数据库(`settings` 表),读取时覆盖 `config.php`。
> 所以 `config.php` 只是"初值",日常调整走后台即可,升级代码时也不必改它。
> 取值顺序:`settings` 表 > `config.php` > `config.example.php`(模板)> 代码内默认值。
---
## 二、部署(宝塔面板 + Apache)
1. 把整个 `feedback` 目录上传到站点根目录下,例如
`/www/wwwroot/你的域名/feedback/`
(对应访问地址:`https://你的域名/feedback/`)
2. 确认该站点使用的 PHP 版本 **≥ 7.4**(推荐 8.0~8.3),并已启用扩展:
**pdo_sqlite、sqlite3、openssl、mbstring**(宝塔默认均已有;在「软件商店 → PHP → 安装扩展」可查看)。
**curl 为可选**:有则优先用它和 OSS 通信,没有会自动退回 PHP 内置的流式请求,功能不受影响。
3. 给数据库目录写权限(宝塔一般已允许):
```bash
chown -R www:www /www/wwwroot/你的域名/feedback
chmod -R 755 /www/wwwroot/你的域名/feedback/data
```
4. 首次访问 `https://你的域名/feedback/` 会自动建库建表。
5. **打开 `config.php`,修改这三项**(务必!):
- `admin_password` —— 管理员口令(回复时填入即以「官方」身份答复;也是审核后台 `admin.php` 的登录口令)
- `api_token` —— 读取接口的令牌(自己设一串随机字符)
- `ip_salt` —— 随机字符串(用于给访客 IP 做哈希)
6. 按需配置邮件(见第四节),并把 `smtp.enabled` 改为 `true`。
7. 如需让用户上传截图 / 录屏,按**第五节**配置阿里云 OSS;不配置也能正常用,只是不显示上传入口。
> 若站点是 **Nginx**,`.htaccess` 不生效,请在站点配置里加一条:
> ```nginx
> location ^~ /feedback/data/ { deny all; }
> location ~ ^/feedback/.*\.(db|md|log)$ { deny all; }
> ```
---
## 三、首页加链接
已在官网 `index.html` 的**顶部导航**与**页脚「产品」列**加入「问题反馈」入口,指向 `feedback/`。
(本地文件:`(你的站点首页)`)
---
## 四、邮件通知(SMTP)
回复提交时会尝试给发帖人发送通知邮件。在 `config.php` 的 `smtp` 段填写即可,例如:
**阿里云邮件推送(推荐,需先开通并验证域名)**
```php
'host' => 'smtpdm.aliyun.com', 'port' => 465, 'secure' => 'ssl',
'user' => 'noreply@example.com', 'pass' => '你的SMTP密码',
'from' => 'noreply@example.com', 'from_name' => '轻反馈',
```
**腾讯企业邮**
```php
'host' => 'smtp.exmail.qq.com', 'port' => 465, 'secure' => 'ssl',
```
**QQ 邮箱 / 163 邮箱**
```php
'host' => 'smtp.qq.com'(或 smtp.163.com), 'port' => 465, 'secure' => 'ssl',
'pass' => '邮箱里生成的"授权码"(不是登录密码)',
```
- `secure = 'ssl'` 用 465 端口;`secure = 'tls'` 用 587 端口(STARTTLS);`''` 用 25 端口(不加密)。
- 若服务器缺少 CA 证书导致 TLS 报错,可临时把 `'verify_ssl' => false`。
- 通知策略在 `notify` 段:`on_every_reply = true` 表示**任何人回复都通知**发帖人;
改为 `false` 则**仅"官方"回复**才通知。
- 发送结果会记录在数据库 `notify_log` 表;失败的可用 `cron.php` 补发。
**可选:计划任务补发**(宝塔 → 计划任务 → Shell 脚本,每天一次)
```bash
php /www/wwwroot/你的域名/feedback/cron.php
```
---
## 四之二、审核发布(先审后显)
**默认行为**:访客提交的问题**不会立刻出现在公开列表里**。它先以"待审核"状态落到库中,
由管理员在审核后台决定**公开**还是**关闭**。这样公开区里只留下确认属于应用自身的问题,
其余的(环境差异、服务器限制、用法误解等)不占用版面,但**提问人一定会收到一封说明邮件**。
### 4.2.1 三种结果
| 管理员操作 | 数据库变化 | 提问人看到什么 |
|---|---|---|
| **通过并公开** | `visible = 1`;若之前是 `closed` 一并改回 `open` | 问题出现在公开列表,所有人可查看与回复 |
| **关闭并邮件告知** | `status = 'closed'`、`visible = 0`、写入 `mod_note` | 收到一封邮件:结论 + 管理员写的说明 + 原始反馈内容 |
| **撤下** | `visible = 0`(不改状态) | 从公开列表消失;管理员仍可在详情页看到 |
> 关闭时**必须填写说明**(留空会拒绝提交)——这段内容就是要发给提问人的正文,
> 所以请写得像"回复用户"而不是"内部备注"。
### 4.2.2 入口与登录
- 地址:`https://你的域名/feedback/admin.php`
- 口令:**初始就是 `config.php` 里的 `admin_password`**;登录后在「站点设置 → 修改登录口令」里改掉它,
新口令以 `password_hash()` 存进数据库,**优先级高于 `config.php`**(config 里的值此后只是出厂默认)
- 登录态存在会话里(`$_SESSION['fb_admin']` + 最后活跃时间),**空闲 2 小时自动退出**;
页面右上角「退出登录」可主动退出
- 口令连续输错 **10 次 / 小时**会被限流(按 IP 哈希计数),防暴力破解
- 该页带 `noindex,nofollow`,不会被搜索引擎收录
### 4.2.3 后台五个页签
| 页签 | 能做什么 |
|---|---|
| **待审核** | 列出 `visible = 0` 且状态非 `closed` 的问题。每张卡片有正文全文与提问人邮箱,配「通过并公开」「关闭并邮件告知」两个操作 |
| **全部问题** | 全量列表,支持按状态 / 分类 / 是否公开筛选,按标题或正文搜索,分页浏览 |
| **问题详情** | 以**官方身份回复**(可顺带改状态)、改处理状态与可见性、关闭并发信、**删除问题**(连同附件一起删) |
| **站点设置** | 附件数量与大小上限、存储方式(关闭 / 本机硬盘 / 阿里云 OSS)、OSS 桶与鉴权方式、审核开关,以及**修改登录口令** |
| **操作日志** | 最近 100 条管理动作(登录 / 通过 / 关闭 / 撤下 / 改状态 / 回复 / 删除 / 改设置 / 改口令),带时间与问题编号 |
> 关闭时**必须填写说明**(留空会拒绝提交)——这段内容就是要发给提问人的正文,
> 所以请写得像"回复用户"而不是"内部备注"。
### 4.2.4 关闭邮件长什么样
`lib/mailer.php` 的 `fb_notify_closed_html()` 生成正文,大致包含:
1. 一句结论("你反馈的问题经确认不属于应用问题");
2. 管理员填写的说明原文(`mod_note`);
3. 原始反馈内容(标题 + 正文摘录),方便对方回想是哪一条;
4. 结尾给出联系邮箱,说明"如仍有疑问可回复本邮件"。
主题由 `config.php` 的 `moderation.close_mail_subject` 决定,支持 `%s` 占位(会换成问题标题)。
### 4.2.5 操作留痕(`mod_log`)
每一次管理动作都会写一条记录到 `mod_log` 表(`action` / `thread_id` / `detail` / `created_at` / `ip_hash`),
在后台「操作日志」页可见。IP 仍只存加盐哈希,不落明文。
### 4.2.6 站点设置存在哪(`settings` 表覆盖 `config.php`)
后台改的设置**不写回 `config.php`**,而是存进数据库的 `settings` 表(键值对,值为 JSON):
- 读取时由 `fb_cfg()` 把 `settings` 的内容**叠加在 `config.php` 之上**(`array_replace_recursive`),
所以 `config.php` 成了"出厂默认值",部署后不必再手工改它;
- 只有白名单里的键能被改(`fb_setting_keys()`,见 `lib/util.php`),`db_path` 之类敏感项改不动;
- 保存后本进程内立即生效(`fb_set_setting()` 会刷新缓存并递增配置版本号)。
> 因此:**升级代码时可以继续不上传 `config.php`**,服务器上的真实配置不会被覆盖,
> 后台在数据库里改过的设置也不会丢。
- 发送结果同样记录进 `notify_log` 表(`reply_id = 0` 表示这是"关闭通知"而非"回复通知")。
- **SMTP 未启用时**:关闭操作照常生效,页面会提示"邮件未发出:SMTP 未启用…",
说明文本仍保存在 `mod_note` 里,配好 SMTP 后可人工补发。
### 4.2.5 相关配置(`config.php` 的 `moderation` 段)
```php
'moderation' => [
'enabled' => true, // false = 回到"提交即公开"的旧行为
'close_mail_subject' => '关于你反馈的问题《%s》',
],
```
### 4.2.6 数据库迁移(自动、幂等)
`lib/db.php` 在每次建库/升级时会检查并补齐三列,老站点不用手工改库:
| 列 | 类型 | 说明 |
|---|---|---|
| `visible` | `INTEGER NOT NULL DEFAULT 0` | 是否公开。**已存在的历史数据会被置为 1**(功能上线前的内容视为已通过) |
| `mod_note` | `TEXT` | 关闭说明(同时是邮件正文) |
| `moderated_at` | `INTEGER` | 最近一次审核时间;有值即出现在「已处理」里 |
另有索引 `idx_threads_visible(visible, created_at DESC)`,保证列表过滤不拖慢。
### 4.2.7 访客侧的配套表现
- 提交成功后跳到 `thread.php?id=…&submitted=1`,显示「提交成功,等待审核」的说明页。
- 审核中的帖子对普通访客**等同不存在**(列表不显示、详情页返回 404),避免泄露 URL 就能围观。
- 审核中的帖子**不允许回复**(POST 会被拦下并提示"还在审核中")。
- 管理员登录后,上述限制全部放开,方便先看完整页再决定。
---
## 五、附件上传(图片 / 视频)
### 5.0 先选存储方式(后台「站点设置 → 附件存储」,**随时可改**)
| 选项 | 文件存在哪 | 适合什么情况 | 代价 |
|---|---|---|---|
| **关闭** | — | 暂时不接受附件 | 上传入口自动隐藏,其余功能照常 |
| **本机硬盘** | 站点目录 `feedback/uploads/YYYY/MM/` | 想零配置、立刻可用;量小、访问不密集 | 占用服务器磁盘与带宽;需保证目录对 PHP 可写 |
| **阿里云 OSS** | 你自己的 Bucket(浏览器直传) | 量大、正式对外 | 需先建桶并授权;会有少量 OSS 费用 |
> **切换不会让旧附件失效**:每条附件走哪种存储,由它自己的对象键前缀决定——
> `local/…` 一律按本机文件读,`fb/…`(或你配的 `oss.prefix`)一律按 OSS 读。
> 所以中途从 OSS 换成硬盘(或反过来)时,**已经发过的帖子里的附件照常显示**。
**本机硬盘模式的两个注意点**
1. 目录由 PHP **按需自动创建**(`fb_local_writable()` 里的 `@mkdir`),属主就是 PHP 进程用户,天然可写。
⚠️ **不要用 SFTP/部署账号手动建这个 `uploads/` 目录**——那样属主会变成部署账号,
PHP 写不进去,上传会失败。后台「存储体检」会明确显示"可写 ✅ / 不可写 ❌"并给出处置办法;
真遇到了,**删掉整个 `uploads/`(含 `.htaccess`)再刷新页面**,PHP 会重建。
2. 目录里自动带一个 `.htaccess`:禁止列目录、禁止执行 `.php`/`.pl`/`.py`/`.cgi`/`.sh` 等脚本,
只允许当静态图片 / 视频读取。对象键由服务端生成并做正则白名单校验,客户端无法指定路径。
---
### 5.1 OSS 直传的原理与开通(约 15 分钟)
**为什么这样设计**:如果让用户把文件传到本站服务器,硬盘和带宽会被迅速吃满。所以本程序让浏览器
**直接把文件 POST 到 OSS**,服务器只做两件事:
1. 下发一段**有时效、有范围**的上传凭证(PostObject 策略签名 + HMAC-SHA1);
2. 提交表单时用 HEAD **回源复核**对象确实存在、大小与类型合规,再把元信息写进 SQLite。
文件实体永远不落在本站磁盘上(选 OSS 模式时)。
### 5.1a 鉴权方式:两种,后台可选
| 选项 | 需要准备 | 说明 |
|---|---|---|
| **ECS 实例 RAM 角色**(推荐) | 给 ECS 实例绑定一个 RAM 角色 | 服务器上**不保存任何长期密钥**;凭证由内网元数据服务(`http://100.100.100.200`)动态取回,阿里云自动轮换 |
| **静态 AccessKey** | 一对长期 AK/SK | 服务器不在阿里云 ECS 上时的唯一选择;密钥存在数据库 `settings` 表里(后台填写,页面永不回显 SK) |
> ⚠️ **RAM 角色名必须和实例上实际绑定的角色一致**。填错会表现为
> 「读取实例 RAM 角色失败(HTTP 404)」——后台「存储体检」会直接报出来。
> 到 ECS 控制台看实例详情里的「RAM 角色名」,或访问
> `http://100.100.100.200/latest/meta-data/ram/security-credentials/` 看可用角色列表。
> **这个角色必须授予该 Bucket 的读写权限**(至少 `oss:PostObject` + `oss:GetObject` + `oss:DeleteObject`)。
### 5.1b 开通步骤
> **前提(选 ECS 实例 RAM 角色时)**:本站运行在**阿里云 ECS** 上。
#### 先花 30 秒理解原理
给 ECS 实例绑定一个 RAM 角色后,实例内部可以通过**内网元数据服务**
(`http://100.100.100.200`,链路本地地址、不出公网)读到一份**临时凭证**:
```
AccessKeyId / AccessKeySecret / SecurityToken / Expiration
```
这份凭证只有几小时时效,并且会由阿里云侧**自动周期轮换**。本程序只负责
「把它取回来 → 缓存起来 → 拿去给上传签名」。好处是:
- 密钥**不落盘、不进配置文件** → 备份、Git、误截图都不会泄漏长期凭证;
- 权限由 RAM 角色决定 → 可以精确到「只有这个 Bucket 的这个前缀」;
- 要吊销?改角色的权限策略即可,不用满世界换密钥。
---
**第 1 步:创建 Bucket**(OSS 控制台 → Bucket 列表 → 创建 Bucket)
| 项 | 建议值 |
|---|---|
| 地域 | 选离你 ECS 近的(如华东1 杭州、华北2 北京) |
| 读写权限 | **公共读**(简单);想更严格选**私有**,程序会自动为展示链接签名 |
| 存储类型 | 标准存储(低频访问会额外产生取回费用,不建议) |
**第 2 步:建一条最小权限的 RAM 策略**(RAM 控制台 → 权限策略 → 创建策略 → 脚本编辑)
只授予**这一个 Bucket** 的读写删权限,其余一概不给:
```json
{
"Version": "1",
"Statement": [
{
"Effect": "Allow",
"Action": ["oss:PutObject", "oss:GetObject", "oss:DeleteObject"],
"Resource": ["acs:oss:*:*:你的bucket名/*"]
}
]
}
```
命名建议 `FeedLiteOssPolicy`。
**第 3 步:建一个 RAM 角色并赋予该策略**(RAM 控制台 → 角色 → 创建角色)
- 角色类型:选「**阿里云账号**」/「当前账号」→ 下一步
- 角色名称:`FeedLiteOssRole`(**记住这个名字**,第 7 步要填)
- 权限:勾上第 2 步创建的 `FeedLiteOssPolicy`
> 这里**不需要**勾「允许 RAM 用户扮演该角色」——扮演它的是 ECS 实例,不是某个用户。
**第 4 步:把角色授予 ECS 实例**
ECS 控制台 → 实例 → 找到本站所在实例 → 更多 → 实例设置 → **授予/收回 RAM 角色**
→ 选 `FeedLiteOssRole` → 确定。
> 菜单里找不到时,走 ECS 控制台左侧「**实例 RAM 角色**」→ 创建 → 再回实例详情授予。
> ⚠️ 同一个实例**只能有一个 RAM 角色**。若该实例已绑定别的角色,请把上面那条 OSS 策略
> **追加到那个角色的权限里**,而不是再绑一个角色。
> ⚠️ 授予以外的新角色后建议**重启一次实例**,让元数据服务立刻生效。
**第 5 步:验证(关键一步,别跳过)**
SSH 登录这台 ECS(角色名换成你自己的):
```bash
curl -s http://100.100.100.200/latest/meta-data/ram/security-credentials/FeedLiteOssRole
```
期望输出(`Code` 必须是 `Success`):
```json
{"AccessKeyId":"STS.NT…","AccessKeySecret":"…","SecurityToken":"CAIS…","Expiration":"2026-09-15T14:20:00Z","Code":"Success"}
```
返回 `404` / `Forbidden` / 连不通 → 说明**角色名写错**或**角色没授予实例**,先解决再往下走。
(服务器上没有 curl 也没关系,程序自带体检,见第 8 步。)
**第 6 步:配置 Bucket 的 CORS**(Bucket → 数据安全 → 跨域设置 → 创建规则)
浏览器是**跨域**把文件直接 POST 到 OSS 的,必须放行:
| 项 | 填什么 |
|---|---|
| 来源 Sources | `https://你的域名`(你的站点域名,可多行) |
| 允许方法 Methods | 勾 **POST、GET、HEAD** |
| 允许 Headers | `*` |
| 暴露 Headers | `ETag` |
**第 7 步:填写 `config.php` 的 `oss` 段**
```php
'oss' => [
'enabled' => true,
'endpoint' => 'oss-cn-hangzhou.aliyuncs.com', // 不含 http://
'bucket' => '你的bucket名',
'instance_role' => 'FeedLiteOssRole', // ★ 第 3 步的角色名
// 以下是可选增强,一般保持默认即可
// 'metadata_v2' => true, // 加固模式:读元数据前先取一次性 token
// 'domain' => 'https://fb-img.example.com', // 自定义访问域名(见第 9 步)
// 'visibility' => 'private', // 私有 Bucket 改成 private
],
```
注意:**这里没有 `access_key` / `secret_key`** —— 真的不需要。
**第 8 步:跑一次体检**
```bash
cd /www/wwwroot/你的站点/feedback
php cron.php
```
(**命令行方式不需要 `--token`**;只有用浏览器访问 `cron.php?token=…` 时才要带。)
期望看到:
```
附件:共 0 个,已清理超过 365 天的附件 0 个(剩余 0 个,占用 0 B)。
附件存储凭证:ECS 实例 RAM 角色(AK 尾号 …abc123),已取得临时安全令牌,有效期至 2026-09-15 14:20。
```
- 出现「**附件存储凭证:…**」且尾号、有效期都有 → 配置成功,去发帖页试传一张图即可。
- 出现「**附件存储未启用(请在 config.php 的 `oss` 段填写 endpoint / bucket / instance_role 并置 `enabled = true`)**」→ 说明第 7 步**没保存成功**:回去检查 `enabled` 是不是 `true`、`instance_role` 是不是填了角色名。
- 出现 `⚠️ 取不到凭证:…` → 按提示回到第 4 / 5 步排查。
**第 9 步(强烈推荐):绑定自定义域名**
Bucket → 传输管理 → 域名管理 → 绑定域名。不绑也能用,但 OSS 默认域名会带
`Content-Disposition: attachment`,图片点开是**下载**而不是预览。
绑定后把域名填到 `oss.domain`(如 `https://fb-img.example.com`)。
> 控制台会给出一个 CNAME 目标,去 DNS 加一条 CNAME 记录;若走 HTTPS,还需要在 OSS 上传该域名的证书。
---
#### 可调项(都在 `config.php` 的 `oss` 段)
| 参数 | 默认 | 说明 |
|---|---|---|
| `instance_role` | 空 | ★ 必填:ECS 实例 RAM 角色名 |
| `metadata_url` | 空 = `http://100.100.100.200` | 元数据服务地址,只有联调/自测才需要改 |
| `metadata_v2` | `false` | `true` = **加固模式**:先取一次性 token 再读元数据(防被 SSRF 探测) |
| `metadata_ttl` | `600` | 加固模式 token 的有效期(秒,最长 21600) |
| `credential_cache` | 空 = 与数据库同目录的 `oss.sts.php` | 凭证缓存文件(含 `<?php exit; ?>` 头 + 权限 0600,站点配置失误也无法被下载) |
| `credential_refresh_margin` | `300` | 到期前多少秒就提前换新,避免恰好在过期瞬间用旧凭证 |
| `sign_ttl` | `600` | 上传凭证有效期(秒) |
| `visibility` | `public` | 改成 `private` 时,展示链接自动带上签名 |
| `image_process` | `true` | 列表缩略图借用 OSS 图片处理,省流量 |
#### 程序做了哪些防呆
- **不会每个请求都问元数据服务**:临时凭证有**进程内记忆 + 本地文件缓存**,到 `credential_refresh_margin` 才换新。
- **上传策略有效期会被夹在临时凭证有效期之内** —— 否则 OSS 会因为「策略比凭证晚到期」而直接拒签。
- **`security-token` 会自动随请求发出**:PostObject 表单里多一个 `x-oss-security-token` 域;
HEAD / DELETE 的签名 URL 上也会带 `security-token`。**它不参与签名计算**,只是让 OSS 认出这是谁的临时身份。
- **取不到凭证时绝不返回"半截"凭证**,而是明确报错;前端也不会显示出上传入口。
- 提交表单时仍会 HEAD **回源复核**(存在性 / 大小 / 类型),防伪造、防超限。
#### 常见问题
| 现象 | 原因与处理 |
|---|---|
| `⚠️ 取不到凭证:读取实例 RAM 角色失败(HTTP 404)` | 角色名写错,或角色没授予该实例 |
| `⚠️ 取不到凭证:…(HTTP 0)` | 元数据服务不通:安全组/网络策略挡住了 `100.100.100.200`,或系统设了 `http_proxy` |
| 上传时浏览器报 CORS | 第 6 步的跨域规则没配,或来源域名写错 |
| 上传返回 403 | 角色策略与 Bucket 不匹配(至少要 `oss:PutObject`;私有 Bucket 还需 `GetObject`/`DeleteObject`) |
| 图片点开变成下载 | 没绑自定义域名(见第 9 步) |
| `SignatureDoesNotMatch` | ① 服务器系统时间不准(签名带时间戳),校准 NTP;② **使用了临时凭证但 `security-token` 没算进签名** —— 这是本程序修过的一个真实缺陷,见 `lib/oss.php` 里 `fb_oss_sign_v1()` 的注释 |
| 站点不在阿里云 ECS 上 | 改用「静态 AccessKey」鉴权(后台可切),或走本机硬盘模式 |
### 5.2 限额与清理
**全部限额都可以在后台「站点设置」里改**,改完立即生效,不必动 `config.php`:
| 项目 | 默认值 | 后台字段 |
|---|---|---|
| 图片数量 | 4 张 / 帖 | 每个问题最多几张图片 |
| 单张图片 | 5 MB | 单张图片上限(MB) |
| 图片格式 | jpg / png / webp / gif | **不含 SVG**(SVG 可执行脚本,属安全风险),此项固定 |
| 视频数量 | 1 个 / 帖 | 每个问题最多几个视频(改为 0 即关闭视频) |
| 单个视频 | 50 MB | 单个视频上限(MB) |
| 视频格式 | mp4 / mov / webm | 这几种浏览器可直接播放,此项固定 |
| 全站总量 | 1 GB | 全站附件总量上限(MB),达到后暂停接收新上传 |
| 保留期 | 365 天 | 附件保留天数,超期由 `cron.php` 自动删除 |
| 上传频率 | 60 次 / IP / 小时 | 每 IP 每小时最多上传几次 |
| 压缩最长边 | 1600 px | 图片压缩后的最长边(像素) |
> 后台对数值做了**上下限夹取**(如单张图片 0.2~50 MB、总量 10 MB~100 GB),
> 避免手滑填成 0 或天文数字。想恢复出厂值,把 `settings` 表里对应的行删掉即可。
### 5.3 计划任务(每天一次)
```bash
php /www/wwwroot/你的域名/feedback/cron.php
```
会做两件事:**删除超过保留期的附件**(先删 OSS 对象,成功后再删库记录,失败留待下次重试)、
**补发发送失败的通知邮件**。
### 5.4 费用参考(免费软件,几乎不花钱)
- 存储:标准存储约 **0.12 元/GB/月**(40GB 资源包约 9 元/年)
- 上传流量:**免费**
- 外网下行流量:约 0.25~0.50 元/GB(这是唯一可能产生费用的项,被恶意刷流量时需留意)
- 请求次数:PUT 每月 500 万次免费
- 图片处理(缩略图用到的 `x-oss-process`):每月 10TB 免费额度
> 按本项目的限额(1GB 上限、365 天清理),存储费一年不到 **2 元**。
> 如担心被刷下行流量,可在 OSS 控制台设置**流量带宽阈值告警**。
### 5.5 查看附件:缩略图 → 点击放大 → 左右切换
列表页显示缩略图(每帖最多 3 张,用 OSS 图片处理接口,流量很小)。详情页的附件区是「画廊」:
| 操作 | 效果 |
|---|---|
| 点击正文里的缩略图 | 页内遮罩放大(不跳新标签页) |
| ‹ › 按钮 / 键盘 ← → | 在同一帖的多个附件间切换 |
| 手机左右滑动 | 同上 |
| 键盘 `Home` / `End` | 跳到第一个 / 最后一个 |
| `Esc` 或点空白处 | 关闭 |
| 底部「查看原图」 | 新标签页打开未经处理的原图 |
- 视频以「放大」按钮进入同一条浏览序列,正文里的内嵌播放器保持可独立播放。
- 同一帖(或同一条回复)的附件构成一个独立画廊;不同帖之间不会互相串。
- 灯箱里加载的是 1600px 的处理图(省流量),需要原图时点「查看原图」。
- 图片本身是 `<a href="原图">`,即使浏览器禁用 JavaScript 也能点开原图。
- GIF 不做 OSS 图片处理 —— 否则会被压成静态首帧、丢失动画。
实现位置:`lib/attach.php` 的 `fb_attach_render()` 输出 `data-gallery` / `data-lb-*` 标记,
`assets/app.js` 的 `bindLightbox()` 负责交互,样式在 `assets/style.css` 的 `.lb-*`。
### 5.6 安全设计
- 对象键(key)由**服务端随机生成**并在策略里用 `eq` 锁定 → 客户端无法改名、无法覆盖已有文件
- 策略里 `content-length-range` 限制大小、`starts-with $Content-Type` 限制类型
- 扩展名 + MIME 双重白名单;凭证接口有 CSRF 校验和按 IP 频率限制
- 提交时**服务端 HEAD 复核**:对象不存在 / 大小不符 / 返回的 Content-Type 不在白名单 → 直接拒绝
- 附件展示的替代文字与文件名全部 HTML 转义
---
## 六、只读数据接口(供自动化 / AI 分析)
所有接口都需要令牌,支持 `?token=XXX` 或请求头 `X-Api-Token`。
| 用途 | 地址 |
|---|---|
| 主题列表 | `api.php?token=XXX&action=list` |
| 单个主题(含回复、附件) | `api.php?token=XXX&action=thread&id=12` |
| 全量导出(含回复、附件) | `api.php?token=XXX&action=export&since=1757000000` |
| 统计(含附件数量与占用) | `api.php?token=XXX&action=stats` |
| **Markdown 摘要**(便于阅读/喂给 AI) | `api.php?token=XXX&action=digest&status=open` |
通用参数:`status`(状态)、`category`(分类)、`since`(Unix 时间戳)、`limit`(默认 100)。
默认**不返回**邮箱与手机号;确需内部联系时加 `&contacts=1`。
附件以 `attachments` 数组返回(`kind` / `url` / `thumb` / `size` / `name`),可直接下载分析。
示例:
```bash
curl "https://你的域名/feedback/api.php?token=XXX&action=digest&status=open"
```
---
## 七、安全与隐私
- 数据库中**只保存**昵称、邮箱、手机号(选填)与内容;页面渲染**从不输出**邮箱与手机号。
- 访客 IP 以**加盐哈希**保存(`ip_hash`),不保存明文 IP。
- 全部输出经过 HTML 转义,SQL 全部使用预处理语句。
- 人机验证采用一次性会话题目,答对即焚;答错 5 次自动换题。
- 另有:蜜罐字段、最短填写耗时校验、按 IP 的每小时发帖/回复上限、CSRF 令牌。
- 附件不经过本站服务器;服务器上**不保存任何 OSS 长期密钥**,改用 ECS 实例 RAM 角色的临时凭证(见 5.1)。
- `data/` 目录用 `.htaccess` 拒绝一切直接访问;请勿删除该文件。
### 更换管理员口令 / 令牌
直接修改 `config.php` 即可,无需改代码。
---
## 七之二、`config.php` 里的三个"必改"参数是什么
这三项都写在 `config.php` 的「安全」段落里,出厂值都是 `CHANGE_ME_...` 占位符,**上线前必须换成自己的随机值**。
### `admin_password` —— 登录口令(初始值)
- **干什么用**:两个地方共用同一串,所以必须一致 ——
① 详情页回复框里填它 → 以「官方」身份发言;
② `/feedback/admin.php` 登录 → 进管理后台(审核、回复、改状态、删帖、改站点设置)。
- **建议**:**登录后台后在「站点设置 → 修改登录口令」里改成你自己的一串**(至少 8 位)。
改完会以 `password_hash()` 存进数据库,`config.php` 里这行就只是"出厂默认值"了 ——
⚠️ 此时改 `config.php` **不会**再影响登录口令,忘了新口令就去数据库把 `settings` 表里
`admin.password_hash` 这一行删掉,即可退回用 `config.php` 里的值。
- **它不是什么**:站点面向访客没有账号体系,也没有访客登录页。它只是一串比对用的口令 ——
表单里填的内容与库里的哈希(或 `config.php` 里的值)比对一次,一致就按官方处理。
- **怎么配**:随便生成一个 20~32 位的随机串,例如
```bash
php -r "echo bin2hex(random_bytes(16));"
```
把它填进 `'admin_password' => '...'`。**不要用你其它网站用过的密码**(这串值会明文躺在配置文件里)。
- **注意**:口令只用来判身份,所以**不要**把它填到公开的地方;一旦怀疑泄露,
到后台「站点设置」改掉即可(或删掉数据库里的哈希回退到 `config.php`)。
### `api_token` —— 给脚本 / AI 用的只读钥匙
- **干什么用**:`api.php` 提供的只读接口(`list` / `thread` / `export` / `stats` / `digest`)都要带令牌才返回数据。你后续想让 AI 或脚本"读一遍所有反馈、按 id 逐条分析、自动出摘要",用的就是它。
- **为什么单独一把钥匙**:读数据是高频且自动化的,用同一个口令既有泄露风险、也没法单独吊销。分开以后,哪天令牌泄了只换它,管理员口令不受影响。
- **怎么配**:同样用随机串(建议 ≥ 32 位),填进 `'api_token' => '...'`。
- **怎么用**:
```bash
curl "https://你的域名/feedback/api.php?token=你的令牌&action=digest&status=open"
# 也可以走请求头: -H "X-Api-Token: 你的令牌"
```
- **重要**:接口默认**不返回**邮箱和手机号;只有显式加 `&contacts=1` 才会带上。也就是说令牌泄露也不会直接泄露访客联系方式(但仍建议改掉)。
- **如果不用**:不配置也没关系(保持 `CHANGE_ME_api_token` 时接口会拒绝所有请求),站点本身照常运行。
### `ip_salt` —— 给访客 IP 加盐,避免存明文 IP
- **干什么用**:发帖、回复、频率限制都要记录"谁",直接存 IP 明文属于个人信息。程序的做法是把 `IP + ip_salt` 做一次 SHA-256,数据库里只落一串哈希(`ip_hash` 字段)。这样一来:同一个 IP 每次算出同一个哈希(所以**限流仍然有效** —— 能判断"这两次是不是同一个人"),但**无法从这个哈希反推出 IP**(因为盐是保密的,彩虹表也打不中)。
- **隐私政策对得上**:`upa.html` 里"不保存明文 IP"的说法,靠的就是这一项。
- **怎么配**:换成一串**随机且保密**的值(长度随意,建议 16 位以上)。**一旦上线就不要再改** —— 改了之后,历史记录的哈希与新哈希对不上,限流统计会从零开始(历史数据不会坏,只是同一个人会被当成新访客)。
- **和另外两项的区别**:它不需要你记住、也不需要日常使用,只是"设置完就忘掉"的一把盐。但它**不能留空、不能是默认值**,否则哈希就等价于 `sha256(IP)`,容易被暴力枚举反查。
### 一次性改完(示例)
```php
'admin_password' => 'a3f1c9e07b2d4f88b6e5c1a09d3f7b62', // 回复框当暗号 + 审核后台登录口令(两处共用)
'api_token' => '9c4d2e7a1b8f50316ed0a7c2b5f4183a6e9d27c5', // 给脚本/AI 读数据
'ip_salt' => 'q7Zx2Lm9Vt4Bp0Ry', // 给 IP 加盐,设完就别再改
```
---
## 八、常见问题
**Q:提交后提示"会话已过期"?**
A:浏览器未接受 Cookie(或站点非 HTTPS 导致 SameSite 问题)。请确认以 https 访问。
**Q:邮件没发出去?**
A:看 `config.php` 的 `smtp.enabled` 是否为 `true`;查看数据库 `notify_log` 表的 `err` 字段里的具体报错。
**Q:想改问题分类?**
A:改 `config.php` 的 `categories` 段即可(键值对,键会存进数据库)。
**Q:用户说"我提交了反馈,怎么列表里看不到"?**
A:这是正常的 —— 新反馈默认进入待审核,通过后才公开。到 `/feedback/admin.php` 处理即可。
**Q:想改回"提交即公开"?**
A:把 `config.php` 的 `moderation.enabled` 改成 `false`(历史数据里 `visible = 0` 的仍不会显示,需在后台手动通过)。
**Q:关闭时提示邮件未发出?**
A:关闭操作已经生效(说明存在 `mod_note` 里),只是 SMTP 没开或报错。检查 `smtp.enabled`
与 `notify_log` 里的 `err` 字段;配好后可在后台「撤下」再「关闭」一次重发。
**Q:想给某条问题改状态(如"已解决")?**
A:进 `/feedback/admin.php` → 全部问题 → 点「管理」进详情页,回复框下方有「处理状态」下拉,选好连同回复一起提交;也可以只改状态不回复。
**Q:后台改了设置,但前台好像没变?**
A:设置存在数据库 `settings` 表里,保存后**当前进程内立即生效**,其他进程最多等一个请求周期也会读到。
若长时间没变化,检查是否被**页面缓存**挡住了(宝塔「网站加速」/`fastcgi_cache`),清一次缓存即可。
**Q:附件上传报"本机附件目录不可写"?**
A:看后台「站点设置 → 存储体检」里的提示。最常见原因是**用 SFTP 手动建了 `uploads/`**,
属主是部署账号而非 PHP 用户。处置:删掉整个 `uploads/` 目录(含里面的 `.htaccess`),
刷新一次设置页,PHP 会自动重建并把它设为自己可写。
**Q:上传成功但图片打不开(403)?**
A:用了 OSS 且桶上配了**防盗链(Referer 白名单)**。到 OSS 控制台把站点域名
(如 `feedback.example.com`,或 `*.example.com`)加入白名单,并**允许空 Referer**
(否则在浏览器地址栏直接打开链接也会 403)。
**Q:想换 OSS 桶 / 换鉴权方式?**
A:后台「站点设置」里改,保存即生效。**旧附件不会失效**——每条附件按自己的对象键前缀
(`local/…` 或 OSS 前缀)去对应存储读取。只有"两个存储都不可用"时旧附件才会打不开。
**Q:数据库文件存在哪里?想备份?**
A:默认在 `data/feedback.db`(及其 `-wal`/`-shm` 文件)。定期打包 `data/` 目录即可(附件在 OSS 上,按需单独备份)。
**Q:看不到上传按钮?**
A:说明 OSS 未配置或 `oss.enabled` 为 `false`。填好 `config.php` 的 `oss` 段(`endpoint` / `bucket` / `instance_role`)并改为 `true` 即可。
**Q:上传失败提示"上传到存储失败(403)"?**
A:三种常见原因:
1. CORS 未配置或来源域名不匹配 → 回到 Bucket「跨域设置」补上你的站点域名,方法勾 POST;
2. 角色策略与 Bucket 不匹配 → 至少要有 `oss:PutObject`(私有 Bucket 还需 `GetObject` / `DeleteObject`);
3. 取不到临时凭证 → 跑 `php cron.php` 看体检输出(多半是角色名写错或角色没授予实例)。
**Q:怎么确认临时凭证是好的?**
A:`php cron.php --token=你的api_token`,会打印一行「附件存储凭证:ECS 实例 RAM 角色(AK 尾号 …),已取得临时安全令牌,有效期至 …」。没有这行或带 ⚠️ 就是有问题。
**Q:提示"附件存储空间已满"?**
A:全站附件总量达到 `upload.total_cap_bytes`(默认 1GB)。可在 OSS 控制台清理,或调大该值。
**Q:想彻底关闭附件功能?**
A:把 `upload.image_max_per_post` 与 `upload.video_max_per_post` 都设为 `0`(或 `oss.enabled = false`)。