From cede838b5e3f0b24ec30b7b87a5ae3c3ea52adf8 Mon Sep 17 00:00:00 2001 From: laoyang Date: Mon, 28 Sep 2026 20:34:37 +0800 Subject: [PATCH] =?UTF-8?q?FeedLite=20=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=EF=BC=9A=E9=80=9A=E7=94=A8=E8=BD=BB=E9=87=8F=E7=BA=A7=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=E7=B3=BB=E7=BB=9F=EF=BC=88PHP=20+=20SQLite=EF=BC=8C?= =?UTF-8?q?=E9=9B=B6=E4=BE=9D=E8=B5=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前台:问题列表 / 详情 / 发布与回复(趣味问答验证码、蜜罐、IP 限流) - 后台:先审后显、站点设置(站点标题/描述、附件存储、OSS 参数)、操作日志 - 附件:关闭 / 本机硬盘 / 阿里云 OSS 浏览器直传(ECS 实例 RAM 角色)三种模式 - 品牌资源:全套图标(favicon.ico / SVG / PNG 各尺寸 / manifest) - 安全:CSRF、session 加固、.htaccess 屏蔽敏感文件、口令仅存哈希 - 配置:config.example.php 为模板,config.php 不入库 --- .gitignore | 25 + .htaccess | 12 + README.md | 674 +++++++++++++++++++++++++++ admin.php | 733 ++++++++++++++++++++++++++++++ api.php | 245 ++++++++++ assets/android-chrome-192x192.png | Bin 0 -> 3588 bytes assets/android-chrome-512x512.png | Bin 0 -> 13253 bytes assets/app.js | 627 +++++++++++++++++++++++++ assets/apple-touch-icon.png | Bin 0 -> 3471 bytes assets/favicon-16x16.png | Bin 0 -> 337 bytes assets/favicon-32x32.png | Bin 0 -> 544 bytes assets/favicon.ico | Bin 0 -> 9811 bytes assets/favicon.svg | 16 + assets/icon-128.png | Bin 0 -> 2132 bytes assets/icon-16.png | Bin 0 -> 337 bytes assets/icon-180.png | Bin 0 -> 3471 bytes assets/icon-192.png | Bin 0 -> 3588 bytes assets/icon-256.png | Bin 0 -> 4853 bytes assets/icon-32.png | Bin 0 -> 544 bytes assets/icon-48.png | Bin 0 -> 854 bytes assets/icon-512.png | Bin 0 -> 13253 bytes assets/icon-64.png | Bin 0 -> 989 bytes assets/site.webmanifest | 14 + assets/style.css | 373 +++++++++++++++ captcha.php | 12 + config.example.php | 167 +++++++ cron.php | 92 ++++ data/.htaccess | 8 + data/index.html | 1 + index.php | 153 +++++++ lib/attach.php | 527 +++++++++++++++++++++ lib/bootstrap.php | 95 ++++ lib/captcha.php | 130 ++++++ lib/db.php | 178 ++++++++ lib/layout.php | 134 ++++++ lib/mailer.php | 256 +++++++++++ lib/oss.php | 492 ++++++++++++++++++++ lib/util.php | 307 +++++++++++++ new.php | 159 +++++++ thread.php | 261 +++++++++++ upload.php | 207 +++++++++ uploads/.htaccess | 13 + 42 files changed, 5911 insertions(+) create mode 100644 .gitignore create mode 100644 .htaccess create mode 100644 README.md create mode 100644 admin.php create mode 100644 api.php create mode 100644 assets/android-chrome-192x192.png create mode 100644 assets/android-chrome-512x512.png create mode 100644 assets/app.js create mode 100644 assets/apple-touch-icon.png create mode 100644 assets/favicon-16x16.png create mode 100644 assets/favicon-32x32.png create mode 100644 assets/favicon.ico create mode 100644 assets/favicon.svg create mode 100644 assets/icon-128.png create mode 100644 assets/icon-16.png create mode 100644 assets/icon-180.png create mode 100644 assets/icon-192.png create mode 100644 assets/icon-256.png create mode 100644 assets/icon-32.png create mode 100644 assets/icon-48.png create mode 100644 assets/icon-512.png create mode 100644 assets/icon-64.png create mode 100644 assets/site.webmanifest create mode 100644 assets/style.css create mode 100644 captcha.php create mode 100644 config.example.php create mode 100644 cron.php create mode 100644 data/.htaccess create mode 100644 data/index.html create mode 100644 index.php create mode 100644 lib/attach.php create mode 100644 lib/bootstrap.php create mode 100644 lib/captcha.php create mode 100644 lib/db.php create mode 100644 lib/layout.php create mode 100644 lib/mailer.php create mode 100644 lib/oss.php create mode 100644 lib/util.php create mode 100644 new.php create mode 100644 thread.php create mode 100644 upload.php create mode 100644 uploads/.htaccess diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a9807e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..08f562d --- /dev/null +++ b/.htaccess @@ -0,0 +1,12 @@ +# 安全与整洁:禁用目录列表;阻止直接下载数据库 / 说明文件 +Options -Indexes + + + + Require all denied + + + Order allow,deny + Deny from all + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..26e969f --- /dev/null +++ b/README.md @@ -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` | 凭证缓存文件(含 `` 头 + 权限 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 的处理图(省流量),需要原图时点「查看原图」。 +- 图片本身是 ``,即使浏览器禁用 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`)。 diff --git a/admin.php b/admin.php new file mode 100644 index 0000000..a31dac0 --- /dev/null +++ b/admin.php @@ -0,0 +1,733 @@ + 'noindex,nofollow']); + ?> +
← 返回问题列表
+
+

管理登录

+

请输入管理员口令(初始口令在 config.php 的 admin_password,登录后在「站点设置」里可以改掉它)。

+ +
+ + +
+ +
+
+
+ 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']); +?> +
+
+

管理后台

+

登录状态 小时内有效,超时自动退出。

+
+ 退出登录 +
+ + + + + + + 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(); + ?> +

待审核 ()

+ +

当前没有待审核的反馈。

+ + + + + + 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(); + ?> +
+ + + + + + + 重置 +
+ +

全部问题 ( 条,第 / 页)

+ + +

没有符合条件的问题。

+ +
+ +
+
+ + + + # + 未公开 +
+

+
+ + · + + · + 条回复 + · + 管理 +
+
+ +
+ 1): ?> +
+ 1): ?>上一页 + 下一页 +
+ + + + + +

问题不存在。返回列表

+ 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); + ?> +
← 返回全部问题
+ +
+
+ + + + # +
+
+ +
+ + · + + · + · + + · + +
+
+ + +
+
+ + 官方 + · + +
+
+ true]); } ?> +
+ + +

以官方身份回复

+
+ + + + +
+ +
+
+ +

状态与可见性

+
+ + +
+ + +
+
+ + + + + + +
+
+ +

关闭(判定不是应用问题,会发邮件给提问人)

+
+ + + +
+ +
+
+ +
+ + +
+ +
+
+ + + + +
+ + +

站点信息

+ + + +

附件存储

+ + +
+ + + + + + + + +
+ +

阿里云 OSS(选「OSS」时才需要填)

+
+ + + + + + + + + + +
+ + + +

审核

+ + + +
+ +
+
+ +

存储体检

+
+
+ 当前存储方式: + + + 上传目录: — + + + + + 鉴权方式: + 凭证状态: + Bucket: / Endpoint: + + 已用附件: / 上限 +
+
+ +

修改登录口令

+
+ +
+ + + +
+
+ +
+

新口令保存在数据库里,优先级高于 config.php 的 admin_password;改完立即生效。

+
+ + + query('SELECT * FROM mod_log ORDER BY created_at DESC, id DESC LIMIT 100')->fetchAll(); + ?> +

最近操作 ( 条)

+ +

还没有操作记录。

+ +
+ +
+
+ + + # + +
+

+
+
+ +
+ + + + +
+
+ + + + # +
+ +
+ +
+ + · + + · + + · + 进入管理 +
+ +
+
+ + +
+ +
+
+ +
+ + + +
+ +
+
+
+
+ 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, +]); diff --git a/assets/android-chrome-192x192.png b/assets/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..e84dffa16d6c0849574f826dbf8e67fee733fcfc GIT binary patch literal 3588 zcmW+(c{tQv8~)7$sUnq zMxx=hwc7W6C(Mj(<{RH1=bY<0_kEt{Irnw0bMEuR(%b}v5Jvz2fHE^Rw&IM`g9e9k zdaI#3FwVf^V`_gF0Ce~c8mOZ>FM>m^HZvw%4$NAiiY~G)7HKx}!I>8daxb~Xss4WPoPceb<&CilUmE$E%ms#< z8eWYAW1pm}v@397d1`9|bb?FJe$BFpcULK{-HgtMYP^YSIV1|HdnJ4LZp3z)VqkOHgkc0BCtQG|Go?ze4)8HXl29n@*L2z7%?<_T0lEe zy<`>JvU(JYNj@G>c}C=Ikb07Ia#G9GJDwCfTvpb(JKxXBImheP#z!tZnn&v{_4)RYeE$VDR6 zBau4+VZ}C7IWD>}+p_wIsUjU^1qqrR>oD-%h70@!*iqyjJW38wgoNF^)4Ja`0!%*X z@htNu^0#-qk!4K~+;U;XgzT)o?vb!<6*e8Teh5EKYs6Mwq^vej`nySlaT#?q!CeMJ zFJ~l%NkrQdb^XjCFaBDb$ZlYyk^W+khLF?*biJ?3My3&sHUCf)EiRAIn9EN;e0K*Z z7#*QrmxH73(bbbSEWAQDltkF~g;ZqJEmu6o+GOu7{y1-bdfSFkiGdkHp2eZx@)U}* zQkuysGG~nR57oI%L@6@=T*&94(L&_?q?M0{*AG`rnE8$k9W!Fk~{cfb)B zv^NTed?5=RMa9or>}YX^5jb^g|Dk3DtW!F`8%+<7#EO3 z;Gqdwvd~4;Oa^fIiX{iQx1y1x(23{~iAe;kgf!MJD?Z29N`QVn6sJAdK=YRBh4b8( z(^&hZuLjduAIii1i{B{OHIPJ|3^p45BimZ?s$m{oLmhH4>$8cA8aswIA`kZUk?O;_X!KiAQwLjN9qN#Vw?4^{AW!uQg zGk$9NyP~b4BF3Dt9ip}Sw7~Tf`+wiNgiKG_>>YbLAUT#F+`ef|TK(}&z)UnpO!B1} z`>D@3)5`yb@e$xn@90oqdfjk1@58n4g{;HjgVN@(u`~;>UpdtQw>|i;sQWhh&Ybhl z(GnxqWfwIi9hWo8*U2e%V#Ha4nEmmn#xlH?m>C_De{E;afd0qiWUCpcM{MjP2( zwC`NWv4l}S(D!DIE(A8mW;|}Dm-3Zke4rVx&1HrH|90a1rK3~Sy0SF0ON z8_lY8u({DjB*q8QBHXzYim(AXMx^eLd|*#XQlz$HUI$0_A+@+&8=}f7o`E;ZU*84M z!K!Z6gE#F8gZsA3GGAVM*N>!)Z6y|udn?GCubH`o%t4l6k_dOxxqSo$XGymiXvmLGFblm)Oy7SOrzM(96bw#YNmUqQ-{w!w)qF-AT?#9ON;v6Qce!d6y z=h8wX|Gx-^tPPUh)u?F?Tn^=;(gEF#(g*&pYT7A{)`>u$6j@tAygy;$BwI=15HJW? zW0lw*pbL*}56GhNAufV-_70O?VMTleUNxn!FdT7+Xsy5kYj&BB-fmUR7ir-Vh6V~e z)cYfDiQ8F)_HCuqZu$GR@~?%Q?g2O-K4K5!u_fV2=lZM{HF)D04?sN5Q34A9U>P46 zAW9vXoa6(19@U@{0RT>3-~$JP)!;R#3hFIAnj-*4pGe}CG6DcD{1_jgLT>o>O%fR6 z@|BPR0cha2Bp02_MMd6t-LiiB%>aY_v1=hQE{Pkrl^X}5<^{eEV^6>a&T%5##Q|DE zqs7ynHuPa0PJV4k;Bf5fpTDrMP>Vq*Cz&}6h@Pmcst+`LQH^pA|CYrE@U;YZZol4T z7kfeM$4(bQ$VTf;Ig==XA(o#$0M!yI-JET0*?NrI)z>r>3GCPgjQ1_~q9)H5hlt=s z=6*&T$Z=5Go}vZmMS#suMA-i3v1QfwtllsG zpS;q4xZh|!prhow(k()eU|s7USZ=PHr!#*`=Gy0YgO1Vm16qyu0~JLw3KIw-a4W&UFKrKNB8Y72o>vdRmT1fnN8)7iDc30;gm zXjEK4#qLRUkf@F2eCaI2JGWBdwaJAK&I}tjFi|w>_^V4zUX~`suOFGslW=GA!mGMCMO12^DGhp{GZacQBQj6}zo30MU`{J19dm_};x^Tg#P2 z{reLR052)4_E0nSzi2&!AU{Z*MHY&>K7Hvz{Ibz~qoT%}e(=^kCw}VsZ)~M;>@1i6 zhYgIRrymO7m5}y$i*Pb-0`> z4z8;s+49gaXUB~>4?SO&Z5X?4U|I3=Jig9A8?qB^WFpw;?^*q6Zd)O9ulbbI!+!h} zn7WmfukfO7xX7g_!0k_e)U>lQ^$=^lYFQge-lo))ra54PL451duj?wkL)8wCH=6rB zS>o2B`!3whq`P$P&A4W=BX)6ZM=LO6qTSufze!$9EuRhL(h-S~Du1Wb{YamrH)%>G z@D=0CZTq=xVK7?LTxmq}ZS=jF(Ttp?5XmW34kzo3ZnX=(G*~^@vvHSsmA=z z@wLS**5#D`mI$cI{>_|OO{aLWPyxe2+#K+H%9k2oa0Dh3!oJvTts9T2e3RGK)$L5drM}*WzJeb zYJYTqX4WxaLr6g?D|1W}jMiXKflY^$+}loO%*tlMnx4Z)2)WcCRJ82YsG9WJ)J;|#$7|v0pxIX!f;BBU zp)Cwk^FV0B3VVn}>d%I04Ahe)h?ST4C;Pn%#grqUwMSVS!_D^Dh~{;K;Fn0}G^lX@ z8Mg1xWj~N+XZaZ|`KO*Tqi_21r+?B)SO-#J6^vZp7d$lNz4#m zdxnh_P(5dhr#+MVg&TT)h^svT>ghi8_mH!2M<~+bq70TH3x|Vm7xt2{*y9^~K|2+x z1J=s7yvbI63i1;R`l};!_3MN6w$7EMJhw rsJ=i6_E(y2Dctc2nV1x+XBsfn;^p9P8gn?`I>79#xp9dh@zMVPatWR> literal 0 HcmV?d00001 diff --git a/assets/android-chrome-512x512.png b/assets/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..8fed75f732d55db70de564991385b4ad481a7a32 GIT binary patch literal 13253 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 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 = + '' + + '
' + + '' + + '
' + + '' + + '
' + + '
' + + '' + + '' + + '查看原图' + + '
'; + 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(); // 图片是 ,有 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(); + } +})(); diff --git a/assets/apple-touch-icon.png b/assets/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e70cbc6d3b80dd2f22cf1806a55eba0f972fc2ec GIT binary patch literal 3471 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 T6Da(j6#!V6UcLCn*e&M&A)I`w literal 0 HcmV?d00001 diff --git a/assets/favicon-16x16.png b/assets/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..3e98350d4cf369d8e48fdc386d8ece727aa4939e GIT binary patch literal 337 zcmV-X0j~auP)l14%?dR5*=eU;u&e9X<@l(n&-?R9J=8m(NQAK@`Uy(I3#E?9`>dAp27y9Sct#41!Jtb}c(} z4La4)b3q-NS%^>}4}#XAA08~SQ(=Xf=xXM~Th||%ySCG~OJ8`f?CyK-GxKI==K*xe zueiWVH-XS|@X{g>#-$j>^0E{V7SeL`6rmmoz3Ujn>-`e#K?e z$h*qI-oSG_2928AoV*ROfzyBCAV3NM-W>xVtN>;&Os~ax?Bj0|2v>ORySq5k-n=A8?g%isQ41FG+%dI%V0HsQe zuY}V)uzP9AR>Nt{_s;;bBn(ik)%lK+TdQ!R!MueJ3>g3>WvVcp{S@%ueVI%KH~g1<$Ml zfDRG87XKUK$W4=zJ^)5Gu@uqPMpJl=_e5fWkP~5iJaEcG`x(vfS~Ly_JIXR};zZne zG6w8|oIRAXkaGZZ-MPku&^*i?jW)U?Y?JJ6%?O?O-X9_aE($wF{v&x)*sEc?L|9U8 i!=lAXYBg_7dqZEsV8sjh`bs4L0000WEC1|b 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 + + + + + + + + + + + + + + + diff --git a/assets/icon-128.png b/assets/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..67b25222dfdd3ca687ea9ac3186e8ecdedfb1d65 GIT binary patch literal 2132 zcmZ8jcR1UN8vZ3ALd9rWa?Ge2t%evyMTv-Ys*X|DsFB)Rik4Oow5o!pSJhrc&7#CO zdK5=>7_rips7R&9taY^nmtK9&{pa34-tYV4eZS}V*7H5zeOns~xR8_(003|+OM?B8 zr~CvAdSv^It_>bJL6W6QBmiJVegZVqR+bC^FsK#5zkdu1C5Ez?8iggH{_Lu|8vC5LpqF}wGp0UjN z*W&jtg7L%P8b-wE7`J$MjI5lF{_2m~s1!}IjWnrNlZny6R?MXL${^*}S1)hW?29iW z1?1v%bL`He-mC6_jmzi)FgLH4W!8&yiylYJA5BhS$;Rs2`j$5+{cng!TOvP!y0@Ur z)7sX78HW5}XI=DEFcVLJ52*9JH(hAXZ|1x)aMdg5x1kX2WAQ$N-l!&4jAE_<7Qwkr z;}P}{Msiy>Qis~*Q~Eor!ho{J`=#U7d8Tka^-E|Ty<%`~#Wn~vr)VH*)Mfmyj>f)A zjuX3S3g~sI#?uJ5Reomu3xR~F>z=e_Edijf1lth|w&H|vv%P~JSYx{4HqNvUopBG% z0PzBnM3U&gCMOm2R>k?z-@hf_%^kl4XY!|>h~gJ} zF6m6BPmsr+^p1AMekA+mvSJ~xRI)(vUYlb!5POEe>BrKIFbC;zBKA8_xRukEZ>tml z=M?UyVS1TKw5>D81#nkuO(49|o#tFZPPOOeg%t$-+droj|lzW{0bP8*);*fkJyF4))Dn=DuOc-(h*v6fl zTG<;mOPKbisM|3obY(uEKQDc5fd*eP7UwTFL8etd@>-E?bAQ#uCWlkZrZe+DXg1|`R~qo3laY&0 z0%ajocSp&ja6s-yOMw6%8+a!@>|#mOlaeY(4hPI{HVpuDq3w$w78A1uiYgwiWaO}E zEUkcu-#sK6t+0ZA*`UX@Qruo}j+HVI5=HShQrDJ4k@A>`CXfNHWaroPqoq`CeFz@~ znKSAzKdJJ0ry~ykWII1TXikJ{0?yGV&WX6YM`yrCe^K`g9}m7+%yjr5%k->o5aO?@ z$1c+EvmUFv={=q1$23Evdm?}x;3j19{}7^u6KhU%Z+`pL7hESSMi1dM>uAU3^_+$9 zp{4azhlf0rk7_X)gl=1Dx8w-cYvD$l6U)bk{+3sSa45oBf6Eo5^_8eU~5slkD`Hwicu(Q&Y21Z|*y!wZmAjTv4Q8LO;@9Bg0YgbNS;^VgQ{8e_Cg2yE-rQ^JN$ahk z0DIGqPcUnltuJg+_6LxsJf(a$EA85|e#Q~#+84H?hIXhR7F)zhV+(9`&6+h)M2^o=agOaR-aclV8g4b^Mm*lr@Puk7_M>} zRWGcSsIjMjDeA`Oksph7@SVfr^&r!%t?t*5J}7B{%KmpVqVxF@tTifx{330l*^^JU_?X!B9e0Y*n>8P znG{fAu#lXFsRY6Kia}z|!yw!xc=d4|zDEU(FvL(mQ;~4fKOvO1{0)nI2zlw6?%)COWcHv$qyuR_5q8BQrRUGH)36iX8)5{jfK0EB9PzRi9m^mf?TYC1R2txrSS~ZE~ z8Rh|-c3$xLeu{@d3aqN6);>f%@0kkn@hBwOb8h*oh{F4#zib_VS^mmiBc?h=Q+c#e zc4S#5JwEYON!bi`)XG9_+?rTZuCU6^kbu8zy{8e)6O|N;B0evmmL9zcfR&jIp#e`! F`VU-P-P-^F literal 0 HcmV?d00001 diff --git a/assets/icon-16.png b/assets/icon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..3e98350d4cf369d8e48fdc386d8ece727aa4939e GIT binary patch literal 337 zcmV-X0j~auP)l14%?dR5*=eU;u&e9X<@yq^Pl#Y*`a-;f`fuhAi1hT}x&f5m`!R zhC)O12xA-9Qb;1dv6Hz?;r1bp67GkbKduy=Q-zm-b9#d@09o@zj%p{kEu@ab<)C`ncH0J zT)YAvSAi`ZeGI@9;U+Pnv46xGEa5jU#%2Pwn-l_(aBgg=GxT#WVk)$uv1e$0l;{bN#arGIs@9el)3J3L4>oE9DHf4o4;-vde;TFRWUv+faU%gWkzx%5-# zH*-Ncsr{6BTWdS+u*Iw6yPrQk$ra9P%Ua?x)1+W3B@%X1nUB(uNC$u#MS$34*@&yv z{ZbO*`Wifooo6W8IDXBD?rE;Lmpa`0yLz(pT^nw68`SLS0gZ=Qcd1%Cn+(prp#`_& z`pjRQ+4$%tr}TDpMp|Vm%WlOsu!j|U5ZR;`i|Eg?`BZ!Mcv83QMPsJ-1;w|A5n(5v zzwq)o+jL9oN$j^adGP#A=rg*}J1J8g54l|C^5Sq7>W_(cK8L)u^9_cYqI%!{M6_L9 z>95p09BgHH(fIgpb*V+8az#_P_dUR)=1KvN}uOvB^vMWIeWbfvg7TxVs&6 z&q9)O>u^#+X0##l;j2yy5ze<4!EHE#=eUKg)N z8fn&$z{lMqq0W&btH{X_DT%|%kVu}e=lC7BGY!YAA@?<}`Jyutag|pHK_bLV4YW;U z&&SiK%`(TCxUd3A9r3v3sxX%a=IYqi)+%WMWk{wm^eoq0y$@C`j|Hg-c|a+*`Z^FY zQW8+U?lx3+miE(ZYMFFM<64g9uYQkK2$P-k!p&3hIyB+}ypvO8ZY>A4A|)D=qYcb^ z!y&Jx%$*i*yLnRo>G_!T<=M<<^Vme$q+4wt{lholvQuXcT!JczQlD!-%M=1hkd$=L zId{`k9epk!HPGxP12-|6l>0=yl7q{Omb34rw7`QP5VO@vS47zcYkJjF@mYHueYQaY z$4gp1#n7oXHp0BKw+<&JP_EU`Z+3;h&x9BSwyfO9l_weQ@__P2_Egvz%S~*P}mJk zHPo!kVUAse?5s+EnOPt>fr9>K)Gt43Ah4ht4f+BM#EIf|IUv`9+cCTLBU5Gm*m=-e z%VzcrJoFsM+dJU*D+}V!1~gvY7`CS3k$pSKJ+o#hU|=jF?1a8_!2w=OZ}K{)PHIu! z*wrRr$31d9r)B;@tk87yr=JnrVu)+!5!%JeWwHy-!yf8XY3qo)vY|cHKHuBp_V}yD zAV#>~K=0G@&JC`5D^|m(KD6H&5)7)~I_gfH^(wG$}KCt2d@59*Xe*)x2+c}5MmNV&2&xuP)_^1j9kK%AHXcKCA zt+3dEw(23r_MTIcxXNrON+T=Gk-o<}hZICbm`iDR!q~3urv{xz9SI`kb#>xV@LF9O zbb1L>lV7iDlgC>u?D)D5$PydTqXtNC=e_!;^T%#L*3x$wN%aagd1%Zer%i^cEbcHz z?I)X0W1iP=jnLzRS>A4F^o@fmeI|iv`QTyyzr*pn_83n_3*j%+Y6d@b!K?3hLm=N| z(mBYLX%`Not+&zcH=8KiW{z={1H#z54#Nz~rhIU#&!W^$dhEGLkMM1pzyU7XTCK=?OPOu) zT|OyYH%Mlu^}eCO)Q6C|(rO%$Q1L%kAKE>5a0Zwk`OZZB$k6zNOsUSI-{3j57%{!` z)GmQ9wc`^jmxD@bE{Fkg$JjtQKVjsFQs`4-ke0(QCh z#(dg^G4!3(;N6)b?cGdRC2BZeK!zbk>IT@N&-c?|DQ2asVO(D%NP@D@oB&fnjLS&? zKWC4X02%Cm!g^uUu6leUxq4_!p=&3H7dFP7h}ikEY4}gVP5tWfBw+eS!;-sJs23BX zU}Kcn$Ymd4mcB8qf8`GF919!w684HEIs77R zJiMNd@KDcH*~@1aS+w$*@rad zuBSn@Zvx&zf1{e_<0r@FtbQ(K0nN@tv>DuXq2}jwG$e*bDGwmL!_LTANi(q+>fG1>Cx4Hoyp0+twa1EnEe5%}InuX*lG z0ml99PiwC2mEVSnd{E_mnwFtXKl#7mX3tb@T(2J^I@Fsr8&44u2Xlh?7bjn`?XE(b(kA26EG zAJRPpWBYI<=aS^EoJfFq*>e&$sbYT{AcP_8&tu`9xq;`uQpN|d?Pa@t=OPok5<2M8 z_k>(^++POzFPiIxsA&$5^%qojp*kOQ*QZ6XTm6 zaD`YYc{lafU8Vdt_s~I`BjMC^-Zz5fjxw5&Kyg|)7#1ahMmWyH!t6QXzF2oP$7;S zzB|h0fe0WZ{YDMz^9=X(f-`Sc^|Y&!xKtjKB+6SnA&cf!$*(qchtHU7%oS~eE`gjn+jW4{qkzk&&KN~Q*BuJE|g`Ge|5RtgAq++Xnd9vbJ>;^-IyOny%T`0#$iPfbmp5Lwr+VBL`<=U9vZz zFj(mNTIh?1ml&8X{eucC;*?i%1V?GJmz-DcC;z$H!x_ungJwvQI8aIZ4WExLK#K4I z67;}PL9I)^SSq=P!~~8a1Ob?!hNy1}n}pSW4myCN#G%#?G+7>5lz%2$v93k+h zxo9+i>9x|m1n@ltdTQjW$uu^_81soiU$>%th+OYmTJ&V8iJ*jzWSkx(JmbYGG(+7Q zHK{Im33_r}*kJuCyc2^>H_Lfnpw^FQGfM}_p=4ODqWq`KA|_vTq=7~?Ei28a?@QJ4 z5f#Pa63OXws>V^obP=DWd=-!GPKG0q`eJH%Sl;v8L{AAK%WlR2t~AF-9U&bZH@FNNHtZqQn_XZSt4?; ztPiqag4*kL)kqQUFD;hEmG-budI<(M60rAinPfzNpR?E0_3LP-<==<*iQWB>Gf64} zt5Y@|I->ly&`v-{7I${$I;j;07FMw4JH^Y6*g*V*q&Qb3 zPSxc(N|1FP#XgYShrGM%rG58YQ4IHsCK@={x>)WQ?-NWH zYYI!HfTCQ$d7M#hxW&25Y^+aWp4C_vshCNfxfWpEW3{v`FS41=*x8m~&(G~q1J(S_ T6Da(j6#!V6UcLCn*e&M&A)I`w literal 0 HcmV?d00001 diff --git a/assets/icon-192.png b/assets/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..e84dffa16d6c0849574f826dbf8e67fee733fcfc GIT binary patch literal 3588 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 literal 0 HcmV?d00001 diff --git a/assets/icon-256.png b/assets/icon-256.png new file mode 100644 index 0000000000000000000000000000000000000000..cde97a1f9a62b1f7d8a5bd0314cb3bc9b664a1c4 GIT binary patch literal 4853 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 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} +} diff --git a/captcha.php b/captcha.php new file mode 100644 index 0000000..44d2617 --- /dev/null +++ b/captcha.php @@ -0,0 +1,12 @@ + true, 'q' => $q], JSON_UNESCAPED_UNICODE); diff --git a/config.example.php b/config.example.php new file mode 100644 index 0000000..ddfd973 --- /dev/null +++ b/config.example.php @@ -0,0 +1,167 @@ + __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, +]; diff --git a/cron.php b/cron.php new file mode 100644 index 0000000..5b37265 --- /dev/null +++ b/cron.php @@ -0,0 +1,92 @@ +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"; diff --git a/data/.htaccess b/data/.htaccess new file mode 100644 index 0000000..f74a59f --- /dev/null +++ b/data/.htaccess @@ -0,0 +1,8 @@ +# 拒绝一切对数据目录的直接访问(数据库文件在此) + + Require all denied + + + Order allow,deny + Deny from all + diff --git a/data/index.html b/data/index.html new file mode 100644 index 0000000..ba3bb47 --- /dev/null +++ b/data/index.html @@ -0,0 +1 @@ + diff --git a/index.php b/index.php new file mode 100644 index 0000000..d2861ae --- /dev/null +++ b/index.php @@ -0,0 +1,153 @@ +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('全部问题'); +?> + + + +
+
+ 全部 + $v): ?> + + +
+
+ + + + + +
+
+ + + + 1): ?> +
+ $p <= 1 ? null : $p]) ?: '?'); + }; + ?> + 1): ?> + 上一页 + + 第 / 页 · 共 个问题 + + 下一页 + +
+ 0): ?> +
共 个问题
+ + +\n" + . " \n Require all denied\n \n" + . " \n Order allow,deny\n Deny from all\n \n" + . "\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 接管: + * · 点击缩略图 → 页内放大(不跳转新标签页,仍是 所以禁用 JS 也能看) + * · ‹ › / ← → / 触摸左右滑 → 在同一帖的多个附件间切换 + * · 视频以「放大」按钮进入同一序列,方便逐条翻阅 + */ +function fb_attach_render(array $rows, array $opt = []) { + if (!$rows) { return ''; } + $compact = !empty($opt['compact']); + $html = ''; +} + +/** 附件展示名(回退为「图片/视频 + 序号」) */ +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; +} diff --git a/lib/bootstrap.php b/lib/bootstrap.php new file mode 100644 index 0000000..23d3d21 --- /dev/null +++ b/lib/bootstrap.php @@ -0,0 +1,95 @@ + 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(); diff --git a/lib/captcha.php b/lib/captcha.php new file mode 100644 index 0000000..555e675 --- /dev/null +++ b/lib/captcha.php @@ -0,0 +1,130 @@ + $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; +} diff --git a/lib/db.php b/lib/db.php new file mode 100644 index 0000000..c6bd377 --- /dev/null +++ b/lib/db.php @@ -0,0 +1,178 @@ + 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)'); +} diff --git a/lib/layout.php b/lib/layout.php new file mode 100644 index 0000000..caa3a0d --- /dev/null +++ b/lib/layout.php @@ -0,0 +1,134 @@ + '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); + ?> + + + + + + +<?php echo e($full); ?> + + + + + + + + + +
+ +
+
+
+
+

本站仅保存你提交的昵称、邮箱与内容,用于问题跟进;页面不会公开展示邮箱与手机号。

+

· 由 FeedLite 驱动

+
+
+ + +' . e($msg) . ''; +} + +/** + * 渲染附件上传控件(图片 / 视频,浏览器直传 OSS) + * 需置于
内部:会输出一个 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) : '', + ]; + } + ?> +
+ + +
+
+ + + tabindex="-1"> +
+
+
+ + + +
+= 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 '
' + . '

' . $safeName . ',你好:

' + . '

你在「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」提交的问题 《' . $safeTitle . '》 有了新的回复:

' + . '
' + . '
' . $safeRName . ' 回复:
' + . $rbody + . '
' + . '

查看并回复

' + . '

若按钮无法点击,请复制链接到浏览器打开:
' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '

' + . '
' + . '

此邮件由系统自动发送,请勿直接回复。

' + . '
'; +} + +/** + * 有人回复后,给发帖人发送通知邮件 + * @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 '
' + . '

' . $name . ',你好:

' + . '

感谢你反馈 《' . $title . '》。我们已经核实过,' + . '该情况并不是应用的问题,因此这条反馈已被关闭(不会公开显示)。

' + . '
' + . '
官方说明:
' + . $noteH + . '
' + . '

你当时反馈的内容:

' + . '
' + . $body + . '
' + . ($mail !== '' ? '

如果上面的说明没能解决你的疑问,可以直接回复本邮件,我们会继续跟进;' + . '也可以发邮件到 ' . $mail . ' 补充说明。

' : '

如果上面的说明没能解决你的疑问,可以直接回复本邮件,我们会继续跟进。

') + . '
' + . '

此邮件由「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」自动发送。

' + . '
'; +} + +/** + * 关闭问题(判定"不是问题")时,把说明邮件发给提问人 + * @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]; +} diff --git a/lib/oss.php b/lib/oss.php new file mode 100644 index 0000000..ead9984 --- /dev/null +++ b/lib/oss.php @@ -0,0 +1,492 @@ + 头,即使站点配置失误也无法被下载) */ +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 = "\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="。 + * @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'; +} diff --git a/lib/util.php b/lib/util.php new file mode 100644 index 0000000..111c0f4 --- /dev/null +++ b/lib/util.php @@ -0,0 +1,307 @@ +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) { /* 日志失败不影响主流程 */ } +} diff --git a/new.php b/new.php new file mode 100644 index 0000000..24e923b --- /dev/null +++ b/new.php @@ -0,0 +1,159 @@ + $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']); +?> + + + +
+

我要反馈

+

请描述你遇到的问题或想要的功能。提交后会先进入审核,确认为有效问题后才会公开显示;若经确认不是应用的问题,我们会把结论与说明通过邮件发送给你(请填写常用邮箱)。

+ + + + + + + + + + + + + + +
+ + +
+ + + + + +
+ +
+ +
+ +
+ + +

提示:本页面不会公开展示你的邮箱与手机号。请勿在内容中填写密码等敏感信息。可上传截图或录屏帮助说明问题;文件会直接传到对象存储,不占用本站服务器空间。

+
+ +prepare('SELECT * FROM threads WHERE id = ?'); +$st->execute([$id]); +$thread = $st->fetch(); +if (!$thread) { + http_response_code(404); + fb_layout_head('问题不存在'); + echo '

该问题不存在或已被删除。

返回问题列表

'; + 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 '
' + . '

提交成功,等待审核

' + . '

你的反馈已收到(编号 #' . (int)$thread['id'] . '),我们会先确认它是否为应用问题:

' + . '

· 确认为问题 —— 审核通过后会公开显示,其他人也能查看与回复;
' + . '· 确认不是问题 —— 我们会关闭它,并把结论与说明发送到你留下的邮箱。

' + . '

审核通常在 1~2 个工作日内完成,请留意邮箱。

' + . '

返回问题列表

' + . '
'; + fb_layout_foot(); + exit; + } + http_response_code(404); + fb_layout_head('问题不存在'); + echo '

该问题不存在或已被删除。

返回问题列表

'; + 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 '

该问题还在审核中,暂时不能回复。

返回问题列表

'; + 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)); +?> + + + +
+
+ + +

+ # +
+
+ + · + + · + 条回复 +
+
+ +
+ +
+

全部回复()

+ +

还没有回复,欢迎在下方参与讨论。

+ + +
+
+ + 官方答复 + · + +
+
+ true]); + } + ?> +
+ + +
+ +
+

我要回复

+ +
+ + + + + +
+ + +
+ + + + + +
+ +
+ + + + + +
+ +
+
+
+ + 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, +]); diff --git a/uploads/.htaccess b/uploads/.htaccess new file mode 100644 index 0000000..e7eff14 --- /dev/null +++ b/uploads/.htaccess @@ -0,0 +1,13 @@ +# 本机附件目录 +# 允许直接读取图片 / 视频,但禁止列出目录、禁止执行任何脚本 +Options -Indexes + + + + Require all denied + + + Order allow,deny + Deny from all + +