FeedLite 初始提交:通用轻量级反馈系统(PHP + SQLite,零依赖)
- 前台:问题列表 / 详情 / 发布与回复(趣味问答验证码、蜜罐、IP 限流) - 后台:先审后显、站点设置(站点标题/描述、附件存储、OSS 参数)、操作日志 - 附件:关闭 / 本机硬盘 / 阿里云 OSS 浏览器直传(ECS 实例 RAM 角色)三种模式 - 品牌资源:全套图标(favicon.ico / SVG / PNG 各尺寸 / manifest) - 安全:CSRF、session 加固、.htaccess 屏蔽敏感文件、口令仅存哈希 - 配置:config.example.php 为模板,config.php 不入库
@@ -0,0 +1,25 @@
|
|||||||
|
# ---- 本地配置(含口令/令牌/密钥,绝不入库)----
|
||||||
|
/config.php
|
||||||
|
/config.local.php
|
||||||
|
|
||||||
|
# ---- 运行时数据 ----
|
||||||
|
/data/*.db
|
||||||
|
/data/*.db-wal
|
||||||
|
/data/*.db-shm
|
||||||
|
/data/oss.sts.php
|
||||||
|
/uploads/*
|
||||||
|
!/uploads/.htaccess
|
||||||
|
|
||||||
|
# ---- 编辑器 / 系统 ----
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# ---- 本地测试脚本与产物(不属于项目)----
|
||||||
|
/_e2e_smoke.py
|
||||||
|
/_e2e_report.txt
|
||||||
|
/_rewrite_*.py
|
||||||
|
/_rewrite_report.txt
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# 安全与整洁:禁用目录列表;阻止直接下载数据库 / 说明文件
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
<FilesMatch "\.(db|sqlite|sqlite3|log|md|ini|bak)$">
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order allow,deny
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
|
</FilesMatch>
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
# 轻反馈 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`)。
|
||||||
@@ -0,0 +1,733 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 反馈管理后台
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* 管理员用口令登录后可以:
|
||||||
|
* · 审核:通过(公开)/ 关闭(判定不是问题,邮件告知提问人)/ 撤下
|
||||||
|
* · 管理:以官方身份回复、改处理状态与可见性、删除问题
|
||||||
|
* · 设置:附件数量与大小上限、附件存储方式(关闭 / 本机硬盘 / 阿里云 OSS)、
|
||||||
|
* OSS 桶信息与鉴权方式(ECS 实例 RAM 角色 或 静态 AccessKey)
|
||||||
|
* · 修改自己的登录口令
|
||||||
|
*
|
||||||
|
* 入口:/feedback/admin.php
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
$pdo = fb_db();
|
||||||
|
|
||||||
|
/* ---------------- 退出 ---------------- */
|
||||||
|
if (isset($_GET['logout'])) {
|
||||||
|
unset($_SESSION['fb_admin'], $_SESSION['fb_admin_at']);
|
||||||
|
fb_redirect('admin.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 登录 ---------------- */
|
||||||
|
$loginErr = '';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login'])) {
|
||||||
|
$pw = isset($_POST['admin_password']) ? (string)$_POST['admin_password'] : '';
|
||||||
|
$cs = isset($_POST['csrf']) ? (string)$_POST['csrf'] : '';
|
||||||
|
if (!fb_csrf_ok($cs)) {
|
||||||
|
$loginErr = '会话已过期,请重试。';
|
||||||
|
} elseif (!fb_rate_check('admin_login', 10)) {
|
||||||
|
$loginErr = '尝试次数过多,请一小时后再试。';
|
||||||
|
} elseif ($pw !== '' && fb_admin_verify_password($pw)) {
|
||||||
|
fb_admin_login();
|
||||||
|
fb_mod_log('login');
|
||||||
|
fb_redirect('admin.php');
|
||||||
|
} else {
|
||||||
|
fb_rate_hit('admin_login');
|
||||||
|
$loginErr = '口令不正确。';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fb_admin_logged_in()) {
|
||||||
|
fb_layout_head('管理登录', ['robots' => 'noindex,nofollow']);
|
||||||
|
?>
|
||||||
|
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div>
|
||||||
|
<section class="narrow">
|
||||||
|
<h1>管理登录</h1>
|
||||||
|
<p class="muted">请输入管理员口令(初始口令在 <code>config.php</code> 的 <code>admin_password</code>,登录后在「站点设置」里可以改掉它)。</p>
|
||||||
|
<?php fb_flash_box($loginErr, 'error'); ?>
|
||||||
|
<form method="post" action="admin.php" class="card-form">
|
||||||
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
|
<label class="field">
|
||||||
|
<span>管理员口令</span>
|
||||||
|
<input type="password" name="admin_password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn primary" type="submit" name="login" value="1">登录</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<?php
|
||||||
|
fb_layout_foot();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================================================================
|
||||||
|
* 已登录:处理各种操作
|
||||||
|
* ================================================================== */
|
||||||
|
$msg = '';
|
||||||
|
$msgType = 'ok';
|
||||||
|
|
||||||
|
/** 取一条主题 */
|
||||||
|
function fb_admin_thread(PDO $pdo, $id) {
|
||||||
|
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
|
||||||
|
$st->execute([(int)$id]);
|
||||||
|
$t = $st->fetch();
|
||||||
|
return is_array($t) ? $t : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
|
||||||
|
$cs = isset($_POST['csrf']) ? (string)$_POST['csrf'] : '';
|
||||||
|
if (!fb_csrf_ok($cs)) {
|
||||||
|
$msg = '会话已过期,请重试。';
|
||||||
|
$msgType = 'error';
|
||||||
|
} else {
|
||||||
|
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
|
||||||
|
$act = (string)$_POST['act'];
|
||||||
|
$t = fb_admin_thread($pdo, $id);
|
||||||
|
|
||||||
|
if (!$t && $act !== 'settings' && $act !== 'password') {
|
||||||
|
$msg = '问题不存在。';
|
||||||
|
$msgType = 'error';
|
||||||
|
|
||||||
|
} elseif ($act === 'approve') {
|
||||||
|
$newStatus = ((string)$t['status'] === 'closed') ? 'open' : (string)$t['status'];
|
||||||
|
$pdo->prepare('UPDATE threads SET visible = 1, status = ?, mod_note = NULL, moderated_at = ? WHERE id = ?')
|
||||||
|
->execute([$newStatus, time(), $id]);
|
||||||
|
fb_mod_log('approve', $id, '通过审核并公开');
|
||||||
|
$msg = '#' . $id . ' 已通过审核,现已公开显示。';
|
||||||
|
|
||||||
|
} elseif ($act === 'close') {
|
||||||
|
$note = trim(isset($_POST['note']) ? (string)$_POST['note'] : '');
|
||||||
|
if ($note === '') {
|
||||||
|
$msg = '关闭前请先填写给提问人的说明(这段内容会随邮件发出)。';
|
||||||
|
$msgType = 'error';
|
||||||
|
} else {
|
||||||
|
$pdo->prepare("UPDATE threads SET status = 'closed', visible = 0, mod_note = ?, moderated_at = ? WHERE id = ?")
|
||||||
|
->execute([$note, time(), $id]);
|
||||||
|
list($sent, $err) = fb_notify_closed($pdo, $t, $note);
|
||||||
|
fb_mod_log('close', $id, '关闭并邮件告知:' . ($sent ? '已发信' : '未发信(' . $err . ')'));
|
||||||
|
$msg = $sent
|
||||||
|
? ('#' . $id . ' 已关闭,说明邮件已发送给 ' . (string)$t['author_email'] . '。')
|
||||||
|
: ('#' . $id . ' 已关闭,但邮件未发出:' . $err);
|
||||||
|
$msgType = $sent ? 'ok' : 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
} elseif ($act === 'hide') {
|
||||||
|
$pdo->prepare('UPDATE threads SET visible = 0, moderated_at = ? WHERE id = ?')->execute([time(), $id]);
|
||||||
|
fb_mod_log('hide', $id, '撤下(转为不公开)');
|
||||||
|
$msg = '#' . $id . ' 已撤下(转为不公开)。';
|
||||||
|
|
||||||
|
} elseif ($act === 'status') {
|
||||||
|
$statuses = fb_statuses();
|
||||||
|
$new = (string)(isset($_POST['status']) ? $_POST['status'] : '');
|
||||||
|
if (!isset($statuses[$new])) {
|
||||||
|
$msg = '未知的状态。';
|
||||||
|
$msgType = 'error';
|
||||||
|
} else {
|
||||||
|
$vis = isset($_POST['visible']) ? 1 : 0;
|
||||||
|
$pdo->prepare('UPDATE threads SET status = ?, visible = ?, updated_at = ? WHERE id = ?')
|
||||||
|
->execute([$new, $vis, time(), $id]);
|
||||||
|
fb_mod_log('status', $id, '状态改为 ' . $statuses[$new] . ',公开=' . $vis);
|
||||||
|
$msg = '#' . $id . ' 已更新:状态「' . $statuses[$new] . '」,' . ($vis ? '公开' : '不公开') . '。';
|
||||||
|
}
|
||||||
|
|
||||||
|
} elseif ($act === 'reply') {
|
||||||
|
$body = trim(isset($_POST['body']) ? (string)$_POST['body'] : '');
|
||||||
|
if (!fb_valid_body($body)) {
|
||||||
|
$msg = '回复内容需要 2~5000 个字符。';
|
||||||
|
$msgType = 'error';
|
||||||
|
} else {
|
||||||
|
$now = time();
|
||||||
|
$statuses = fb_statuses();
|
||||||
|
$new = (string)(isset($_POST['status']) ? $_POST['status'] : '');
|
||||||
|
$setStatus = isset($statuses[$new]) ? $new : null;
|
||||||
|
|
||||||
|
$ins = $pdo->prepare('INSERT INTO replies(thread_id, body, author_name, author_email, is_admin, created_at, ip_hash) VALUES(?,?,?,?,?,?,?)');
|
||||||
|
$ins->execute([$id, $body, '官方', null, 1, $now, fb_ip_hash()]);
|
||||||
|
$replyId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
if ($setStatus !== null) {
|
||||||
|
$pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ?, status = ? WHERE id = ?')
|
||||||
|
->execute([$now, $setStatus, $id]);
|
||||||
|
} else {
|
||||||
|
$pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ? WHERE id = ?')
|
||||||
|
->execute([$now, $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$replyRow = [
|
||||||
|
'id' => $replyId,
|
||||||
|
'body' => $body,
|
||||||
|
'author_name' => '官方',
|
||||||
|
'author_email' => null,
|
||||||
|
'is_admin' => 1,
|
||||||
|
];
|
||||||
|
$err2 = fb_notify_reply($pdo, $t, $replyRow, true);
|
||||||
|
fb_mod_log('reply', $id, '官方回复' . ($setStatus !== null ? ',状态改为 ' . $statuses[$setStatus] : ''));
|
||||||
|
$msg = '#' . $id . ' 已以官方身份回复' . ($err2 === '' ? '' : '(邮件:' . $err2 . ')') . '。';
|
||||||
|
}
|
||||||
|
|
||||||
|
} elseif ($act === 'delete') {
|
||||||
|
$st = $pdo->prepare('SELECT oss_key FROM attachments WHERE thread_id = ?');
|
||||||
|
$st->execute([$id]);
|
||||||
|
foreach ($st->fetchAll() as $r) { fb_attach_delete((string)$r['oss_key']); }
|
||||||
|
$pdo->prepare('DELETE FROM threads WHERE id = ?')->execute([$id]);
|
||||||
|
fb_mod_log('delete', $id, '删除问题:' . fb_excerpt((string)$t['title'], 40));
|
||||||
|
$msg = '#' . $id . ' 已删除。';
|
||||||
|
|
||||||
|
} elseif ($act === 'settings') {
|
||||||
|
$msg = fb_admin_save_settings();
|
||||||
|
|
||||||
|
} elseif ($act === 'password') {
|
||||||
|
$old = (string)(isset($_POST['old_password']) ? $_POST['old_password'] : '');
|
||||||
|
$n1 = (string)(isset($_POST['new_password']) ? $_POST['new_password'] : '');
|
||||||
|
$n2 = (string)(isset($_POST['new_password2']) ? $_POST['new_password2'] : '');
|
||||||
|
if (!fb_admin_verify_password($old)) {
|
||||||
|
$msg = '当前口令不正确。';
|
||||||
|
$msgType = 'error';
|
||||||
|
} elseif (fb_len($n1) < 8) {
|
||||||
|
$msg = '新口令至少 8 个字符。';
|
||||||
|
$msgType = 'error';
|
||||||
|
} elseif ($n1 !== $n2) {
|
||||||
|
$msg = '两次输入的新口令不一致。';
|
||||||
|
$msgType = 'error';
|
||||||
|
} else {
|
||||||
|
fb_admin_set_password($n1);
|
||||||
|
fb_mod_log('password');
|
||||||
|
$msg = '口令已修改,下次登录请使用新口令。';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存站点设置(把表单值写进 settings 表;config.php 仍是出厂默认值)
|
||||||
|
* @return string 提示语
|
||||||
|
*/
|
||||||
|
function fb_admin_save_settings() {
|
||||||
|
// PHP 会把表单字段名里的「.」替换成「_」,所以两种写法都读一遍
|
||||||
|
$post = function ($k, $def = null) {
|
||||||
|
if (isset($_POST[$k])) { return $_POST[$k]; }
|
||||||
|
$alt = str_replace('.', '_', $k);
|
||||||
|
if (isset($_POST[$alt])) { return $_POST[$alt]; }
|
||||||
|
return $def;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 站点信息(对外展示,用于说明「本系统为哪个软件提供反馈服务」)
|
||||||
|
// 留空 = 删除该设置项,回落到 config.php 的出厂值,便于恢复默认。
|
||||||
|
foreach (['site_name', 'site_desc'] as $k) {
|
||||||
|
$v = trim((string)$post($k, ''));
|
||||||
|
if ($v === '') { fb_unset_setting($k); }
|
||||||
|
else { fb_set_setting($k, $v); }
|
||||||
|
}
|
||||||
|
|
||||||
|
$ints = [
|
||||||
|
'upload.image_max_per_post' => [0, 20],
|
||||||
|
'upload.video_max_per_post' => [0, 10],
|
||||||
|
'upload.retention_days' => [1, 3650],
|
||||||
|
'upload.upload_per_hour' => [1, 2000],
|
||||||
|
'upload.image_max_side' => [480, 4096],
|
||||||
|
];
|
||||||
|
foreach ($ints as $k => $range) {
|
||||||
|
$v = (int)$post($k, 0);
|
||||||
|
$v = max($range[0], min($range[1], $v));
|
||||||
|
fb_set_setting($k, $v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MB → 字节
|
||||||
|
$mbs = [
|
||||||
|
'upload.image_max_bytes' => [0.2, 50],
|
||||||
|
'upload.video_max_bytes' => [1, 500],
|
||||||
|
'upload.total_cap_bytes' => [10, 100000],
|
||||||
|
];
|
||||||
|
foreach ($mbs as $k => $range) {
|
||||||
|
$mb = (float)$post($k, 0);
|
||||||
|
$mb = max($range[0], min($range[1], $mb));
|
||||||
|
fb_set_setting($k, (int)round($mb * 1048576));
|
||||||
|
}
|
||||||
|
|
||||||
|
$storage = (string)$post('attachment.storage', 'off');
|
||||||
|
if (!in_array($storage, ['off', 'local', 'oss'], true)) { $storage = 'off'; }
|
||||||
|
fb_set_setting('attachment.storage', $storage);
|
||||||
|
// OSS 模式下 oss.enabled 必须跟着开;其它模式关掉,避免误判
|
||||||
|
fb_set_setting('oss.enabled', $storage === 'oss' ? 1 : 0);
|
||||||
|
|
||||||
|
$texts = ['oss.endpoint', 'oss.bucket', 'oss.instance_role', 'oss.domain', 'oss.prefix', 'moderation.close_mail_subject'];
|
||||||
|
foreach ($texts as $k) {
|
||||||
|
$v = trim((string)$post($k, ''));
|
||||||
|
// 对象键前缀留空会让对象键变成 "/2026/...",回落到出厂默认 'fb'
|
||||||
|
if ($k === 'oss.prefix' && $v === '') { fb_unset_setting($k); continue; }
|
||||||
|
fb_set_setting($k, $v);
|
||||||
|
}
|
||||||
|
|
||||||
|
$src = (string)$post('oss.credential_source', 'instance_role');
|
||||||
|
fb_set_setting('oss.credential_source', $src === 'static' ? 'static' : 'instance_role');
|
||||||
|
fb_set_setting('oss.access_key', trim((string)$post('oss.access_key', '')));
|
||||||
|
$sk = trim((string)$post('oss.secret_key', ''));
|
||||||
|
if ($sk !== '') { fb_set_setting('oss.secret_key', $sk); }
|
||||||
|
|
||||||
|
fb_set_setting('oss.visibility', ((string)$post('oss.visibility', 'public')) === 'private' ? 'private' : 'public');
|
||||||
|
fb_set_setting('oss.url_scheme', ((string)$post('oss.url_scheme', 'https')) === 'http' ? 'http' : 'https');
|
||||||
|
fb_set_setting('oss.verify', $post('oss.verify') ? 1 : 0);
|
||||||
|
fb_set_setting('oss.image_process', $post('oss.image_process') ? 1 : 0);
|
||||||
|
fb_set_setting('moderation.enabled', $post('moderation.enabled') ? 1 : 0);
|
||||||
|
|
||||||
|
fb_mod_log('settings', null, '保存站点设置:存储=' . $storage);
|
||||||
|
return '设置已保存。';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================================================================
|
||||||
|
* 页面
|
||||||
|
* ================================================================== */
|
||||||
|
$tab = (string)(isset($_GET['tab']) ? $_GET['tab'] : 'pending');
|
||||||
|
if (!in_array($tab, ['pending', 'all', 'thread', 'settings', 'log'], true)) { $tab = 'pending'; }
|
||||||
|
|
||||||
|
fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
||||||
|
?>
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>管理后台</h1>
|
||||||
|
<p class="muted">登录状态 <?php echo (int)floor(fb_admin_idle_ttl() / 3600); ?> 小时内有效,超时自动退出。</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn" href="admin.php?logout=1">退出登录</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="tabs">
|
||||||
|
<a class="tab<?php echo $tab === 'pending' ? ' on' : ''; ?>" href="admin.php?tab=pending">待审核</a>
|
||||||
|
<a class="tab<?php echo $tab === 'all' ? ' on' : ''; ?>" href="admin.php?tab=all">全部问题</a>
|
||||||
|
<a class="tab<?php echo $tab === 'settings' ? ' on' : ''; ?>" href="admin.php?tab=settings">站点设置</a>
|
||||||
|
<a class="tab<?php echo $tab === 'log' ? ' on' : ''; ?>" href="admin.php?tab=log">操作日志</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<?php fb_flash_box($msg, $msgType === 'ok' ? 'ok' : 'error'); ?>
|
||||||
|
|
||||||
|
<?php if ($tab === 'pending'): ?>
|
||||||
|
<?php
|
||||||
|
$pending = $pdo->query(
|
||||||
|
"SELECT id, title, body, category, status, author_name, author_email, created_at
|
||||||
|
FROM threads WHERE visible = 0 AND status <> 'closed' ORDER BY created_at DESC"
|
||||||
|
)->fetchAll();
|
||||||
|
?>
|
||||||
|
<h2 style="margin:22px 0 10px">待审核 <span class="muted">(<?php echo count($pending); ?>)</span></h2>
|
||||||
|
<?php if (!$pending): ?>
|
||||||
|
<div class="empty"><p>当前没有待审核的反馈。</p></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($pending as $t) { echo fb_admin_thread_card($t); } ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php elseif ($tab === 'all'): ?>
|
||||||
|
<?php
|
||||||
|
$where = [];
|
||||||
|
$args = [];
|
||||||
|
$stF = (string)(isset($_GET['status']) ? $_GET['status'] : '');
|
||||||
|
$visF = (string)(isset($_GET['vis']) ? $_GET['vis'] : '');
|
||||||
|
$catF = (string)(isset($_GET['cat']) ? $_GET['cat'] : '');
|
||||||
|
$qF = trim((string)(isset($_GET['q']) ? $_GET['q'] : ''));
|
||||||
|
if ($stF !== '') { $where[] = 'status = ?'; $args[] = $stF; }
|
||||||
|
if ($visF === '1' || $visF === '0') { $where[] = 'visible = ?'; $args[] = (int)$visF; }
|
||||||
|
if ($catF !== '') { $where[] = 'category = ?'; $args[] = $catF; }
|
||||||
|
if ($qF !== '') { $where[] = '(title LIKE ? OR body LIKE ?)'; $args[] = '%' . $qF . '%'; $args[] = '%' . $qF . '%'; }
|
||||||
|
$wSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
|
||||||
|
|
||||||
|
$cntSt = $pdo->prepare('SELECT COUNT(*) FROM threads ' . $wSql);
|
||||||
|
$cntSt->execute($args);
|
||||||
|
$total = (int)$cntSt->fetchColumn();
|
||||||
|
$pageSize = max(5, (int)fb_cfg('page_size', 20));
|
||||||
|
$pages = max(1, (int)ceil($total / $pageSize));
|
||||||
|
$page = min(max(1, (int)(isset($_GET['page']) ? $_GET['page'] : 1)), $pages);
|
||||||
|
$offset = ($page - 1) * $pageSize;
|
||||||
|
|
||||||
|
$lst = $pdo->prepare('SELECT * FROM threads ' . $wSql . ' ORDER BY updated_at DESC LIMIT ' . (int)$pageSize . ' OFFSET ' . (int)$offset);
|
||||||
|
$lst->execute($args);
|
||||||
|
$rows = $lst->fetchAll();
|
||||||
|
?>
|
||||||
|
<form class="filters" method="get" action="admin.php">
|
||||||
|
<input type="hidden" name="tab" value="all">
|
||||||
|
<input type="text" name="q" value="<?php echo e($qF); ?>" placeholder="搜索标题或正文">
|
||||||
|
<select name="status">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<?php foreach (fb_statuses() as $k => $label): ?>
|
||||||
|
<option value="<?php echo e($k); ?>"<?php echo $stF === $k ? ' selected' : ''; ?>><?php echo e($label); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<select name="cat">
|
||||||
|
<option value="">全部分类</option>
|
||||||
|
<?php foreach (fb_categories() as $k => $label): ?>
|
||||||
|
<option value="<?php echo e($k); ?>"<?php echo $catF === $k ? ' selected' : ''; ?>><?php echo e($label); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<select name="vis">
|
||||||
|
<option value="">公开与否不限</option>
|
||||||
|
<option value="1"<?php echo $visF === '1' ? ' selected' : ''; ?>>已公开</option>
|
||||||
|
<option value="0"<?php echo $visF === '0' ? ' selected' : ''; ?>>未公开</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn" type="submit">筛选</button>
|
||||||
|
<a class="btn" href="admin.php?tab=all">重置</a>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 style="margin:16px 0 10px">全部问题 <span class="muted">(<?php echo $total; ?> 条,第 <?php echo $page; ?>/<?php echo $pages; ?> 页)</span></h2>
|
||||||
|
|
||||||
|
<?php if (!$rows): ?>
|
||||||
|
<div class="empty"><p>没有符合条件的问题。</p></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="list">
|
||||||
|
<?php foreach ($rows as $t): ?>
|
||||||
|
<div class="item" style="cursor:default">
|
||||||
|
<div class="item-top">
|
||||||
|
<span class="badge cat"><?php echo e(fb_category_label($t['category'])); ?></span>
|
||||||
|
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
|
||||||
|
<a class="item-title" href="admin.php?tab=thread&id=<?php echo (int)$t['id']; ?>"><?php echo e($t['title']); ?></a>
|
||||||
|
<span class="item-id">#<?php echo (int)$t['id']; ?></span>
|
||||||
|
<?php if ((int)$t['visible'] !== 1): ?><span class="badge st st-closed">未公开</span><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<p class="item-ex"><?php echo e(fb_excerpt((string)$t['body'], 120)); ?></p>
|
||||||
|
<div class="item-meta">
|
||||||
|
<span class="who"><?php echo e($t['author_name']); ?></span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span><?php echo e(fb_fmt_time($t['created_at'])); ?></span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span><?php echo (int)$t['reply_count']; ?> 条回复</span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<a href="admin.php?tab=thread&id=<?php echo (int)$t['id']; ?>">管理</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php if ($pages > 1): ?>
|
||||||
|
<div class="pager">
|
||||||
|
<?php if ($page > 1): ?><a class="btn" href="admin.php?<?php echo e(http_build_query(array_merge($_GET, ['tab' => 'all', 'page' => $page - 1]))); ?>">上一页</a><?php endif; ?>
|
||||||
|
<?php if ($page < $pages): ?><a class="btn" href="admin.php?<?php echo e(http_build_query(array_merge($_GET, ['tab' => 'all', 'page' => $page + 1]))); ?>">下一页</a><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php elseif ($tab === 'thread'): ?>
|
||||||
|
<?php
|
||||||
|
$id = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
|
||||||
|
$t = fb_admin_thread($pdo, $id);
|
||||||
|
if (!$t): ?>
|
||||||
|
<div class="empty"><p>问题不存在。<a href="admin.php?tab=all">返回列表</a></p></div>
|
||||||
|
<?php else:
|
||||||
|
$rs = $pdo->prepare('SELECT * FROM replies WHERE thread_id = ? ORDER BY created_at ASC, id ASC');
|
||||||
|
$rs->execute([$id]);
|
||||||
|
$replies = $rs->fetchAll();
|
||||||
|
$atts = fb_attach_for_thread($pdo, $id);
|
||||||
|
?>
|
||||||
|
<div class="crumbs"><a href="admin.php?tab=all">← 返回全部问题</a></div>
|
||||||
|
|
||||||
|
<div class="card-form" style="margin:0 0 16px">
|
||||||
|
<div class="item-top">
|
||||||
|
<span class="badge cat"><?php echo e(fb_category_label($t['category'])); ?></span>
|
||||||
|
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
|
||||||
|
<span class="item-title"><?php echo e($t['title']); ?></span>
|
||||||
|
<span class="item-id">#<?php echo (int)$t['id']; ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="item-ex" style="white-space:normal"><?php echo nl2br(e($t['body'])); ?></div>
|
||||||
|
<?php if (!empty($atts['main'])) { echo fb_attach_render($atts['main']); } ?>
|
||||||
|
<div class="item-meta">
|
||||||
|
<span class="who"><?php echo e($t['author_name']); ?></span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span><?php echo e($t['author_email']); ?></span>
|
||||||
|
<?php if (!empty($t['author_phone'])): ?><span class="dot">·</span><span><?php echo e($t['author_phone']); ?></span><?php endif; ?>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span><?php echo e(fb_fmt_time($t['created_at'])); ?></span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span><?php echo ((int)$t['visible'] === 1) ? '已公开' : '未公开'; ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php foreach ($replies as $r): ?>
|
||||||
|
<div class="reply<?php echo ((int)$r['is_admin'] === 1) ? ' admin' : ''; ?>">
|
||||||
|
<div class="reply-head">
|
||||||
|
<span class="who"><?php echo e($r['author_name']); ?></span>
|
||||||
|
<?php if ((int)$r['is_admin'] === 1): ?><span class="badge official">官方</span><?php endif; ?>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span><?php echo e(fb_fmt_time($r['created_at'])); ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="reply-body"><?php echo nl2br(e($r['body'])); ?></div>
|
||||||
|
<?php if (isset($atts['replies'][(int)$r['id']])) { echo fb_attach_render($atts['replies'][(int)$r['id']], ['compact' => true]); } ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<h2 style="margin:24px 0 10px">以官方身份回复</h2>
|
||||||
|
<form method="post" action="admin.php?tab=thread&id=<?php echo (int)$id; ?>" class="card-form">
|
||||||
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
|
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
|
||||||
|
<label class="field">
|
||||||
|
<span>回复内容</span>
|
||||||
|
<textarea name="body" rows="5" maxlength="5000" placeholder="写给提问人的答复;提交后会给对方发一封通知邮件"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>同时把状态改为</span>
|
||||||
|
<select name="status">
|
||||||
|
<option value="">(不改)</option>
|
||||||
|
<?php foreach (fb_statuses() as $k => $label): ?>
|
||||||
|
<option value="<?php echo e($k); ?>"><?php echo e($label); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn primary" type="submit" name="act" value="reply">发布官方回复</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 style="margin:24px 0 10px">状态与可见性</h2>
|
||||||
|
<form method="post" action="admin.php?tab=thread&id=<?php echo (int)$id; ?>" class="card-form">
|
||||||
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
|
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
|
||||||
|
<div class="row2">
|
||||||
|
<label class="field">
|
||||||
|
<span>处理状态</span>
|
||||||
|
<select name="status">
|
||||||
|
<?php foreach (fb_statuses() as $k => $label): ?>
|
||||||
|
<option value="<?php echo e($k); ?>"<?php echo ((string)$t['status'] === $k) ? ' selected' : ''; ?>><?php echo e($label); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>公开显示</span>
|
||||||
|
<label class="check"><input type="checkbox" name="visible" value="1"<?php echo ((int)$t['visible'] === 1) ? ' checked' : ''; ?>> 在公开列表中显示</label>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn primary" type="submit" name="act" value="status">保存</button>
|
||||||
|
<?php if ((int)$t['visible'] !== 1): ?>
|
||||||
|
<button class="btn" type="submit" name="act" value="approve">通过并公开</button>
|
||||||
|
<?php else: ?>
|
||||||
|
<button class="btn" type="submit" name="act" value="hide">撤下</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 style="margin:24px 0 10px">关闭(判定不是应用问题,会发邮件给提问人)</h2>
|
||||||
|
<form method="post" action="admin.php?tab=thread&id=<?php echo (int)$id; ?>" class="card-form">
|
||||||
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
|
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
|
||||||
|
<label class="field">
|
||||||
|
<span>关闭说明 <b class="req">*</b> <i class="hint">会作为邮件正文发给 <?php echo e($t['author_email']); ?></i></span>
|
||||||
|
<textarea name="note" rows="4" maxlength="4000" placeholder="例如:该现象来自服务器端的 CalDAV 限制,并非应用问题;建议…"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn" type="submit" name="act" value="close">关闭并邮件告知</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="admin.php?tab=all" class="card-form" style="margin-top:24px"
|
||||||
|
onsubmit="return confirm('确定删除 #<?php echo (int)$id; ?>?该问题的回复与附件会一并删除,且不可恢复。');">
|
||||||
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
|
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn danger" type="submit" name="act" value="delete">删除这条问题</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php elseif ($tab === 'settings'): ?>
|
||||||
|
<?php
|
||||||
|
$mode = fb_store_mode();
|
||||||
|
$ossStat = fb_oss_cred_status();
|
||||||
|
$mb = function ($bytes) { return round(((int)$bytes) / 1048576, 1); };
|
||||||
|
?>
|
||||||
|
<form method="post" action="admin.php?tab=settings" class="card-form">
|
||||||
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
|
|
||||||
|
<h2 style="margin:8px 0 10px">站点信息</h2>
|
||||||
|
<label class="field"><span>站点标题</span>
|
||||||
|
<input type="text" name="site_name" maxlength="80" placeholder="轻反馈"
|
||||||
|
value="<?php echo e((string)fb_cfg('site_name', '')); ?>">
|
||||||
|
<i class="hint">显示在浏览器标签、页面顶栏与页脚。用它说明「本系统为哪个软件提供反馈服务」,如「XX 软件 · 问题反馈」。</i></label>
|
||||||
|
<label class="field"><span>站点描述</span>
|
||||||
|
<textarea name="site_desc" rows="3" maxlength="300"
|
||||||
|
placeholder="欢迎在这里反馈使用问题、提出功能建议。"><?php echo e((string)fb_cfg('site_desc', '')); ?></textarea>
|
||||||
|
<i class="hint">显示在首页标题下方,一句话说明这里可以反馈什么。两项都留空则恢复为出厂默认。</i></label>
|
||||||
|
|
||||||
|
<h2 style="margin:24px 0 10px">附件存储</h2>
|
||||||
|
<label class="field">
|
||||||
|
<span>附件保存在哪</span>
|
||||||
|
<select name="attachment_storage">
|
||||||
|
<option value="off"<?php echo $mode === 'off' ? ' selected' : ''; ?>>关闭(不开放上传)</option>
|
||||||
|
<option value="local"<?php echo $mode === 'local' ? ' selected' : ''; ?>>本机硬盘(站点 uploads/ 目录)</option>
|
||||||
|
<option value="oss"<?php echo $mode === 'oss' ? ' selected' : ''; ?>>阿里云 OSS(浏览器直传)</option>
|
||||||
|
</select>
|
||||||
|
<i class="hint">本机:文件落在站点目录,占用服务器磁盘与带宽,但零配置、立刻可用;OSS:文件不经过服务器,需先建桶并授权。</i>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="grid2">
|
||||||
|
<label class="field"><span>每个问题最多几张图片</span>
|
||||||
|
<input type="number" name="upload_image_max_per_post" min="0" max="20" value="<?php echo (int)fb_upload_cfg('image_max_per_post', 4); ?>"></label>
|
||||||
|
<label class="field"><span>单张图片上限(MB)</span>
|
||||||
|
<input type="number" step="0.1" min="0.2" max="50" name="upload_image_max_bytes" value="<?php echo $mb(fb_upload_cfg('image_max_bytes', 5242880)); ?>"></label>
|
||||||
|
<label class="field"><span>每个问题最多几个视频</span>
|
||||||
|
<input type="number" name="upload_video_max_per_post" min="0" max="10" value="<?php echo (int)fb_upload_cfg('video_max_per_post', 1); ?>"></label>
|
||||||
|
<label class="field"><span>单个视频上限(MB)</span>
|
||||||
|
<input type="number" step="1" min="1" max="500" name="upload_video_max_bytes" value="<?php echo $mb(fb_upload_cfg('video_max_bytes', 52428800)); ?>"></label>
|
||||||
|
<label class="field"><span>全站附件总量上限(MB)</span>
|
||||||
|
<input type="number" step="16" min="10" name="upload_total_cap_bytes" value="<?php echo $mb(fb_upload_cfg('total_cap_bytes', 1073741824)); ?>"></label>
|
||||||
|
<label class="field"><span>附件保留天数</span>
|
||||||
|
<input type="number" name="upload_retention_days" min="1" max="3650" value="<?php echo (int)fb_upload_cfg('retention_days', 365); ?>"></label>
|
||||||
|
<label class="field"><span>每 IP 每小时最多上传几次</span>
|
||||||
|
<input type="number" name="upload_upload_per_hour" min="1" max="2000" value="<?php echo (int)fb_upload_cfg('upload_per_hour', 60); ?>"></label>
|
||||||
|
<label class="field"><span>图片压缩后的最长边(像素)</span>
|
||||||
|
<input type="number" name="upload_image_max_side" min="480" max="4096" value="<?php echo (int)fb_upload_cfg('image_max_side', 1600); ?>"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin:24px 0 10px">阿里云 OSS(选「OSS」时才需要填)</h2>
|
||||||
|
<div class="grid2">
|
||||||
|
<label class="field"><span>Endpoint</span>
|
||||||
|
<input type="text" name="oss_endpoint" placeholder="oss-cn-beijing.aliyuncs.com" value="<?php echo e((string)fb_cfg('oss.endpoint', '')); ?>"></label>
|
||||||
|
<label class="field"><span>Bucket 名</span>
|
||||||
|
<input type="text" name="oss_bucket" value="<?php echo e((string)fb_cfg('oss.bucket', '')); ?>"></label>
|
||||||
|
<label class="field"><span>鉴权方式</span>
|
||||||
|
<select name="oss_credential_source">
|
||||||
|
<option value="instance_role"<?php echo fb_oss_credential_source() === 'instance_role' ? ' selected' : ''; ?>>ECS 实例 RAM 角色(推荐)</option>
|
||||||
|
<option value="static"<?php echo fb_oss_credential_source() === 'static' ? ' selected' : ''; ?>>静态 AccessKey</option>
|
||||||
|
</select></label>
|
||||||
|
<label class="field"><span>ECS 实例 RAM 角色名</span>
|
||||||
|
<input type="text" name="oss_instance_role" placeholder="FeedLiteOssRole" value="<?php echo e((string)fb_cfg('oss.instance_role', '')); ?>"></label>
|
||||||
|
<label class="field"><span>AccessKeyId(静态鉴权用)</span>
|
||||||
|
<input type="text" name="oss_access_key" value="<?php echo e((string)fb_cfg('oss.access_key', '')); ?>"></label>
|
||||||
|
<label class="field"><span>AccessKeySecret(留空=不修改)</span>
|
||||||
|
<input type="password" name="oss_secret_key" autocomplete="new-password"
|
||||||
|
placeholder="<?php echo (string)fb_cfg('oss.secret_key', '') !== '' ? '已设置,留空保持不变' : '未设置'; ?>"></label>
|
||||||
|
<label class="field"><span>自定义访问域名(可选)</span>
|
||||||
|
<input type="text" name="oss_domain" placeholder="https://fb-img.example.com" value="<?php echo e((string)fb_cfg('oss.domain', '')); ?>"></label>
|
||||||
|
<label class="field"><span>对象键前缀</span>
|
||||||
|
<input type="text" name="oss_prefix" value="<?php echo e((string)fb_cfg('oss.prefix', 'fb')); ?>"></label>
|
||||||
|
<label class="field"><span>桶读写权限</span>
|
||||||
|
<select name="oss_visibility">
|
||||||
|
<option value="public"<?php echo ((string)fb_cfg('oss.visibility', 'public')) === 'public' ? ' selected' : ''; ?>>公共读</option>
|
||||||
|
<option value="private"<?php echo ((string)fb_cfg('oss.visibility', 'public')) === 'private' ? ' selected' : ''; ?>>私有(展示时签名)</option>
|
||||||
|
</select></label>
|
||||||
|
<label class="field"><span>访问协议</span>
|
||||||
|
<select name="oss_url_scheme">
|
||||||
|
<option value="https"<?php echo ((string)fb_cfg('oss.url_scheme', 'https')) === 'https' ? ' selected' : ''; ?>>https</option>
|
||||||
|
<option value="http"<?php echo ((string)fb_cfg('oss.url_scheme', 'https')) === 'http' ? ' selected' : ''; ?>>http</option>
|
||||||
|
</select></label>
|
||||||
|
</div>
|
||||||
|
<label class="check"><input type="checkbox" name="oss_verify" value="1"<?php echo fb_cfg('oss.verify', true) ? ' checked' : ''; ?>> 提交时回源复核附件(防伪造)</label>
|
||||||
|
<label class="check"><input type="checkbox" name="oss_image_process" value="1"<?php echo fb_cfg('oss.image_process', true) ? ' checked' : ''; ?>> 缩略图使用 OSS 图片处理(省流量)</label>
|
||||||
|
|
||||||
|
<h2 style="margin:24px 0 10px">审核</h2>
|
||||||
|
<label class="check"><input type="checkbox" name="moderation_enabled" value="1"<?php echo fb_moderation_on() ? ' checked' : ''; ?>> 先审后显(新提交的问题默认不公开,需你通过后才显示)</label>
|
||||||
|
<label class="field"><span>关闭通知邮件标题</span>
|
||||||
|
<input type="text" name="moderation_close_mail_subject" value="<?php echo e((string)fb_cfg('moderation.close_mail_subject', '关于你反馈的问题《%s》')); ?>">
|
||||||
|
<i class="hint">%s 会替换成问题标题。</i></label>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn primary" type="submit" name="act" value="settings">保存设置</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 style="margin:26px 0 10px">存储体检</h2>
|
||||||
|
<div class="card-form">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px">
|
||||||
|
<span>当前存储方式:<b><?php echo $mode === 'oss' ? '阿里云 OSS' : ($mode === 'local' ? '本机硬盘' : '已关闭'); ?></b></span>
|
||||||
|
<?php if ($mode === 'local'): ?>
|
||||||
|
<?php $ld = fb_local_diag(); ?>
|
||||||
|
<span>上传目录:<code><?php echo e($ld['dir']); ?></code> — <?php echo $ld['ok'] ? '可写 ✅' : '不可写 ❌'; ?></span>
|
||||||
|
<?php if (!$ld['ok']): ?>
|
||||||
|
<span class="muted small"><?php echo e($ld['tip']); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php elseif ($mode === 'oss'): ?>
|
||||||
|
<span>鉴权方式:<b><?php echo e($ossStat['label']); ?></b></span>
|
||||||
|
<span>凭证状态:<?php echo !empty($ossStat['ok']) ? ('已取得 AccessKey(尾号 ' . e($ossStat['ak_tail']) . ')✅') : ('未取得 ❌ ' . e((string)$ossStat['error'])); ?></span>
|
||||||
|
<span>Bucket:<?php echo e((string)fb_cfg('oss.bucket', '')); ?> / Endpoint:<?php echo e((string)fb_cfg('oss.endpoint', '')); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span>已用附件:<?php echo e(fb_size_h(fb_attach_total_bytes($pdo))); ?> / 上限 <?php echo e(fb_size_h(fb_upload_cfg('total_cap_bytes', 0))); ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin:26px 0 10px">修改登录口令</h2>
|
||||||
|
<form method="post" action="admin.php?tab=settings" class="card-form">
|
||||||
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
|
<div class="grid2">
|
||||||
|
<label class="field"><span>当前口令</span><input type="password" name="old_password" autocomplete="current-password"></label>
|
||||||
|
<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>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn" type="submit" name="act" value="password">修改口令</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted small">新口令保存在数据库里,优先级高于 <code>config.php</code> 的 <code>admin_password</code>;改完立即生效。</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php else: ?>
|
||||||
|
<?php
|
||||||
|
$logs = $pdo->query('SELECT * FROM mod_log ORDER BY created_at DESC, id DESC LIMIT 100')->fetchAll();
|
||||||
|
?>
|
||||||
|
<h2 style="margin:8px 0 10px">最近操作 <span class="muted">(<?php echo count($logs); ?> 条)</span></h2>
|
||||||
|
<?php if (!$logs): ?>
|
||||||
|
<div class="empty"><p>还没有操作记录。</p></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="list">
|
||||||
|
<?php foreach ($logs as $l): ?>
|
||||||
|
<div class="item" style="cursor:default">
|
||||||
|
<div class="item-top">
|
||||||
|
<span class="badge cat"><?php echo e((string)$l['action']); ?></span>
|
||||||
|
<?php if (!empty($l['thread_id'])): ?>
|
||||||
|
<a class="item-title" href="admin.php?tab=thread&id=<?php echo (int)$l['thread_id']; ?>">#<?php echo (int)$l['thread_id']; ?></a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<p class="item-ex"><?php echo e((string)$l['detail']); ?></p>
|
||||||
|
<div class="item-meta"><span><?php echo e(fb_fmt_time($l['created_at'])); ?></span></div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
fb_layout_foot();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待审核卡片(含「通过」与「关闭并邮件告知」两个操作)
|
||||||
|
*/
|
||||||
|
function fb_admin_thread_card(array $t) {
|
||||||
|
ob_start(); ?>
|
||||||
|
<div class="card-form" style="margin:0 0 16px">
|
||||||
|
<div class="item-top">
|
||||||
|
<span class="badge cat"><?php echo e(fb_category_label($t['category'])); ?></span>
|
||||||
|
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
|
||||||
|
<span class="item-title"><?php echo e($t['title']); ?></span>
|
||||||
|
<span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-ex" style="white-space:normal"><?php echo nl2br(e($t['body'])); ?></div>
|
||||||
|
|
||||||
|
<div class="item-meta">
|
||||||
|
<span class="who"><?php echo e($t['author_name']); ?></span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span><?php echo e(fb_fmt_time($t['created_at'])); ?></span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span><?php echo e($t['author_email']); ?></span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<a href="admin.php?tab=thread&id=<?php echo (int)$t['id']; ?>">进入管理</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row2" style="margin-top:14px">
|
||||||
|
<form method="post" action="admin.php?tab=pending">
|
||||||
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
|
<input type="hidden" name="id" value="<?php echo (int)$t['id']; ?>">
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn primary" type="submit" name="act" value="approve">通过并公开</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="admin.php?tab=pending">
|
||||||
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
|
<input type="hidden" name="id" value="<?php echo (int)$t['id']; ?>">
|
||||||
|
<label class="field">
|
||||||
|
<span>关闭说明 <b class="req">*</b> <i class="hint">判定"不是问题"时填写,会随邮件发给提问人</i></span>
|
||||||
|
<textarea name="note" rows="4" maxlength="4000" placeholder="例如:该现象是服务器端的 CalDAV 限制,并非应用问题;建议改用…"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn" type="submit" name="act" value="close">关闭并邮件告知</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 只读数据接口(供自动化 / AI 分析使用)
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* 需要令牌(config.php 里的 api_token):
|
||||||
|
* api.php?token=XXX&action=list
|
||||||
|
* api.php?token=XXX&action=thread&id=12
|
||||||
|
* api.php?token=XXX&action=export # 全量(默认不含邮箱/手机号)
|
||||||
|
* api.php?token=XXX&action=export&contacts=1 # 含联系方式(仅内部使用)
|
||||||
|
* api.php?token=XXX&action=digest # 返回 Markdown 摘要,便于阅读
|
||||||
|
* api.php?token=XXX&action=stats
|
||||||
|
*
|
||||||
|
* 通用参数:status / category / since(Unix 时间戳)/ limit
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
function fb_api_json($data, $code = 200) {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected = (string)fb_cfg('api_token');
|
||||||
|
$got = isset($_GET['token']) ? (string)$_GET['token'] : '';
|
||||||
|
if (!empty($_SERVER['HTTP_X_API_TOKEN'])) { $got = (string)$_SERVER['HTTP_X_API_TOKEN']; }
|
||||||
|
if ($expected === '' || $expected === 'CHANGE_ME_api_token' || !hash_equals($expected, $got)) {
|
||||||
|
fb_api_json(['ok' => false, 'error' => 'invalid token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = fb_db();
|
||||||
|
$action = isset($_GET['action']) ? (string)$_GET['action'] : 'list';
|
||||||
|
$contacts = !empty($_GET['contacts']);
|
||||||
|
$limit = isset($_GET['limit']) ? max(1, min(500, (int)$_GET['limit'])) : 100;
|
||||||
|
|
||||||
|
$cats = fb_categories();
|
||||||
|
$statuses = fb_statuses();
|
||||||
|
|
||||||
|
/** 过滤条件 */
|
||||||
|
function fb_api_where(&$args, $cats, $statuses) {
|
||||||
|
$w = [];
|
||||||
|
if (!empty($_GET['status']) && isset($statuses[(string)$_GET['status']])) {
|
||||||
|
$w[] = 'status = ?'; $args[] = (string)$_GET['status'];
|
||||||
|
}
|
||||||
|
if (!empty($_GET['category']) && isset($cats[(string)$_GET['category']])) {
|
||||||
|
$w[] = 'category = ?'; $args[] = (string)$_GET['category'];
|
||||||
|
}
|
||||||
|
if (!empty($_GET['since'])) {
|
||||||
|
$w[] = 'updated_at >= ?'; $args[] = (int)$_GET['since'];
|
||||||
|
}
|
||||||
|
return $w ? (' WHERE ' . implode(' AND ', $w)) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主题行 -> 对外结构 */
|
||||||
|
function fb_api_thread(array $t, $contacts) {
|
||||||
|
$out = [
|
||||||
|
'id' => (int)$t['id'],
|
||||||
|
'title' => $t['title'],
|
||||||
|
'category' => $t['category'],
|
||||||
|
'category_cn' => fb_category_label($t['category']),
|
||||||
|
'status' => $t['status'],
|
||||||
|
'status_cn' => fb_status_label($t['status']),
|
||||||
|
'author' => $t['author_name'],
|
||||||
|
'reply_count' => (int)$t['reply_count'],
|
||||||
|
'created_at' => (int)$t['created_at'],
|
||||||
|
'created_time' => date('Y-m-d H:i', (int)$t['created_at']),
|
||||||
|
'updated_at' => (int)$t['updated_at'],
|
||||||
|
'updated_time' => date('Y-m-d H:i', (int)$t['updated_at']),
|
||||||
|
'url' => fb_base_url() . '/thread.php?id=' . (int)$t['id'],
|
||||||
|
];
|
||||||
|
if (isset($t['body'])) { $out['body'] = $t['body']; }
|
||||||
|
if ($contacts) {
|
||||||
|
$out['author_email'] = isset($t['author_email']) ? $t['author_email'] : null;
|
||||||
|
$out['author_phone'] = isset($t['author_phone']) ? $t['author_phone'] : null;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fb_api_reply(array $r, $contacts) {
|
||||||
|
$out = [
|
||||||
|
'id' => (int)$r['id'],
|
||||||
|
'author' => !empty($r['is_admin']) ? '官方' : $r['author_name'],
|
||||||
|
'is_official'=> !empty($r['is_admin']),
|
||||||
|
'created_at' => (int)$r['created_at'],
|
||||||
|
'created_time' => date('Y-m-d H:i', (int)$r['created_at']),
|
||||||
|
'body' => $r['body'],
|
||||||
|
];
|
||||||
|
if ($contacts && !empty($r['author_email'])) { $out['author_email'] = $r['author_email']; }
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单条附件的对外结构(不含任何本地路径) */
|
||||||
|
function fb_api_attach(array $a) {
|
||||||
|
$kind = (string)$a['kind'];
|
||||||
|
$key = (string)$a['oss_key'];
|
||||||
|
$out = [
|
||||||
|
'id' => (int)$a['id'],
|
||||||
|
'kind' => $kind,
|
||||||
|
'name' => isset($a['orig_name']) ? $a['orig_name'] : null,
|
||||||
|
'mime' => isset($a['mime']) ? $a['mime'] : null,
|
||||||
|
'size' => (int)$a['size'],
|
||||||
|
'url' => fb_attach_url($key),
|
||||||
|
];
|
||||||
|
if ($kind === 'image') {
|
||||||
|
$out['thumb'] = fb_attach_thumb_url($key, 640);
|
||||||
|
$out['width'] = isset($a['width']) ? (int)$a['width'] : null;
|
||||||
|
$out['height'] = isset($a['height']) ? (int)$a['height'] : null;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取主题的全部附件:[$mainList, [reply_id => [..]]] */
|
||||||
|
function fb_api_attach_rows(PDO $pdo, $threadId) {
|
||||||
|
$st = $pdo->prepare('SELECT * FROM attachments WHERE thread_id = ? ORDER BY seq ASC, id ASC');
|
||||||
|
$st->execute([(int)$threadId]);
|
||||||
|
$main = [];
|
||||||
|
$rep = [];
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
$a = fb_api_attach($r);
|
||||||
|
if (empty($r['reply_id'])) { $main[] = $a; }
|
||||||
|
else { $rep[(int)$r['reply_id']][] = $a; }
|
||||||
|
}
|
||||||
|
return [$main, $rep];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取一个主题的全部回复 */
|
||||||
|
function fb_api_replies(PDO $pdo, $threadId, $contacts, array $attByReply = []) {
|
||||||
|
$st = $pdo->prepare('SELECT * FROM replies WHERE thread_id = ? ORDER BY created_at ASC, id ASC');
|
||||||
|
$st->execute([(int)$threadId]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
$item = fb_api_reply($r, $contacts);
|
||||||
|
if (!empty($attByReply[(int)$r['id']])) {
|
||||||
|
$item['attachments'] = $attByReply[(int)$r['id']];
|
||||||
|
}
|
||||||
|
$out[] = $item;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------- 路由 -------------------- */
|
||||||
|
|
||||||
|
if ($action === 'stats') {
|
||||||
|
$byStatus = [];
|
||||||
|
foreach ($pdo->query('SELECT status, COUNT(*) c FROM threads GROUP BY status')->fetchAll() as $r) {
|
||||||
|
$byStatus[$r['status']] = (int)$r['c'];
|
||||||
|
}
|
||||||
|
$byCat = [];
|
||||||
|
foreach ($pdo->query('SELECT category, COUNT(*) c FROM threads GROUP BY category')->fetchAll() as $r) {
|
||||||
|
$byCat[$r['category']] = (int)$r['c'];
|
||||||
|
}
|
||||||
|
fb_api_json([
|
||||||
|
'ok' => true,
|
||||||
|
'total' => (int)$pdo->query('SELECT COUNT(*) FROM threads')->fetchColumn(),
|
||||||
|
'replies' => (int)$pdo->query('SELECT COUNT(*) FROM replies')->fetchColumn(),
|
||||||
|
'attachments'=> (int)$pdo->query('SELECT COUNT(*) FROM attachments')->fetchColumn(),
|
||||||
|
'attach_bytes' => fb_attach_total_bytes($pdo),
|
||||||
|
'by_status' => $byStatus,
|
||||||
|
'by_category'=> $byCat,
|
||||||
|
'server_time'=> date('c'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'thread') {
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
|
if ($id <= 0) { fb_api_json(['ok' => false, 'error' => 'id required'], 400); }
|
||||||
|
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
|
||||||
|
$st->execute([$id]);
|
||||||
|
$t = $st->fetch();
|
||||||
|
if (!$t) { fb_api_json(['ok' => false, 'error' => 'not found'], 404); }
|
||||||
|
list($mainAtt, $repAtt) = fb_api_attach_rows($pdo, $id);
|
||||||
|
$data = fb_api_thread($t, $contacts);
|
||||||
|
$data['attachments'] = $mainAtt;
|
||||||
|
$data['replies'] = fb_api_replies($pdo, $id, $contacts, $repAtt);
|
||||||
|
fb_api_json(['ok' => true, 'thread' => $data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'digest') {
|
||||||
|
$args = [];
|
||||||
|
$w = fb_api_where($args, $cats, $statuses);
|
||||||
|
$sql = 'SELECT * FROM threads' . $w . ' ORDER BY updated_at DESC LIMIT ' . $limit;
|
||||||
|
$st = $pdo->prepare($sql);
|
||||||
|
$st->execute($args);
|
||||||
|
$rows = $st->fetchAll();
|
||||||
|
|
||||||
|
header('Content-Type: text/markdown; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
echo '# ' . fb_cfg('site_name', '轻反馈') . " · 反馈摘要\n\n";
|
||||||
|
echo '- 生成时间:' . date('Y-m-d H:i') . "\n";
|
||||||
|
echo '- 筛选:状态=' . (isset($_GET['status']) ? $_GET['status'] : '全部')
|
||||||
|
. ',分类=' . (isset($_GET['category']) ? $_GET['category'] : '全部') . "\n";
|
||||||
|
echo '- 条数:' . count($rows) . "\n\n---\n\n";
|
||||||
|
foreach ($rows as $t) {
|
||||||
|
echo '## #' . (int)$t['id'] . ' [' . fb_category_label($t['category']) . '] ' . $t['title'] . "\n\n";
|
||||||
|
echo '- 状态:' . fb_status_label($t['status']) . "\n";
|
||||||
|
echo '- 发起人:' . $t['author_name'] . '(' . date('Y-m-d H:i', (int)$t['created_at']) . ")\n";
|
||||||
|
echo '- 链接:' . fb_base_url() . '/thread.php?id=' . (int)$t['id'] . "\n";
|
||||||
|
list($mainAtt, ) = fb_api_attach_rows($pdo, $t['id']);
|
||||||
|
if ($mainAtt) {
|
||||||
|
echo '- 附件:' . count($mainAtt) . " 个\n";
|
||||||
|
foreach ($mainAtt as $a) {
|
||||||
|
echo ' - [' . $a['kind'] . '] ' . $a['url'] . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "\n" . $t['body'] . "\n\n";
|
||||||
|
foreach (fb_api_replies($pdo, $t['id'], false) as $r) {
|
||||||
|
echo '> **' . $r['author'] . '**(' . $r['created_time'] . ")\n>\n";
|
||||||
|
echo '> ' . str_replace("\n", "\n> ", $r['body']) . "\n\n";
|
||||||
|
}
|
||||||
|
echo "---\n\n";
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 默认:list / export */
|
||||||
|
$args = [];
|
||||||
|
$w = fb_api_where($args, $cats, $statuses);
|
||||||
|
$st = $pdo->prepare('SELECT * FROM threads' . $w . ' ORDER BY updated_at DESC LIMIT ' . $limit);
|
||||||
|
$st->execute($args);
|
||||||
|
$rows = $st->fetchAll();
|
||||||
|
|
||||||
|
$tids = [];
|
||||||
|
foreach ($rows as $t) { $tids[] = (int)$t['id']; }
|
||||||
|
$counts = $tids ? fb_attach_count_map($pdo, $tids) : [];
|
||||||
|
|
||||||
|
$threads = [];
|
||||||
|
foreach ($rows as $t) {
|
||||||
|
$item = fb_api_thread($t, $contacts);
|
||||||
|
$item['attachment_count'] = isset($counts[(int)$t['id']]) ? $counts[(int)$t['id']] : 0;
|
||||||
|
if ($action === 'export') {
|
||||||
|
list($mainAtt, $repAtt) = fb_api_attach_rows($pdo, $t['id']);
|
||||||
|
$item['body'] = $t['body'];
|
||||||
|
$item['attachments'] = $mainAtt;
|
||||||
|
$item['replies'] = fb_api_replies($pdo, $t['id'], $contacts, $repAtt);
|
||||||
|
}
|
||||||
|
$threads[] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
fb_api_json([
|
||||||
|
'ok' => true,
|
||||||
|
'action' => $action,
|
||||||
|
'count' => count($threads),
|
||||||
|
'threads' => $threads,
|
||||||
|
]);
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,627 @@
|
|||||||
|
/* FeedLite · 轻反馈 —— 轻量交互(含图片/视频直传 OSS) */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* ---------- 公共小工具 ---------- */
|
||||||
|
function $(sel, root) { return (root || document).querySelector(sel); }
|
||||||
|
function $$(sel, root) {
|
||||||
|
var out = [], list = (root || document).querySelectorAll(sel);
|
||||||
|
for (var i = 0; i < list.length; i++) { out.push(list[i]); }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function qs(obj) {
|
||||||
|
var parts = [];
|
||||||
|
for (var k in obj) {
|
||||||
|
if (obj.hasOwnProperty(k) && obj[k] !== undefined && obj[k] !== null) {
|
||||||
|
parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(obj[k]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join('&');
|
||||||
|
}
|
||||||
|
function human(bytes) {
|
||||||
|
var b = Number(bytes) || 0;
|
||||||
|
if (b < 1024) { return b + ' B'; }
|
||||||
|
if (b < 1048576) { return (b / 1024).toFixed(1) + ' KB'; }
|
||||||
|
return (b / 1048576).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
function extByMime(mime) {
|
||||||
|
if (mime === 'image/webp') { return 'webp'; }
|
||||||
|
if (mime === 'image/png') { return 'png'; }
|
||||||
|
if (mime === 'image/gif') { return 'gif'; }
|
||||||
|
if (mime === 'video/mp4') { return 'mp4'; }
|
||||||
|
if (mime === 'video/quicktime') { return 'mov'; }
|
||||||
|
if (mime === 'video/webm') { return 'webm'; }
|
||||||
|
return 'jpg';
|
||||||
|
}
|
||||||
|
function renameExt(name, mime) {
|
||||||
|
var base = String(name || 'file').replace(/\.[A-Za-z0-9]{1,8}$/, '') || 'file';
|
||||||
|
return base + '.' + extByMime(mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 换一题:AJAX 刷新验证题,不丢失已填内容 ---------- */
|
||||||
|
function bindCaptchaRefresh() {
|
||||||
|
var links = document.querySelectorAll('[data-captcha-refresh]');
|
||||||
|
for (var i = 0; i < links.length; i++) {
|
||||||
|
links[i].addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var box = this.closest('.captcha-line');
|
||||||
|
var qEl = box ? box.querySelector('.c-q') : null;
|
||||||
|
var input = box ? box.querySelector('input[name=captcha]') : null;
|
||||||
|
var self = this;
|
||||||
|
self.style.opacity = '.5';
|
||||||
|
fetch('captcha.php', { credentials: 'same-origin' })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (d && d.ok && qEl) { qEl.textContent = d.q; }
|
||||||
|
if (input) { input.value = ''; input.focus(); }
|
||||||
|
})
|
||||||
|
.catch(function () { window.location.href = 'captcha.php'; })
|
||||||
|
.then(function () { self.style.opacity = '1'; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 管理员口令:填写后显示"处理状态",并解除邮箱必填 ---------- */
|
||||||
|
function bindAdminToggle() {
|
||||||
|
var pw = document.querySelector('input[name=admin_password]');
|
||||||
|
if (!pw) { return; }
|
||||||
|
var statusBox = document.querySelector('.admin-status');
|
||||||
|
var emailInput = document.querySelector('input[name=email]');
|
||||||
|
var emailField = document.getElementById('emailField');
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
var isAdmin = pw.value.trim() !== '';
|
||||||
|
if (statusBox) { statusBox.hidden = !isAdmin; }
|
||||||
|
if (emailInput) {
|
||||||
|
emailInput.required = !isAdmin;
|
||||||
|
if (isAdmin) { emailInput.removeAttribute('required'); }
|
||||||
|
}
|
||||||
|
if (emailField) {
|
||||||
|
var req = emailField.querySelector('.req');
|
||||||
|
if (req) { req.style.visibility = isAdmin ? 'hidden' : 'visible'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw.addEventListener('input', sync);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 防止重复提交(上传未完成时阻止提交) ---------- */
|
||||||
|
function bindNoDoubleSubmit() {
|
||||||
|
var forms = document.querySelectorAll('form');
|
||||||
|
for (var i = 0; i < forms.length; i++) {
|
||||||
|
forms[i].addEventListener('submit', function (ev) {
|
||||||
|
var box = this.querySelector('[data-uploader-root]');
|
||||||
|
if (box && Number(box.getAttribute('data-pending') || 0) > 0) {
|
||||||
|
ev.preventDefault();
|
||||||
|
alert('还有文件正在上传,请等待上传完成后再提交。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var btn = this.querySelector('button[type=submit]');
|
||||||
|
if (btn) {
|
||||||
|
setTimeout(function () { btn.disabled = true; btn.style.opacity = '.6'; }, 0);
|
||||||
|
setTimeout(function () { btn.disabled = false; btn.style.opacity = '1'; }, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 图片压缩(canvas,保留动图) ---------- */
|
||||||
|
function prepareImage(file, maxSide) {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
// GIF 可能带动画,压缩会丢失动画 —— 原样上传
|
||||||
|
if (file.type === 'image/gif') {
|
||||||
|
resolve({ blob: file, mime: file.type, name: file.name });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var url = URL.createObjectURL(file);
|
||||||
|
var img = new Image();
|
||||||
|
img.onload = function () {
|
||||||
|
var w = img.naturalWidth, h = img.naturalHeight;
|
||||||
|
var scale = Math.min(1, maxSide / Math.max(w, h));
|
||||||
|
var tw = Math.max(1, Math.round(w * scale));
|
||||||
|
var th = Math.max(1, Math.round(h * scale));
|
||||||
|
var small = file.size <= 600 * 1024;
|
||||||
|
if (small && scale >= 1) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve({ blob: file, mime: file.type, name: file.name });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var cv = document.createElement('canvas');
|
||||||
|
cv.width = tw; cv.height = th;
|
||||||
|
cv.getContext('2d').drawImage(img, 0, 0, tw, th);
|
||||||
|
|
||||||
|
var prefer = (file.type === 'image/png' || file.type === 'image/webp') ? 'image/webp' : 'image/jpeg';
|
||||||
|
var quality = 0.82;
|
||||||
|
|
||||||
|
function done(blob, mime) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
if (!blob || (blob.size >= file.size && small)) {
|
||||||
|
resolve({ blob: file, mime: file.type, name: file.name });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var real = blob.type || mime;
|
||||||
|
resolve({ blob: blob, mime: real, name: renameExt(file.name, real) });
|
||||||
|
}
|
||||||
|
cv.toBlob(function (blob) {
|
||||||
|
if (blob && blob.type !== prefer) {
|
||||||
|
cv.toBlob(function (b2) { done(b2, 'image/jpeg'); }, 'image/jpeg', quality);
|
||||||
|
} else {
|
||||||
|
done(blob, prefer);
|
||||||
|
}
|
||||||
|
}, prefer, quality);
|
||||||
|
};
|
||||||
|
img.onerror = function () {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve({ blob: file, mime: file.type, name: file.name });
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 附件直传 ---------- */
|
||||||
|
function initUploaderRoot(root) {
|
||||||
|
var csrf = root.getAttribute('data-csrf') || '';
|
||||||
|
var signUrl = root.getAttribute('data-sign-url') || 'upload.php';
|
||||||
|
var field = root.querySelector('.att-field');
|
||||||
|
var state = []; // 已成功的附件描述
|
||||||
|
var pending = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var init = JSON.parse(root.getAttribute('data-init') || '[]');
|
||||||
|
for (var i = 0; i < init.length; i++) { state.push(init[i]); }
|
||||||
|
} catch (e) { /* 忽略 */ }
|
||||||
|
|
||||||
|
function setPending(n) {
|
||||||
|
pending = Math.max(0, pending + n);
|
||||||
|
root.setAttribute('data-pending', String(pending));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
if (!field) { return; }
|
||||||
|
var out = [];
|
||||||
|
for (var i = 0; i < state.length; i++) {
|
||||||
|
var s = state[i];
|
||||||
|
out.push({ kind: s.kind, key: s.key, size: s.size, name: s.name || '', mime: s.mime || '', w: s.w || 0, h: s.h || 0 });
|
||||||
|
}
|
||||||
|
field.value = out.length ? JSON.stringify(out) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function countOf(kind) {
|
||||||
|
var n = 0;
|
||||||
|
for (var i = 0; i < state.length; i++) { if (state[i].kind === kind) { n++; } }
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNode(box, meta) {
|
||||||
|
var list = box.querySelector('.up-list');
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'up-item' + (meta.kind === 'video' ? ' video' : '');
|
||||||
|
|
||||||
|
var thumb = document.createElement('div');
|
||||||
|
thumb.className = 'up-thumb';
|
||||||
|
if (meta.kind === 'image') {
|
||||||
|
var im = document.createElement('img');
|
||||||
|
im.alt = '';
|
||||||
|
if (meta.thumb) { im.src = meta.thumb; }
|
||||||
|
thumb.appendChild(im);
|
||||||
|
} else {
|
||||||
|
thumb.classList.add('up-thumb-video');
|
||||||
|
thumb.textContent = 'VIDEO';
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = document.createElement('div');
|
||||||
|
info.className = 'up-info';
|
||||||
|
var nm = document.createElement('div');
|
||||||
|
nm.className = 'up-name';
|
||||||
|
nm.textContent = meta.name || (meta.kind === 'image' ? '图片' : '视频');
|
||||||
|
var st = document.createElement('div');
|
||||||
|
st.className = 'up-state';
|
||||||
|
st.textContent = '等待上传…';
|
||||||
|
var bar = document.createElement('div');
|
||||||
|
bar.className = 'up-bar';
|
||||||
|
var fill = document.createElement('i');
|
||||||
|
bar.appendChild(fill);
|
||||||
|
info.appendChild(nm); info.appendChild(st); info.appendChild(bar);
|
||||||
|
|
||||||
|
var rm = document.createElement('button');
|
||||||
|
rm.type = 'button';
|
||||||
|
rm.className = 'up-del';
|
||||||
|
rm.textContent = '移除';
|
||||||
|
|
||||||
|
el.appendChild(thumb); el.appendChild(info); el.appendChild(rm);
|
||||||
|
list.appendChild(el);
|
||||||
|
return { el: el, fill: fill, st: st, rm: rm, thumb: thumb };
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(node, item) {
|
||||||
|
if (node && node.el && node.el.parentNode) { node.el.parentNode.removeChild(node.el); }
|
||||||
|
if (item) {
|
||||||
|
for (var i = 0; i < state.length; i++) {
|
||||||
|
if (state[i].key === item.key) { state.splice(i, 1); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign(kind, ext, size, mime) {
|
||||||
|
return fetch(signUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
|
||||||
|
body: qs({ csrf: csrf, kind: kind, ext: ext, size: size, mime: mime })
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json().catch(function () { throw new Error('服务器返回异常(' + r.status + ')'); });
|
||||||
|
}).then(function (d) {
|
||||||
|
if (!d || !d.ok) { throw new Error((d && d.error) || '获取上传凭证失败'); }
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function upload(cred, fields, blob, filename, onProgress) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var fd = new FormData();
|
||||||
|
for (var k in fields) {
|
||||||
|
if (fields.hasOwnProperty(k)) { fd.append(k, fields[k]); }
|
||||||
|
}
|
||||||
|
fd.append('file', blob, filename); // 文件必须是最后一个字段
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', cred.host, true);
|
||||||
|
xhr.upload.onprogress = function (e) {
|
||||||
|
if (e.lengthComputable && onProgress) { onProgress(e.loaded / e.total); }
|
||||||
|
};
|
||||||
|
xhr.onload = function () {
|
||||||
|
if (xhr.status === 200 || xhr.status === 204) { resolve(true); }
|
||||||
|
else {
|
||||||
|
var msg = String(xhr.responseText || '').replace(/<[^>]*>/g, ' ').slice(0, 160).trim();
|
||||||
|
reject(new Error('上传到存储失败(' + xhr.status + ')' + (msg ? ':' + msg : '')));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = function () { reject(new Error('网络异常,上传中断')); };
|
||||||
|
xhr.send(fd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFiles(box, files) {
|
||||||
|
var kind = box.getAttribute('data-kind') || 'image';
|
||||||
|
var maxCount = parseInt(box.getAttribute('data-max-count') || '1', 10);
|
||||||
|
var maxBytes = parseInt(box.getAttribute('data-max-bytes') || '0', 10);
|
||||||
|
var maxSide = parseInt(box.getAttribute('data-max-side') || '1600', 10);
|
||||||
|
var label = box.getAttribute('data-label') || '附件';
|
||||||
|
var allowed = (box.getAttribute('data-accept') || '').split(',');
|
||||||
|
|
||||||
|
for (var i = 0; i < files.length; i++) {
|
||||||
|
var file = files[i];
|
||||||
|
|
||||||
|
if (allowed.length && allowed.indexOf(file.type) < 0) {
|
||||||
|
alert('「' + file.name + '」格式不支持,请选择:' + allowed.join(' / '));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (maxBytes > 0 && file.size > maxBytes) {
|
||||||
|
alert('「' + file.name + '」超过 ' + human(maxBytes) + ' 上限。');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (countOf(kind) >= maxCount) {
|
||||||
|
alert(label + '最多上传 ' + maxCount + ' 个,请先移除已上传的文件。');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var node = makeNode(box, { kind: kind, name: file.name, thumb: '' });
|
||||||
|
setPending(1);
|
||||||
|
node.rm.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
node.st.textContent = '处理中…';
|
||||||
|
var prepared;
|
||||||
|
if (kind === 'image') {
|
||||||
|
prepared = await prepareImage(file, maxSide);
|
||||||
|
if (prepared.blob !== file && prepared.blob.size > 0) {
|
||||||
|
node.st.textContent = '已压缩 ' + human(file.size) + ' → ' + human(prepared.blob.size);
|
||||||
|
try { node.thumb.querySelector('img').src = URL.createObjectURL(prepared.blob); } catch (e2) { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
prepared = { blob: file, mime: file.type, name: file.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
var ext = extByMime(prepared.mime);
|
||||||
|
var d = await sign(kind, ext, prepared.blob.size, prepared.mime);
|
||||||
|
|
||||||
|
node.st.textContent = '上传中…';
|
||||||
|
await upload(d.credential, d.fields, prepared.blob, prepared.name, function (p) {
|
||||||
|
node.fill.style.width = Math.round(p * 100) + '%';
|
||||||
|
node.st.textContent = '上传中 ' + Math.round(p * 100) + '%';
|
||||||
|
});
|
||||||
|
|
||||||
|
var item = {
|
||||||
|
kind: kind,
|
||||||
|
key: d.credential.key,
|
||||||
|
size: prepared.blob.size,
|
||||||
|
name: prepared.name,
|
||||||
|
mime: prepared.mime,
|
||||||
|
w: 0, h: 0,
|
||||||
|
thumb: d.credential.url || ''
|
||||||
|
};
|
||||||
|
state.push(item);
|
||||||
|
sync();
|
||||||
|
|
||||||
|
node.el.classList.add('done');
|
||||||
|
node.fill.style.width = '100%';
|
||||||
|
node.st.textContent = '已上传 · ' + human(item.size);
|
||||||
|
if (kind === 'image' && item.thumb) {
|
||||||
|
var imgEl = node.thumb.querySelector('img');
|
||||||
|
if (imgEl) { imgEl.src = item.thumb; }
|
||||||
|
}
|
||||||
|
node.rm.disabled = false;
|
||||||
|
node.rm.addEventListener('click', function (it) {
|
||||||
|
return function () { removeItem(node, it); };
|
||||||
|
}(item));
|
||||||
|
} catch (e) {
|
||||||
|
node.el.classList.add('fail');
|
||||||
|
node.st.textContent = (e && e.message) ? e.message : '上传失败';
|
||||||
|
node.rm.textContent = '删除';
|
||||||
|
node.rm.disabled = false;
|
||||||
|
node.rm.addEventListener('click', function () { removeItem(node, null); });
|
||||||
|
} finally {
|
||||||
|
setPending(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回填服务端已保存的附件(校验失败后重新提交时)
|
||||||
|
var byKind = {};
|
||||||
|
for (var s = 0; s < state.length; s++) {
|
||||||
|
var it0 = state[s];
|
||||||
|
var box0 = root.querySelector('[data-uploader][data-kind="' + it0.kind + '"]');
|
||||||
|
if (!box0) { continue; }
|
||||||
|
byKind[it0.kind] = (byKind[it0.kind] || 0) + 1;
|
||||||
|
var nd = makeNode(box0, it0);
|
||||||
|
nd.el.classList.add('done');
|
||||||
|
nd.fill.style.width = '100%';
|
||||||
|
nd.st.textContent = '已上传 · ' + human(it0.size);
|
||||||
|
nd.rm.addEventListener('click', function (item, node) {
|
||||||
|
return function () { removeItem(node, item); };
|
||||||
|
}(it0, nd));
|
||||||
|
}
|
||||||
|
sync();
|
||||||
|
|
||||||
|
var boxes = $$('[data-uploader]', root);
|
||||||
|
for (var b = 0; b < boxes.length; b++) {
|
||||||
|
(function (box) {
|
||||||
|
var pick = box.querySelector('.up-pick');
|
||||||
|
var input = box.querySelector('.up-file');
|
||||||
|
if (!pick || !input) { return; }
|
||||||
|
pick.addEventListener('click', function () { input.click(); });
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
var files = [];
|
||||||
|
for (var k = 0; k < input.files.length; k++) { files.push(input.files[k]); }
|
||||||
|
input.value = '';
|
||||||
|
if (files.length) { handleFiles(box, files); }
|
||||||
|
});
|
||||||
|
// 拖拽上传
|
||||||
|
['dragenter', 'dragover'].forEach(function (ev) {
|
||||||
|
box.addEventListener(ev, function (e) { e.preventDefault(); box.classList.add('dragging'); });
|
||||||
|
});
|
||||||
|
['dragleave', 'drop'].forEach(function (ev) {
|
||||||
|
box.addEventListener(ev, function (e) { e.preventDefault(); box.classList.remove('dragging'); });
|
||||||
|
});
|
||||||
|
box.addEventListener('drop', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var files = [];
|
||||||
|
if (e.dataTransfer && e.dataTransfer.files) {
|
||||||
|
for (var k = 0; k < e.dataTransfer.files.length; k++) { files.push(e.dataTransfer.files[k]); }
|
||||||
|
}
|
||||||
|
if (files.length) { handleFiles(box, files); }
|
||||||
|
});
|
||||||
|
}(boxes[b]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindUploaders() {
|
||||||
|
var roots = $$('[data-uploader-root]');
|
||||||
|
for (var i = 0; i < roots.length; i++) { initUploaderRoot(roots[i]); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 附件灯箱:点击放大 + 左右切换 ---------- */
|
||||||
|
function bindLightbox() {
|
||||||
|
var galleries = $$('[data-gallery]');
|
||||||
|
if (!galleries.length) { return; }
|
||||||
|
|
||||||
|
var ov = null, items = [], cur = 0, lastFocus = null, built = false;
|
||||||
|
var media = null, nameEl = null, metaEl = null, origEl = null, prevBtn = null, nextBtn = null;
|
||||||
|
|
||||||
|
function buildOverlay() {
|
||||||
|
ov = document.createElement('div');
|
||||||
|
ov.className = 'lb';
|
||||||
|
ov.hidden = true;
|
||||||
|
ov.setAttribute('role', 'dialog');
|
||||||
|
ov.setAttribute('aria-modal', 'true');
|
||||||
|
ov.setAttribute('aria-label', '附件查看');
|
||||||
|
ov.innerHTML =
|
||||||
|
'<button type="button" class="lb-x" aria-label="关闭">✕</button>' +
|
||||||
|
'<div class="lb-stage">' +
|
||||||
|
'<button type="button" class="lb-nav lb-prev" aria-label="上一个">‹</button>' +
|
||||||
|
'<div class="lb-media"></div>' +
|
||||||
|
'<button type="button" class="lb-nav lb-next" aria-label="下一个">›</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="lb-bar">' +
|
||||||
|
'<span class="lb-name"></span>' +
|
||||||
|
'<span class="lb-meta"></span>' +
|
||||||
|
'<a class="lb-orig" href="#" target="_blank" rel="noopener noreferrer">查看原图</a>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
|
||||||
|
media = $('.lb-media', ov);
|
||||||
|
nameEl = $('.lb-name', ov);
|
||||||
|
metaEl = $('.lb-meta', ov);
|
||||||
|
origEl = $('.lb-orig', ov);
|
||||||
|
prevBtn = $('.lb-prev', ov);
|
||||||
|
nextBtn = $('.lb-next', ov);
|
||||||
|
|
||||||
|
$('.lb-x', ov).addEventListener('click', close);
|
||||||
|
prevBtn.addEventListener('click', function () { go(-1); });
|
||||||
|
nextBtn.addEventListener('click', function () { go(1); });
|
||||||
|
ov.addEventListener('click', function (e) {
|
||||||
|
// 点空白处(遮罩本身或图片外的区域)关闭
|
||||||
|
if (e.target === ov || e.target === media) { close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 触摸左右滑动
|
||||||
|
var tx = 0, ty = 0, tracking = false;
|
||||||
|
ov.addEventListener('touchstart', function (e) {
|
||||||
|
if (!e.touches || e.touches.length !== 1) { tracking = false; return; }
|
||||||
|
tx = e.touches[0].clientX; ty = e.touches[0].clientY; tracking = true;
|
||||||
|
}, { passive: true });
|
||||||
|
ov.addEventListener('touchend', function (e) {
|
||||||
|
if (!tracking || !e.changedTouches || !e.changedTouches.length) { return; }
|
||||||
|
tracking = false;
|
||||||
|
var dx = e.changedTouches[0].clientX - tx;
|
||||||
|
var dy = e.changedTouches[0].clientY - ty;
|
||||||
|
if (Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(dy)) { return; }
|
||||||
|
go(dx < 0 ? 1 : -1); // 左滑=看下一个,与浏览图片的直觉一致
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (!ov || ov.hidden) { return; }
|
||||||
|
var k = e.key;
|
||||||
|
if (k === 'Escape') { close(); }
|
||||||
|
else if (k === 'ArrowLeft') { go(-1); }
|
||||||
|
else if (k === 'ArrowRight') { go(1); }
|
||||||
|
else if (k === 'Home') { cur = 0; render(); }
|
||||||
|
else if (k === 'End') { cur = items.length - 1; render(); }
|
||||||
|
else { return; }
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!items.length) { return; }
|
||||||
|
var it = items[cur];
|
||||||
|
media.className = 'lb-media loading';
|
||||||
|
media.innerHTML = '';
|
||||||
|
|
||||||
|
var node;
|
||||||
|
if (it.kind === 'video') {
|
||||||
|
node = document.createElement('video');
|
||||||
|
node.controls = true;
|
||||||
|
node.autoplay = true;
|
||||||
|
node.playsInline = true;
|
||||||
|
node.setAttribute('playsinline', '');
|
||||||
|
node.src = it.src;
|
||||||
|
if (it.poster) { node.poster = it.poster; }
|
||||||
|
} else {
|
||||||
|
node = document.createElement('img');
|
||||||
|
node.alt = it.name || '';
|
||||||
|
node.src = it.src;
|
||||||
|
}
|
||||||
|
function ready() { media.classList.remove('loading'); }
|
||||||
|
node.addEventListener('load', ready);
|
||||||
|
node.addEventListener('loadeddata', ready);
|
||||||
|
node.addEventListener('error', ready);
|
||||||
|
media.appendChild(node);
|
||||||
|
|
||||||
|
nameEl.textContent = it.name || (it.kind === 'video' ? '视频' : '图片');
|
||||||
|
metaEl.textContent = (cur + 1) + ' / ' + items.length + (it.size ? ' · ' + it.size : '');
|
||||||
|
if (it.kind === 'video') {
|
||||||
|
origEl.hidden = true;
|
||||||
|
} else {
|
||||||
|
origEl.hidden = false;
|
||||||
|
origEl.href = it.full || it.src;
|
||||||
|
}
|
||||||
|
|
||||||
|
var multi = items.length > 1;
|
||||||
|
prevBtn.hidden = !multi;
|
||||||
|
nextBtn.hidden = !multi;
|
||||||
|
|
||||||
|
// 预载相邻图片,切换时更快
|
||||||
|
if (multi) {
|
||||||
|
var nb = [items[(cur + 1) % items.length], items[(cur - 1 + items.length) % items.length]];
|
||||||
|
for (var i = 0; i < nb.length; i++) {
|
||||||
|
if (nb[i].kind === 'image' && nb[i].src) { (new Image()).src = nb[i].src; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(step) {
|
||||||
|
if (items.length < 2) { return; }
|
||||||
|
cur = (cur + step + items.length) % items.length;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(list, idx) {
|
||||||
|
if (!built) { buildOverlay(); built = true; }
|
||||||
|
if (!list.length) { return; }
|
||||||
|
items = list;
|
||||||
|
cur = Math.max(0, Math.min(idx, list.length - 1));
|
||||||
|
lastFocus = document.activeElement;
|
||||||
|
|
||||||
|
// 打开灯箱时暂停正文里的内嵌视频,避免两个声音一起响
|
||||||
|
var inline = document.querySelectorAll('.atts video');
|
||||||
|
for (var i = 0; i < inline.length; i++) { try { inline[i].pause(); } catch (e) {} }
|
||||||
|
|
||||||
|
ov.hidden = false;
|
||||||
|
document.documentElement.classList.add('lb-open');
|
||||||
|
document.body.classList.add('lb-open');
|
||||||
|
render();
|
||||||
|
var x = $('.lb-x', ov);
|
||||||
|
if (x) { x.focus(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!ov || ov.hidden) { return; }
|
||||||
|
ov.hidden = true;
|
||||||
|
media.innerHTML = ''; // 顺带停止灯箱里的视频播放
|
||||||
|
document.documentElement.classList.remove('lb-open');
|
||||||
|
document.body.classList.remove('lb-open');
|
||||||
|
if (lastFocus && lastFocus.focus) { try { lastFocus.focus(); } catch (e) {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集一个画廊里的全部附件(按 DOM 顺序)
|
||||||
|
function itemsOf(gal) {
|
||||||
|
var els = $$('[data-lb-kind]', gal), out = [];
|
||||||
|
for (var i = 0; i < els.length; i++) {
|
||||||
|
var el = els[i];
|
||||||
|
out.push({
|
||||||
|
kind: el.getAttribute('data-lb-kind') || 'image',
|
||||||
|
src: el.getAttribute('data-lb-src') || el.getAttribute('href') || '',
|
||||||
|
full: el.getAttribute('data-lb-full') || '',
|
||||||
|
poster: el.getAttribute('data-lb-poster') || '',
|
||||||
|
name: el.getAttribute('data-lb-name') || '',
|
||||||
|
size: el.getAttribute('data-lb-size') || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var g = 0; g < galleries.length; g++) {
|
||||||
|
(function (gal) {
|
||||||
|
gal.addEventListener('click', function (e) {
|
||||||
|
var t = e.target, trigger = null;
|
||||||
|
while (t && t !== gal) {
|
||||||
|
if (t.getAttribute && t.getAttribute('data-lb-kind')) { trigger = t; break; }
|
||||||
|
t = t.parentNode;
|
||||||
|
}
|
||||||
|
if (!trigger) { return; }
|
||||||
|
e.preventDefault(); // 图片是 <a>,有 JS 时就在页内放大
|
||||||
|
|
||||||
|
var all = $$('[data-lb-kind]', gal), idx = 0;
|
||||||
|
for (var i = 0; i < all.length; i++) { if (all[i] === trigger) { idx = i; break; } }
|
||||||
|
open(itemsOf(gal), idx);
|
||||||
|
});
|
||||||
|
}(galleries[g]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
bindCaptchaRefresh();
|
||||||
|
bindAdminToggle();
|
||||||
|
bindNoDoubleSubmit();
|
||||||
|
bindUploaders();
|
||||||
|
bindLightbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', boot);
|
||||||
|
} else {
|
||||||
|
boot();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 337 B |
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 9.6 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="轻量反馈系统图标">
|
||||||
|
<!-- 潘通蓝 PANTONE 2935 C = #0057B8,纯色无渐变 -->
|
||||||
|
<rect x="0" y="0" width="512" height="512" rx="112" fill="#0057B8"/>
|
||||||
|
|
||||||
|
<!-- 气泡外接框 320x280(占画布 63%),居中 -->
|
||||||
|
<g fill="#ffffff">
|
||||||
|
<rect x="96" y="112" width="320" height="240" rx="60"/>
|
||||||
|
<path d="M214 348 Q256 408 298 348 Z"/>
|
||||||
|
</g>
|
||||||
|
<!-- 三条反馈横线,左对齐,宽度递减 -->
|
||||||
|
<g fill="#0057B8">
|
||||||
|
<rect x="140" y="176" width="232" height="26" rx="13"/>
|
||||||
|
<rect x="140" y="232" width="180" height="26" rx="13"/>
|
||||||
|
<rect x="140" y="288" width="126" height="26" rx="13"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 740 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 337 B |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 854 B |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 989 B |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "轻反馈 FeedLite",
|
||||||
|
"short_name": "轻反馈",
|
||||||
|
"description": "轻量级反馈系统:任何人都可以提交反馈与回复,无需注册。",
|
||||||
|
"start_url": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f5f6f8",
|
||||||
|
"theme_color": "#0057B8",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" },
|
||||||
|
{ "src": "android-chrome-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
/* FeedLite · 轻反馈 —— 简洁、克制的基础视觉基调 */
|
||||||
|
:root{
|
||||||
|
--fg:#1f2329; --muted:#6b7280; --line:#e5e7eb; --line-strong:#d7dbe0;
|
||||||
|
--bg:#f5f6f8; --card:#ffffff; --brand:#0057B8; --brand-600:#004a9e; --brand-soft:#e8f0f9;
|
||||||
|
/* 顶栏品牌图标:与官网首页同一枚应用图标(内联,离线预览也能显示) */
|
||||||
|
--icon-mark:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAADsOAAA7DgHMtqGDAAADj0lEQVR4nO1b205TQRTdGB/UB78B0BdfRNA3NUT/QPHMHIgaP0BeMPqoog8mJqDyCfoBImJnDuCFyEWhIGiUegONimkFodYEaqpj9qEEwZbS0zOXXlaykqZNO2uvM9M9Z8/ZABkhysBy9gLhl4Dwh0B5CAj7CZQLo4iaCJtwNRLWDITVZI4tY+CMAOGvtQfnnSGwnWNuLFmBBnYAZU8NCMAvDoLlVGwseCtwAAgLGyDaZ7IZsJ3D6wdP+CEg7Jd+sdIYBytQm+bKOxVAecQAkfJngsV3rolelBXYms/EgdV/jJRTA0SpJeF1K1c/v1OdRwPYxFL8dtc+7WJ00Q5U4/S/rF2INrKLkNzeiqIkYfcBCHujXYg+hgAoixkgRBNZDHL5gU22IxraxsXdkYiYnlsUid9/hGzgGDhWRzAi6tvGXQ05mQAev1jZ2CtGp6JCN0Ymo64WpQZUNvaKSDQuTEE4GhcVXk0AD9PehCu/FsH3UW/LAbL8Aq55U2HfGJNvQOdoRJiKO8GwfAO+zi0KU/Hl+6J8AxIKUp1XoDbpBqTCp9kFsftsf/aDe2TVuX7xeXYhpRYtBrR0TikLfpmt9z6YY8DQu3mx5XiXsuC3nuh2054xBiBwe9rzYkZ0P5dLHGO9P2JtBpiCkgG0NANEQaTBbSe7xdWOyfxYAi2S0uD2Uz35YcCQpDRoXRvLDwNkpMEnb+c9bbu1GWAKSgbQ0gwQUpdAIsW69JoGNzc4oq71mW+32Epuh6dT7MNzTYNY2fUDeIss3YCOYMTXNIjV3NhCwhcDbg8pKInVpymKekmDj17O+hY8gl5XUBTdZDu+TVk/MayqLA7JgxE8jDAFqKX8tKKDEfhn7aaryqi+8p5PhWgOBiwvBzyMwHo8lqRVHY7iWO3DYXfNazscBQ88cyuUMcCmmyFlekC1Acgr7env8fEz1XpA+YDJ88WP31bq+vga39OhBbQMmtwG72p67BJf69IB2gY2haBbgG4C5T+0i9BFwqNQlI/JrnpclrIHxf6gZLN2Ifp4Hg2oKd4ZwKuSzRI8VHzBs1crDRPYWqZbkGragSNrWmb4oHZRysj6/u8lpKy8aJqmsC8yTc9grdtapl2kNMaBsoMZeged/QXbOIl9kRuC5fYQDhRQ8H3uEs8OArvJ6tztovYAckh1lB2FnGEHqt0GI9w6uoaY2GXCYkvt86iRXwDL2bOR0P4Cy8Hrhv+SooQAAAAASUVORK5CYII=");
|
||||||
|
--ok:#0f9d58; --ok-soft:#e9f7ef; --warn:#b7791f; --warn-soft:#fff8e6;
|
||||||
|
--danger:#d93025; --danger-soft:#fdecea; --shadow:0 1px 3px rgba(16,24,40,.06);
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html{-webkit-text-size-adjust:100%}
|
||||||
|
body{
|
||||||
|
margin:0; background:var(--bg); color:var(--fg);
|
||||||
|
font-family:-apple-system,BlinkMacSystemFont,"HarmonyOS Sans SC","HarmonyOS Sans","PingFang SC","Helvetica Neue","Microsoft YaHei",sans-serif;
|
||||||
|
font-size:15.5px; line-height:1.75; letter-spacing:.01em;
|
||||||
|
}
|
||||||
|
.wrap{max-width:880px; margin:0 auto; padding:0 16px}
|
||||||
|
a{color:var(--brand); text-decoration:none}
|
||||||
|
a:hover{text-decoration:underline}
|
||||||
|
h1{font-size:23px; margin:0 0 8px; font-weight:700}
|
||||||
|
h2{font-size:17px; margin:0 0 12px; font-weight:700}
|
||||||
|
p{margin:8px 0}
|
||||||
|
.muted{color:var(--muted)}
|
||||||
|
.small{font-size:13px}
|
||||||
|
.dot{color:var(--line-strong); padding:0 2px}
|
||||||
|
|
||||||
|
/* ---------- 顶栏(与官网首页同一套导航样式) / 页脚 ---------- */
|
||||||
|
.topbar{
|
||||||
|
position:sticky; top:0; z-index:20;
|
||||||
|
background:rgba(255,255,255,.82);
|
||||||
|
backdrop-filter:saturate(180%) blur(14px);
|
||||||
|
-webkit-backdrop-filter:saturate(180%) blur(14px);
|
||||||
|
border-bottom:1px solid var(--line);
|
||||||
|
}
|
||||||
|
.topbar .wrap{display:flex; align-items:center; gap:14px; height:64px}
|
||||||
|
.topbar .brand-mini{display:flex; align-items:center; gap:10px; font-weight:700; font-size:17px; color:var(--fg); min-width:0}
|
||||||
|
.topbar .brand-mini:hover{text-decoration:none; color:var(--fg)}
|
||||||
|
.topbar .brand-text{overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
|
||||||
|
.topbar .logo-mark{
|
||||||
|
width:30px; height:30px; flex:none; display:block; border-radius:8px;
|
||||||
|
background-image:var(--icon-mark); background-size:cover; background-position:center;
|
||||||
|
box-shadow:var(--shadow);
|
||||||
|
}
|
||||||
|
.topbar .spacer{flex:1}
|
||||||
|
.topbar .navlink{color:var(--muted); font-size:14.5px; padding:8px 10px; border-radius:8px; white-space:nowrap}
|
||||||
|
.topbar .navlink:hover{color:var(--brand); background:var(--brand-soft); text-decoration:none}
|
||||||
|
.topbar .btn-mini{
|
||||||
|
flex:none; font-size:14.5px; font-weight:600; color:#fff; background:var(--brand);
|
||||||
|
padding:9px 18px; border-radius:999px; white-space:nowrap;
|
||||||
|
box-shadow:0 4px 14px -4px rgba(0,87,184,.55);
|
||||||
|
}
|
||||||
|
.topbar .btn-mini:hover{background:var(--brand-600); color:#fff; text-decoration:none}
|
||||||
|
.hide-sm{display:inline-block}
|
||||||
|
@media (max-width:640px){
|
||||||
|
.hide-sm{display:none}
|
||||||
|
.topbar .wrap{gap:10px}
|
||||||
|
.topbar .brand-mini{font-size:15.5px; gap:9px}
|
||||||
|
.topbar .logo-mark{width:28px; height:28px}
|
||||||
|
}
|
||||||
|
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 p{margin:4px 0; text-align:center; font-size:13px; color:var(--muted)}
|
||||||
|
.foot a{color:var(--muted); text-decoration:underline}
|
||||||
|
|
||||||
|
/* ---------- 按钮 ---------- */
|
||||||
|
.btn{
|
||||||
|
display:inline-block; border:1px solid var(--line-strong); background:var(--card);
|
||||||
|
color:var(--fg); border-radius:9px; padding:8px 16px; font-size:14.5px;
|
||||||
|
cursor:pointer; line-height:1.4; font-family:inherit;
|
||||||
|
}
|
||||||
|
.btn:hover{border-color:var(--brand); color:var(--brand); text-decoration:none}
|
||||||
|
.btn.primary{background:var(--brand); border-color:var(--brand); color:#fff; font-weight:600}
|
||||||
|
.btn.primary:hover{background:var(--brand-600); color:#fff}
|
||||||
|
|
||||||
|
/* ---------- 页头 ---------- */
|
||||||
|
.page-head{display:flex; align-items:flex-start; gap:16px; margin:6px 0 18px}
|
||||||
|
.page-head .btn{margin-left:auto; flex:none; margin-top:4px}
|
||||||
|
.page-head h1{margin-bottom:4px}
|
||||||
|
.page-head .muted{font-size:14px; margin:0}
|
||||||
|
|
||||||
|
/* ---------- 筛选 ---------- */
|
||||||
|
.filters{margin-bottom:16px}
|
||||||
|
.chips{display:flex; flex-wrap:wrap; gap:8px; margin-bottom:12px}
|
||||||
|
.chip{
|
||||||
|
padding:5px 13px; border-radius:999px; background:var(--card);
|
||||||
|
border:1px solid var(--line); font-size:13.5px; color:var(--fg);
|
||||||
|
}
|
||||||
|
.chip:hover{border-color:var(--brand); color:var(--brand); text-decoration:none}
|
||||||
|
.chip.on{background:var(--brand); border-color:var(--brand); color:#fff; font-weight:600}
|
||||||
|
.filter-row{display:flex; flex-wrap:wrap; gap:8px}
|
||||||
|
.filter-row select,.filter-row input[type=search]{
|
||||||
|
border:1px solid var(--line-strong); border-radius:9px; padding:8px 10px;
|
||||||
|
font-size:14px; background:var(--card); color:var(--fg); font-family:inherit;
|
||||||
|
}
|
||||||
|
.filter-row input[type=search]{flex:1; min-width:160px}
|
||||||
|
|
||||||
|
/* ---------- 列表 ---------- */
|
||||||
|
.list{display:flex; flex-direction:column; gap:12px}
|
||||||
|
.item{
|
||||||
|
display:block; background:var(--card); border:1px solid var(--line);
|
||||||
|
border-radius:13px; padding:15px 17px; box-shadow:var(--shadow); color:inherit;
|
||||||
|
}
|
||||||
|
.item:hover{border-color:var(--brand); text-decoration:none; box-shadow:0 3px 12px rgba(0,87,184,.10)}
|
||||||
|
.item-top{display:flex; align-items:center; flex-wrap:wrap; gap:8px}
|
||||||
|
.item-title{font-weight:600; font-size:16px; flex:1; min-width:160px}
|
||||||
|
.item-ex{color:var(--muted); font-size:14px; margin:8px 0 8px; word-break:break-word}
|
||||||
|
.item-meta{display:flex; flex-wrap:wrap; align-items:center; gap:6px; font-size:13px; color:var(--muted)}
|
||||||
|
.item-meta .who{color:#3a424d; font-weight:600}
|
||||||
|
/* 问题编号:便于按 id 逐条跟进与引用 */
|
||||||
|
.item-id{
|
||||||
|
flex:none; font-size:12.5px; font-weight:600; color:var(--muted);
|
||||||
|
font-variant-numeric:tabular-nums; letter-spacing:.02em;
|
||||||
|
border:1px solid var(--line); border-radius:6px; padding:0 7px; line-height:1.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 徽标 ---------- */
|
||||||
|
.badge{
|
||||||
|
display:inline-block; font-size:12px; line-height:1.6; padding:1px 9px;
|
||||||
|
border-radius:999px; background:var(--brand-soft); color:var(--brand);
|
||||||
|
font-weight:600; white-space:nowrap;
|
||||||
|
}
|
||||||
|
.badge.st{background:#f1f3f5; color:#5b636e}
|
||||||
|
.badge.st-open{background:var(--warn-soft); color:var(--warn)}
|
||||||
|
.badge.st-doing{background:var(--brand-soft); color:var(--brand)}
|
||||||
|
.badge.st-resolved{background:var(--ok-soft); color:var(--ok)}
|
||||||
|
.badge.st-closed{background:#f1f3f5; color:#8a929c}
|
||||||
|
.badge.official{background:var(--brand); color:#fff}
|
||||||
|
|
||||||
|
/* ---------- 分页 ---------- */
|
||||||
|
.pager{display:flex; align-items:center; gap:12px; margin:22px 0 0; justify-content:center}
|
||||||
|
.pager-info{font-size:13.5px; color:var(--muted)}
|
||||||
|
|
||||||
|
/* ---------- 空态 ---------- */
|
||||||
|
.empty{
|
||||||
|
background:var(--card); border:1px dashed var(--line-strong); border-radius:13px;
|
||||||
|
padding:34px 20px; text-align:center; color:var(--muted);
|
||||||
|
}
|
||||||
|
.empty.small{padding:20px}
|
||||||
|
|
||||||
|
/* ---------- 面包屑 ---------- */
|
||||||
|
.crumbs{margin:4px 0 14px; font-size:14px}
|
||||||
|
|
||||||
|
/* ---------- 主题 / 回复 ---------- */
|
||||||
|
.subject{background:var(--card); border:1px solid var(--line); border-radius:14px; padding:20px 22px; box-shadow:var(--shadow)}
|
||||||
|
.subject-title{font-size:21px; margin:0; flex:1; min-width:200px}
|
||||||
|
.subject-body{margin-top:14px; white-space:normal; word-break:break-word; line-height:1.85}
|
||||||
|
.subject-body br{content:""}
|
||||||
|
|
||||||
|
.replies{margin-top:22px}
|
||||||
|
.reply{
|
||||||
|
background:var(--card); border:1px solid var(--line); border-left:3px solid var(--line-strong);
|
||||||
|
border-radius:11px; padding:13px 16px; margin-bottom:11px;
|
||||||
|
}
|
||||||
|
.reply.admin{border-left-color:var(--brand); background:linear-gradient(180deg,var(--brand-soft),var(--card) 42%)}
|
||||||
|
.reply-head{display:flex; align-items:center; gap:8px; font-size:13px; color:var(--muted); margin-bottom:6px}
|
||||||
|
.reply-head .who{font-weight:600; color:#3a424d; font-size:14px}
|
||||||
|
.reply-body{word-break:break-word; line-height:1.8}
|
||||||
|
|
||||||
|
/* ---------- 表单 ---------- */
|
||||||
|
.narrow{max-width:640px}
|
||||||
|
.card-form{background:var(--card); border:1px solid var(--line); border-radius:14px; padding:20px 22px; box-shadow:var(--shadow); margin-top:14px}
|
||||||
|
.field{display:block; margin-bottom:16px}
|
||||||
|
.field > span{display:block; font-size:14px; font-weight:600; margin-bottom:6px}
|
||||||
|
.field .req{color:var(--danger)}
|
||||||
|
.field .hint{font-style:normal; font-weight:400; font-size:12.5px; color:var(--muted); margin-left:6px}
|
||||||
|
.field input[type=text],.field input[type=email],.field input[type=tel],
|
||||||
|
.field input[type=password],.field textarea,.field select{
|
||||||
|
width:100%; border:1px solid var(--line-strong); border-radius:9px;
|
||||||
|
padding:9px 12px; font-size:15px; font-family:inherit; background:var(--card);
|
||||||
|
color:var(--fg); line-height:1.6;
|
||||||
|
}
|
||||||
|
.field textarea{resize:vertical; min-height:110px}
|
||||||
|
.field input:focus,.field textarea:focus,.field select:focus{
|
||||||
|
outline:none; border-color:var(--brand); box-shadow:0 0 0 3px var(--brand-soft);
|
||||||
|
}
|
||||||
|
.row2{display:grid; grid-template-columns:1fr 1fr; gap:14px}
|
||||||
|
@media (max-width:560px){ .row2{grid-template-columns:1fr} }
|
||||||
|
|
||||||
|
.captcha{margin:18px 0 0; padding:14px 16px; background:#fafbfc; border:1px solid var(--line); border-radius:11px}
|
||||||
|
.captcha .field{margin-bottom:0}
|
||||||
|
.captcha-line{display:flex; align-items:center; gap:10px; flex-wrap:wrap}
|
||||||
|
.captcha-line .c-q{
|
||||||
|
flex:1; min-width:200px; font-weight:600; color:var(--brand);
|
||||||
|
background:var(--brand-soft); border-radius:8px; padding:8px 12px;
|
||||||
|
}
|
||||||
|
.captcha-line input{flex:none; width:110px}
|
||||||
|
.captcha-line .reload{font-size:13.5px; white-space:nowrap}
|
||||||
|
|
||||||
|
.form-actions{margin-top:20px}
|
||||||
|
.form-actions .btn{padding:10px 26px; font-size:15px}
|
||||||
|
.admin-pw{margin-top:6px}
|
||||||
|
.admin-status{margin-bottom:16px}
|
||||||
|
|
||||||
|
/* 蜜罐字段:对用户不可见,但保留在 DOM 中诱捕机器人 */
|
||||||
|
.hp-field{position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden}
|
||||||
|
|
||||||
|
/* ---------- 提示条 ---------- */
|
||||||
|
.flash{border-radius:10px; padding:11px 15px; font-size:14px; margin:0 0 16px}
|
||||||
|
.flash.error{background:var(--danger-soft); border:1px solid #f6c6c1; color:#9c2b21}
|
||||||
|
.flash.ok{background:var(--ok-soft); border:1px solid #bfe5cd; color:#0b6b3d}
|
||||||
|
|
||||||
|
/* ---------- 附件上传(浏览器直传 OSS) ---------- */
|
||||||
|
.uploader-box{
|
||||||
|
margin:18px 0 0; padding:14px 16px; background:#fafbfc;
|
||||||
|
border:1px solid var(--line); border-radius:11px;
|
||||||
|
}
|
||||||
|
.uploader{border-radius:10px; transition:background .15s, box-shadow .15s}
|
||||||
|
.uploader + .uploader{margin-top:14px; padding-top:14px; border-top:1px dashed var(--line)}
|
||||||
|
.uploader.dragging{background:var(--brand-soft); box-shadow:0 0 0 2px var(--brand) inset}
|
||||||
|
.up-head{display:flex; align-items:center; gap:10px; flex-wrap:wrap}
|
||||||
|
.up-title{font-size:14px; font-weight:600}
|
||||||
|
.up-head .btn{margin-left:auto; padding:6px 14px; font-size:13.5px; flex:none}
|
||||||
|
.up-file{display:none}
|
||||||
|
.up-list{display:flex; flex-wrap:wrap; gap:9px; margin-top:10px}
|
||||||
|
.up-list:empty{display:none}
|
||||||
|
.up-item{
|
||||||
|
position:relative; display:flex; gap:10px; align-items:center; width:100%;
|
||||||
|
padding:8px 10px; border:1px solid var(--line); border-radius:10px; background:var(--card);
|
||||||
|
}
|
||||||
|
.up-thumb{
|
||||||
|
width:52px; height:52px; flex:none; border-radius:8px; overflow:hidden; background:#eef1f4;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
}
|
||||||
|
.up-thumb img{width:100%; height:100%; object-fit:cover; display:block}
|
||||||
|
.up-thumb-video{font-size:10px; font-weight:700; color:var(--muted); letter-spacing:.08em}
|
||||||
|
.up-info{flex:1; min-width:0}
|
||||||
|
.up-name{font-size:13.5px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
|
||||||
|
.up-state{font-size:12.5px; color:var(--muted); word-break:break-word}
|
||||||
|
.up-bar{height:4px; border-radius:2px; background:#e6eaee; overflow:hidden; margin-top:5px}
|
||||||
|
.up-bar i{display:block; height:100%; width:0; background:var(--brand); transition:width .2s}
|
||||||
|
.up-item.done .up-bar{display:none}
|
||||||
|
.up-item.fail{border-color:#f6c6c1; background:var(--danger-soft)}
|
||||||
|
.up-item.fail .up-state{color:var(--danger)}
|
||||||
|
.up-del{
|
||||||
|
flex:none; border:1px solid var(--line-strong); background:var(--card); color:var(--muted);
|
||||||
|
border-radius:7px; padding:5px 10px; font-size:12.5px; cursor:pointer; font-family:inherit;
|
||||||
|
}
|
||||||
|
.up-del:hover{border-color:var(--danger); color:var(--danger)}
|
||||||
|
.up-del:disabled{opacity:.45; cursor:default}
|
||||||
|
|
||||||
|
/* ---------- 附件展示 ---------- */
|
||||||
|
/* align-items:flex-start —— 否则同行里有视频(多了标题栏)时,图片盒子会被拉伸,底部留白 */
|
||||||
|
.atts{display:flex; flex-wrap:wrap; align-items:flex-start; gap:10px; margin-top:14px}
|
||||||
|
.atts .att{
|
||||||
|
margin:0; max-width:100%; border:1px solid var(--line); border-radius:11px;
|
||||||
|
overflow:hidden; background:var(--card);
|
||||||
|
}
|
||||||
|
.atts .att-image img{display:block; width:220px; height:auto; max-height:200px; object-fit:cover}
|
||||||
|
.atts.compact .att-image img{width:150px; max-height:140px}
|
||||||
|
.atts .att-video{flex:1 1 320px; max-width:440px}
|
||||||
|
.atts .att-video video{display:block; width:100%; max-height:330px; background:#000}
|
||||||
|
.att-video-ph{
|
||||||
|
width:360px; max-width:100%; height:200px; background:#11161c; color:#8b97a5;
|
||||||
|
display:flex; align-items:center; justify-content:center; font-size:13px;
|
||||||
|
}
|
||||||
|
.atts figcaption{
|
||||||
|
display:flex; gap:8px; align-items:center; padding:6px 9px; font-size:12px; color:var(--muted);
|
||||||
|
}
|
||||||
|
.atts .att-name{overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
|
||||||
|
.atts .att-size{margin-left:auto; flex:none}
|
||||||
|
.thumbs{display:flex; gap:8px; margin:0 0 8px}
|
||||||
|
.thumbs img{
|
||||||
|
width:78px; height:78px; object-fit:cover; display:block;
|
||||||
|
border-radius:9px; border:1px solid var(--line);
|
||||||
|
}
|
||||||
|
.att-tag{color:var(--brand); font-weight:600}
|
||||||
|
|
||||||
|
/* ---------- 附件灯箱(点击放大 + 左右切换) ---------- */
|
||||||
|
.atts .att-image .att-lb{display:block; line-height:0; cursor:zoom-in}
|
||||||
|
.atts .att-image .att-lb:focus-visible{outline:2px solid var(--brand); outline-offset:2px}
|
||||||
|
.att-zoom{
|
||||||
|
flex:none; border:1px solid var(--line-strong); background:var(--card); color:var(--muted);
|
||||||
|
border-radius:6px; padding:2px 9px; font-size:12px; line-height:1.6; cursor:pointer; font-family:inherit;
|
||||||
|
}
|
||||||
|
.att-zoom:hover{border-color:var(--brand); color:var(--brand)}
|
||||||
|
|
||||||
|
html.lb-open,body.lb-open{overflow:hidden}
|
||||||
|
.lb{
|
||||||
|
position:fixed; inset:0; z-index:1000; background:rgba(12,14,17,.94);
|
||||||
|
display:flex; flex-direction:column; animation:lb-in .16s ease-out;
|
||||||
|
}
|
||||||
|
.lb[hidden]{display:none}
|
||||||
|
@keyframes lb-in{from{opacity:0} to{opacity:1}}
|
||||||
|
.lb-stage{flex:1; min-height:0; position:relative}
|
||||||
|
.lb-media{
|
||||||
|
position:absolute; inset:0; z-index:1; padding:48px 62px;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
}
|
||||||
|
.lb-media img,.lb-media video{
|
||||||
|
display:block; max-width:100%; max-height:100%; border-radius:6px;
|
||||||
|
box-shadow:0 10px 44px rgba(0,0,0,.55);
|
||||||
|
}
|
||||||
|
.lb-media video{background:#000; width:auto}
|
||||||
|
.lb-media.loading::after{
|
||||||
|
content:''; position:absolute; width:26px; height:26px; border-radius:50%;
|
||||||
|
border:3px solid rgba(255,255,255,.26); border-top-color:#fff;
|
||||||
|
animation:lb-spin .8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes lb-spin{to{transform:rotate(360deg)}}
|
||||||
|
.lb-nav{
|
||||||
|
position:absolute; top:50%; transform:translateY(-50%); z-index:2;
|
||||||
|
width:46px; height:46px; padding:0 0 3px; border:0; border-radius:50%; cursor:pointer;
|
||||||
|
background:rgba(255,255,255,.14); color:#fff; font-size:26px; line-height:1;
|
||||||
|
display:flex; align-items:center; justify-content:center; font-family:inherit;
|
||||||
|
}
|
||||||
|
.lb-nav:hover{background:rgba(255,255,255,.32)}
|
||||||
|
.lb-nav[hidden]{display:none}
|
||||||
|
.lb-prev{left:14px}
|
||||||
|
.lb-next{right:14px}
|
||||||
|
.lb-x{
|
||||||
|
position:absolute; top:14px; right:14px; z-index:3;
|
||||||
|
width:40px; height:40px; border:0; border-radius:50%; cursor:pointer;
|
||||||
|
background:rgba(255,255,255,.14); color:#fff; font-size:17px; font-family:inherit;
|
||||||
|
}
|
||||||
|
.lb-x:hover{background:rgba(255,255,255,.32)}
|
||||||
|
.lb-bar{
|
||||||
|
flex:none; display:flex; align-items:center; gap:10px; flex-wrap:wrap;
|
||||||
|
padding:10px 18px 16px; color:#e9edf2; font-size:13.5px;
|
||||||
|
}
|
||||||
|
.lb-name{font-weight:600; max-width:58vw; overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
|
||||||
|
.lb-meta{color:#9aa4b0; white-space:nowrap}
|
||||||
|
.lb-orig{margin-left:auto; color:#9ec5ff; white-space:nowrap}
|
||||||
|
.lb-orig[hidden]{display:none}
|
||||||
|
@media (max-width:560px){
|
||||||
|
.lb-media{padding:40px 10px}
|
||||||
|
.lb-nav{width:38px; height:38px; font-size:22px}
|
||||||
|
.lb-prev{left:6px} .lb-next{right:6px}
|
||||||
|
.lb-name{max-width:44vw}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 深色模式 ---------- */
|
||||||
|
@media (prefers-color-scheme: dark){
|
||||||
|
:root{
|
||||||
|
--fg:#e6e8eb; --muted:#9aa2ad; --line:#2c3138; --line-strong:#3a4149;
|
||||||
|
--bg:#15181c; --card:#1c2024; --brand:#5aa2ff; --brand-soft:#1e2a3d;
|
||||||
|
--ok:#4ec98a; --ok-soft:#16301f; --warn:#e2b559; --warn-soft:#2a2517;
|
||||||
|
--danger:#ff8a80; --danger-soft:#33191a; --shadow:0 1px 3px rgba(0,0,0,.35);
|
||||||
|
}
|
||||||
|
.item-meta .who,.reply-head .who{color:#cfd4da}
|
||||||
|
.field > span{color:#e6e8eb}
|
||||||
|
.captcha{background:#21262b}
|
||||||
|
.uploader-box{background:#21262b}
|
||||||
|
.up-thumb{background:#2c3138}
|
||||||
|
.up-bar{background:#2c3138}
|
||||||
|
.up-item.fail{border-color:#5a2a2a}
|
||||||
|
.badge.st{background:#242a30; color:#a8b0ba}
|
||||||
|
.badge.st-closed{background:#242a30; color:#8a929c}
|
||||||
|
.topbar{background:rgba(21,24,28,.82)}
|
||||||
|
.topbar .navlink:hover{background:var(--brand-soft)}
|
||||||
|
.btn:hover{border-color:var(--brand)}
|
||||||
|
.reply.admin{background:linear-gradient(180deg,var(--brand-soft),var(--card) 42%)}
|
||||||
|
.flash.error{background:var(--danger-soft); border-color:#5a2a2a; color:#ffb4ab}
|
||||||
|
.flash.ok{background:var(--ok-soft); border-color:#26503a; color:#9fe3bd}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 管理后台 ---- */
|
||||||
|
.tabs{display:flex; flex-wrap:wrap; gap:8px; margin:16px 0 4px; border-bottom:1px solid var(--line); padding-bottom:10px}
|
||||||
|
.tab{padding:7px 14px; border-radius:999px; font-size:14px; color:var(--muted); background:var(--card); border:1px solid var(--line)}
|
||||||
|
.tab:hover{color:var(--brand); border-color:var(--brand)}
|
||||||
|
.tab.on{background:var(--brand); border-color:var(--brand); color:#fff}
|
||||||
|
.grid2{display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:12px 16px; margin:12px 0}
|
||||||
|
.grid2 .field{margin:0}
|
||||||
|
.check{display:flex; align-items:center; gap:8px; font-size:14px; color:var(--muted); margin:8px 0}
|
||||||
|
.check input{width:16px; height:16px}
|
||||||
|
.btn.danger{border-color:#e08b84; color:#c0392b}
|
||||||
|
.btn.danger:hover{background:#fdecea}
|
||||||
|
.filters{display:flex; flex-wrap:wrap; gap:8px; margin:18px 0 4px}
|
||||||
|
.filters input[type=text]{flex:1 1 220px; min-width:180px}
|
||||||
|
.filters select{max-width:160px}
|
||||||
|
@media (max-width:640px){
|
||||||
|
.grid2{grid-template-columns:1fr}
|
||||||
|
.filters input[type=text],.filters select{width:100%; max-width:none}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 刷新验证题(返回 JSON),供"换一题"使用,不刷新页面即可换题。
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
$q = fb_captcha_new();
|
||||||
|
echo json_encode(['ok' => true, 'q' => $q], JSON_UNESCAPED_UNICODE);
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* FeedLite · 轻反馈 · 配置文件
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* 部署后请务必修改 admin_password 与 api_token(及 ip_salt),
|
||||||
|
* 并按需填写下方的 smtp 段以开启"回复邮件通知"。
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/* ---------------- 基本 ---------------- */
|
||||||
|
// SQLite 数据库文件路径。
|
||||||
|
// 放在站内 data/ 目录(已被 .htaccess 拒绝直接访问);也可改为站点目录之外的绝对路径。
|
||||||
|
'db_path' => __DIR__ . '/data/feedback.db',
|
||||||
|
|
||||||
|
'site_name' => '轻反馈',
|
||||||
|
'site_desc' => '欢迎在这里反馈使用问题、提出功能建议。任何人都可以提问与回复,无需注册;只需填写昵称与邮箱。',
|
||||||
|
|
||||||
|
// 展示用的官方邮箱(可留空)
|
||||||
|
'admin_email' => 'support@example.com',
|
||||||
|
|
||||||
|
// 站点根地址(用于邮件正文里的链接)。留空则按访问域名自动探测。
|
||||||
|
// 例如:'https://feedback.example.com'
|
||||||
|
'base_url' => '',
|
||||||
|
|
||||||
|
/* ---------------- 安全 ---------------- */
|
||||||
|
// 管理员口令:在"回复"表单里填入它,即以「官方」身份回复(高亮显示),并触发邮件通知。
|
||||||
|
// ★★ 请务必修改! ★★
|
||||||
|
'admin_password' => 'CHANGE_ME_admin',
|
||||||
|
|
||||||
|
// API 令牌:供自动化脚本 / AI 读取数据用。★ 请务必修改 ★
|
||||||
|
'api_token' => 'CHANGE_ME_api_token',
|
||||||
|
|
||||||
|
// 对访客 IP 做加盐哈希(数据库只保存哈希,不保存明文 IP)。★ 请修改为随机值 ★
|
||||||
|
'ip_salt' => 'CHANGE_ME_random_salt',
|
||||||
|
|
||||||
|
/* ---------------- 问题分类 ---------------- */
|
||||||
|
'categories' => [
|
||||||
|
'usage' => '使用问题',
|
||||||
|
'bug' => 'Bug 反馈',
|
||||||
|
'feature' => '功能建议',
|
||||||
|
'sync' => '同步问题',
|
||||||
|
'other' => '其他',
|
||||||
|
],
|
||||||
|
|
||||||
|
/* ---------------- 处理状态 ---------------- */
|
||||||
|
'statuses' => [
|
||||||
|
'open' => '待处理',
|
||||||
|
'doing' => '处理中',
|
||||||
|
'resolved' => '已解决',
|
||||||
|
'closed' => '已关闭',
|
||||||
|
],
|
||||||
|
|
||||||
|
/* ---------------- 邮件通知(SMTP) ---------------- */
|
||||||
|
'smtp' => [
|
||||||
|
'enabled' => false, // 填好下面参数后改为 true 才会真正发信
|
||||||
|
'host' => 'smtp.example.com', // 如 smtp.qiye.aliyun.com / smtp.exmail.qq.com
|
||||||
|
'port' => 465,
|
||||||
|
'secure' => 'ssl', // ssl(465)| tls(587 STARTTLS)| ''(25 不加密)
|
||||||
|
'user' => 'noreply@example.com',
|
||||||
|
'pass' => '', // 邮箱密码或"授权码"
|
||||||
|
'from' => 'noreply@example.com', // 发件地址(通常与 user 相同)
|
||||||
|
'from_name' => '轻反馈',
|
||||||
|
'timeout' => 15,
|
||||||
|
'verify_ssl' => true, // 若服务器缺少 CA 证书可临时设为 false
|
||||||
|
],
|
||||||
|
|
||||||
|
// 通知策略
|
||||||
|
'notify' => [
|
||||||
|
'enabled' => true, // 回复时是否尝试发送通知邮件
|
||||||
|
'on_every_reply' => true, // true=任何人回复都通知发帖人;false=仅"官方"回复才通知
|
||||||
|
'cooldown_seconds' => 300, // 同一帖同一收件人的通知节流(秒)
|
||||||
|
],
|
||||||
|
|
||||||
|
/* ---------------- 审核(先审后显) ----------------
|
||||||
|
* enabled = true:新提交的问题默认**不公开**,由管理员在 admin.php 审核;
|
||||||
|
* · 通过 → 公开显示(visible=1)
|
||||||
|
* · 关闭 → 判定"不是应用的问题",保持不公开,并把管理员填写的说明邮件发给提问人
|
||||||
|
* 审核后台入口:/feedback/admin.php(口令= admin_password)
|
||||||
|
*/
|
||||||
|
'moderation' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'close_mail_subject' => '关于你反馈的问题《%s》', // %s 会替换成问题标题
|
||||||
|
],
|
||||||
|
|
||||||
|
/* ---------------- 附件上传(阿里云 OSS 直传) ----------------
|
||||||
|
* 浏览器把文件「直接」传到 OSS,不经过本站服务器 —— 因此不占用服务器磁盘、不消耗服务器带宽,
|
||||||
|
* 也就不会出现"硬盘被挤爆"的风险。服务器只做两件事:
|
||||||
|
* 1) 下发一段有时效的上传凭证(签名),限制能传到哪个路径、多大、什么类型;
|
||||||
|
* 2) 提交表单时校验对象确实存在且合规,并把元信息写进 SQLite。
|
||||||
|
*
|
||||||
|
* ★ 凭证方式:ECS 实例 RAM 角色(唯一支持的方式,服务器上不保存长期 AccessKey)
|
||||||
|
* 给这台 ECS 绑定一个 RAM 角色后,程序从内网元数据服务
|
||||||
|
* (http://100.100.100.200,链路本地地址、不出公网)读取一份「有时效、
|
||||||
|
* 会自动周期轮换」的临时凭证来签名。AccessKey 永不落盘,也永不进本文件。
|
||||||
|
*
|
||||||
|
* ★ 开通步骤(详见 README.md「5.1 开通与配置」):
|
||||||
|
* ① 创建 Bucket(读写权限建议「公共读」;想更严可用 private,本程序会自动签发访问 URL)
|
||||||
|
* ② RAM 建权限策略:只授予该 Bucket(某前缀下)的 oss:PutObject / oss:GetObject / oss:DeleteObject
|
||||||
|
* ③ RAM 建角色(如 FeedLiteOssRole)并赋予上一步的策略
|
||||||
|
* ④ ECS 控制台把该角色【授予本实例】
|
||||||
|
* ⑤ Bucket → 数据安全 → 跨域设置(CORS):来源填你的站点域名,方法勾 POST/GET/HEAD
|
||||||
|
* ⑥ 把 endpoint / bucket / instance_role 填到下面,最后 enabled = true
|
||||||
|
* ⑦(可选但强烈推荐)绑定自定义域名,避免默认域名带 Content-Disposition 被迫下载
|
||||||
|
*
|
||||||
|
* ★ 前提:站点必须运行在【阿里云 ECS】上并且已绑定 RAM 角色;
|
||||||
|
* 未绑定则取不到凭证,上传接口会明确报错(其它功能不受影响)。
|
||||||
|
*/
|
||||||
|
'oss' => [
|
||||||
|
'enabled' => false, // 填好参数后改为 true 才启用上传
|
||||||
|
'endpoint' => 'oss-cn-beijing.aliyuncs.com', // 不含 http://,如 oss-cn-hangzhou.aliyuncs.com
|
||||||
|
'bucket' => 'your-bucket-name',
|
||||||
|
|
||||||
|
// ★ ECS 实例 RAM 角色名 —— 必须与【ECS 实例实际绑定的角色名】逐字一致。
|
||||||
|
// 查法:curl http://100.100.100.200/latest/meta-data/ram/security-credentials/
|
||||||
|
// 注意:RAM 里"创建的角色名"和"授予实例后实例上生效的角色名"可能不同(如
|
||||||
|
// 建的是 MyOssRole,授予实例后生效名可能是系统预置策略对应的角色),以查询结果为准。
|
||||||
|
'instance_role' => '',
|
||||||
|
|
||||||
|
// ---- 以下一般不用改 ----
|
||||||
|
'metadata_url' => '', // 留空=http://100.100.100.200(ECS 内网元数据服务)
|
||||||
|
'metadata_v2' => false, // true=加固模式(先取一次性 token 再读元数据,防 SSRF 探测)
|
||||||
|
'metadata_ttl' => 600, // 加固模式 token 有效期(秒,最长 21600)
|
||||||
|
'credential_cache' => '', // 凭证缓存文件;留空=与数据库同目录的 oss.sts.php
|
||||||
|
'credential_refresh_margin' => 300, // 到期前多少秒就提前换新(秒)
|
||||||
|
|
||||||
|
'domain' => '', // 自定义访问域名,如 https://fb-img.example.com(留空用默认域名)
|
||||||
|
'url_scheme' => 'https', // 拼接访问地址时使用的协议
|
||||||
|
'prefix' => 'fb', // 对象键前缀:fb/2026/09/xxxxxxxx.jpg
|
||||||
|
'visibility' => 'public', // public=直接可读 | private=展示时生成签名 URL
|
||||||
|
'sign_ttl' => 600, // 上传凭证有效期(秒)
|
||||||
|
'link_ttl' => 604800, // private 模式下展示链接的有效期(秒)
|
||||||
|
'image_process' => true, // 列表缩略图使用 OSS 图片处理(免费额度内,省流量)
|
||||||
|
'verify' => true, // 提交时 HEAD 复核对象(防伪造/防超限)
|
||||||
|
'host_override' => '', // 仅特殊网关/联调使用;留空自动按 bucket.endpoint 拼接
|
||||||
|
],
|
||||||
|
|
||||||
|
/* ---------------- 附件限额 ----------------
|
||||||
|
* 单位:字节。原图上限指「压缩前」的原始文件大小(前端会先压缩到最长边 1600px)。
|
||||||
|
*/
|
||||||
|
'upload' => [
|
||||||
|
'image_max_per_post' => 4,
|
||||||
|
'image_max_bytes' => 5242880, // 5 MB / 张
|
||||||
|
'image_exts' => ['jpg', 'jpeg', 'png', 'webp', 'gif'],
|
||||||
|
'image_mimes' => ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
|
||||||
|
'image_max_side' => 1600, // 前端压缩后的最长边(像素)
|
||||||
|
|
||||||
|
'video_max_per_post' => 1,
|
||||||
|
'video_max_bytes' => 52428800, // 50 MB / 个
|
||||||
|
'video_exts' => ['mp4', 'mov', 'webm'],
|
||||||
|
'video_mimes' => ['video/mp4', 'video/quicktime', 'video/webm'],
|
||||||
|
|
||||||
|
'total_cap_bytes' => 1073741824, // 全站附件总量上限 1 GB,达到后暂停接收新上传
|
||||||
|
'retention_days' => 365, // 附件保留天数,超期由 cron.php 清理
|
||||||
|
'upload_per_hour' => 60, // 每 IP 每小时最多申请多少次上传凭证
|
||||||
|
],
|
||||||
|
|
||||||
|
/* ---------------- 反垃圾 ---------------- */
|
||||||
|
'rate_limit' => [
|
||||||
|
'thread_per_hour' => 5, // 每个 IP 每小时最多发起的主题数
|
||||||
|
'reply_per_hour' => 20, // 每个 IP 每小时最多回复数
|
||||||
|
],
|
||||||
|
'post_min_seconds' => 3, // 打开表单到提交不得少于该秒数(过快视为机器人)
|
||||||
|
|
||||||
|
/* ---------------- 列表 ---------------- */
|
||||||
|
'page_size' => 20,
|
||||||
|
];
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 维护脚本(建议每天跑一次)
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* 1) 清理超过保留期的附件(先删 OSS 对象,再删库记录)
|
||||||
|
* 2) 补发失败的通知邮件
|
||||||
|
*
|
||||||
|
* 用法(宝塔 → 计划任务 → Shell 脚本):
|
||||||
|
* php /www/wwwroot/xxx/feedback/cron.php --token=你的api_token
|
||||||
|
* 命令行(CLI)运行时可不带令牌。
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
$isCli = (PHP_SAPI === 'cli');
|
||||||
|
|
||||||
|
if (!$isCli) {
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
$token = isset($_GET['token']) ? (string)$_GET['token'] : '';
|
||||||
|
$expected = (string)fb_cfg('api_token');
|
||||||
|
if ($expected === '' || !hash_equals($expected, $token)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo "forbidden\n";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = fb_db();
|
||||||
|
|
||||||
|
/* ---------------- 1. 附件过期清理 ---------------- */
|
||||||
|
$days = (int)fb_upload_cfg('retention_days', 365);
|
||||||
|
$kept = (int)$pdo->query('SELECT COUNT(*) FROM attachments')->fetchColumn();
|
||||||
|
$gone = 0;
|
||||||
|
if ($days > 0) {
|
||||||
|
$gone = fb_attach_cleanup($pdo, $days, 500);
|
||||||
|
}
|
||||||
|
echo "附件:共 {$kept} 个,已清理超过 {$days} 天的附件 {$gone} 个(剩余 "
|
||||||
|
. (int)$pdo->query('SELECT COUNT(*) FROM attachments')->fetchColumn() . " 个,占用 "
|
||||||
|
. fb_size_h(fb_attach_total_bytes($pdo)) . ")。\n";
|
||||||
|
|
||||||
|
/* ---------------- 1b. 附件存储凭证自检 ----------------
|
||||||
|
* 顺手确认一次「能不能从 ECS 实例 RAM 角色拿到上传凭证」:角色名写错、
|
||||||
|
* 角色没授予实例、元数据服务被网络策略挡住时,这里会直接说明原因,
|
||||||
|
* 比等用户上传失败再来排查容易得多。
|
||||||
|
*/
|
||||||
|
if (fb_oss_enabled()) {
|
||||||
|
$cs = fb_oss_cred_status();
|
||||||
|
$line = '附件存储凭证:' . $cs['label'] . '(AK 尾号 ' . ($cs['ak_tail'] !== '' ? '…' . $cs['ak_tail'] : '无') . ')';
|
||||||
|
if ($cs['has_token']) { $line .= ',已取得临时安全令牌'; }
|
||||||
|
if ($cs['expire'] > 0) { $line .= ',有效期至 ' . date('Y-m-d H:i', $cs['expire']); }
|
||||||
|
if (!$cs['ok']) { $line .= ' ⚠️ 取不到凭证:' . ($cs['error'] !== '' ? $cs['error'] : '未知原因'); }
|
||||||
|
echo $line . "。\n";
|
||||||
|
} else {
|
||||||
|
echo "附件存储未启用(请在 config.php 的 oss 段填写 endpoint / bucket / instance_role 并置 enabled = true)。\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 2. 通知邮件补发 ---------------- */
|
||||||
|
$smtp = (array)fb_cfg('smtp');
|
||||||
|
if (empty($smtp['enabled'])) {
|
||||||
|
echo "SMTP 未启用,跳过邮件补发。\n";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = 50;
|
||||||
|
$rows = $pdo->query(
|
||||||
|
'SELECT * FROM notify_log WHERE ok = 0 AND tries < 5 ORDER BY id ASC LIMIT ' . $limit
|
||||||
|
)->fetchAll();
|
||||||
|
|
||||||
|
$sent = 0; $fail = 0;
|
||||||
|
foreach ($rows as $n) {
|
||||||
|
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
|
||||||
|
$st->execute([(int)$n['thread_id']]);
|
||||||
|
$thread = $st->fetch();
|
||||||
|
if (!$thread) { continue; }
|
||||||
|
|
||||||
|
$st = $pdo->prepare('SELECT * FROM replies WHERE id = ?');
|
||||||
|
$st->execute([(int)$n['reply_id']]);
|
||||||
|
$reply = $st->fetch();
|
||||||
|
if (!$reply) { continue; }
|
||||||
|
|
||||||
|
$isAdmin = !empty($reply['is_admin']);
|
||||||
|
$subject = '【' . fb_cfg('site_name') . '】有人回复了你的问题:' . fb_excerpt($thread['title'], 40);
|
||||||
|
$html = fb_notify_html($thread, $reply, $isAdmin);
|
||||||
|
|
||||||
|
list($ok, $err) = fb_smtp_send($smtp, (string)$n['to_email'], $subject, $html);
|
||||||
|
|
||||||
|
$up = $pdo->prepare('UPDATE notify_log SET ok = ?, tries = tries + 1, err = ? WHERE id = ?');
|
||||||
|
$up->execute([$ok ? 1 : 0, $ok ? null : $err, (int)$n['id']]);
|
||||||
|
|
||||||
|
if ($ok) { $sent++; } else { $fail++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "邮件:成功 {$sent} 封,失败 {$fail} 封(最多处理 {$limit} 条待发记录)。\n";
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 拒绝一切对数据目录的直接访问(数据库文件在此)
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order allow,deny
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!-- silence is golden -->
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 问题列表
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
$pdo = fb_db();
|
||||||
|
$cats = fb_categories();
|
||||||
|
$statuses = fb_statuses();
|
||||||
|
|
||||||
|
$cat = isset($_GET['cat']) ? (string)$_GET['cat'] : '';
|
||||||
|
$st = isset($_GET['status']) ? (string)$_GET['status'] : '';
|
||||||
|
$q = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
|
||||||
|
$sort = isset($_GET['sort']) ? (string)$_GET['sort'] : 'active';
|
||||||
|
|
||||||
|
if ($cat !== '' && !isset($cats[$cat])) { $cat = ''; }
|
||||||
|
if ($st !== '' && !isset($statuses[$st])) { $st = ''; }
|
||||||
|
if ($sort !== 'new') { $sort = 'active'; }
|
||||||
|
|
||||||
|
$where = [];
|
||||||
|
$args = [];
|
||||||
|
// 审核(先审后显):访客只看已通过审核的;管理员登录审核后台后可看全部
|
||||||
|
if (fb_moderation_on() && !fb_is_admin()) { $where[] = 'visible = 1'; }
|
||||||
|
if ($cat !== '') { $where[] = 'category = ?'; $args[] = $cat; }
|
||||||
|
if ($st !== '') { $where[] = 'status = ?'; $args[] = $st; }
|
||||||
|
if ($q !== '') {
|
||||||
|
$like = '%' . addcslashes($q, '%_\\') . '%';
|
||||||
|
$where[] = "(title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')";
|
||||||
|
$args[] = $like;
|
||||||
|
$args[] = $like;
|
||||||
|
}
|
||||||
|
$wsql = $where ? (' WHERE ' . implode(' AND ', $where)) : '';
|
||||||
|
$order = ($sort === 'new') ? 'created_at DESC' : 'updated_at DESC';
|
||||||
|
|
||||||
|
$stc = $pdo->prepare('SELECT COUNT(*) FROM threads' . $wsql);
|
||||||
|
$stc->execute($args);
|
||||||
|
$total = (int)$stc->fetchColumn();
|
||||||
|
|
||||||
|
$pageSize = max(5, (int)fb_cfg('page_size', 20));
|
||||||
|
$pages = max(1, (int)ceil($total / $pageSize));
|
||||||
|
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
||||||
|
if ($page < 1) { $page = 1; }
|
||||||
|
if ($page > $pages){ $page = $pages; }
|
||||||
|
$offset = ($page - 1) * $pageSize;
|
||||||
|
|
||||||
|
$sql = 'SELECT id, title, category, status, author_name, reply_count, created_at, updated_at,'
|
||||||
|
. ' substr(body, 1, 300) AS excerpt FROM threads'
|
||||||
|
. $wsql . ' ORDER BY ' . $order . ' LIMIT ' . $pageSize . ' OFFSET ' . $offset;
|
||||||
|
$sth = $pdo->prepare($sql);
|
||||||
|
$sth->execute($args);
|
||||||
|
$threads = $sth->fetchAll();
|
||||||
|
|
||||||
|
// 附件缩略图与数量(只在有附件时才有开销)
|
||||||
|
$tids = [];
|
||||||
|
foreach ($threads as $t) { $tids[] = (int)$t['id']; }
|
||||||
|
$attImgs = $tids ? fb_attach_map_images($pdo, $tids, 3) : [];
|
||||||
|
$attCount = $tids ? fb_attach_count_map($pdo, $tids) : [];
|
||||||
|
|
||||||
|
fb_layout_head('全部问题');
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>全部问题</h1>
|
||||||
|
<p class="muted"><?php echo e((string)fb_cfg('site_desc')); ?></p>
|
||||||
|
</div>
|
||||||
|
<a class="btn primary" href="new.php">我要反馈</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<div class="chips">
|
||||||
|
<a class="chip <?php echo $cat === '' ? 'on' : ''; ?>" href="<?php echo e(fb_qs(['cat' => null, 'page' => null]) ?: '?'); ?>">全部</a>
|
||||||
|
<?php foreach ($cats as $k => $v): ?>
|
||||||
|
<a class="chip <?php echo $cat === $k ? 'on' : ''; ?>" href="<?php echo e(fb_qs(['cat' => $k, 'page' => null])); ?>"><?php echo e($v); ?></a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<form class="filter-row" method="get" action="">
|
||||||
|
<input type="hidden" name="cat" value="<?php echo e($cat); ?>">
|
||||||
|
<select name="status" onchange="this.form.submit()">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<?php foreach ($statuses as $k => $v): ?>
|
||||||
|
<option value="<?php echo e($k); ?>" <?php echo $st === $k ? 'selected' : ''; ?>><?php echo e($v); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<select name="sort" onchange="this.form.submit()">
|
||||||
|
<option value="active" <?php echo $sort === 'active' ? 'selected' : ''; ?>>按最新回复</option>
|
||||||
|
<option value="new" <?php echo $sort === 'new' ? 'selected' : ''; ?>>按最新发布</option>
|
||||||
|
</select>
|
||||||
|
<input type="search" name="q" value="<?php echo e($q); ?>" placeholder="搜索标题或内容…">
|
||||||
|
<button class="btn" type="submit">搜索</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list">
|
||||||
|
<?php if (!$threads): ?>
|
||||||
|
<div class="empty">
|
||||||
|
<p>还没有符合条件的问题。</p>
|
||||||
|
<p><a class="btn primary" href="new.php">来提第一个吧</a></p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($threads as $t): ?>
|
||||||
|
<a class="item" href="thread.php?id=<?php echo (int)$t['id']; ?>">
|
||||||
|
<div class="item-top">
|
||||||
|
<span class="badge cat"><?php echo e(fb_category_label($t['category'])); ?></span>
|
||||||
|
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
|
||||||
|
<span class="item-title"><?php echo e($t['title']); ?></span>
|
||||||
|
<span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span>
|
||||||
|
</div>
|
||||||
|
<p class="item-ex"><?php echo e(fb_excerpt($t['excerpt'], 120)); ?></p>
|
||||||
|
<?php if (!empty($attImgs[(int)$t['id']])): ?>
|
||||||
|
<div class="thumbs">
|
||||||
|
<?php foreach ($attImgs[(int)$t['id']] as $im): ?>
|
||||||
|
<img loading="lazy" alt="" src="<?php echo e(fb_attach_thumb_url($im['oss_key'], 200, isset($im['mime']) ? $im['mime'] : '')); ?>">
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="item-meta">
|
||||||
|
<span class="who"><?php echo e($t['author_name']); ?></span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span><?php echo e(fb_fmt_time($t['updated_at'])); ?></span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span><?php echo (int)$t['reply_count']; ?> 条回复</span>
|
||||||
|
<?php if (!empty($attCount[(int)$t['id']])): ?>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span class="att-tag">附件 <?php echo (int)$attCount[(int)$t['id']]; ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($pages > 1): ?>
|
||||||
|
<div class="pager">
|
||||||
|
<?php
|
||||||
|
$mk = function ($p) {
|
||||||
|
return e(fb_qs(['page' => $p <= 1 ? null : $p]) ?: '?');
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<?php if ($page > 1): ?>
|
||||||
|
<a class="btn" href="<?php echo $mk($page - 1); ?>">上一页</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span class="pager-info">第 <?php echo (int)$page; ?> / <?php echo (int)$pages; ?> 页 · 共 <?php echo (int)$total; ?> 个问题</span>
|
||||||
|
<?php if ($page < $pages): ?>
|
||||||
|
<a class="btn" href="<?php echo $mk($page + 1); ?>">下一页</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php elseif ($total > 0): ?>
|
||||||
|
<div class="pager"><span class="pager-info">共 <?php echo (int)$total; ?> 个问题</span></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
fb_layout_foot();
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 附件(图片 / 视频)—— 校验、入库、查询、渲染、过期清理
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* 设计要点:
|
||||||
|
* - 文件实体只存在 OSS,本地库只存元信息(对象键、大小、类型、尺寸)。
|
||||||
|
* - 客户端提交的只是「描述」,服务端必须 HEAD 回源复核,防止伪造或超限。
|
||||||
|
*/
|
||||||
|
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
|
||||||
|
|
||||||
|
/** 读取 upload 配置 */
|
||||||
|
function fb_upload_cfg($k = null, $d = null) {
|
||||||
|
$c = (array)fb_cfg('upload', []);
|
||||||
|
if ($k === null) { return $c; }
|
||||||
|
return array_key_exists($k, $c) ? $c[$k] : $d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================================================================
|
||||||
|
* 存储方式:off(关闭)| local(存本机磁盘)| oss(存阿里云 OSS)
|
||||||
|
* 同一条附件走哪种存储,由它的 key 前缀决定:
|
||||||
|
* local/... → 本机 uploads/ 目录 fb/... → OSS
|
||||||
|
* 这样中途改存储方式也不会让旧附件失效。
|
||||||
|
* ================================================================== */
|
||||||
|
|
||||||
|
/** 当前存储方式(配置 attachment.storage;未配置则按 oss.enabled 推断) */
|
||||||
|
function fb_store_mode() {
|
||||||
|
$m = strtolower(trim((string)fb_cfg('attachment.storage', '')));
|
||||||
|
if ($m === 'local' || $m === 'oss' || $m === 'off') { return $m; }
|
||||||
|
return fb_oss_enabled() ? 'oss' : 'off';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本机附件目录(站点内可直连访问) */
|
||||||
|
function fb_local_dir() {
|
||||||
|
return rtrim(str_replace('\\', '/', FB_ROOT . '/uploads'), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保本机附件目录存在,并补齐安全防护文件(.htaccess / index.html)。
|
||||||
|
* ⚠️ 目录被删掉后由 PHP 重建时,.htaccess 不会自动回来 —— 必须在这里补写,
|
||||||
|
* 否则「禁列目录 + 禁执行脚本」的防护就丢了。
|
||||||
|
*/
|
||||||
|
function fb_local_ensure_dir() {
|
||||||
|
$dir = fb_local_dir();
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0755, true);
|
||||||
|
if (!is_dir($dir)) { return false; }
|
||||||
|
@chmod($dir, 0755);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ht = $dir . '/.htaccess';
|
||||||
|
if (!is_file($ht)) {
|
||||||
|
$rule = "# 本机附件目录(由程序自动生成)\n"
|
||||||
|
. "# 允许直接读取图片 / 视频,但禁止列出目录、禁止执行任何脚本\n"
|
||||||
|
. "Options -Indexes\n\n"
|
||||||
|
. "<FilesMatch \"\\.(php|php[0-9]|phtml|pht|phar|pl|py|cgi|sh|htaccess)$\">\n"
|
||||||
|
. " <IfModule mod_authz_core.c>\n Require all denied\n </IfModule>\n"
|
||||||
|
. " <IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n </IfModule>\n"
|
||||||
|
. "</FilesMatch>\n";
|
||||||
|
@file_put_contents($ht, $rule);
|
||||||
|
@chmod($ht, 0644);
|
||||||
|
}
|
||||||
|
|
||||||
|
$idx = $dir . '/index.html';
|
||||||
|
if (!is_file($idx)) {
|
||||||
|
@file_put_contents($idx, '');
|
||||||
|
@chmod($idx, 0644);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本机附件是否可写 */
|
||||||
|
function fb_local_writable() {
|
||||||
|
fb_local_ensure_dir();
|
||||||
|
$dir = fb_local_dir();
|
||||||
|
return is_dir($dir) && is_writable($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本机附件目录的诊断信息(供后台「存储体检」显示)。
|
||||||
|
* 真实踩过的坑:用 SFTP/部署账号手动建 uploads/ 时,目录属主是部署账号,
|
||||||
|
* PHP 进程(www)写不进去 —— 表现就是「文件已存在但不可写」。
|
||||||
|
* 此时最省事的办法是删掉该目录,让 PHP 自己重建(属主即 www)。
|
||||||
|
* @return array{ok:bool,dir:string,tip:string}
|
||||||
|
*/
|
||||||
|
function fb_local_diag() {
|
||||||
|
$dir = fb_local_dir();
|
||||||
|
fb_local_ensure_dir(); // 顺带补齐 .htaccess / index.html
|
||||||
|
$exist = is_dir($dir);
|
||||||
|
if (!$exist) {
|
||||||
|
return ['ok' => false, 'dir' => $dir,
|
||||||
|
'tip' => '目录不存在且无法创建:上级目录 feedback/ 对 PHP 进程不可写,请检查目录权限。'];
|
||||||
|
}
|
||||||
|
if (!is_writable($dir)) {
|
||||||
|
$owner = function_exists('posix_getpwuid') ? @posix_getpwuid(@fileowner($dir)) : null;
|
||||||
|
$oname = is_array($owner) && isset($owner['name']) ? $owner['name'] : ('uid ' . @fileowner($dir));
|
||||||
|
$me = function_exists('posix_geteuid') ? @posix_getpwuid(@posix_geteuid()) : null;
|
||||||
|
$mname = is_array($me) && isset($me['name']) ? $me['name'] : ('uid ' . @getmyuid());
|
||||||
|
return ['ok' => false, 'dir' => $dir,
|
||||||
|
'tip' => '目录属主是 ' . $oname . ',而 PHP 以 ' . $mname . ' 运行 → 不可写。'
|
||||||
|
. '处理办法:删掉整个 uploads/ 目录(包括里面的 .htaccess),再刷新本页,'
|
||||||
|
. 'PHP 会自动重建并把属主设为自己;或在宝塔面板里把 uploads/ 属主改为站点运行用户。'];
|
||||||
|
}
|
||||||
|
return ['ok' => true, 'dir' => $dir, 'tip' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前存储是否可用(决定要不要显示上传入口) */
|
||||||
|
function fb_store_enabled() {
|
||||||
|
$m = fb_store_mode();
|
||||||
|
if ($m === 'local') { return fb_local_writable(); }
|
||||||
|
if ($m === 'oss') { return fb_oss_enabled(); }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成「本机存储」的对象键 */
|
||||||
|
function fb_local_make_key($ext) {
|
||||||
|
$ext = strtolower(preg_replace('/[^A-Za-z0-9]/', '', (string)$ext));
|
||||||
|
return 'local/' . date('Y/m') . '/' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 该附件是不是存在本机 */
|
||||||
|
function fb_attach_is_local($key) {
|
||||||
|
return strpos((string)$key, 'local/') === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** key → 本机绝对路径(已做穿越防护) */
|
||||||
|
function fb_local_path_of($key) {
|
||||||
|
$rel = ltrim((string)$key, '/');
|
||||||
|
if (strpos($rel, 'local/') === 0) { $rel = substr($rel, 6); }
|
||||||
|
$rel = str_replace(['..', "\0"], '', $rel);
|
||||||
|
return fb_local_dir() . '/' . ltrim($rel, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 附件的访问地址(自动按 key 前缀分流) */
|
||||||
|
function fb_attach_url($key) {
|
||||||
|
if (fb_attach_is_local($key)) {
|
||||||
|
return fb_base_url() . '/uploads/' . substr(ltrim((string)$key, '/'), 6);
|
||||||
|
}
|
||||||
|
return fb_oss_url($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 附件的展示地址(缩略图;本机存储没有图片处理,直接给原图) */
|
||||||
|
function fb_attach_thumb_url($key, $w = 640, $mime = '') {
|
||||||
|
if (fb_attach_is_local($key)) { return fb_attach_url($key); }
|
||||||
|
return fb_oss_thumb_url($key, $w, $mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除附件实体(先按 key 分流,返回是否可继续删库记录) */
|
||||||
|
function fb_attach_delete($key) {
|
||||||
|
if (fb_attach_is_local($key)) {
|
||||||
|
$p = fb_local_path_of($key);
|
||||||
|
if (!is_file($p)) { return true; }
|
||||||
|
return @unlink($p);
|
||||||
|
}
|
||||||
|
if (!fb_oss_enabled()) { return true; }
|
||||||
|
return fb_oss_delete($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 该类型是否可用(数量上限为 0 或未配置则视为关闭) */
|
||||||
|
function fb_upload_kind_on($kind) {
|
||||||
|
if (!fb_store_enabled()) { return false; }
|
||||||
|
return (int)fb_upload_cfg($kind . '_max_per_post', 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一化扩展名 */
|
||||||
|
function fb_upload_norm_ext($ext) {
|
||||||
|
return strtolower(preg_replace('/[^A-Za-z0-9]/', '', (string)$ext));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扩展名白名单 */
|
||||||
|
function fb_upload_ext_ok($kind, $ext) {
|
||||||
|
$list = (array)fb_upload_cfg($kind . '_exts', []);
|
||||||
|
return in_array(fb_upload_norm_ext($ext), array_map('strtolower', $list), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MIME 白名单 */
|
||||||
|
function fb_upload_mime_ok($kind, $mime) {
|
||||||
|
$list = (array)fb_upload_cfg($kind . '_mimes', []);
|
||||||
|
return in_array(strtolower(trim((string)$mime)), array_map('strtolower', $list), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按扩展名猜 MIME(HEAD 复核时服务端返回缺失的兜底) */
|
||||||
|
function fb_upload_mime_of_ext($ext) {
|
||||||
|
$map = [
|
||||||
|
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
|
||||||
|
'webp' => 'image/webp', 'gif' => 'image/gif',
|
||||||
|
'mp4' => 'video/mp4', 'mov' => 'video/quicktime', 'webm' => 'video/webm',
|
||||||
|
];
|
||||||
|
$e = fb_upload_norm_ext($ext);
|
||||||
|
return isset($map[$e]) ? $map[$e] : 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 截断(不依赖 mbstring 扩展) */
|
||||||
|
function fb_upload_shorten($s, $len) {
|
||||||
|
$s = trim((string)$s);
|
||||||
|
$s = preg_replace('/[\r\n\t]+/', ' ', $s);
|
||||||
|
if (fb_len($s) <= $len) { return $s; }
|
||||||
|
return (function_exists('mb_substr') ? mb_substr($s, 0, $len, 'UTF-8') : substr($s, 0, $len));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对象键是否合法(且位于本站前缀之下) */
|
||||||
|
function fb_upload_key_ok($key) {
|
||||||
|
$key = (string)$key;
|
||||||
|
if (strlen($key) < 8 || strlen($key) > 200) { return false; }
|
||||||
|
if (!preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $key)) { return false; }
|
||||||
|
if (strpos($key, '..') !== false || strpos($key, '//') !== false) { return false; }
|
||||||
|
|
||||||
|
if (fb_attach_is_local($key)) {
|
||||||
|
// 本机存储:必须是 local/YYYY/MM/随机名.扩展名
|
||||||
|
return (bool)preg_match('#^local/\d{4}/\d{2}/[a-f0-9]{16}\.[a-z0-9]{2,5}$#', $key);
|
||||||
|
}
|
||||||
|
$prefix = trim((string)fb_cfg('oss.prefix', 'fb'), '/');
|
||||||
|
return strpos($key, $prefix . '/') === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 键的扩展名 */
|
||||||
|
function fb_upload_key_ext($key) {
|
||||||
|
$e = pathinfo((string)$key, PATHINFO_EXTENSION);
|
||||||
|
return fb_upload_norm_ext($e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前附件总量(字节) */
|
||||||
|
function fb_attach_total_bytes(?PDO $pdo = null) {
|
||||||
|
$pdo = $pdo ?: fb_db();
|
||||||
|
return (int)$pdo->query('SELECT COALESCE(SUM(size), 0) FROM attachments')->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析客户端提交的附件 JSON(隐藏字段 attachments)
|
||||||
|
* @return array [bool $ok, array $list, string $err]
|
||||||
|
*/
|
||||||
|
function fb_attach_parse($raw) {
|
||||||
|
$empty = [[], []];
|
||||||
|
$raw = trim((string)$raw);
|
||||||
|
if ($raw === '') { return [true, [], '']; }
|
||||||
|
if (strlen($raw) > 20000) { return [false, [], '附件信息过长。']; }
|
||||||
|
|
||||||
|
$arr = json_decode($raw, true);
|
||||||
|
if (!is_array($arr)) { return [false, [], '附件信息格式不正确。']; }
|
||||||
|
|
||||||
|
$list = [];
|
||||||
|
$counts = ['image' => 0, 'video' => 0];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ($arr as $it) {
|
||||||
|
if (!is_array($it)) { return [false, [], '附件信息格式不正确。']; }
|
||||||
|
|
||||||
|
$kind = isset($it['kind']) ? strtolower(trim((string)$it['kind'])) : '';
|
||||||
|
if ($kind !== 'image' && $kind !== 'video') { return [false, [], '不支持的附件类型。']; }
|
||||||
|
if (!fb_upload_kind_on($kind)) { return [false, [], '该类型附件未开放上传。']; }
|
||||||
|
|
||||||
|
$key = isset($it['key']) ? trim((string)$it['key']) : '';
|
||||||
|
$size = isset($it['size']) ? (int)$it['size'] : 0;
|
||||||
|
$mime = isset($it['mime']) ? strtolower(trim((string)$it['mime'])) : '';
|
||||||
|
|
||||||
|
if (!fb_upload_key_ok($key)) { return [false, [], '附件地址不合法,请重新上传。']; }
|
||||||
|
if (isset($seen[$key])) { return [false, [], '存在重复的附件,请重新上传。']; }
|
||||||
|
|
||||||
|
$ext = fb_upload_key_ext($key);
|
||||||
|
if (!fb_upload_ext_ok($kind, $ext)) { return [false, [], '附件格式不被允许(' . e($ext) . ')。']; }
|
||||||
|
|
||||||
|
$max = (int)fb_upload_cfg($kind . '_max_bytes', 0);
|
||||||
|
if ($size <= 0 || ($max > 0 && $size > $max)) {
|
||||||
|
return [false, [], ($kind === 'image' ? '单张图片' : '视频') . '大小超出限制。'];
|
||||||
|
}
|
||||||
|
if ($mime !== '' && !fb_upload_mime_ok($kind, $mime)) {
|
||||||
|
return [false, [], '附件类型不被允许。'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts[$kind]++;
|
||||||
|
if ($counts[$kind] > (int)fb_upload_cfg($kind . '_max_per_post', 0)) {
|
||||||
|
return [false, [], ($kind === 'image' ? '图片最多上传 ' : '视频最多上传 ')
|
||||||
|
. (int)fb_upload_cfg($kind . '_max_per_post', 0) . ' 个。'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$key] = true;
|
||||||
|
$list[] = [
|
||||||
|
'kind' => $kind,
|
||||||
|
'key' => $key,
|
||||||
|
'size' => $size,
|
||||||
|
'mime' => ($mime !== '' ? $mime : fb_upload_mime_of_ext($ext)),
|
||||||
|
'name' => isset($it['name']) ? fb_upload_shorten($it['name'], 120) : '',
|
||||||
|
'width' => isset($it['w']) ? max(0, (int)$it['w']) : null,
|
||||||
|
'height' => isset($it['h']) ? max(0, (int)$it['h']) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [true, $list, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回源复核:确认对象确实存在、大小一致、内容类型在白名单内。
|
||||||
|
* 复核结果会以服务端返回的 Content-Type / Content-Length 为准写回 $list。
|
||||||
|
* @return array [bool $ok, array $list, string $err]
|
||||||
|
*/
|
||||||
|
function fb_attach_verify(array $list) {
|
||||||
|
if (!$list) { return [true, $list, '']; }
|
||||||
|
if (!fb_store_enabled()) { return [false, [], '附件服务未启用。']; }
|
||||||
|
|
||||||
|
// 本机存储:直接查磁盘,不联网
|
||||||
|
if (fb_store_mode() === 'local') {
|
||||||
|
foreach ($list as $i => $it) {
|
||||||
|
if (!fb_attach_is_local($it['key'])) {
|
||||||
|
return [false, [], '附件地址与当前存储方式不符,请重新上传。'];
|
||||||
|
}
|
||||||
|
$p = fb_local_path_of($it['key']);
|
||||||
|
if (!is_file($p)) {
|
||||||
|
return [false, [], '附件上传未完成或已失效,请重新上传后提交。'];
|
||||||
|
}
|
||||||
|
$len = (int)@filesize($p);
|
||||||
|
if ($len > 0) {
|
||||||
|
if (abs($len - (int)$it['size']) > 1024) {
|
||||||
|
return [false, [], '附件大小与服务器记录不一致,请重新上传。'];
|
||||||
|
}
|
||||||
|
$list[$i]['size'] = $len;
|
||||||
|
}
|
||||||
|
$ct = '';
|
||||||
|
if (function_exists('finfo_open')) {
|
||||||
|
$f = @finfo_open(FILEINFO_MIME_TYPE);
|
||||||
|
if ($f) { $ct = strtolower(trim((string)@finfo_file($f, $p))); @finfo_close($f); }
|
||||||
|
} elseif (function_exists('mime_content_type')) {
|
||||||
|
$ct = strtolower(trim((string)@mime_content_type($p)));
|
||||||
|
}
|
||||||
|
if ($ct !== '' && $ct !== 'application/octet-stream') {
|
||||||
|
if (!fb_upload_mime_ok($it['kind'], $ct)) {
|
||||||
|
return [false, [], '附件内容类型不被允许。'];
|
||||||
|
}
|
||||||
|
$list[$i]['mime'] = $ct;
|
||||||
|
}
|
||||||
|
if (!fb_upload_mime_ok($it['kind'], $list[$i]['mime'])) {
|
||||||
|
return [false, [], '附件内容类型不被允许。'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [true, $list, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fb_oss_enabled()) { return [false, [], '附件服务未启用。']; }
|
||||||
|
if (!fb_oss_cfg('verify', true)) { return [true, $list, '']; }
|
||||||
|
|
||||||
|
foreach ($list as $i => $it) {
|
||||||
|
list($status, $h) = fb_oss_head($it['key']);
|
||||||
|
if ($status < 200 || $status >= 300) {
|
||||||
|
return [false, [], '附件上传未完成或已失效,请重新上传后提交。'];
|
||||||
|
}
|
||||||
|
$len = isset($h['content-length']) ? (int)$h['content-length'] : 0;
|
||||||
|
if ($len > 0) {
|
||||||
|
if (abs($len - (int)$it['size']) > 1024) {
|
||||||
|
return [false, [], '附件大小与服务器记录不一致,请重新上传。'];
|
||||||
|
}
|
||||||
|
$list[$i]['size'] = $len;
|
||||||
|
}
|
||||||
|
if (!empty($h['content-type'])) {
|
||||||
|
$ct = strtolower(trim((string)explode(';', $h['content-type'])[0]));
|
||||||
|
// 服务端返回的类型必须可信:不在白名单里就直接拒绝;
|
||||||
|
// 仅在「缺失 / octet-stream」时才回落到客户端声明的类型。
|
||||||
|
if ($ct !== '' && $ct !== 'application/octet-stream') {
|
||||||
|
if (!fb_upload_mime_ok($it['kind'], $ct)) {
|
||||||
|
return [false, [], '附件内容类型不被允许。'];
|
||||||
|
}
|
||||||
|
$list[$i]['mime'] = $ct;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!fb_upload_mime_ok($it['kind'], $list[$i]['mime'])) {
|
||||||
|
return [false, [], '附件内容类型不被允许。'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [true, $list, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 总量上限检查 */
|
||||||
|
function fb_attach_cap_ok(PDO $pdo, array $list) {
|
||||||
|
$cap = (int)fb_upload_cfg('total_cap_bytes', 0);
|
||||||
|
if ($cap <= 0) { return true; }
|
||||||
|
$add = 0;
|
||||||
|
foreach ($list as $it) { $add += (int)$it['size']; }
|
||||||
|
return (fb_attach_total_bytes($pdo) + $add) <= $cap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 落库(在主题 / 回复写入之后调用) */
|
||||||
|
function fb_attach_save(PDO $pdo, $threadId, $replyId, array $list) {
|
||||||
|
if (!$list) { return 0; }
|
||||||
|
$now = time();
|
||||||
|
$st = $pdo->prepare(
|
||||||
|
'INSERT INTO attachments(thread_id, reply_id, kind, oss_key, mime, size, width, height, orig_name, seq, created_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)'
|
||||||
|
);
|
||||||
|
$n = 0;
|
||||||
|
foreach ($list as $i => $it) {
|
||||||
|
$st->execute([
|
||||||
|
(int)$threadId,
|
||||||
|
$replyId ? (int)$replyId : null,
|
||||||
|
$it['kind'], $it['key'], $it['mime'], (int)$it['size'],
|
||||||
|
$it['width'] ?: null, $it['height'] ?: null,
|
||||||
|
$it['name'] !== '' ? $it['name'] : null,
|
||||||
|
$i, $now,
|
||||||
|
]);
|
||||||
|
$n++;
|
||||||
|
}
|
||||||
|
return $n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取某个主题的附件:['main' => [...], 'replies' => [reply_id => [...]]] */
|
||||||
|
function fb_attach_for_thread(PDO $pdo, $threadId) {
|
||||||
|
$st = $pdo->prepare('SELECT * FROM attachments WHERE thread_id = ? ORDER BY seq ASC, id ASC');
|
||||||
|
$st->execute([(int)$threadId]);
|
||||||
|
$out = ['main' => [], 'replies' => []];
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
if (empty($r['reply_id'])) { $out['main'][] = $r; }
|
||||||
|
else { $out['replies'][(int)$r['reply_id']][] = $r; }
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量取(列表页),返回 [thread_id => [image rows]] */
|
||||||
|
function fb_attach_map_images(PDO $pdo, array $threadIds, $perThread = 3) {
|
||||||
|
$ids = [];
|
||||||
|
foreach ($threadIds as $i) { $i = (int)$i; if ($i > 0) { $ids[] = $i; } }
|
||||||
|
if (!$ids) { return []; }
|
||||||
|
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
$st = $pdo->prepare("SELECT id, thread_id, oss_key, mime FROM attachments
|
||||||
|
WHERE kind = 'image' AND thread_id IN ($in) ORDER BY thread_id ASC, seq ASC, id ASC");
|
||||||
|
$st->execute($ids);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
$tid = (int)$r['thread_id'];
|
||||||
|
if (!isset($out[$tid])) { $out[$tid] = []; }
|
||||||
|
if (count($out[$tid]) < $perThread) { $out[$tid][] = $r; }
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染附件区($rows 为 attachments 行数组)
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* 输出带 data-gallery 的容器;同一容器内(=同一个主题 / 同一条回复)的全部附件
|
||||||
|
* 构成一个「灯箱画廊」,由 assets/app.js 接管:
|
||||||
|
* · 点击缩略图 → 页内放大(不跳转新标签页,仍是 <a href> 所以禁用 JS 也能看)
|
||||||
|
* · ‹ › / ← → / 触摸左右滑 → 在同一帖的多个附件间切换
|
||||||
|
* · 视频以「放大」按钮进入同一序列,方便逐条翻阅
|
||||||
|
*/
|
||||||
|
function fb_attach_render(array $rows, array $opt = []) {
|
||||||
|
if (!$rows) { return ''; }
|
||||||
|
$compact = !empty($opt['compact']);
|
||||||
|
$html = '<div class="atts' . ($compact ? ' compact' : '') . '" data-gallery>';
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$kind = (string)$r['kind'];
|
||||||
|
$key = (string)$r['oss_key'];
|
||||||
|
$mime = isset($r['mime']) ? (string)$r['mime'] : '';
|
||||||
|
$full = fb_attach_url($key);
|
||||||
|
$name = fb_attach_label($r);
|
||||||
|
$sizeH = fb_size_h($r['size']);
|
||||||
|
|
||||||
|
if ($kind === 'video') {
|
||||||
|
$html .= '<figure class="att att-video">'
|
||||||
|
. '<video controls preload="metadata" playsinline src="' . e($full) . '"></video>'
|
||||||
|
. '<figcaption><span class="att-name">' . e($name) . '</span>'
|
||||||
|
. '<span class="att-size">' . e($sizeH) . '</span>'
|
||||||
|
. '<button type="button" class="att-zoom" aria-label="放大查看该视频"'
|
||||||
|
. ' data-lb-kind="video" data-lb-src="' . e($full) . '"'
|
||||||
|
. ' data-lb-name="' . e($name) . '" data-lb-size="' . e($sizeH) . '">放大</button>'
|
||||||
|
. '</figcaption>'
|
||||||
|
. '</figure>';
|
||||||
|
} else {
|
||||||
|
// 缩略图(列表/正文)与灯箱显示图分开取尺寸,避免灯箱里加载原图
|
||||||
|
$thumb = fb_attach_thumb_url($key, $compact ? 360 : 640, $mime);
|
||||||
|
$view = fb_attach_thumb_url($key, 1600, $mime);
|
||||||
|
$html .= '<figure class="att att-image">'
|
||||||
|
. '<a class="att-lb" href="' . e($full) . '" target="_blank" rel="noopener noreferrer"'
|
||||||
|
. ' data-lb-kind="image" data-lb-src="' . e($view) . '" data-lb-full="' . e($full) . '"'
|
||||||
|
. ' data-lb-name="' . e($name) . '" data-lb-size="' . e($sizeH) . '">'
|
||||||
|
. '<img loading="lazy" src="' . e($thumb) . '" alt="' . e($name) . '"></a>'
|
||||||
|
. '</figure>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $html . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 附件展示名(回退为「图片/视频 + 序号」) */
|
||||||
|
function fb_attach_label(array $r) {
|
||||||
|
$n = isset($r['orig_name']) ? trim((string)$r['orig_name']) : '';
|
||||||
|
if ($n !== '') { return fb_excerpt($n, 40); }
|
||||||
|
return ((string)$r['kind'] === 'video') ? '视频' : '图片';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人类可读大小 */
|
||||||
|
function fb_size_h($bytes) {
|
||||||
|
$b = (int)$bytes;
|
||||||
|
if ($b < 1024) { return $b . ' B'; }
|
||||||
|
if ($b < 1048576) { return round($b / 1024, 1) . ' KB'; }
|
||||||
|
return round($b / 1048576, 1) . ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计某主题附件数(列表页标记用) */
|
||||||
|
function fb_attach_count_map(PDO $pdo, array $threadIds) {
|
||||||
|
$ids = [];
|
||||||
|
foreach ($threadIds as $i) { $i = (int)$i; if ($i > 0) { $ids[] = $i; } }
|
||||||
|
if (!$ids) { return []; }
|
||||||
|
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
$st = $pdo->prepare("SELECT thread_id, COUNT(*) c FROM attachments WHERE thread_id IN ($in) GROUP BY thread_id");
|
||||||
|
$st->execute($ids);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st->fetchAll() as $r) { $out[(int)$r['thread_id']] = (int)$r['c']; }
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期附件:先删 OSS 对象,成功后再删库记录(失败则留待下次重试)。
|
||||||
|
* @return int 已清理条数
|
||||||
|
*/
|
||||||
|
function fb_attach_cleanup(PDO $pdo, $days = null, $limit = 200) {
|
||||||
|
$days = $days === null ? (int)fb_upload_cfg('retention_days', 365) : (int)$days;
|
||||||
|
if ($days <= 0) { return 0; }
|
||||||
|
$cut = time() - $days * 86400;
|
||||||
|
|
||||||
|
$st = $pdo->prepare('SELECT id, oss_key FROM attachments WHERE created_at < ? ORDER BY created_at ASC LIMIT ' . (int)$limit);
|
||||||
|
$st->execute([$cut]);
|
||||||
|
$rows = $st->fetchAll();
|
||||||
|
|
||||||
|
$del = $pdo->prepare('DELETE FROM attachments WHERE id = ?');
|
||||||
|
$n = 0;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if (fb_attach_delete($r['oss_key'])) {
|
||||||
|
$del->execute([(int)$r['id']]);
|
||||||
|
$n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $n;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 启动引导:加载配置、注册自动加载、开启会话、初始化数据库。
|
||||||
|
* 所有页面(index/thread/new/api)都以此为入口。
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('FB_ROOT')) {
|
||||||
|
define('FB_ROOT', dirname(__DIR__));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------
|
||||||
|
* 错误显示策略必须最先设置:
|
||||||
|
* 任何被打印到页面的 Warning/Deprecated 都会让响应头提前发出,
|
||||||
|
* 从而令随后的 session_start() 失败(表现为「会话已过期」、CSRF 全部失效)。
|
||||||
|
* 所以这里在 require 任何库文件之前就把 display_errors 关掉。
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
@ini_set('display_errors', '0');
|
||||||
|
@ini_set('log_errors', '1');
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// 兜底:捕获致命错误时也不要向页面输出堆栈(生产环境)
|
||||||
|
if (!defined('FB_BOOTSTRAPPED')) {
|
||||||
|
define('FB_BOOTSTRAPPED', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台改设置后自增,用来让 fb_cfg 的进程内缓存失效
|
||||||
|
if (!isset($GLOBALS['__fb_cfg_stamp'])) {
|
||||||
|
$GLOBALS['__fb_cfg_stamp'] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('fb_cfg')) {
|
||||||
|
/**
|
||||||
|
* 读取配置(点分路径,如 fb_cfg('smtp.host'))
|
||||||
|
* 取值顺序:settings 表(后台改过的)> config.php(本地配置)> config.example.php(出厂默认)> $default
|
||||||
|
*
|
||||||
|
* config.php 不参与版本管理(见 .gitignore),新部署时可从 config.example.php 复制一份;
|
||||||
|
* 若不存在则直接以 config.example.php 为默认值运行,保证 clone 下来即可跑。
|
||||||
|
*/
|
||||||
|
function fb_cfg($path = null, $default = null) {
|
||||||
|
static $merged = null;
|
||||||
|
static $stamp = -1;
|
||||||
|
$now = isset($GLOBALS['__fb_cfg_stamp']) ? (int)$GLOBALS['__fb_cfg_stamp'] : 0;
|
||||||
|
if ($merged === null || $stamp !== $now) {
|
||||||
|
$local = FB_ROOT . '/config.php';
|
||||||
|
$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() : [];
|
||||||
|
$merged = is_array($tree) && $tree ? array_replace_recursive($cfg, $tree) : $cfg;
|
||||||
|
$stamp = $now;
|
||||||
|
}
|
||||||
|
if ($path === null || $path === '') { return $merged; }
|
||||||
|
$cur = $merged;
|
||||||
|
foreach (explode('.', (string)$path) as $k) {
|
||||||
|
if (is_array($cur) && array_key_exists($k, $cur)) {
|
||||||
|
$cur = $cur[$k];
|
||||||
|
} else {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $cur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once FB_ROOT . '/lib/util.php';
|
||||||
|
require_once FB_ROOT . '/lib/db.php';
|
||||||
|
require_once FB_ROOT . '/lib/oss.php';
|
||||||
|
require_once FB_ROOT . '/lib/attach.php';
|
||||||
|
require_once FB_ROOT . '/lib/captcha.php';
|
||||||
|
require_once FB_ROOT . '/lib/mailer.php';
|
||||||
|
require_once FB_ROOT . '/lib/layout.php';
|
||||||
|
|
||||||
|
// 会话(CLI 下不启用)
|
||||||
|
if (PHP_SAPI !== 'cli' && session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
$p = session_get_cookie_params();
|
||||||
|
$https = (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off');
|
||||||
|
@session_set_cookie_params([
|
||||||
|
'lifetime' => 0,
|
||||||
|
'path' => !empty($p['path']) ? $p['path'] : '/',
|
||||||
|
'domain' => '',
|
||||||
|
'secure' => $https,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
@session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化数据库(建表)
|
||||||
|
fb_db();
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 人机验证:趣味问答
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* 无需外部服务、无需图片库。每次生成一道随机小题(算术为主,
|
||||||
|
* 混入日历 / 生活常识题),答案存放在 Session 中,一次性使用。
|
||||||
|
*/
|
||||||
|
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
|
||||||
|
|
||||||
|
/** 静态趣味题库:[题目, 可接受的答案(数组,忽略大小写与标点)] */
|
||||||
|
function fb_captcha_bank() {
|
||||||
|
return [
|
||||||
|
['一年有几个月?', ['12', '十二']],
|
||||||
|
['闰年的二月有多少天?', ['29', '二十九']],
|
||||||
|
['一星期有几天?', ['7', '七']],
|
||||||
|
['一年有几个季节?', ['4', '四']],
|
||||||
|
['一个季度有几个月?', ['3', '三']],
|
||||||
|
['时针从 3 点走到 9 点,一共走了几个小时?', ['6', '六']],
|
||||||
|
['钟表从 12 点顺时针走 3 个小时,是几点?', ['3', '三点', '3点']],
|
||||||
|
['把 「789」 倒过来念,是多少?', ['987']],
|
||||||
|
['「轻反馈」这几个字,一共几个字?', ['3', '三']],
|
||||||
|
['汉字「五」表示的数字是几?', ['5', '五']],
|
||||||
|
['2、4、6、8、10 之后的下一个数是多少?', ['12', '十二']],
|
||||||
|
['从 10 倒着数,10 之后是几?', ['9', '九']],
|
||||||
|
['「日月」两个字里,有几个「日」字?', ['1', '一']],
|
||||||
|
['把 3 个 6 相加,结果是多少?', ['18', '十八']],
|
||||||
|
['100 减去 100 等于多少?', ['0', '零']],
|
||||||
|
['星期三之后是星期几?', ['4', '四', '星期四', '周四']],
|
||||||
|
['把 4 个苹果平均分给 2 个人,每人几个?', ['2', '二', '两']],
|
||||||
|
['「日历」这个词的第一个字是什么?', ['日']],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成一道动态算术题 */
|
||||||
|
function fb_captcha_arith() {
|
||||||
|
$op = random_int(1, 4);
|
||||||
|
if ($op === 1) {
|
||||||
|
$a = random_int(3, 30); $b = random_int(2, 30);
|
||||||
|
return ['题目: ' . $a . ' 加 ' . $b . ' 等于多少?', [(string)($a + $b)]];
|
||||||
|
}
|
||||||
|
if ($op === 2) {
|
||||||
|
$a = random_int(10, 40); $b = random_int(1, 9);
|
||||||
|
if ($b > $a) { $t = $a; $a = $b; $b = $t; }
|
||||||
|
return ['题目: ' . $a . ' 减 ' . $b . ' 等于多少?', [(string)($a - $b)]];
|
||||||
|
}
|
||||||
|
if ($op === 3) {
|
||||||
|
$a = random_int(2, 9); $b = random_int(2, 9);
|
||||||
|
return ['题目: ' . $a . ' 乘以 ' . $b . ' 等于多少?', [(string)($a * $b)]];
|
||||||
|
}
|
||||||
|
// 先乘后加
|
||||||
|
$a = random_int(1, 5); $b = random_int(2, 6); $c = random_int(2, 6);
|
||||||
|
return ['题目: ' . $a . ' 加 ' . $b . ' 乘 ' . $c . ' 等于多少?(先乘后加)', [(string)($a + $b * $c)]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一化答案:全角转半角、去空白与常见标点、转小写 */
|
||||||
|
function fb_captcha_norm($s) {
|
||||||
|
$s = (string)$s;
|
||||||
|
if (function_exists('mb_convert_kana')) {
|
||||||
|
$s = mb_convert_kana($s, 'as', 'UTF-8'); // 全角字母/数字/空格 -> 半角
|
||||||
|
}
|
||||||
|
$s = str_replace(
|
||||||
|
['?', '?', '。', '.', '.', ',', ',', '!', '!', '、', ':', ':', ';', ';', '"', "'", '“', '”', '‘', '’', ' ', ' ', "\r", "\n", "\t"],
|
||||||
|
'',
|
||||||
|
$s
|
||||||
|
);
|
||||||
|
if (function_exists('mb_strtolower')) {
|
||||||
|
$s = mb_strtolower($s, 'UTF-8');
|
||||||
|
} else {
|
||||||
|
$s = strtolower($s);
|
||||||
|
}
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成新的验证题,写入 Session,返回题面文本 */
|
||||||
|
function fb_captcha_new() {
|
||||||
|
if (random_int(1, 100) <= 65) {
|
||||||
|
list($q, $answers) = fb_captcha_arith();
|
||||||
|
} else {
|
||||||
|
$bank = fb_captcha_bank();
|
||||||
|
list($q, $answers) = $bank[array_rand($bank)];
|
||||||
|
}
|
||||||
|
$_SESSION['fb_cap'] = [
|
||||||
|
'q' => $q,
|
||||||
|
'answers'=> $answers,
|
||||||
|
'ts' => time(),
|
||||||
|
'tries' => 0,
|
||||||
|
];
|
||||||
|
return $q;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取当前题目(不存在则新建) */
|
||||||
|
function fb_captcha_question() {
|
||||||
|
if (empty($_SESSION['fb_cap']['q']) || !empty($_SESSION['fb_cap']['used'])) {
|
||||||
|
return fb_captcha_new();
|
||||||
|
}
|
||||||
|
// 超过 30 分钟重新出题
|
||||||
|
if (time() - (int)$_SESSION['fb_cap']['ts'] > 1800) {
|
||||||
|
return fb_captcha_new();
|
||||||
|
}
|
||||||
|
return (string)$_SESSION['fb_cap']['q'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验用户答案;一次性使用,答错累计,超过 5 次作废需换题 */
|
||||||
|
function fb_captcha_check($input) {
|
||||||
|
if (empty($_SESSION['fb_cap']['answers'])) { return false; }
|
||||||
|
$cap = $_SESSION['fb_cap'];
|
||||||
|
if (time() - (int)$cap['ts'] > 1800) {
|
||||||
|
unset($_SESSION['fb_cap']);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$n = fb_captcha_norm($input);
|
||||||
|
$ok = false;
|
||||||
|
if ($n !== '') {
|
||||||
|
foreach ((array)$cap['answers'] as $a) {
|
||||||
|
$an = fb_captcha_norm($a);
|
||||||
|
if ($an === '') { continue; }
|
||||||
|
if ($n === $an) { $ok = true; break; }
|
||||||
|
if (is_numeric($n) && is_numeric($an) && (float)$n === (float)$an) { $ok = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$_SESSION['fb_cap']['tries'] = (int)$cap['tries'] + 1;
|
||||||
|
if ($ok) {
|
||||||
|
unset($_SESSION['fb_cap']); // 用后即焚
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($_SESSION['fb_cap']['tries'] >= 5) {
|
||||||
|
unset($_SESSION['fb_cap']); // 作废,下次自动换题
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* SQLite 数据库初始化与迁移
|
||||||
|
*/
|
||||||
|
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
|
||||||
|
|
||||||
|
/** 打开(或创建)数据库连接,单例 */
|
||||||
|
function fb_db_init($cfg) {
|
||||||
|
static $pdo = null;
|
||||||
|
if ($pdo instanceof PDO) { return $pdo; }
|
||||||
|
|
||||||
|
$path = (string)$cfg['db_path'];
|
||||||
|
$dir = dirname($path);
|
||||||
|
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
|
||||||
|
|
||||||
|
$fresh = !file_exists($path);
|
||||||
|
|
||||||
|
$pdo = new PDO('sqlite:' . $path, null, null, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]);
|
||||||
|
$pdo->exec('PRAGMA journal_mode = WAL');
|
||||||
|
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||||
|
$pdo->exec('PRAGMA busy_timeout = 5000');
|
||||||
|
|
||||||
|
fb_db_migrate($pdo);
|
||||||
|
|
||||||
|
if ($fresh) { @chmod($path, 0640); }
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 便捷访问器 */
|
||||||
|
function fb_db() {
|
||||||
|
return fb_db_init(fb_cfg());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建表(幂等) */
|
||||||
|
function fb_db_migrate(PDO $pdo) {
|
||||||
|
$pdo->exec(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS threads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL DEFAULT 'other',
|
||||||
|
author_name TEXT NOT NULL,
|
||||||
|
author_email TEXT NOT NULL,
|
||||||
|
author_phone TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
reply_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
ip_hash TEXT
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
);
|
||||||
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_updated ON threads(updated_at DESC)');
|
||||||
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_cat ON threads(category)');
|
||||||
|
|
||||||
|
$pdo->exec(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS replies (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
thread_id INTEGER NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
author_name TEXT NOT NULL,
|
||||||
|
author_email TEXT,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
ip_hash TEXT,
|
||||||
|
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
);
|
||||||
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_replies_thread ON replies(thread_id, created_at)');
|
||||||
|
|
||||||
|
$pdo->exec(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS notify_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
thread_id INTEGER NOT NULL,
|
||||||
|
reply_id INTEGER NOT NULL,
|
||||||
|
to_email TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
ok INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tries INTEGER NOT NULL DEFAULT 0,
|
||||||
|
err TEXT
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
);
|
||||||
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_notify_thread ON notify_log(thread_id, to_email)');
|
||||||
|
|
||||||
|
// 兼容早期建库:确保 notify_log.tries 存在
|
||||||
|
$cols = $pdo->query('PRAGMA table_info(notify_log)')->fetchAll();
|
||||||
|
$has = false;
|
||||||
|
foreach ($cols as $c) { if (isset($c['name']) && $c['name'] === 'tries') { $has = true; break; } }
|
||||||
|
if (!$has) { $pdo->exec('ALTER TABLE notify_log ADD COLUMN tries INTEGER NOT NULL DEFAULT 0'); }
|
||||||
|
|
||||||
|
$pdo->exec(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS attachments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
thread_id INTEGER NOT NULL,
|
||||||
|
reply_id INTEGER,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'image',
|
||||||
|
oss_key TEXT NOT NULL,
|
||||||
|
mime TEXT,
|
||||||
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
orig_name TEXT,
|
||||||
|
seq INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(reply_id) REFERENCES replies(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
);
|
||||||
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_thread ON attachments(thread_id, seq)');
|
||||||
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_reply ON attachments(reply_id)');
|
||||||
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_age ON attachments(created_at)');
|
||||||
|
|
||||||
|
$pdo->exec(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limit (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ip_hash TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
);
|
||||||
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_rl ON rate_limit(ip_hash, action, created_at)');
|
||||||
|
|
||||||
|
/* ---- 站点设置(后台可改,覆盖 config.php 的同名项)----
|
||||||
|
* 只存「后台允许修改」的键,见 util.php 的 fb_setting_keys()。
|
||||||
|
* value 存 JSON,读取时自动还原类型。
|
||||||
|
*/
|
||||||
|
$pdo->exec(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at INTEGER
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- 管理员操作日志 ---- */
|
||||||
|
$pdo->exec(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS mod_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
thread_id INTEGER,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
detail TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
ip_hash TEXT
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
);
|
||||||
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_modlog_thread ON mod_log(thread_id, created_at DESC)');
|
||||||
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_modlog_time ON mod_log(created_at DESC)');
|
||||||
|
|
||||||
|
// ---- 审核(先审后显)----
|
||||||
|
// visible:0=待审核/未公开,1=审核通过已公开
|
||||||
|
// mod_note:审核说明(关闭时写给提问人的解释,会随邮件发出)
|
||||||
|
// moderated_at:审核时间
|
||||||
|
$tcols = $pdo->query('PRAGMA table_info(threads)')->fetchAll();
|
||||||
|
$tnames = [];
|
||||||
|
foreach ($tcols as $c) { if (isset($c['name'])) { $tnames[(string)$c['name']] = true; } }
|
||||||
|
|
||||||
|
if (!isset($tnames['visible'])) {
|
||||||
|
$pdo->exec('ALTER TABLE threads ADD COLUMN visible INTEGER NOT NULL DEFAULT 0');
|
||||||
|
// 功能上线前已存在的内容视为"已审核通过",保持原来的可见性
|
||||||
|
$pdo->exec('UPDATE threads SET visible = 1');
|
||||||
|
}
|
||||||
|
if (!isset($tnames['mod_note'])) {
|
||||||
|
$pdo->exec('ALTER TABLE threads ADD COLUMN mod_note TEXT');
|
||||||
|
}
|
||||||
|
if (!isset($tnames['moderated_at'])) {
|
||||||
|
$pdo->exec('ALTER TABLE threads ADD COLUMN moderated_at INTEGER');
|
||||||
|
}
|
||||||
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_visible ON threads(visible, created_at DESC)');
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 页面外壳(头部 / 底部)
|
||||||
|
*/
|
||||||
|
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输出页面头部
|
||||||
|
* @param string $title 页面标题
|
||||||
|
* @param array $opt ['robots' => 'noindex', 'desc' => '...']
|
||||||
|
*/
|
||||||
|
function fb_layout_head($title, array $opt = []) {
|
||||||
|
$site = (string)fb_cfg('site_name');
|
||||||
|
$robots = isset($opt['robots']) ? (string)$opt['robots'] : 'index,follow';
|
||||||
|
$full = $title === '' ? $site : ($title . ' · ' . $site);
|
||||||
|
?><!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#0057B8">
|
||||||
|
<meta name="robots" content="<?php echo e($robots); ?>">
|
||||||
|
<title><?php echo e($full); ?></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="icon" type="image/png" sizes="32x32" href="assets/favicon-32x32.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="manifest" href="assets/site.webmanifest">
|
||||||
|
<link rel="stylesheet" href="assets/style.css?v=6">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="wrap">
|
||||||
|
<a class="brand-mini" href="./" title="<?php echo e($site); ?>">
|
||||||
|
<span class="logo-mark" role="img" aria-label="轻反馈图标"></span>
|
||||||
|
<span class="brand-text"><?php echo e($site); ?></span>
|
||||||
|
</a>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<a class="navlink hide-sm" href="./">全部问题</a>
|
||||||
|
<?php if (fb_admin_logged_in()): ?><a class="navlink" href="admin.php">管理后台</a><?php endif; ?>
|
||||||
|
<a class="btn-mini" href="new.php">我要反馈</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="wrap">
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 输出页面底部 */
|
||||||
|
function fb_layout_foot() {
|
||||||
|
?><footer class="foot">
|
||||||
|
<div class="wrap">
|
||||||
|
<p class="muted">本站仅保存你提交的昵称、邮箱与内容,用于问题跟进;页面不会公开展示邮箱与手机号。</p>
|
||||||
|
<p class="muted"><?php echo e((string)fb_cfg('site_name', '轻反馈')); ?> · 由 FeedLite 驱动</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<script src="assets/app.js?v=5"></script>
|
||||||
|
</body>
|
||||||
|
</html><?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染提示条 */
|
||||||
|
function fb_flash_box($msg, $type = 'error') {
|
||||||
|
if ($msg === '' || $msg === null) { return; }
|
||||||
|
echo '<div class="flash ' . e($type) . '">' . e($msg) . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染附件上传控件(图片 / 视频,浏览器直传 OSS)
|
||||||
|
* 需置于 <form> 内部:会输出一个 name="attachments" 的隐藏字段,
|
||||||
|
* 由 assets/app.js 在文件上传成功后回填 JSON 描述。
|
||||||
|
*
|
||||||
|
* @param array $existing 校验失败时已上传的附件描述(避免用户重复上传)
|
||||||
|
*/
|
||||||
|
function fb_uploaders(array $existing = []) {
|
||||||
|
$kinds = [];
|
||||||
|
if (fb_upload_kind_on('image')) { $kinds[] = 'image'; }
|
||||||
|
if (fb_upload_kind_on('video')) { $kinds[] = 'video'; }
|
||||||
|
if (!$kinds) { return; }
|
||||||
|
|
||||||
|
$init = [];
|
||||||
|
foreach ($existing as $it) {
|
||||||
|
$kind = isset($it['kind']) ? (string)$it['kind'] : 'image';
|
||||||
|
$key = isset($it['key']) ? (string)$it['key'] : '';
|
||||||
|
if ($key === '') { continue; }
|
||||||
|
$init[] = [
|
||||||
|
'kind' => $kind,
|
||||||
|
'key' => $key,
|
||||||
|
'size' => isset($it['size']) ? (int)$it['size'] : 0,
|
||||||
|
'name' => isset($it['name']) ? (string)$it['name'] : '',
|
||||||
|
'mime' => isset($it['mime']) ? (string)$it['mime'] : '',
|
||||||
|
'w' => isset($it['width']) ? (int)$it['width'] : 0,
|
||||||
|
'h' => isset($it['height']) ? (int)$it['height'] : 0,
|
||||||
|
'url' => fb_attach_url($key),
|
||||||
|
'thumb' => $kind === 'image' ? fb_attach_thumb_url($key, 360) : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="uploader-box"
|
||||||
|
data-uploader-root
|
||||||
|
data-csrf="<?php echo e(fb_csrf_token()); ?>"
|
||||||
|
data-sign-url="upload.php"
|
||||||
|
data-init="<?php echo e(json_encode($init, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); ?>">
|
||||||
|
|
||||||
|
<?php foreach ($kinds as $kind):
|
||||||
|
$isImg = ($kind === 'image');
|
||||||
|
$maxN = (int)fb_upload_cfg($kind . '_max_per_post', 1);
|
||||||
|
$maxB = (int)fb_upload_cfg($kind . '_max_bytes', 0);
|
||||||
|
$accept = implode(',', (array)fb_upload_cfg($kind . '_mimes', []));
|
||||||
|
$label = $isImg ? '图片' : '视频';
|
||||||
|
$desc = $isImg
|
||||||
|
? ('最多 ' . $maxN . ' 张,单张不超过 ' . fb_size_h($maxB) . ';大图会自动压缩后上传')
|
||||||
|
: ('最多 ' . $maxN . ' 个,单个不超过 ' . fb_size_h($maxB));
|
||||||
|
?>
|
||||||
|
<div class="uploader" data-uploader data-kind="<?php echo e($kind); ?>"
|
||||||
|
data-max-count="<?php echo $maxN; ?>"
|
||||||
|
data-max-bytes="<?php echo $maxB; ?>"
|
||||||
|
data-max-side="<?php echo (int)fb_upload_cfg('image_max_side', 1600); ?>"
|
||||||
|
data-accept="<?php echo e($accept); ?>"
|
||||||
|
data-label="<?php echo e($label); ?>">
|
||||||
|
<div class="up-head">
|
||||||
|
<span class="up-title"><?php echo e($label); ?> <i class="hint"><?php echo e($desc); ?></i></span>
|
||||||
|
<button type="button" class="btn up-pick">选择<?php echo e($label); ?></button>
|
||||||
|
<input type="file" class="up-file" accept="<?php echo e($accept); ?>"
|
||||||
|
<?php echo $isImg ? 'multiple' : ''; ?> tabindex="-1">
|
||||||
|
</div>
|
||||||
|
<div class="up-list"></div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<input type="hidden" name="attachments" class="att-field" value="">
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 极简 SMTP 客户端(无第三方依赖)+ 回复通知逻辑
|
||||||
|
*/
|
||||||
|
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
|
||||||
|
|
||||||
|
/** 读取一行 SMTP 响应(支持多行 250-... ) */
|
||||||
|
function fb_smtp_read($fp) {
|
||||||
|
$data = '';
|
||||||
|
while (($line = fgets($fp, 1024)) !== false) {
|
||||||
|
$data .= $line;
|
||||||
|
if (strlen($line) >= 4 && $line[3] === ' ') { break; }
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送命令并校验响应码 */
|
||||||
|
function fb_smtp_cmd($fp, $cmd, $codes, &$err, $hideLog = false) {
|
||||||
|
if ($cmd !== null && $cmd !== '') {
|
||||||
|
fwrite($fp, $cmd . "\r\n");
|
||||||
|
}
|
||||||
|
$resp = fb_smtp_read($fp);
|
||||||
|
$code = (int)substr(ltrim($resp), 0, 3);
|
||||||
|
$want = is_array($codes) ? $codes : [(int)$codes];
|
||||||
|
if (!in_array($code, $want, true)) {
|
||||||
|
$err = ($hideLog ? '(命令内容已隐藏) ' : '') . trim($resp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 邮件头编码(UTF-8 -> =?UTF-8?B?...?=) */
|
||||||
|
function fb_mail_header($s) {
|
||||||
|
return '=?UTF-8?B?' . base64_encode((string)$s) . '?=';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送一封 HTML 邮件
|
||||||
|
* @return array [bool ok, string err]
|
||||||
|
*/
|
||||||
|
function fb_smtp_send(array $s, $to, $subject, $html) {
|
||||||
|
$err = '';
|
||||||
|
$host = (string)$s['host'];
|
||||||
|
$port = (int)$s['port'];
|
||||||
|
$secure = strtolower(isset($s['secure']) ? (string)$s['secure'] : '');
|
||||||
|
$timeout= (int)(isset($s['timeout']) ? $s['timeout'] : 15);
|
||||||
|
$verify = !isset($s['verify_ssl']) || $s['verify_ssl'];
|
||||||
|
|
||||||
|
if ($host === '' || $port <= 0) { return [false, 'SMTP 参数不完整']; }
|
||||||
|
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { return [false, '收件地址非法']; }
|
||||||
|
|
||||||
|
$sslOpt = $verify
|
||||||
|
? ['verify_peer' => true, 'verify_peer_name' => true]
|
||||||
|
: ['verify_peer' => false, 'verify_peer_name' => false, 'allow_self_signed' => true];
|
||||||
|
$ctx = stream_context_create(['ssl' => $sslOpt]);
|
||||||
|
$remote = (($secure === 'ssl' || $secure === 'smtps') ? 'ssl://' : '') . $host . ':' . $port;
|
||||||
|
|
||||||
|
$fp = @stream_socket_client($remote, $eno, $estr, $timeout, STREAM_CLIENT_CONNECT, $ctx);
|
||||||
|
if (!$fp) { return [false, '连接失败: ' . $estr . ' (' . $eno . ')']; }
|
||||||
|
stream_set_timeout($fp, $timeout);
|
||||||
|
|
||||||
|
$hello = isset($_SERVER['HTTP_HOST']) ? (string)$_SERVER['HTTP_HOST'] : 'localhost';
|
||||||
|
$hello = preg_replace('/[^A-Za-z0-9\.\-]/', '', $hello);
|
||||||
|
if ($hello === '') { $hello = 'localhost'; }
|
||||||
|
|
||||||
|
if (!fb_smtp_cmd($fp, null, 220, $err)) { fclose($fp); return [false, '服务器问候失败: ' . $err]; }
|
||||||
|
if (!fb_smtp_cmd($fp, 'EHLO ' . $hello, 250, $err)) { fclose($fp); return [false, 'EHLO 失败: ' . $err]; }
|
||||||
|
|
||||||
|
if ($secure === 'tls' || $secure === 'starttls') {
|
||||||
|
if (!fb_smtp_cmd($fp, 'STARTTLS', 220, $err)) { fclose($fp); return [false, 'STARTTLS 失败: ' . $err]; }
|
||||||
|
if (!@stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||||
|
fclose($fp); return [false, 'TLS 握手失败'];
|
||||||
|
}
|
||||||
|
if (!fb_smtp_cmd($fp, 'EHLO ' . $hello, 250, $err)) { fclose($fp); return [false, 'EHLO(TLS) 失败: ' . $err]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = (string)(isset($s['user']) ? $s['user'] : '');
|
||||||
|
if ($user !== '') {
|
||||||
|
if (!fb_smtp_cmd($fp, 'AUTH LOGIN', 334, $err)) { fclose($fp); return [false, 'AUTH LOGIN 不受支持: ' . $err]; }
|
||||||
|
if (!fb_smtp_cmd($fp, base64_encode($user), 334, $err, true)) { fclose($fp); return [false, '用户名未被接受: ' . $err]; }
|
||||||
|
if (!fb_smtp_cmd($fp, base64_encode((string)$s['pass']), 235, $err, true)) { fclose($fp); return [false, '认证失败(账号 / 密码 / 授权码): ' . $err]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = !empty($s['from']) ? (string)$s['from'] : $user;
|
||||||
|
if (!filter_var($from, FILTER_VALIDATE_EMAIL)) { fclose($fp); return [false, '发件地址非法']; }
|
||||||
|
|
||||||
|
if (!fb_smtp_cmd($fp, 'MAIL FROM:<' . $from . '>', 250, $err)) { fclose($fp); return [false, 'MAIL FROM 失败: ' . $err]; }
|
||||||
|
if (!fb_smtp_cmd($fp, 'RCPT TO:<' . $to . '>', [250, 251], $err)) { fclose($fp); return [false, 'RCPT TO 失败: ' . $err]; }
|
||||||
|
if (!fb_smtp_cmd($fp, 'DATA', 354, $err)) { fclose($fp); return [false, 'DATA 失败: ' . $err]; }
|
||||||
|
|
||||||
|
$fromName = isset($s['from_name']) ? (string)$s['from_name'] : '';
|
||||||
|
$headers = [];
|
||||||
|
$headers[] = 'Date: ' . date('r');
|
||||||
|
$headers[] = 'From: ' . ($fromName !== '' ? fb_mail_header($fromName) . ' ' : '') . '<' . $from . '>';
|
||||||
|
$headers[] = 'To: <' . $to . '>';
|
||||||
|
$headers[] = 'Subject: ' . fb_mail_header($subject);
|
||||||
|
$headers[] = 'MIME-Version: 1.0';
|
||||||
|
$headers[] = 'Content-Type: text/html; charset=UTF-8';
|
||||||
|
$headers[] = 'Content-Transfer-Encoding: base64';
|
||||||
|
$headers[] = 'Message-ID: <' . bin2hex(random_bytes(12)) . '@' . $hello . '>';
|
||||||
|
$headers[] = 'X-Mailer: FeedLite';
|
||||||
|
|
||||||
|
// 正文用 base64,天然不含以 "." 开头的行,因此无需 dot-stuffing
|
||||||
|
$body = chunk_split(base64_encode($html), 76, "\r\n");
|
||||||
|
$data = rtrim(implode("\r\n", $headers) . "\r\n\r\n" . $body, "\r\n");
|
||||||
|
|
||||||
|
if (fwrite($fp, $data . "\r\n.\r\n") === false) { fclose($fp); return [false, '写入邮件正文失败']; }
|
||||||
|
if (!fb_smtp_cmd($fp, null, 250, $err)) { fclose($fp); return [false, '投递失败: ' . $err]; }
|
||||||
|
|
||||||
|
@fb_smtp_cmd($fp, 'QUIT', 221, $e2);
|
||||||
|
fclose($fp);
|
||||||
|
return [true, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染通知邮件正文 */
|
||||||
|
function fb_notify_html($thread, $reply, $isAdmin) {
|
||||||
|
$base = fb_base_url();
|
||||||
|
$url = $base . '/thread.php?id=' . (int)$thread['id'];
|
||||||
|
$site = (string)fb_cfg('site_name');
|
||||||
|
$name = (string)$thread['author_name'];
|
||||||
|
$title = (string)$thread['title'];
|
||||||
|
$rname = $isAdmin ? '官方' : (string)$reply['author_name'];
|
||||||
|
$rbody = nl2br(htmlspecialchars((string)$reply['body'], ENT_QUOTES, 'UTF-8'));
|
||||||
|
$safeName = htmlspecialchars($name, ENT_QUOTES, 'UTF-8');
|
||||||
|
$safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
|
||||||
|
$safeRName = htmlspecialchars($rname, ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
|
return '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">'
|
||||||
|
. '<p>' . $safeName . ',你好:</p>'
|
||||||
|
. '<p>你在「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」提交的问题 <strong>《' . $safeTitle . '》</strong> 有了新的回复:</p>'
|
||||||
|
. '<blockquote style="margin:12px 0;padding:12px 16px;border-left:3px solid #0a6cf5;background:#f5f8ff;border-radius:6px">'
|
||||||
|
. '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">' . $safeRName . ' 回复:</div>'
|
||||||
|
. $rbody
|
||||||
|
. '</blockquote>'
|
||||||
|
. '<p><a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" style="display:inline-block;background:#0a6cf5;color:#fff;text-decoration:none;padding:9px 18px;border-radius:8px">查看并回复</a></p>'
|
||||||
|
. '<p style="color:#6b7280;font-size:13px">若按钮无法点击,请复制链接到浏览器打开:<br>' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '</p>'
|
||||||
|
. '<hr style="border:none;border-top:1px solid #e5e7eb;margin:18px 0">'
|
||||||
|
. '<p style="color:#9aa2ad;font-size:12px">此邮件由系统自动发送,请勿直接回复。</p>'
|
||||||
|
. '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有人回复后,给发帖人发送通知邮件
|
||||||
|
* @return string 空串=未发送/已发送成功;非空=失败原因
|
||||||
|
*/
|
||||||
|
function fb_notify_reply(PDO $pdo, array $thread, array $reply, $isAdmin) {
|
||||||
|
$cfg = fb_cfg();
|
||||||
|
$nt = isset($cfg['notify']) ? (array)$cfg['notify'] : [];
|
||||||
|
if (empty($nt['enabled'])) { return ''; }
|
||||||
|
if (empty($nt['on_every_reply']) && !$isAdmin) { return ''; }
|
||||||
|
|
||||||
|
$to = trim((string)$thread['author_email']);
|
||||||
|
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { return ''; }
|
||||||
|
|
||||||
|
// 自己回复自己:不发
|
||||||
|
if (!$isAdmin && !empty($reply['author_email'])
|
||||||
|
&& strcasecmp(trim((string)$reply['author_email']), $to) === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 节流:同一帖同一收件人 N 秒内不重复发
|
||||||
|
$cd = (int)(isset($nt['cooldown_seconds']) ? $nt['cooldown_seconds'] : 0);
|
||||||
|
if ($cd > 0) {
|
||||||
|
$st = $pdo->prepare('SELECT created_at FROM notify_log WHERE thread_id=? AND to_email=? ORDER BY id DESC LIMIT 1');
|
||||||
|
$st->execute([(int)$thread['id'], $to]);
|
||||||
|
$last = $st->fetchColumn();
|
||||||
|
if ($last !== false && (time() - (int)$last) < $cd) { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$smtp = isset($cfg['smtp']) ? (array)$cfg['smtp'] : [];
|
||||||
|
if (empty($smtp['enabled'])) {
|
||||||
|
fb_notify_log($pdo, $thread, $reply, $to, 0, 'SMTP 未启用');
|
||||||
|
return 'SMTP 未启用';
|
||||||
|
}
|
||||||
|
|
||||||
|
$subject = '【' . $cfg['site_name'] . '】有人回复了你的问题:' . fb_excerpt($thread['title'], 40);
|
||||||
|
$html = fb_notify_html($thread, $reply, $isAdmin);
|
||||||
|
|
||||||
|
list($ok, $err) = fb_smtp_send($smtp, $to, $subject, $html);
|
||||||
|
fb_notify_log($pdo, $thread, $reply, $to, $ok ? 1 : 0, $ok ? null : $err);
|
||||||
|
return $ok ? '' : $err;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记录通知尝试 */
|
||||||
|
function fb_notify_log(PDO $pdo, array $thread, array $reply, $to, $ok, $err) {
|
||||||
|
try {
|
||||||
|
$st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)');
|
||||||
|
$st->execute([(int)$thread['id'], (int)$reply['id'], $to, time(), $ok ? 1 : 0, $err]);
|
||||||
|
} catch (Exception $ex) {
|
||||||
|
// 静默
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 关闭通知(判定"不是问题",写给提问人的说明邮件) ---------------- */
|
||||||
|
|
||||||
|
/** 关闭通知的邮件正文 */
|
||||||
|
function fb_notify_closed_html(array $thread, string $note) {
|
||||||
|
$site = (string)fb_cfg('site_name');
|
||||||
|
$name = htmlspecialchars((string)$thread['author_name'], ENT_QUOTES, 'UTF-8');
|
||||||
|
$title = htmlspecialchars((string)$thread['title'], ENT_QUOTES, 'UTF-8');
|
||||||
|
$body = nl2br(htmlspecialchars((string)$thread['body'], ENT_QUOTES, 'UTF-8'));
|
||||||
|
$noteH = nl2br(htmlspecialchars($note, ENT_QUOTES, 'UTF-8'));
|
||||||
|
$mail = htmlspecialchars((string)fb_cfg('admin_email'), ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
|
return '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">'
|
||||||
|
. '<p>' . $name . ',你好:</p>'
|
||||||
|
. '<p>感谢你反馈 <strong>《' . $title . '》</strong>。我们已经核实过,'
|
||||||
|
. '<strong>该情况并不是应用的问题</strong>,因此这条反馈已被关闭(不会公开显示)。</p>'
|
||||||
|
. '<div style="margin:14px 0;padding:12px 16px;border-left:3px solid #0a6cf5;background:#f5f8ff;border-radius:6px">'
|
||||||
|
. '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">官方说明:</div>'
|
||||||
|
. $noteH
|
||||||
|
. '</div>'
|
||||||
|
. '<p style="color:#6b7280;font-size:13px">你当时反馈的内容:</p>'
|
||||||
|
. '<div style="margin:8px 0 16px;padding:10px 14px;background:#f7f8fa;border-radius:6px;color:#4b5563;font-size:14px">'
|
||||||
|
. $body
|
||||||
|
. '</div>'
|
||||||
|
. ($mail !== '' ? '<p>如果上面的说明没能解决你的疑问,可以直接回复本邮件,我们会继续跟进;'
|
||||||
|
. '也可以发邮件到 ' . $mail . ' 补充说明。</p>' : '<p>如果上面的说明没能解决你的疑问,可以直接回复本邮件,我们会继续跟进。</p>')
|
||||||
|
. '<hr style="border:none;border-top:1px solid #e5e7eb;margin:18px 0">'
|
||||||
|
. '<p style="color:#9aa2ad;font-size:12px">此邮件由「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」自动发送。</p>'
|
||||||
|
. '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭问题(判定"不是问题")时,把说明邮件发给提问人
|
||||||
|
* @return array [bool 是否发送成功, string 失败原因(空串=成功或未启用)]
|
||||||
|
*/
|
||||||
|
function fb_notify_closed(PDO $pdo, array $thread, string $note) {
|
||||||
|
$cfg = fb_cfg();
|
||||||
|
$sm = isset($cfg['smtp']) ? (array)$cfg['smtp'] : [];
|
||||||
|
if (empty($sm['enabled'])) {
|
||||||
|
// 仅记录,不算失败
|
||||||
|
try {
|
||||||
|
$st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)');
|
||||||
|
$st->execute([(int)$thread['id'], 0, (string)$thread['author_email'], time(), 0, 'SMTP 未启用,关闭通知未发送']);
|
||||||
|
} catch (Exception $ex) { /* 静默 */ }
|
||||||
|
return [false, 'SMTP 未启用,邮件未发送(已在配置里关闭)'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$to = (string)$thread['author_email'];
|
||||||
|
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return [false, '提问人邮箱不可用,未发送'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tpl = (string)fb_cfg('moderation.close_mail_subject', '关于你反馈的问题《%s》');
|
||||||
|
$subject = sprintf($tpl, (string)$thread['title']);
|
||||||
|
$html = fb_notify_closed_html($thread, $note);
|
||||||
|
|
||||||
|
list($ok, $err) = fb_smtp_send($sm, $to, $subject, $html);
|
||||||
|
try {
|
||||||
|
$st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)');
|
||||||
|
$st->execute([(int)$thread['id'], 0, $to, time(), $ok ? 1 : 0, $ok ? null : $err]);
|
||||||
|
} catch (Exception $ex) { /* 静默 */ }
|
||||||
|
|
||||||
|
return [$ok, $ok ? '' : $err];
|
||||||
|
}
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 阿里云 OSS —— 浏览器直传支持(零依赖,只用 PHP 内置函数)
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* 只用 HMAC-SHA1 签名(PostObject 策略签名 / V1 签名 URL),
|
||||||
|
* 不依赖官方 SDK,也不依赖 ext-curl(有 curl 时自动优先使用)。
|
||||||
|
*/
|
||||||
|
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
|
||||||
|
|
||||||
|
/** 读取 oss 配置 */
|
||||||
|
function fb_oss_cfg($k = null, $d = null) {
|
||||||
|
$c = (array)fb_cfg('oss', []);
|
||||||
|
if ($k === null) { return $c; }
|
||||||
|
return array_key_exists($k, $c) ? $c[$k] : $d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否已正确配置(未配置则整个上传功能自动隐藏)
|
||||||
|
* 两种鉴权方式:
|
||||||
|
* instance_role —— ECS 实例 RAM 角色(推荐,服务器上不存长期密钥)
|
||||||
|
* static —— 静态 AccessKey(非 ECS 环境 / 自建机用这个)
|
||||||
|
*/
|
||||||
|
function fb_oss_enabled() {
|
||||||
|
$c = fb_oss_cfg();
|
||||||
|
if (empty($c['enabled'])) { return false; }
|
||||||
|
foreach (['bucket', 'endpoint'] as $k) {
|
||||||
|
if (trim((string)(isset($c[$k]) ? $c[$k] : '')) === '') { return false; }
|
||||||
|
}
|
||||||
|
if (fb_oss_credential_source() === 'static') {
|
||||||
|
foreach (['access_key', 'secret_key'] as $k) {
|
||||||
|
if (trim((string)(isset($c[$k]) ? $c[$k] : '')) === '') { return false; }
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return trim((string)fb_oss_cfg('instance_role', '')) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前鉴权方式:instance_role | static */
|
||||||
|
function fb_oss_credential_source() {
|
||||||
|
$s = strtolower(trim((string)fb_oss_cfg('credential_source', 'instance_role')));
|
||||||
|
return $s === 'static' ? 'static' : 'instance_role';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================================================================
|
||||||
|
* 临时凭证(ECS 实例 RAM 角色)
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* 服务器上**不保存**任何长期 AccessKey:
|
||||||
|
* 给 ECS 实例绑定一个 RAM 角色后,实例内可以通过内网元数据服务
|
||||||
|
* (http://100.100.100.200,链路本地地址、不出公网)读到一份
|
||||||
|
* 「有时效、会自动周期轮换」的临时凭证:
|
||||||
|
* AccessKeyId / AccessKeySecret / SecurityToken / Expiration
|
||||||
|
* 本程序只负责「把它取回来、缓存起来、拿去签名」。
|
||||||
|
*
|
||||||
|
* 对外统一成 ['ak','sk','token','expire'],下游签名逻辑无需关心来源。
|
||||||
|
* ================================================================== */
|
||||||
|
|
||||||
|
/** 临时凭证的本地缓存文件(含 <?php exit; ?> 头,即使站点配置失误也无法被下载) */
|
||||||
|
function fb_oss_cache_file() {
|
||||||
|
$p = trim((string)fb_oss_cfg('credential_cache', ''));
|
||||||
|
if ($p !== '') { return $p; }
|
||||||
|
$db = (string)fb_cfg('db_path', '');
|
||||||
|
$dir = $db !== '' ? dirname($db) : sys_get_temp_dir();
|
||||||
|
return rtrim(str_replace('\\', '/', $dir), '/') . '/oss.sts.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析凭证过期时间(ISO8601)→ unix 时间戳 */
|
||||||
|
function fb_oss_parse_expire($s, $fallbackTtl = 900) {
|
||||||
|
$s = trim((string)$s);
|
||||||
|
if ($s !== '') {
|
||||||
|
$t = strtotime($s);
|
||||||
|
if ($t !== false && $t > 0) { return (int)$t; }
|
||||||
|
}
|
||||||
|
return time() + max(60, (int)$fallbackTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fb_oss_cache_read() {
|
||||||
|
$f = fb_oss_cache_file();
|
||||||
|
if (!is_file($f)) { return null; }
|
||||||
|
$raw = (string)@file_get_contents($f);
|
||||||
|
$p = strpos($raw, "\n");
|
||||||
|
if ($p === false) { return null; }
|
||||||
|
$j = json_decode(substr($raw, $p + 1), true);
|
||||||
|
if (!is_array($j) || empty($j['ak']) || empty($j['sk'])) { return null; }
|
||||||
|
return $j;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fb_oss_cache_write(array $c) {
|
||||||
|
$f = fb_oss_cache_file();
|
||||||
|
$dir = dirname($f);
|
||||||
|
if (!is_dir($dir)) { @mkdir($dir, 0777, true); }
|
||||||
|
$tmp = $f . '.' . bin2hex(random_bytes(4)) . '.tmp';
|
||||||
|
$body = "<?php exit; ?>\n" . json_encode($c, JSON_UNESCAPED_SLASHES);
|
||||||
|
if (@file_put_contents($tmp, $body) === false) { return false; }
|
||||||
|
@chmod($tmp, 0600);
|
||||||
|
if (!@rename($tmp, $f)) { @unlink($tmp); return false; }
|
||||||
|
@chmod($f, 0600);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fb_oss_cache_clear() {
|
||||||
|
$f = fb_oss_cache_file();
|
||||||
|
if (is_file($f)) { @unlink($f); }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ECS 元数据服务基地址(100.100.100.200 是链路本地地址,不走外网) */
|
||||||
|
function fb_oss_metadata_base() {
|
||||||
|
$u = trim((string)fb_oss_cfg('metadata_url', ''));
|
||||||
|
if ($u === '') { $u = 'http://100.100.100.200'; }
|
||||||
|
return rtrim($u, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加固模式:先取一次性 token(普通模式返回空串) */
|
||||||
|
function fb_oss_metadata_token() {
|
||||||
|
if (!fb_oss_cfg('metadata_v2', false)) { return ''; }
|
||||||
|
$ttl = (int)fb_oss_cfg('metadata_ttl', 600);
|
||||||
|
$ttl = max(60, min(21600, $ttl));
|
||||||
|
list($st, , $body) = fb_http('PUT', fb_oss_metadata_base() . '/latest/api/token', [
|
||||||
|
'timeout' => 3,
|
||||||
|
'noproxy' => true,
|
||||||
|
'headers' => ['X-aliyun-ecs-metadata-token-ttl-seconds: ' . $ttl],
|
||||||
|
]);
|
||||||
|
if ($st < 200 || $st >= 300) { return ''; }
|
||||||
|
return trim((string)$body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 ECS 元数据读取实例 RAM 角色凭证 @return array [bool, array|string] */
|
||||||
|
function fb_oss_instance_creds() {
|
||||||
|
$role = trim((string)fb_oss_cfg('instance_role', ''));
|
||||||
|
if ($role === '') { return [false, '未配置 oss.instance_role']; }
|
||||||
|
|
||||||
|
$url = fb_oss_metadata_base() . '/latest/meta-data/ram/security-credentials/' . rawurlencode($role);
|
||||||
|
$headers = ['Accept: application/json'];
|
||||||
|
$tok = fb_oss_metadata_token();
|
||||||
|
if ($tok !== '') { $headers[] = 'X-aliyun-ecs-metadata-token: ' . $tok; }
|
||||||
|
|
||||||
|
list($st, , $body) = fb_http('GET', $url, ['timeout' => 3, 'noproxy' => true, 'headers' => $headers]);
|
||||||
|
if ($st < 200 || $st >= 300) {
|
||||||
|
return [false, '读取实例 RAM 角色失败(HTTP ' . $st . ')'];
|
||||||
|
}
|
||||||
|
$j = json_decode((string)$body, true);
|
||||||
|
if (!is_array($j)) { return [false, '实例元数据返回格式异常']; }
|
||||||
|
if (isset($j['Code']) && strcasecmp((string)$j['Code'], 'Success') !== 0) {
|
||||||
|
return [false, '实例元数据返回 ' . (string)$j['Code']];
|
||||||
|
}
|
||||||
|
$ak = trim((string)(isset($j['AccessKeyId']) ? $j['AccessKeyId'] : ''));
|
||||||
|
$sk = trim((string)(isset($j['AccessKeySecret']) ? $j['AccessKeySecret'] : ''));
|
||||||
|
$tk = trim((string)(isset($j['SecurityToken']) ? $j['SecurityToken'] : ''));
|
||||||
|
if ($ak === '' || $sk === '') { return [false, '实例 RAM 角色凭证不完整']; }
|
||||||
|
|
||||||
|
return [true, [
|
||||||
|
'ak' => $ak,
|
||||||
|
'sk' => $sk,
|
||||||
|
'token' => $tk,
|
||||||
|
'expire' => fb_oss_parse_expire(isset($j['Expiration']) ? $j['Expiration'] : '', 900),
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前生效的凭证(进程内记忆 + 文件缓存)。
|
||||||
|
* 缓存提前 credential_refresh_margin 秒失效,避免恰好在过期瞬间用旧凭证。
|
||||||
|
*/
|
||||||
|
function fb_oss_credentials($force = false) {
|
||||||
|
static $memo = null;
|
||||||
|
if (!$force && is_array($memo)) { return $memo; }
|
||||||
|
|
||||||
|
$margin = max(30, (int)fb_oss_cfg('credential_refresh_margin', 300));
|
||||||
|
if (!$force) {
|
||||||
|
$c = fb_oss_cache_read();
|
||||||
|
if (is_array($c) && !empty($c['expire']) && ((int)$c['expire'] - $margin) > time()) {
|
||||||
|
$memo = $c;
|
||||||
|
return $memo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 静态 AccessKey:直接就是长期凭证,不需要缓存与刷新
|
||||||
|
if (fb_oss_credential_source() === 'static') {
|
||||||
|
$memo = [
|
||||||
|
'ak' => trim((string)fb_oss_cfg('access_key', '')),
|
||||||
|
'sk' => trim((string)fb_oss_cfg('secret_key', '')),
|
||||||
|
'token' => '',
|
||||||
|
'expire' => time() + 86400,
|
||||||
|
'fetched' => time(),
|
||||||
|
];
|
||||||
|
if ($memo['ak'] === '' || $memo['sk'] === '') {
|
||||||
|
$memo = ['ak' => '', 'sk' => '', 'token' => '', 'expire' => 0, 'error' => '未配置 oss.access_key / oss.secret_key'];
|
||||||
|
}
|
||||||
|
return $memo;
|
||||||
|
}
|
||||||
|
|
||||||
|
list($ok, $r) = fb_oss_instance_creds();
|
||||||
|
if (!$ok) {
|
||||||
|
// 读不到就退回空凭证(调用方需自己判断 ok),绝不返回"半截"凭证
|
||||||
|
$memo = ['ak' => '', 'sk' => '', 'token' => '', 'expire' => 0, 'error' => $r];
|
||||||
|
return $memo;
|
||||||
|
}
|
||||||
|
|
||||||
|
$r['fetched'] = time();
|
||||||
|
fb_oss_cache_write($r);
|
||||||
|
$memo = $r;
|
||||||
|
return $memo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊断信息(不含密钥,可安全输出到日志/页面)
|
||||||
|
* ⚠️ 这里【不能】传 $force=true:强制刷新会覆盖 fb_oss_credentials() 的进程内记忆,
|
||||||
|
* 使同一次请求里「先做体检、再签名」两步拿到不同批次的临时凭证 → SignatureDoesNotMatch。
|
||||||
|
* 非强制调用会自动优先命中进程内记忆 / 文件缓存,顺带也省掉一次元数据请求。
|
||||||
|
*/
|
||||||
|
function fb_oss_cred_status() {
|
||||||
|
$c = fb_oss_credentials();
|
||||||
|
$out = [
|
||||||
|
'label' => fb_oss_credential_source() === 'static' ? '静态 AccessKey' : 'ECS 实例 RAM 角色',
|
||||||
|
'source' => fb_oss_credential_source(),
|
||||||
|
'ak_tail' => $c['ak'] !== '' ? substr((string)$c['ak'], -6) : '',
|
||||||
|
'has_token'=> !empty($c['token']),
|
||||||
|
'expire' => (int)(isset($c['expire']) ? $c['expire'] : 0),
|
||||||
|
'ok' => ($c['ak'] !== '' && $c['sk'] !== ''),
|
||||||
|
'error' => isset($c['error']) ? (string)$c['error'] : '',
|
||||||
|
];
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前 AccessKeyId */
|
||||||
|
function fb_oss_ak() { $c = fb_oss_credentials(); return (string)$c['ak']; }
|
||||||
|
|
||||||
|
/** 当前 AccessKeySecret(仅服务器内部使用,绝不返回浏览器) */
|
||||||
|
function fb_oss_sk() { $c = fb_oss_credentials(); return (string)$c['sk']; }
|
||||||
|
|
||||||
|
/** 当前安全令牌(使用临时凭证时必不为空) */
|
||||||
|
function fb_oss_token() { $c = fb_oss_credentials(); return (string)$c['token']; }
|
||||||
|
|
||||||
|
/** 上传入口 host(含协议),形如 https://bucket.oss-cn-beijing.aliyuncs.com */
|
||||||
|
function fb_oss_host() {
|
||||||
|
$o = trim((string)fb_oss_cfg('host_override', ''));
|
||||||
|
if ($o !== '') { return rtrim($o, '/'); }
|
||||||
|
$c = fb_oss_cfg();
|
||||||
|
$scheme = (strtolower((string)fb_oss_cfg('url_scheme', 'https')) === 'http') ? 'http' : 'https';
|
||||||
|
$ep = preg_replace('#^https?://#i', '', trim((string)$c['endpoint']));
|
||||||
|
return $scheme . '://' . trim((string)$c['bucket']) . '.' . $ep;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对外的访问基地址(配置了自定义域名就用它) */
|
||||||
|
function fb_oss_public_base() {
|
||||||
|
$d = trim((string)fb_oss_cfg('domain', ''));
|
||||||
|
if ($d !== '') {
|
||||||
|
if (!preg_match('#^https?://#i', $d)) { $d = 'https://' . $d; }
|
||||||
|
return rtrim($d, '/');
|
||||||
|
}
|
||||||
|
return fb_oss_host();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成对象键:fb/2026/09/1a2b3c4d5e6f7788.jpg */
|
||||||
|
function fb_oss_make_key($ext) {
|
||||||
|
$ext = strtolower(preg_replace('/[^A-Za-z0-9]/', '', (string)$ext));
|
||||||
|
$prefix = trim((string)fb_oss_cfg('prefix', 'fb'), '/');
|
||||||
|
return $prefix . '/' . date('Y/m') . '/' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 PostObject 策略签名(独立函数,便于单元测试交叉验证)
|
||||||
|
* @param string $policyB64 base64(policy JSON)
|
||||||
|
* @return string base64(HMAC-SHA1(secret, policyB64))
|
||||||
|
*/
|
||||||
|
function fb_oss_sign_policy_string($policyB64, $secret) {
|
||||||
|
return base64_encode(hash_hmac('sha1', (string)$policyB64, (string)$secret, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拼一个对象的访问 URL */
|
||||||
|
function fb_oss_object_url($key, $forcePublicBase = false) {
|
||||||
|
return ($forcePublicBase ? fb_oss_host() : fb_oss_public_base()) . '/' . ltrim((string)$key, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 OSS V1 签名参数(用于私有 Bucket 的临时访问、HEAD 校验、删除、签名直传)
|
||||||
|
* StringToSign = METHOD "\n" Content-MD5 "\n" Content-Type "\n" Expires "\n" Resource
|
||||||
|
* 其中 Resource = /bucket/key,若使用临时凭证则再拼上 "?security-token=<token>"。
|
||||||
|
* @return array ['OSSAccessKeyId'=>..,'Expires'=>..,'Signature'=>..]
|
||||||
|
*
|
||||||
|
* ⚠️ 两个都踩过的坑,改这里之前先读完:
|
||||||
|
* ① 【必须一次性取出整份凭证】。曾写成 fb_oss_ak()/fb_oss_sk()/fb_oss_token()
|
||||||
|
* 各调一次 fb_oss_credentials():只要期间发生凭证刷新(或缓存到期换新),
|
||||||
|
* 签名用的 SK 与随请求发出的 security-token 就来自不同批次 → SignatureDoesNotMatch。
|
||||||
|
* ② 【security-token 必须算进 Resource】。它是以 URL 查询参数(子资源)形式出现的,
|
||||||
|
* OSS 计算签名时会把它连同 Resource 一起算。若照搬"官方说 token 不参与签名"而只把它
|
||||||
|
* 挂在 URL 上,PUT/HEAD/DELETE 这类用签名 URL 的请求会全部 403 SignatureDoesNotMatch
|
||||||
|
* (PostObject 表单直传不受影响,所以只有"浏览器直传能成功、删不掉"这种诡异现象)。
|
||||||
|
*/
|
||||||
|
function fb_oss_sign_v1($method, $key, $expires, $contentType = '', $contentMd5 = '') {
|
||||||
|
$c = fb_oss_cfg();
|
||||||
|
$cred = fb_oss_credentials();
|
||||||
|
$tok = isset($cred['token']) ? (string)$cred['token'] : '';
|
||||||
|
|
||||||
|
$res = '/' . trim((string)$c['bucket']) . '/' . ltrim((string)$key, '/');
|
||||||
|
if ($tok !== '') { $res .= '?security-token=' . $tok; }
|
||||||
|
|
||||||
|
$st = strtoupper($method) . "\n" . $contentMd5 . "\n" . $contentType . "\n"
|
||||||
|
. (int)$expires . "\n" . $res;
|
||||||
|
|
||||||
|
$out = [
|
||||||
|
'OSSAccessKeyId' => (string)$cred['ak'],
|
||||||
|
'Expires' => (int)$expires,
|
||||||
|
'Signature' => base64_encode(hash_hmac('sha1', $st, (string)$cred['sk'], true)),
|
||||||
|
];
|
||||||
|
// security-token 除了参与上面的签名计算,也仍要作为 URL 参数发出去
|
||||||
|
if ($tok !== '') { $out['security-token'] = $tok; }
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 给对象 URL 追加签名参数(私有 Bucket 展示时用;公共读时返回原 URL) */
|
||||||
|
function fb_oss_url($key, $ttl = 0) {
|
||||||
|
$url = fb_oss_object_url($key);
|
||||||
|
if (strtolower((string)fb_oss_cfg('visibility', 'public')) !== 'private') { return $url; }
|
||||||
|
$ttl = $ttl > 0 ? (int)$ttl : (int)fb_oss_cfg('link_ttl', 604800);
|
||||||
|
$sign = fb_oss_sign_v1('GET', $key, time() + $ttl);
|
||||||
|
return $url . '?' . http_build_query($sign, '', '&', PHP_QUERY_RFC3986);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成浏览器直传所需的上传凭证
|
||||||
|
* @param string $key 服务端指定的对象键(策略里用 eq 锁定,客户端无法改名)
|
||||||
|
* @param int $maxBytes content-length-range 上限
|
||||||
|
* @param string $typePrefix 允许的 Content-Type 前缀(image/ 或 video/)
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
function fb_oss_upload_credential($key, $maxBytes, $typePrefix) {
|
||||||
|
$c = fb_oss_cfg();
|
||||||
|
$cred = fb_oss_credentials();
|
||||||
|
$ttl = max(60, (int)fb_oss_cfg('sign_ttl', 600));
|
||||||
|
$expire = time() + $ttl;
|
||||||
|
|
||||||
|
// 临时凭证本身可能更快到期 —— 策略有效期必须落在凭证有效期内,否则 OSS 会拒
|
||||||
|
if (!empty($cred['expire']) && (int)$cred['expire'] > 0) {
|
||||||
|
$expire = min($expire, (int)$cred['expire'] - 30);
|
||||||
|
if ($expire <= time()) { $expire = time() + 60; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$policy = [
|
||||||
|
'expiration' => gmdate('Y-m-d\TH:i:s\Z', $expire),
|
||||||
|
'conditions' => [
|
||||||
|
['bucket' => (string)$c['bucket']],
|
||||||
|
['eq', '$key', (string)$key],
|
||||||
|
['starts-with', '$Content-Type', (string)$typePrefix],
|
||||||
|
['content-length-range', 1, (int)$maxBytes],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$json = json_encode($policy, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
$b64 = base64_encode($json);
|
||||||
|
|
||||||
|
// 使用实例 RAM 角色的临时凭证时,表单里必须多带一个 x-oss-security-token 域
|
||||||
|
$extra = [];
|
||||||
|
if (!empty($cred['token'])) { $extra['x-oss-security-token'] = (string)$cred['token']; }
|
||||||
|
|
||||||
|
return [
|
||||||
|
'host' => fb_oss_host(), // 浏览器 POST 到这里
|
||||||
|
'key' => (string)$key,
|
||||||
|
'policy' => $b64,
|
||||||
|
// ⚠️ 用本次已取出的 $cred 计算,别再去调 fb_oss_sk()/fb_oss_ak()
|
||||||
|
// (多调一次 fb_oss_credentials() 可能在刷新后拿到不同批次)
|
||||||
|
'signature' => fb_oss_sign_policy_string($b64, (string)$cred['sk']),
|
||||||
|
'access_key_id' => (string)$cred['ak'],
|
||||||
|
'content_type' => (string)$typePrefix,
|
||||||
|
'max_bytes' => (int)$maxBytes,
|
||||||
|
'expires' => $expire,
|
||||||
|
'extra_fields' => $extra, // 恒含 x-oss-security-token
|
||||||
|
'url' => fb_oss_url($key),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 极简 HTTP 客户端:curl 优先,无 curl 时用 stream 兜底。
|
||||||
|
* @return array [$status, $headers(lowercase keys), $body]
|
||||||
|
*/
|
||||||
|
function fb_http($method, $url, array $opt = []) {
|
||||||
|
$method = strtoupper((string)$method);
|
||||||
|
$timeout = isset($opt['timeout']) ? max(1, (int)$opt['timeout']) : 8;
|
||||||
|
$headers = isset($opt['headers']) ? (array)$opt['headers'] : [];
|
||||||
|
$noproxy = !empty($opt['noproxy']);
|
||||||
|
|
||||||
|
if (function_exists('curl_init')) {
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HEADER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => false,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => $timeout,
|
||||||
|
CURLOPT_TIMEOUT => $timeout,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => 2,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
]);
|
||||||
|
// 元数据服务走链路本地地址,绝不能经过 http_proxy(否则永远读不到凭证)
|
||||||
|
if ($noproxy && defined('CURLOPT_NOPROXY')) { curl_setopt($ch, CURLOPT_NOPROXY, '*'); }
|
||||||
|
if ($method === 'HEAD') { curl_setopt($ch, CURLOPT_NOBODY, true); }
|
||||||
|
$raw = curl_exec($ch);
|
||||||
|
$bad = ($raw === false) || curl_errno($ch);
|
||||||
|
$hsz = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||||
|
$st = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($bad) { return [0, [], '']; }
|
||||||
|
return [$st, fb_parse_headers(substr((string)$raw, 0, $hsz)), (string)substr((string)$raw, $hsz)];
|
||||||
|
}
|
||||||
|
|
||||||
|
$streamOpt = [
|
||||||
|
'method' => $method,
|
||||||
|
'timeout' => $timeout,
|
||||||
|
'ignore_errors' => true,
|
||||||
|
'header' => implode("\r\n", $headers),
|
||||||
|
];
|
||||||
|
if ($noproxy) { $streamOpt['proxy'] = ''; $streamOpt['request_fulluri'] = false; }
|
||||||
|
$ctx = stream_context_create(['http' => $streamOpt]);
|
||||||
|
$body = @file_get_contents($url, false, $ctx);
|
||||||
|
|
||||||
|
$status = 0; $rawHead = '';
|
||||||
|
if (isset($http_response_header) && is_array($http_response_header)) {
|
||||||
|
$lines = [];
|
||||||
|
foreach ($http_response_header as $line) {
|
||||||
|
if (preg_match('#^HTTP/\S+\s+(\d{3})#i', $line, $m)) { $status = (int)$m[1]; $lines = []; }
|
||||||
|
else { $lines[] = $line; }
|
||||||
|
}
|
||||||
|
$rawHead = implode("\r\n", $lines);
|
||||||
|
}
|
||||||
|
return [$status, fb_parse_headers($rawHead), ($body === false ? '' : $body)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析响应头为小写键数组 */
|
||||||
|
function fb_parse_headers($raw) {
|
||||||
|
$out = [];
|
||||||
|
foreach (preg_split('/\r\n|\n/', (string)$raw) as $line) {
|
||||||
|
$p = strpos($line, ':');
|
||||||
|
if ($p === false) { continue; }
|
||||||
|
$out[strtolower(trim(substr($line, 0, $p)))] = trim(substr($line, $p + 1));
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HEAD 一个对象,用于提交时复核(是否存在 / 大小 / 类型)
|
||||||
|
* @return array [$status, $headers]
|
||||||
|
*/
|
||||||
|
function fb_oss_head($key) {
|
||||||
|
// 一律带签名:使用 STS 临时凭证时 security-token 必须随请求发出,
|
||||||
|
// 否则私有 Bucket、或要求「经过身份验证的请求」的场景会被 OSS 直接 403。
|
||||||
|
$signed = fb_oss_object_url($key, true) . '?'
|
||||||
|
. http_build_query(fb_oss_sign_v1('HEAD', $key, time() + 300), '', '&', PHP_QUERY_RFC3986);
|
||||||
|
list($status, $h, ) = fb_http('HEAD', $signed, ['timeout' => 6]);
|
||||||
|
if ($status === 0) {
|
||||||
|
// 某些环境禁用了 curl 的 NOBODY,退化为 GET 只取头
|
||||||
|
list($status, $h, ) = fb_http('GET', $signed, ['timeout' => 6]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公共读 Bucket 的兜底:签名请求若因「该身份没有 GetObject 权限」被拒,
|
||||||
|
// 改走匿名请求(公共读对象匿名即可读取),避免误判为「对象不存在」。
|
||||||
|
if ($status === 403 && strtolower((string)fb_oss_cfg('visibility', 'public')) !== 'private') {
|
||||||
|
$anon = fb_oss_object_url($key, true);
|
||||||
|
list($s2, $h2, ) = fb_http('HEAD', $anon, ['timeout' => 6]);
|
||||||
|
if ($s2 === 0) { list($s2, $h2, ) = fb_http('GET', $anon, ['timeout' => 6]); }
|
||||||
|
if ($s2 !== 0) { return [$s2, $h2]; }
|
||||||
|
}
|
||||||
|
return [$status, $h];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除一个对象(清理过期附件时用)。返回是否成功(404 视作已删除) */
|
||||||
|
function fb_oss_delete($key) {
|
||||||
|
$exp = time() + 300;
|
||||||
|
$sign = fb_oss_sign_v1('DELETE', $key, $exp);
|
||||||
|
$url = fb_oss_object_url($key, true) . '?' . http_build_query($sign, '', '&', PHP_QUERY_RFC3986);
|
||||||
|
list($status, , ) = fb_http('DELETE', $url, ['timeout' => 8]);
|
||||||
|
return ($status >= 200 && $status < 300) || $status === 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表/灯箱用的缩放地址:借用 OSS 图片处理,省流量(可关闭)
|
||||||
|
* @param string $mime 已知的内容类型;GIF 会被 OSS 处理成静态首帧(动画丢失),故原样返回
|
||||||
|
*/
|
||||||
|
function fb_oss_thumb_url($key, $w = 560, $mime = '') {
|
||||||
|
$url = fb_oss_url($key);
|
||||||
|
if (!fb_oss_cfg('image_process', true)) { return $url; }
|
||||||
|
|
||||||
|
$m = strtolower(trim((string)$mime));
|
||||||
|
if ($m === '' || $m === 'application/octet-stream') {
|
||||||
|
$e = strtolower((string)pathinfo((string)$key, PATHINFO_EXTENSION));
|
||||||
|
if ($e === 'gif') { return $url; }
|
||||||
|
} elseif ($m === 'image/gif') {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sep = (strpos($url, '?') === false) ? '?' : '&';
|
||||||
|
return $url . $sep . 'x-oss-process=image/resize,w_' . (int)$w . '/quality,q_80';
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 通用工具函数
|
||||||
|
*/
|
||||||
|
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
|
||||||
|
|
||||||
|
/** HTML 转义(所有用户内容输出前必须经过它) */
|
||||||
|
function e($s) {
|
||||||
|
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相对时间显示 */
|
||||||
|
function fb_fmt_time($ts) {
|
||||||
|
$ts = (int)$ts;
|
||||||
|
$diff = time() - $ts;
|
||||||
|
if ($diff < 0) { $diff = 0; }
|
||||||
|
if ($diff < 60) { return '刚刚'; }
|
||||||
|
if ($diff < 3600) { return ((int)floor($diff / 60)) . ' 分钟前'; }
|
||||||
|
if ($diff < 86400) { return ((int)floor($diff / 3600)) . ' 小时前'; }
|
||||||
|
if ($diff < 604800) { return ((int)floor($diff / 86400)) . ' 天前'; }
|
||||||
|
if (date('Y', $ts) === date('Y')) { return date('m-d H:i', $ts); }
|
||||||
|
return date('Y-m-d H:i', $ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 访客 IP(兼容反向代理 / CDN) */
|
||||||
|
function fb_client_ip() {
|
||||||
|
$keys = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'];
|
||||||
|
foreach ($keys as $k) {
|
||||||
|
if (!empty($_SERVER[$k])) {
|
||||||
|
$v = explode(',', (string)$_SERVER[$k]);
|
||||||
|
$v = trim($v[0]);
|
||||||
|
if (filter_var($v, FILTER_VALIDATE_IP)) { return $v; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '0.0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 访客 IP 的加盐哈希(不保存明文 IP) */
|
||||||
|
function fb_ip_hash() {
|
||||||
|
return hash('sha256', fb_client_ip() . '|' . (string)fb_cfg('ip_salt'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 站点基地址(优先取配置,否则自动探测) */
|
||||||
|
function fb_base_url() {
|
||||||
|
$b = (string)fb_cfg('base_url', '');
|
||||||
|
if ($b !== '') { return rtrim($b, '/'); }
|
||||||
|
$https = (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off');
|
||||||
|
$scheme = $https ? 'https' : 'http';
|
||||||
|
$host = isset($_SERVER['HTTP_HOST']) ? (string)$_SERVER['HTTP_HOST'] : 'localhost';
|
||||||
|
$script = isset($_SERVER['SCRIPT_NAME']) ? (string)$_SERVER['SCRIPT_NAME'] : '/index.php';
|
||||||
|
$dir = rtrim(str_replace('\\', '/', dirname($script)), '/');
|
||||||
|
return $scheme . '://' . $host . $dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跳转并结束 */
|
||||||
|
function fb_redirect($url) {
|
||||||
|
header('Location: ' . $url);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分类 / 状态 */
|
||||||
|
function fb_categories() { return (array)fb_cfg('categories', []); }
|
||||||
|
function fb_category_label($k) {
|
||||||
|
$c = fb_categories();
|
||||||
|
return isset($c[$k]) ? $c[$k] : '其他';
|
||||||
|
}
|
||||||
|
function fb_statuses() { return (array)fb_cfg('statuses', []); }
|
||||||
|
function fb_status_label($k) {
|
||||||
|
$s = fb_statuses();
|
||||||
|
return isset($s[$k]) ? $s[$k] : (string)$k;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSRF */
|
||||||
|
function fb_csrf_token() {
|
||||||
|
if (empty($_SESSION['csrf'])) {
|
||||||
|
$_SESSION['csrf'] = bin2hex(random_bytes(16));
|
||||||
|
}
|
||||||
|
return $_SESSION['csrf'];
|
||||||
|
}
|
||||||
|
function fb_csrf_ok($t) {
|
||||||
|
return !empty($_SESSION['csrf']) && is_string($t) && hash_equals($_SESSION['csrf'], $t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 频率限制 */
|
||||||
|
function fb_rate_check($action, $limit) {
|
||||||
|
$pdo = fb_db();
|
||||||
|
$since = time() - 3600;
|
||||||
|
$st = $pdo->prepare('SELECT COUNT(*) FROM rate_limit WHERE ip_hash=? AND action=? AND created_at>=?');
|
||||||
|
$st->execute([fb_ip_hash(), $action, $since]);
|
||||||
|
return ((int)$st->fetchColumn()) < (int)$limit;
|
||||||
|
}
|
||||||
|
function fb_rate_hit($action) {
|
||||||
|
$pdo = fb_db();
|
||||||
|
$st = $pdo->prepare('INSERT INTO rate_limit(ip_hash, action, created_at) VALUES(?,?,?)');
|
||||||
|
$st->execute([fb_ip_hash(), $action, time()]);
|
||||||
|
// 顺手清理 2 小时前的记录
|
||||||
|
if (random_int(1, 20) === 1) {
|
||||||
|
$pdo->exec('DELETE FROM rate_limit WHERE created_at < ' . (time() - 7200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文本长度(按字符计) */
|
||||||
|
function fb_len($s) {
|
||||||
|
return function_exists('mb_strlen') ? mb_strlen((string)$s, 'UTF-8') : strlen((string)$s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 截断文本 */
|
||||||
|
function fb_excerpt($s, $len = 160) {
|
||||||
|
$s = trim(preg_replace('/\s+/u', ' ', (string)$s));
|
||||||
|
if (fb_len($s) <= $len) { return $s; }
|
||||||
|
return (function_exists('mb_substr') ? mb_substr($s, 0, $len, 'UTF-8') : substr($s, 0, $len)) . '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验昵称:1~24 字符,不含换行 */
|
||||||
|
function fb_valid_name($s) {
|
||||||
|
$s = trim((string)$s);
|
||||||
|
if ($s === '' || fb_len($s) > 24) { return false; }
|
||||||
|
if (preg_match('/[\r\n\t]/', $s)) { return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验正文:2~5000 字符 */
|
||||||
|
function fb_valid_body($s) {
|
||||||
|
$s = trim((string)$s);
|
||||||
|
$n = fb_len($s);
|
||||||
|
return ($n >= 2 && $n <= 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建当前查询串(保留筛选条件) */
|
||||||
|
function fb_qs(array $over = []) {
|
||||||
|
$q = $_GET;
|
||||||
|
foreach ($over as $k => $v) {
|
||||||
|
if ($v === null) { unset($q[$k]); } else { $q[$k] = $v; }
|
||||||
|
}
|
||||||
|
$q = array_filter($q, function ($v) { return $v !== '' && $v !== null; });
|
||||||
|
return $q ? ('?' . http_build_query($q)) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 审核(先审后显) ---------------- */
|
||||||
|
|
||||||
|
/** 审核总开关:开启后新提交的问题默认不公开,需管理员审核通过才显示 */
|
||||||
|
function fb_moderation_on() {
|
||||||
|
return (bool)fb_cfg('moderation.enabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前访问者是否已登录审核后台(admin.php 登录后置位) */
|
||||||
|
function fb_is_admin() {
|
||||||
|
return !empty($_SESSION['fb_admin']) && $_SESSION['fb_admin'] === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 该问题对当前访客可见吗?(管理员可看全部) */
|
||||||
|
function fb_thread_public(array $thread) {
|
||||||
|
if (!fb_moderation_on()) { return true; }
|
||||||
|
if (isset($thread['visible']) && (int)$thread['visible'] === 1) { return true; }
|
||||||
|
return fb_is_admin();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 站点设置(数据库覆盖 config.php) ----------------
|
||||||
|
* 后台改的设置存在 settings 表,读取时覆盖 config.php 的同名项;
|
||||||
|
* config.php 因此变成「出厂默认值」,部署后不必再手工改它。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 允许在后台修改的设置键(白名单,防止改到 db_path 之类) */
|
||||||
|
function fb_setting_keys() {
|
||||||
|
return [
|
||||||
|
'site_name', 'site_desc',
|
||||||
|
'attachment.storage',
|
||||||
|
'upload.image_max_per_post', 'upload.image_max_bytes',
|
||||||
|
'upload.video_max_per_post', 'upload.video_max_bytes',
|
||||||
|
'upload.total_cap_bytes', 'upload.retention_days',
|
||||||
|
'upload.upload_per_hour', 'upload.image_max_side',
|
||||||
|
'oss.endpoint', 'oss.bucket', 'oss.instance_role', 'oss.enabled',
|
||||||
|
'oss.credential_source', 'oss.access_key', 'oss.secret_key',
|
||||||
|
'oss.domain', 'oss.visibility', 'oss.prefix', 'oss.url_scheme',
|
||||||
|
'oss.verify', 'oss.image_process',
|
||||||
|
'moderation.enabled', 'moderation.close_mail_subject',
|
||||||
|
'admin.password_hash',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全部设置(扁平:'oss.bucket' => 值)。带进程内缓存;$reload=true 强制重读。
|
||||||
|
* 与 fb_db() 之间可能互相调用,用 $loading 防止递归。
|
||||||
|
*/
|
||||||
|
function fb_settings_flat($reload = false) {
|
||||||
|
static $flat = null;
|
||||||
|
static $loading = false;
|
||||||
|
if ($flat !== null && !$reload) { return $flat; }
|
||||||
|
if ($loading) { return []; }
|
||||||
|
$loading = true;
|
||||||
|
$out = [];
|
||||||
|
try {
|
||||||
|
$pdo = fb_db();
|
||||||
|
$rows = $pdo->query('SELECT key, value FROM settings')->fetchAll();
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$k = (string)$r['key'];
|
||||||
|
$v = json_decode((string)$r['value'], true);
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) { $v = (string)$r['value']; }
|
||||||
|
$out[$k] = $v;
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$out = [];
|
||||||
|
}
|
||||||
|
$loading = false;
|
||||||
|
$flat = $out;
|
||||||
|
return $flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把扁平的 'a.b.c' => v 转成嵌套数组,供 fb_cfg 与文件配置合并 */
|
||||||
|
function fb_setting_tree() {
|
||||||
|
$tree = [];
|
||||||
|
foreach (fb_settings_flat() as $k => $v) {
|
||||||
|
$parts = explode('.', (string)$k);
|
||||||
|
$cur =& $tree;
|
||||||
|
foreach ($parts as $i => $p) {
|
||||||
|
if ($i === count($parts) - 1) { $cur[$p] = $v; }
|
||||||
|
else {
|
||||||
|
if (!isset($cur[$p]) || !is_array($cur[$p])) { $cur[$p] = []; }
|
||||||
|
$cur =& $cur[$p];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($cur);
|
||||||
|
}
|
||||||
|
return $tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读单个设置(未经白名单限制,仅用于内部读取) */
|
||||||
|
function fb_setting($key, $default = null) {
|
||||||
|
$flat = fb_settings_flat();
|
||||||
|
return array_key_exists($key, $flat) ? $flat[$key] : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写单个设置 @return bool 是否成功(不在白名单里会失败) */
|
||||||
|
function fb_set_setting($key, $value) {
|
||||||
|
if (!in_array((string)$key, fb_setting_keys(), true)) { return false; }
|
||||||
|
$pdo = fb_db();
|
||||||
|
$st = $pdo->prepare('INSERT OR REPLACE INTO settings(key, value, updated_at) VALUES(?,?,?)');
|
||||||
|
$st->execute([(string)$key, json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), time()]);
|
||||||
|
// 立刻失效:本进程内再读 fb_cfg 也要拿到新值(后台保存后同页要显示新状态)
|
||||||
|
fb_settings_flat(true);
|
||||||
|
$GLOBALS['__fb_cfg_stamp'] = ((int)$GLOBALS['__fb_cfg_stamp']) + 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除单个设置(回落为 config.php 里的出厂值) @return bool */
|
||||||
|
function fb_unset_setting($key) {
|
||||||
|
if (!in_array((string)$key, fb_setting_keys(), true)) { return false; }
|
||||||
|
$pdo = fb_db();
|
||||||
|
$st = $pdo->prepare('DELETE FROM settings WHERE key = ?');
|
||||||
|
$st->execute([(string)$key]);
|
||||||
|
// 同样立刻失效,保证后续 fb_cfg 读到的是一致状态
|
||||||
|
fb_settings_flat(true);
|
||||||
|
$GLOBALS['__fb_cfg_stamp'] = ((int)$GLOBALS['__fb_cfg_stamp']) + 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 管理员登录 ---------------- */
|
||||||
|
|
||||||
|
/** 后台会话的有效期(秒):超时自动退出 */
|
||||||
|
function fb_admin_idle_ttl() {
|
||||||
|
return 7200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已登录且在有效期内(每次访问续期) */
|
||||||
|
function fb_admin_logged_in() {
|
||||||
|
if (!fb_is_admin()) { return false; }
|
||||||
|
$at = isset($_SESSION['fb_admin_at']) ? (int)$_SESSION['fb_admin_at'] : 0;
|
||||||
|
if ($at <= 0 || (time() - $at) > fb_admin_idle_ttl()) {
|
||||||
|
unset($_SESSION['fb_admin'], $_SESSION['fb_admin_at']);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$_SESSION['fb_admin_at'] = time();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 置位登录态 */
|
||||||
|
function fb_admin_login() {
|
||||||
|
$_SESSION['fb_admin'] = 1;
|
||||||
|
$_SESSION['fb_admin_at'] = time();
|
||||||
|
@session_regenerate_id(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验管理员口令。
|
||||||
|
* 后台改过口令后存的是 password_hash;没改过就用 config.php 的 admin_password。
|
||||||
|
*/
|
||||||
|
function fb_admin_verify_password($pw) {
|
||||||
|
$pw = (string)$pw;
|
||||||
|
$hash = (string)fb_setting('admin.password_hash', '');
|
||||||
|
if ($hash !== '') { return password_verify($pw, $hash); }
|
||||||
|
$cfg = (string)fb_cfg('admin_password', '');
|
||||||
|
return $cfg !== '' && hash_equals($cfg, $pw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改管理员口令(存哈希到数据库,立即生效) */
|
||||||
|
function fb_admin_set_password($pw) {
|
||||||
|
return fb_set_setting('admin.password_hash', password_hash((string)$pw, PASSWORD_DEFAULT));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写一条操作日志 */
|
||||||
|
function fb_mod_log($action, $threadId = null, $detail = '') {
|
||||||
|
try {
|
||||||
|
$pdo = fb_db();
|
||||||
|
$st = $pdo->prepare('INSERT INTO mod_log(thread_id, action, detail, created_at, ip_hash) VALUES(?,?,?,?,?)');
|
||||||
|
$st->execute([$threadId ? (int)$threadId : null, (string)$action, (string)$detail, time(), fb_ip_hash()]);
|
||||||
|
} catch (Throwable $e) { /* 日志失败不影响主流程 */ }
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 发布新问题
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
$pdo = fb_db();
|
||||||
|
$cats = fb_categories();
|
||||||
|
$err = '';
|
||||||
|
$atts = []; // 校验失败时保留已上传的附件
|
||||||
|
$catKeys = array_keys($cats);
|
||||||
|
$form = [
|
||||||
|
'category' => $catKeys ? (string)$catKeys[0] : 'other',
|
||||||
|
'title' => '',
|
||||||
|
'body' => '',
|
||||||
|
'name' => '',
|
||||||
|
'email' => '',
|
||||||
|
'phone' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
foreach (['category', 'title', 'body', 'name', 'email', 'phone'] as $k) {
|
||||||
|
$form[$k] = trim(isset($_POST[$k]) ? (string)$_POST[$k] : '');
|
||||||
|
}
|
||||||
|
$capInput = isset($_POST['captcha']) ? (string)$_POST['captcha'] : '';
|
||||||
|
$hp = trim(isset($_POST['website']) ? (string)$_POST['website'] : '');
|
||||||
|
$t0 = isset($_POST['t0']) ? (int)$_POST['t0'] : 0;
|
||||||
|
|
||||||
|
list($attOk, $attList, $attErr) = fb_attach_parse(isset($_POST['attachments']) ? $_POST['attachments'] : '');
|
||||||
|
|
||||||
|
if (!isset($cats[$form['category']])) { $form['category'] = 'other'; }
|
||||||
|
|
||||||
|
if (!fb_csrf_ok(isset($_POST['csrf']) ? $_POST['csrf'] : '')) {
|
||||||
|
$err = '会话已过期,请重新提交。';
|
||||||
|
} elseif ($hp !== '') {
|
||||||
|
$err = '提交未通过校验,请重试。';
|
||||||
|
} elseif ($t0 > 0 && (time() - $t0) < max(0, (int)fb_cfg('post_min_seconds', 3))) {
|
||||||
|
$err = '提交过于频繁,请稍候再试。';
|
||||||
|
} elseif (fb_len($form['title']) < 4 || fb_len($form['title']) > 80) {
|
||||||
|
$err = '标题请填写 4~80 个字符。';
|
||||||
|
} elseif (!fb_valid_body($form['body'])) {
|
||||||
|
$err = '问题描述请填写 2~5000 个字符。';
|
||||||
|
} elseif (!fb_valid_name($form['name'])) {
|
||||||
|
$err = '请填写昵称(1~24 个字符)。';
|
||||||
|
} elseif (!filter_var($form['email'], FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$err = '请填写有效的邮箱地址(用于接收回复通知)。';
|
||||||
|
} elseif ($form['phone'] !== '' && !preg_match('/^[0-9+\-\s()]{5,30}$/', $form['phone'])) {
|
||||||
|
$err = '手机号格式不正确(可留空)。';
|
||||||
|
} elseif (!$attOk) {
|
||||||
|
$err = $attErr;
|
||||||
|
} elseif (!fb_rate_check('thread', (int)fb_cfg('rate_limit.thread_per_hour', 5))) {
|
||||||
|
$err = '发帖过于频繁,请稍后再试。';
|
||||||
|
} else {
|
||||||
|
// 附件回源复核 + 全站总量检查(放在人机验证之前,避免验证码被无效消耗)
|
||||||
|
list($vOk, $vList, $vErr) = fb_attach_verify($attList);
|
||||||
|
if (!$vOk) {
|
||||||
|
$err = $vErr;
|
||||||
|
$atts = $attList;
|
||||||
|
} elseif (!fb_attach_cap_ok($pdo, $vList)) {
|
||||||
|
$err = '附件存储空间已满,请减少附件后重试。';
|
||||||
|
$atts = $vList;
|
||||||
|
} elseif (!fb_captcha_check($capInput)) {
|
||||||
|
$err = '人机验证未通过,请重新作答。';
|
||||||
|
$atts = $vList;
|
||||||
|
} else {
|
||||||
|
$now = time();
|
||||||
|
// 审核:开启后新提交默认不公开(visible=0),管理员通过后才会出现在列表
|
||||||
|
$visible = fb_moderation_on() ? 0 : 1;
|
||||||
|
$ins = $pdo->prepare('INSERT INTO threads(title, body, category, author_name, author_email, author_phone, status, visible, reply_count, created_at, updated_at, ip_hash) VALUES(?,?,?,?,?,?,?,?,0,?,?,?)');
|
||||||
|
$ins->execute([
|
||||||
|
$form['title'], $form['body'], $form['category'],
|
||||||
|
$form['name'], $form['email'],
|
||||||
|
$form['phone'] !== '' ? $form['phone'] : null,
|
||||||
|
'open', $visible, $now, $now, fb_ip_hash(),
|
||||||
|
]);
|
||||||
|
$newId = (int)$pdo->lastInsertId();
|
||||||
|
fb_attach_save($pdo, $newId, null, $vList);
|
||||||
|
fb_rate_hit('thread');
|
||||||
|
fb_redirect('thread.php?id=' . $newId . '&submitted=1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fb_layout_head('我要反馈', ['robots' => 'noindex,follow']);
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div>
|
||||||
|
|
||||||
|
<section class="narrow">
|
||||||
|
<h1>我要反馈</h1>
|
||||||
|
<p class="muted">请描述你遇到的问题或想要的功能。提交后会先进入审核,确认为有效问题后才会公开显示;<b>若经确认不是应用的问题,我们会把结论与说明通过邮件发送给你</b>(请填写常用邮箱)。</p>
|
||||||
|
|
||||||
|
<?php fb_flash_box($err, 'error'); ?>
|
||||||
|
|
||||||
|
<form method="post" action="new.php" class="card-form">
|
||||||
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
|
<input type="hidden" name="t0" value="<?php echo time(); ?>">
|
||||||
|
<div class="hp-field" aria-hidden="true">
|
||||||
|
<label>请勿填写此栏<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>问题分类 <b class="req">*</b></span>
|
||||||
|
<select name="category">
|
||||||
|
<?php foreach ($cats as $k => $v): ?>
|
||||||
|
<option value="<?php echo e($k); ?>" <?php echo $form['category'] === $k ? 'selected' : ''; ?>><?php echo e($v); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>标题 <b class="req">*</b></span>
|
||||||
|
<input type="text" name="title" maxlength="80" value="<?php echo e($form['title']); ?>" placeholder="一句话概括你的问题" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>详细描述 <b class="req">*</b></span>
|
||||||
|
<textarea name="body" rows="9" maxlength="5000" placeholder="请尽量说明:你做了什么、期望的结果、实际的结果;如是同步问题,可说明使用的邮箱 / 服务器类型。" required><?php echo e($form['body']); ?></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="row2">
|
||||||
|
<label class="field">
|
||||||
|
<span>昵称 <b class="req">*</b></span>
|
||||||
|
<input type="text" name="name" maxlength="24" value="<?php echo e($form['name']); ?>" placeholder="怎么称呼你" required>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>邮箱 <b class="req">*</b><i class="hint">仅用于接收回复通知,不会公开</i></span>
|
||||||
|
<input type="email" name="email" maxlength="120" value="<?php echo e($form['email']); ?>" placeholder="you@example.com" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>手机号 <i class="hint">选填;仅用于必要时联系,不会公开</i></span>
|
||||||
|
<input type="tel" name="phone" maxlength="30" value="<?php echo e($form['phone']); ?>" placeholder="选填">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<?php fb_uploaders($atts); ?>
|
||||||
|
|
||||||
|
<div class="captcha">
|
||||||
|
<label class="field">
|
||||||
|
<span>人机验证 <b class="req">*</b> <i class="hint">防机器人,随手答一下</i></span>
|
||||||
|
<div class="captcha-line">
|
||||||
|
<span class="c-q"><?php echo e(fb_captcha_question()); ?></span>
|
||||||
|
<input type="text" name="captcha" value="" placeholder="答案" autocomplete="off" required>
|
||||||
|
<a class="reload" href="captcha.php" data-captcha-refresh>换一题</a>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn primary" type="submit">提交反馈</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="muted small">提示:本页面不会公开展示你的邮箱与手机号。请勿在内容中填写密码等敏感信息。可上传截图或录屏帮助说明问题;文件会直接传到对象存储,不占用本站服务器空间。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
fb_layout_foot();
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 问题详情 + 回复
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
$pdo = fb_db();
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : (isset($_POST['id']) ? (int)$_POST['id'] : 0);
|
||||||
|
if ($id <= 0) { fb_redirect('index.php'); }
|
||||||
|
|
||||||
|
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
|
||||||
|
$st->execute([$id]);
|
||||||
|
$thread = $st->fetch();
|
||||||
|
if (!$thread) {
|
||||||
|
http_response_code(404);
|
||||||
|
fb_layout_head('问题不存在');
|
||||||
|
echo '<div class="empty"><p>该问题不存在或已被删除。</p><p><a class="btn primary" href="index.php">返回问题列表</a></p></div>';
|
||||||
|
fb_layout_foot();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 审核(先审后显):未通过审核的问题对访客不可见
|
||||||
|
if (!fb_thread_public($thread)) {
|
||||||
|
$justSubmitted = (isset($_GET['submitted']) && (string)$_GET['submitted'] === '1');
|
||||||
|
if ($justSubmitted) {
|
||||||
|
fb_layout_head('提交成功', ['robots' => 'noindex,nofollow']);
|
||||||
|
echo '<div class="empty">'
|
||||||
|
. '<h2 style="margin:0 0 10px">提交成功,等待审核</h2>'
|
||||||
|
. '<p>你的反馈已收到(编号 #' . (int)$thread['id'] . '),我们会先确认它是否为应用问题:</p>'
|
||||||
|
. '<p>· 确认为问题 —— 审核通过后会公开显示,其他人也能查看与回复;<br>'
|
||||||
|
. '· 确认不是问题 —— 我们会关闭它,并把结论与说明发送到你留下的邮箱。</p>'
|
||||||
|
. '<p class="muted">审核通常在 1~2 个工作日内完成,请留意邮箱。</p>'
|
||||||
|
. '<p><a class="btn primary" href="index.php">返回问题列表</a></p>'
|
||||||
|
. '</div>';
|
||||||
|
fb_layout_foot();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
http_response_code(404);
|
||||||
|
fb_layout_head('问题不存在');
|
||||||
|
echo '<div class="empty"><p>该问题不存在或已被删除。</p><p><a class="btn primary" href="index.php">返回问题列表</a></p></div>';
|
||||||
|
fb_layout_foot();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statuses = fb_statuses();
|
||||||
|
$err = '';
|
||||||
|
$atts = [];
|
||||||
|
$form = ['name' => '', 'email' => '', 'body' => '', 'captcha' => ''];
|
||||||
|
|
||||||
|
/* ---------------- 处理回复提交 ---------------- */
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
// 审核中(未公开)的问题不允许回复
|
||||||
|
if (!fb_thread_public($thread)) {
|
||||||
|
http_response_code(403);
|
||||||
|
fb_layout_head('暂不可回复');
|
||||||
|
echo '<div class="empty"><p>该问题还在审核中,暂时不能回复。</p><p><a class="btn primary" href="index.php">返回问题列表</a></p></div>';
|
||||||
|
fb_layout_foot();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$form['name'] = trim(isset($_POST['name']) ? (string)$_POST['name'] : '');
|
||||||
|
$form['email'] = trim(isset($_POST['email']) ? (string)$_POST['email'] : '');
|
||||||
|
$form['body'] = trim(isset($_POST['body']) ? (string)$_POST['body'] : '');
|
||||||
|
$capInput = isset($_POST['captcha']) ? (string)$_POST['captcha'] : '';
|
||||||
|
$adminPw = isset($_POST['admin_password']) ? (string)$_POST['admin_password'] : '';
|
||||||
|
$hp = trim(isset($_POST['website']) ? (string)$_POST['website'] : '');
|
||||||
|
$t0 = isset($_POST['t0']) ? (int)$_POST['t0'] : 0;
|
||||||
|
|
||||||
|
list($attOk, $attList, $attErr) = fb_attach_parse(isset($_POST['attachments']) ? $_POST['attachments'] : '');
|
||||||
|
|
||||||
|
$isAdmin = ($adminPw !== '' && fb_admin_verify_password($adminPw));
|
||||||
|
|
||||||
|
if (!fb_csrf_ok(isset($_POST['csrf']) ? $_POST['csrf'] : '')) {
|
||||||
|
$err = '会话已过期,请重新提交。';
|
||||||
|
} elseif ($hp !== '') {
|
||||||
|
$err = '提交未通过校验,请重试。';
|
||||||
|
} elseif ($t0 > 0 && (time() - $t0) < max(0, (int)fb_cfg('post_min_seconds', 3))) {
|
||||||
|
$err = '提交过于频繁,请稍候再试。';
|
||||||
|
} elseif (!fb_valid_name($form['name']) && !$isAdmin) {
|
||||||
|
$err = '请填写昵称(1~24 个字符)。';
|
||||||
|
} elseif (!$isAdmin && !filter_var($form['email'], FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$err = '请填写有效的邮箱地址(用于接收回复通知)。';
|
||||||
|
} elseif (!fb_valid_body($form['body'])) {
|
||||||
|
$err = '回复内容请填写 2~5000 个字符。';
|
||||||
|
} elseif (!$attOk) {
|
||||||
|
$err = $attErr;
|
||||||
|
} elseif (!fb_rate_check('reply', (int)fb_cfg('rate_limit.reply_per_hour', 20))) {
|
||||||
|
$err = '回复过于频繁,请稍后再试。';
|
||||||
|
} else {
|
||||||
|
list($vOk, $vList, $vErr) = fb_attach_verify($attList);
|
||||||
|
if (!$vOk) {
|
||||||
|
$err = $vErr;
|
||||||
|
$atts = $attList;
|
||||||
|
} elseif (!fb_attach_cap_ok($pdo, $vList)) {
|
||||||
|
$err = '附件存储空间已满,请减少附件后重试。';
|
||||||
|
$atts = $vList;
|
||||||
|
} elseif (!fb_captcha_check($capInput)) {
|
||||||
|
$err = '人机验证未通过,请重新作答。';
|
||||||
|
$atts = $vList;
|
||||||
|
} else {
|
||||||
|
$now = time();
|
||||||
|
$replyName = $isAdmin ? '官方' : $form['name'];
|
||||||
|
$replyMail = $isAdmin ? null : $form['email'];
|
||||||
|
|
||||||
|
$ins = $pdo->prepare('INSERT INTO replies(thread_id, body, author_name, author_email, is_admin, created_at, ip_hash) VALUES(?,?,?,?,?,?,?)');
|
||||||
|
$ins->execute([$id, $form['body'], $replyName, $replyMail, $isAdmin ? 1 : 0, $now, fb_ip_hash()]);
|
||||||
|
$replyId = (int)$pdo->lastInsertId();
|
||||||
|
fb_attach_save($pdo, $id, $replyId, $vList);
|
||||||
|
|
||||||
|
// 更新主题:回复数 +1、活跃时间;管理员可同时调整状态
|
||||||
|
$newStatus = null;
|
||||||
|
if ($isAdmin && isset($_POST['status']) && isset($statuses[(string)$_POST['status']])) {
|
||||||
|
$newStatus = (string)$_POST['status'];
|
||||||
|
}
|
||||||
|
if ($newStatus !== null) {
|
||||||
|
$upd = $pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ?, status = ? WHERE id = ?');
|
||||||
|
$upd->execute([$now, $newStatus, $id]);
|
||||||
|
} else {
|
||||||
|
$upd = $pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ? WHERE id = ?');
|
||||||
|
$upd->execute([$now, $id]);
|
||||||
|
}
|
||||||
|
fb_rate_hit('reply');
|
||||||
|
|
||||||
|
// 发送通知邮件给发帖人
|
||||||
|
$replyRow = [
|
||||||
|
'id' => $replyId,
|
||||||
|
'body' => $form['body'],
|
||||||
|
'author_name' => $replyName,
|
||||||
|
'author_email' => $replyMail,
|
||||||
|
'is_admin' => $isAdmin ? 1 : 0,
|
||||||
|
];
|
||||||
|
fb_notify_reply($pdo, $thread, $replyRow, $isAdmin);
|
||||||
|
|
||||||
|
fb_redirect('thread.php?id=' . $id . '#r' . $replyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 读取数据 ---------------- */
|
||||||
|
$st = $pdo->prepare('SELECT id, body, author_name, is_admin, created_at FROM replies WHERE thread_id = ? ORDER BY created_at ASC, id ASC');
|
||||||
|
$st->execute([$id]);
|
||||||
|
$replies = $st->fetchAll();
|
||||||
|
|
||||||
|
// 重新读取主题(可能在 POST 中更新过状态)
|
||||||
|
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
|
||||||
|
$st->execute([$id]);
|
||||||
|
$thread = $st->fetch();
|
||||||
|
|
||||||
|
// 附件(主题级 + 各回复)
|
||||||
|
$attMap = fb_attach_for_thread($pdo, $id);
|
||||||
|
|
||||||
|
fb_layout_head(fb_excerpt($thread['title'], 50));
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div>
|
||||||
|
|
||||||
|
<article class="subject">
|
||||||
|
<div class="item-top">
|
||||||
|
<span class="badge cat"><?php echo e(fb_category_label($thread['category'])); ?></span>
|
||||||
|
<span class="badge st st-<?php echo e($thread['status']); ?>"><?php echo e(fb_status_label($thread['status'])); ?></span>
|
||||||
|
<h1 class="subject-title"><?php echo e($thread['title']); ?></h1>
|
||||||
|
<span class="item-id" title="问题编号">#<?php echo (int)$id; ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="item-meta">
|
||||||
|
<span class="who"><?php echo e($thread['author_name']); ?></span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span><?php echo e(fb_fmt_time($thread['created_at'])); ?></span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span><?php echo (int)$thread['reply_count']; ?> 条回复</span>
|
||||||
|
</div>
|
||||||
|
<div class="subject-body"><?php echo nl2br(e($thread['body'])); ?></div>
|
||||||
|
<?php echo fb_attach_render($attMap['main']); ?>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<section class="replies">
|
||||||
|
<h2>全部回复(<?php echo count($replies); ?>)</h2>
|
||||||
|
<?php if (!$replies): ?>
|
||||||
|
<div class="empty small"><p>还没有回复,欢迎在下方参与讨论。</p></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($replies as $r): ?>
|
||||||
|
<div class="reply <?php echo $r['is_admin'] ? 'admin' : ''; ?>" id="r<?php echo (int)$r['id']; ?>">
|
||||||
|
<div class="reply-head">
|
||||||
|
<span class="who"><?php echo e($r['is_admin'] ? '官方' : $r['author_name']); ?></span>
|
||||||
|
<?php if ($r['is_admin']): ?><span class="badge official">官方答复</span><?php endif; ?>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span class="time"><?php echo e(fb_fmt_time($r['created_at'])); ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="reply-body"><?php echo nl2br(e($r['body'])); ?></div>
|
||||||
|
<?php
|
||||||
|
$rid = (int)$r['id'];
|
||||||
|
if (!empty($attMap['replies'][$rid])) {
|
||||||
|
echo fb_attach_render($attMap['replies'][$rid], ['compact' => true]);
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="reply-form">
|
||||||
|
<h2>我要回复</h2>
|
||||||
|
<?php fb_flash_box($err, 'error'); ?>
|
||||||
|
<form method="post" action="thread.php?id=<?php echo (int)$id; ?>">
|
||||||
|
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
|
||||||
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
|
<input type="hidden" name="t0" value="<?php echo time(); ?>">
|
||||||
|
<div class="hp-field" aria-hidden="true">
|
||||||
|
<label>请勿填写此栏<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row2">
|
||||||
|
<label class="field">
|
||||||
|
<span>昵称 <b class="req">*</b></span>
|
||||||
|
<input type="text" name="name" maxlength="24" value="<?php echo e($form['name']); ?>" placeholder="怎么称呼你" required>
|
||||||
|
</label>
|
||||||
|
<label class="field" id="emailField">
|
||||||
|
<span>邮箱 <b class="req">*</b><i class="hint">仅用于接收回复通知,不会公开</i></span>
|
||||||
|
<input type="email" name="email" maxlength="120" value="<?php echo e($form['email']); ?>" placeholder="you@example.com" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>回复内容 <b class="req">*</b></span>
|
||||||
|
<textarea name="body" rows="6" maxlength="5000" placeholder="说说你的看法或补充…" required><?php echo e($form['body']); ?></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<?php fb_uploaders($atts); ?>
|
||||||
|
|
||||||
|
<div class="captcha">
|
||||||
|
<label class="field">
|
||||||
|
<span>人机验证 <b class="req">*</b> <i class="hint">防机器人,随手答一下</i></span>
|
||||||
|
<div class="captcha-line">
|
||||||
|
<span class="c-q"><?php echo e(fb_captcha_question()); ?></span>
|
||||||
|
<input type="text" name="captcha" value="" placeholder="答案" autocomplete="off" required>
|
||||||
|
<a class="reload" href="captcha.php" data-captcha-refresh>换一题</a>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field admin-pw">
|
||||||
|
<span>管理员口令 <i class="hint">选填;填写后将以「官方」身份回复</i></span>
|
||||||
|
<input type="password" name="admin_password" value="" autocomplete="off" placeholder="管理员专用">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="field admin-status" hidden>
|
||||||
|
<span>处理状态 <i class="hint">仅管理员可见</i></span>
|
||||||
|
<select name="status">
|
||||||
|
<?php foreach ($statuses as $k => $v): ?>
|
||||||
|
<option value="<?php echo e($k); ?>" <?php echo $thread['status'] === $k ? 'selected' : ''; ?>><?php echo e($v); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn primary" type="submit">提交回复</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
fb_layout_foot();
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 附件上传接口
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* 两种存储方式由后台设置决定(attachment.storage):
|
||||||
|
*
|
||||||
|
* ① oss(阿里云 OSS 直传,原行为)
|
||||||
|
* POST upload.php kind=&ext=&size=&mime=&csrf=
|
||||||
|
* → 返回 host / key / policy / signature…,浏览器把文件直接 POST 到 OSS,
|
||||||
|
* 文件不经过本服务器。凭证来自 ECS 实例 RAM 角色的临时凭证(或静态 AK)。
|
||||||
|
*
|
||||||
|
* ② local(存本机磁盘)
|
||||||
|
* 第一步同上,只是返回的 host 指向本文件的 ?action=put;
|
||||||
|
* 第二步浏览器把文件 multipart POST 到 upload.php?action=put,
|
||||||
|
* 服务端校验后落到 uploads/YYYY/MM/ 下。
|
||||||
|
*
|
||||||
|
* 两种方式共用同一套校验:扩展名 / MIME 白名单、单文件大小、每 IP 频率、全站总量上限。
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
/** 统一 JSON 输出 */
|
||||||
|
function fb_up_json($data, $code = 200) {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strtoupper(isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET') !== 'POST') {
|
||||||
|
fb_up_json(['ok' => false, 'error' => 'method not allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mode = fb_store_mode();
|
||||||
|
$putAt = (isset($_GET['action']) && (string)$_GET['action'] === 'put');
|
||||||
|
|
||||||
|
if ($mode === 'off') {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '附件上传未启用。'], 403);
|
||||||
|
}
|
||||||
|
if ($mode === 'oss' && !fb_oss_enabled()) {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '对象存储未配置完整,请到后台「站点设置」补齐桶信息。'], 403);
|
||||||
|
}
|
||||||
|
if ($mode === 'local' && !fb_local_writable()) {
|
||||||
|
$ld = fb_local_diag();
|
||||||
|
fb_up_json(['ok' => false, 'error' => '本机附件目录不可写(uploads/)。' . ($ld['tip'] !== '' ? ' ' . $ld['tip'] : '')], 403);
|
||||||
|
}
|
||||||
|
if (!fb_csrf_ok(isset($_POST['csrf']) ? $_POST['csrf'] : '')) {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '会话已过期,请刷新页面后重试。'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================================================================
|
||||||
|
* 本机存储:接收文件本体
|
||||||
|
* ================================================================== */
|
||||||
|
if ($putAt) {
|
||||||
|
if ($mode !== 'local') { fb_up_json(['ok' => false, 'error' => '当前存储方式不支持本机上传。'], 400); }
|
||||||
|
|
||||||
|
$kind = strtolower(trim(isset($_POST['kind']) ? (string)$_POST['kind'] : ''));
|
||||||
|
$key = trim(isset($_POST['key']) ? (string)$_POST['key'] : '');
|
||||||
|
|
||||||
|
if ($kind !== 'image' && $kind !== 'video') { fb_up_json(['ok' => false, 'error' => '不支持的附件类型。'], 400); }
|
||||||
|
if (!fb_upload_kind_on($kind)) { fb_up_json(['ok' => false, 'error' => '该类型附件未开放上传。'], 403); }
|
||||||
|
if (!fb_attach_is_local($key) || !fb_upload_key_ok($key)) {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '附件地址不合法,请重新上传。'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = fb_upload_key_ext($key);
|
||||||
|
if (!fb_upload_ext_ok($kind, $ext)) { fb_up_json(['ok' => false, 'error' => '不支持的文件格式。'], 400); }
|
||||||
|
|
||||||
|
if (empty($_FILES['file']) || !is_array($_FILES['file'])) {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '没有收到文件。'], 400);
|
||||||
|
}
|
||||||
|
$f = $_FILES['file'];
|
||||||
|
if (!isset($f['error']) || (int)$f['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '文件上传失败(错误码 ' . (int)(isset($f['error']) ? $f['error'] : -1) . ')。'], 400);
|
||||||
|
}
|
||||||
|
if (!is_uploaded_file((string)$f['tmp_name'])) {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '文件上传失败。'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = (int)@filesize($f['tmp_name']);
|
||||||
|
$maxB = (int)fb_upload_cfg($kind . '_max_bytes', 0);
|
||||||
|
if ($size <= 0) { fb_up_json(['ok' => false, 'error' => '文件为空。'], 400); }
|
||||||
|
if ($maxB > 0 && $size > $maxB) {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '文件超过大小限制(' . fb_size_h($maxB) . ')。'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mime = strtolower(trim(isset($_POST['mime']) ? (string)$_POST['mime'] : ''));
|
||||||
|
if ($mime === '') { $mime = fb_upload_mime_of_ext($ext); }
|
||||||
|
if (!fb_upload_mime_ok($kind, $mime)) { fb_up_json(['ok' => false, 'error' => '不支持的文件类型。'], 400); }
|
||||||
|
|
||||||
|
// 二次确认总量上限(防止并发绕过)
|
||||||
|
$pdo = fb_db();
|
||||||
|
$cap = (int)fb_upload_cfg('total_cap_bytes', 0);
|
||||||
|
if ($cap > 0 && (fb_attach_total_bytes($pdo) + $size) > $cap) {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '附件存储空间已满,暂时无法上传。'], 507);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = fb_local_path_of($key);
|
||||||
|
$dir = dirname($path);
|
||||||
|
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
|
||||||
|
if (!is_dir($dir) || !@move_uploaded_file((string)$f['tmp_name'], $path)) {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '保存文件失败,请稍后重试。'], 500);
|
||||||
|
}
|
||||||
|
@chmod($path, 0644);
|
||||||
|
|
||||||
|
fb_up_json([
|
||||||
|
'ok' => true,
|
||||||
|
'key' => $key,
|
||||||
|
'url' => fb_attach_url($key),
|
||||||
|
'size' => $size,
|
||||||
|
'mime' => $mime,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================================================================
|
||||||
|
* 领取上传凭证
|
||||||
|
* ================================================================== */
|
||||||
|
$kind = strtolower(trim(isset($_POST['kind']) ? (string)$_POST['kind'] : ''));
|
||||||
|
if ($kind !== 'image' && $kind !== 'video') {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '不支持的附件类型。'], 400);
|
||||||
|
}
|
||||||
|
if (!fb_upload_kind_on($kind)) {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '该类型附件未开放上传。'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = fb_upload_norm_ext(isset($_POST['ext']) ? (string)$_POST['ext'] : '');
|
||||||
|
$size = (int)(isset($_POST['size']) ? $_POST['size'] : 0);
|
||||||
|
$mime = strtolower(trim(isset($_POST['mime']) ? (string)$_POST['mime'] : ''));
|
||||||
|
if ($mime === '') { $mime = fb_upload_mime_of_ext($ext); }
|
||||||
|
|
||||||
|
$maxBytes = (int)fb_upload_cfg($kind . '_max_bytes', 0);
|
||||||
|
|
||||||
|
if ($ext === '' || !fb_upload_ext_ok($kind, $ext)) {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '不支持的文件格式。'], 400);
|
||||||
|
}
|
||||||
|
if (!fb_upload_mime_ok($kind, $mime)) {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '不支持的文件类型。'], 400);
|
||||||
|
}
|
||||||
|
if ($size <= 0) {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '文件为空。'], 400);
|
||||||
|
}
|
||||||
|
if ($maxBytes > 0 && $size > $maxBytes) {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '文件超过大小限制(' . fb_size_h($maxBytes) . ')。'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = (int)fb_upload_cfg('upload_per_hour', 60);
|
||||||
|
if (!fb_rate_check('upload', $limit)) {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '上传过于频繁,请稍后再试。'], 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全站总量上限:达到后不再发放凭证
|
||||||
|
$pdo = fb_db();
|
||||||
|
$cap = (int)fb_upload_cfg('total_cap_bytes', 0);
|
||||||
|
if ($cap > 0 && (fb_attach_total_bytes($pdo) + $size) > $cap) {
|
||||||
|
fb_up_json(['ok' => false, 'error' => '附件存储空间已满,暂时无法上传,请稍后再试。'], 507);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 本机存储:返回一个指向本文件的上传地址 ---------- */
|
||||||
|
if ($mode === 'local') {
|
||||||
|
$key = fb_local_make_key($ext);
|
||||||
|
fb_rate_hit('upload');
|
||||||
|
fb_up_json([
|
||||||
|
'ok' => true,
|
||||||
|
'credential' => [
|
||||||
|
'host' => 'upload.php?action=put',
|
||||||
|
'key' => $key,
|
||||||
|
'url' => fb_attach_url($key),
|
||||||
|
],
|
||||||
|
'fields' => [
|
||||||
|
'csrf' => fb_csrf_token(),
|
||||||
|
'key' => $key,
|
||||||
|
'kind' => $kind,
|
||||||
|
'mime' => $mime,
|
||||||
|
],
|
||||||
|
'mime' => $mime,
|
||||||
|
'kind' => $kind,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- OSS 直传 ---------- */
|
||||||
|
$key = fb_oss_make_key($ext);
|
||||||
|
$cred = fb_oss_upload_credential($key, $maxBytes, $kind . '/');
|
||||||
|
|
||||||
|
fb_rate_hit('upload');
|
||||||
|
|
||||||
|
// extra_fields 是除固定项外需要浏览器一并提交的表单域:使用实例 RAM 角色的
|
||||||
|
// 临时凭证时,就是 x-oss-security-token(OSS 靠它识别这份临时身份)。
|
||||||
|
$fields = [
|
||||||
|
'key' => $cred['key'],
|
||||||
|
'policy' => $cred['policy'],
|
||||||
|
'OSSAccessKeyId' => $cred['access_key_id'],
|
||||||
|
'signature' => $cred['signature'],
|
||||||
|
'success_action_status' => '200',
|
||||||
|
'Content-Type' => $mime,
|
||||||
|
];
|
||||||
|
if (!empty($cred['extra_fields']) && is_array($cred['extra_fields'])) {
|
||||||
|
foreach ($cred['extra_fields'] as $k => $v) { $fields[$k] = $v; }
|
||||||
|
}
|
||||||
|
|
||||||
|
fb_up_json([
|
||||||
|
'ok' => true,
|
||||||
|
'credential' => $cred,
|
||||||
|
'fields' => $fields,
|
||||||
|
'mime' => $mime,
|
||||||
|
'kind' => $kind,
|
||||||
|
]);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 本机附件目录
|
||||||
|
# 允许直接读取图片 / 视频,但禁止列出目录、禁止执行任何脚本
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
<FilesMatch "\.(php|php[0-9]|phtml|pht|phar|pl|py|cgi|sh|htaccess)$">
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order allow,deny
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
|
</FilesMatch>
|
||||||