OSS 附件改为私有桶+内网Endpoint+服务器代理架构

背景:桶为私有、且只用内网 Endpoint(外网不可达),浏览器既无法直传、
也无法直连。原「浏览器直传 + 签名URL直连」两条路都失效,改为全站代理。

lib/oss.php:
- endpoint 自动转内网(*-internal),use_internal_endpoint 开关控制,内网走 http
- 移除浏览器直传凭证(PostObject),改为服务器端 PUT(流式 fb_oss_put_file)
- 新增 fb_oss_stream_get 流式读取(回调式,支持 Range,不整文件进内存)
- 签名支持 x-oss-process 子资源 + Authorization 头签名(Content-Type 入签)
- 新增 fb_oss_probe 连通性自检、fb_oss_error_text 错误归因(含跨地域提示)

media.php(新增): 附件代理输出,鉴权边界=对象键须在本站附件表(或本人待入库
  登记)且帖子对访客可见,防止本站沦为能读整个共用桶的开放代理;透传 Range。

upload.php: local/oss 统一走两步(领键→action=put),OSS 模式由服务器代理 PUT。
lib/db.php: 新增 pending_attachments 表 + idx_att_key 索引(迁移幂等自动建)。
lib/attach.php: 待入库登记(mark/clear/mine/gc),解决上传后入库前的预览鉴权。
cron.php: 清理过期待入库登记 + 桶连通性自检输出。
admin.php: 设置页加「使用内网 Endpoint」开关、存储体检显示实际连接地址与探测按钮。
README/config: 全面更新为代理架构(无需CORS、无需自定义域名、同地域要求)。

验证:本地 e2e 25/25 + 附件专项 13/13(含防开放代理/Range/内网改写);
线上真机上传→内网PUT→media.php代理读回→Range 全通过。
This commit is contained in:
2026-09-28 22:12:28 +08:00
parent cede838b5e
commit 1401ad899b
12 changed files with 866 additions and 246 deletions
+71 -54
View File
@@ -9,7 +9,7 @@
- 纯 PHP + SQLite,**无任何第三方依赖**(不需要 Composer、不需要 MySQL) - 纯 PHP + SQLite,**无任何第三方依赖**(不需要 Composer、不需要 MySQL)
- 自带**趣味问答**人机验证(防机器人)+ 蜜罐字段 + 提交耗时校验 + 按 IP 频率限制 - 自带**趣味问答**人机验证(防机器人)+ 蜜罐字段 + 提交耗时校验 + 按 IP 频率限制
- 支持**图片 / 视频附件**:浏览器直传阿里云 OSS,**不占用服务器磁盘与带宽**(不配置也可正常运行,只是不显示上传入口) - 支持**图片 / 视频附件**:可存本机磁盘,或存**私有**阿里云 OSS 桶(走**内网 Endpoint + 服务器代理**,公网无法直连桶);不配置也可正常运行,只是不显示上传入口
- 自带只读 **API / Markdown 摘要**接口,方便自动化脚本或 AI 读取问题并分析 - 自带只读 **API / Markdown 摘要**接口,方便自动化脚本或 AI 读取问题并分析
- 上手即用:clone 下来直接部署,无需建库、无需装依赖(含深色模式与响应式布局) - 上手即用:clone 下来直接部署,无需建库、无需装依赖(含深色模式与响应式布局)
@@ -25,7 +25,8 @@ feedback/
├─ thread.php 问题详情 + 回复(含"官方答复"与状态管理、附件展示) ├─ thread.php 问题详情 + 回复(含"官方答复"与状态管理、附件展示)
├─ new.php 发布新问题(含图片 / 视频上传) ├─ new.php 发布新问题(含图片 / 视频上传)
├─ admin.php 管理后台(口令登录:审核 / 管理全部问题 / 站点设置 / 操作日志) ├─ admin.php 管理后台(口令登录:审核 / 管理全部问题 / 站点设置 / 操作日志)
├─ upload.php 附件上传接口(OSS 直传下发凭证;本机模式下接收文件本体) ├─ upload.php 附件上传接口(两步:领对象键 → 收文件本体,local 落盘 / OSS 代理 PUT)
├─ media.php 附件代理输出(私有桶 + 内网 Endpoint 下图片/视频的唯一对外读取通道,带鉴权与 Range)
├─ captcha.php "换一题"用的人机验证刷新接口(返回 JSON) ├─ captcha.php "换一题"用的人机验证刷新接口(返回 JSON)
├─ api.php 只读数据接口(需令牌),供自动化 / AI 读取 ├─ api.php 只读数据接口(需令牌),供自动化 / AI 读取
├─ cron.php 附件过期清理 + 通知邮件补发(配计划任务) ├─ cron.php 附件过期清理 + 通知邮件补发(配计划任务)
@@ -227,7 +228,7 @@ php /www/wwwroot/你的域名/feedback/cron.php
|---|---|---|---| |---|---|---|---|
| **关闭** | — | 暂时不接受附件 | 上传入口自动隐藏,其余功能照常 | | **关闭** | — | 暂时不接受附件 | 上传入口自动隐藏,其余功能照常 |
| **本机硬盘** | 站点目录 `feedback/uploads/YYYY/MM/` | 想零配置、立刻可用;量小、访问不密集 | 占用服务器磁盘与带宽;需保证目录对 PHP 可写 | | **本机硬盘** | 站点目录 `feedback/uploads/YYYY/MM/` | 想零配置、立刻可用;量小、访问不密集 | 占用服务器磁盘与带宽;需保证目录对 PHP 可写 |
| **阿里云 OSS** | 你自己的 Bucket(浏览器直传) | 量大、正式对外 | 需先建桶并授权;会有少量 OSS 费用 | | **阿里云 OSS** | 你自己的**私有** Bucket(经服务器代理读写,走内网 Endpoint) | 量大、正式对外;希望桶私有、公网不可直连 | 附件流量经服务器中转(占带宽);需先建**同地域**桶并授权 |
> **切换不会让旧附件失效**:每条附件走哪种存储,由它自己的对象键前缀决定—— > **切换不会让旧附件失效**:每条附件走哪种存储,由它自己的对象键前缀决定——
> `local/…` 一律按本机文件读,`fb/…`(或你配的 `oss.prefix`)一律按 OSS 读。 > `local/…` 一律按本机文件读,`fb/…`(或你配的 `oss.prefix`)一律按 OSS 读。
@@ -244,15 +245,27 @@ php /www/wwwroot/你的域名/feedback/cron.php
--- ---
### 5.1 OSS 直传的原理与开通(约 15 分钟) ### 5.1 OSS 代理架构的原理与开通(约 15 分钟)
**为什么这样设计**:如果让用户把文件传到本站服务器,硬盘和带宽会被迅速吃满。所以本程序让浏览器 **架构:私有桶 + 内网 Endpoint + 服务器代理**
**直接把文件 POST 到 OSS**,服务器只做两件事:
1. 下发一段**有时效、有范围**的上传凭证(PostObject 策略签名 + HMAC-SHA1); 本程序把 OSS 桶设为**私有**,且只使用**内网 Endpoint**(`*-internal.aliyuncs.com`,只有同地域的阿里云 ECS 能连)。
2. 提交表单时用 HEAD **回源复核**对象确实存在、大小与类型合规,再把元信息写进 SQLite。 这样一来,浏览器**既拿不到可直连的 URL,也连不上内网地址**,所以附件的读写都经本站服务器中转:
文件实体永远不落在本站磁盘上(选 OSS 模式时)。 ```
上传:浏览器 → upload.php(收文件) → 服务器用临时凭证 PUT 到内网 OSS
读取:浏览器 → media.php(鉴权 + Range) → 服务器 GET 内网 OSS 并流式转发
```
**为什么这样设计**
- **桶可以彻底私有**:公网完全无法直连,AccessKey / 签名永不出服务器,`media.php` 只在「对象属于本站且帖子对当前访客可见」时才转发。
- **只走内网 Endpoint**:不暴露公网地址,内网访问**免流量费、延迟更低**。
- **代价**:附件流量经过服务器(占带宽),且服务器磁盘临时空间要能放下单个上传文件(PHP 会先落临时文件再 PUT)。若你的量特别大、又希望省服务器带宽,可改用「公共读桶 + 自定义域名」的经典直连方案(本程序不再内置该路径)。
> ⚠️ **内网 Endpoint 跨地域不通**!ECS 在哪个地域,桶就必须建在哪个地域。
> 例如 ECS 在 `cn-qingdao`,桶却在 `cn-beijing`,则 `oss-cn-beijing-internal` 完全连不上(连接超时)。
> 后台「存储体检 → 测一下桶连通性」会真实探测并提示;跨地域时要么把桶换到同地域,要么在后台关掉「使用内网 Endpoint」回落公网。
### 5.1a 鉴权方式:两种,后台可选 ### 5.1a 鉴权方式:两种,后台可选
@@ -265,7 +278,7 @@ php /www/wwwroot/你的域名/feedback/cron.php
> 「读取实例 RAM 角色失败(HTTP 404)」——后台「存储体检」会直接报出来。 > 「读取实例 RAM 角色失败(HTTP 404)」——后台「存储体检」会直接报出来。
> 到 ECS 控制台看实例详情里的「RAM 角色名」,或访问 > 到 ECS 控制台看实例详情里的「RAM 角色名」,或访问
> `http://100.100.100.200/latest/meta-data/ram/security-credentials/` 看可用角色列表。 > `http://100.100.100.200/latest/meta-data/ram/security-credentials/` 看可用角色列表。
> **这个角色必须授予该 Bucket 的读写权限**(至少 `oss:PostObject` + `oss:GetObject` + `oss:DeleteObject`)。 > **这个角色必须授予该 Bucket 的读写权限**(至少 `oss:PutObject` + `oss:GetObject` + `oss:DeleteObject`)。
### 5.1b 开通步骤 ### 5.1b 开通步骤
@@ -293,8 +306,8 @@ AccessKeyId / AccessKeySecret / SecurityToken / Expiration
| 项 | 建议值 | | 项 | 建议值 |
|---|---| |---|---|
| 地域 | 选离你 ECS 近的(如华东1 杭州、华北2 北京) | | 地域 | ⚠️ **必须与 ECS 同地域**(内网 Endpoint 跨地域不通);ECS 在哪个地域就选哪个 |
| 读写权限 | **公共读**(简单);想更严格选**私有**,程序会自动为展示链接签名 | | 读写权限 | **私有**(本程序经 `media.php` 代理读取,桶无需公共读;私有最安全) |
| 存储类型 | 标准存储(低频访问会额外产生取回费用,不建议) | | 存储类型 | 标准存储(低频访问会额外产生取回费用,不建议) |
**第 2 步:建一条最小权限的 RAM 策略**(RAM 控制台 → 权限策略 → 创建策略 → 脚本编辑) **第 2 步:建一条最小权限的 RAM 策略**(RAM 控制台 → 权限策略 → 创建策略 → 脚本编辑)
@@ -351,34 +364,33 @@ curl -s http://100.100.100.200/latest/meta-data/ram/security-credentials/FeedLit
返回 `404` / `Forbidden` / 连不通 → 说明**角色名写错**或**角色没授予实例**,先解决再往下走。 返回 `404` / `Forbidden` / 连不通 → 说明**角色名写错**或**角色没授予实例**,先解决再往下走。
(服务器上没有 curl 也没关系,程序自带体检,见第 8 步。) (服务器上没有 curl 也没关系,程序自带体检,见第 8 步。)
**第 6 步:配置 Bucket 的 CORS**(Bucket → 数据安全 → 跨域设置 → 创建规则) **第 6 步:无需配置 CORS,也无需绑定自定义域名**
浏览器是**跨域**把文件直接 POST 到 OSS 的,必须放行: 代理架构下浏览器**不直接访问 OSS**(既不同域、也不碰公网 Endpoint),所以:
| 项 | 填什么 | - **不用配 CORS**:不存在浏览器跨域 POST 到 OSS 的环节;
|---|---| - **不用绑自定义域名**:附件一律经本站 `media.php` 输出,图片点开是**预览**而非下载,不受 OSS 默认域名的 `Content-Disposition` 影响。
| 来源 Sources | `https://你的域名`(你的站点域名,可多行) |
| 允许方法 Methods | 勾 **POST、GET、HEAD** |
| 允许 Headers | `*` |
| 暴露 Headers | `ETag` |
**第 7 步:填写 `config.php` 的 `oss` 段** (这两件事在旧的「浏览器直传 + 公共读」方案里是必需的,代理方案把它们都省掉了。)
**第 7 步:填写 `config.php` 的 `oss` 段(或在后台「站点设置」里填)**
```php ```php
'oss' => [ 'oss' => [
'enabled' => true, 'enabled' => true,
'endpoint' => 'oss-cn-hangzhou.aliyuncs.com', // 不含 http:// 'endpoint' => 'oss-cn-qingdao.aliyuncs.com', // 不含 http://;填公网形式即可,程序自动转内网
'bucket' => '你的bucket名', 'bucket' => '你的bucket名',
'instance_role' => 'FeedLiteOssRole', // ★ 第 3 步的角色名 'instance_role' => 'FeedLiteOssRole', // ★ 第 3 步的角色名
'use_internal_endpoint' => true, // ★ 走内网(同地域必须开;跨地域不可达时改 false)
'prefix' => 'fb', // 对象键前缀,多应用共用一个桶时用它隔离
// 以下是可选增强,一般保持默认即可 // 以下是可选增强,一般保持默认即可
// 'metadata_v2' => true, // 加固模式:读元数据前先取一次性 token // 'metadata_v2' => true, // 加固模式:读元数据前先取一次性 token
// 'domain' => 'https://fb-img.example.com', // 自定义访问域名(见第 9 步)
// 'visibility' => 'private', // 私有 Bucket 改成 private
], ],
``` ```
注意:**这里没有 `access_key` / `secret_key`** —— 真的不需要。 注意:**这里没有 `access_key` / `secret_key`** —— 实例角色鉴权真的不需要。
(若站点不在阿里云 ECS 上,才改用后台的「静态 AccessKey」鉴权方式。)
**第 8 步:跑一次体检** **第 8 步:跑一次体检**
@@ -394,44 +406,45 @@ php cron.php
``` ```
附件:共 0 个,已清理超过 365 天的附件 0 个(剩余 0 个,占用 0 B)。 附件:共 0 个,已清理超过 365 天的附件 0 个(剩余 0 个,占用 0 B)。
附件存储凭证:ECS 实例 RAM 角色(AK 尾号 …abc123),已取得临时安全令牌,有效期至 2026-09-15 14:20。 附件存储凭证:ECS 实例 RAM 角色(AK 尾号 …abc123),已取得临时安全令牌,有效期至 2026-09-15 14:20。
桶连通性:✅ http://你的bucket.oss-cn-qingdao-internal.aliyuncs.com(内网 Endpoint,HTTP 404)— 连通正常。
``` ```
- 出现「**附件存储凭证:…**」且尾号、有效期都有 → 配置成功,去发帖页试传一张图即可。 - 「**附件存储凭证**」有尾号、有效期,且「**桶连通性:✅**」→ 配置成功,去发帖页试传一张图即可。
- 出现「**附件存储未启用(请在 config.php 的 `oss` 段填写 endpoint / bucket / instance_role 并置 `enabled = true`)**」→ 说明第 7 步**没保存成功**:回去检查 `enabled` 是不是 `true`、`instance_role` 是不是填了角色名。 - 「桶连通性:❌ … HTTP 0」→ 连不上桶:多半是**桶与 ECS 不同地域**(内网不可达),换同地域的桶,或后台关掉「使用内网 Endpoint」。
- 出现 `⚠️ 取不到凭证:…` → 按提示回到第 4 / 5 步排查。 - 「附件存储未启用(…)」→ 第 7 步没保存成功:检查 `enabled` 是否 `true`、`instance_role` 是否填了角色名。
- `⚠️ 取不到凭证:…` → 按提示回到第 4 / 5 步排查。
**第 9 步(强烈推荐):绑定自定义域名** **第 9 步:无需绑定自定义域名**
Bucket → 传输管理 → 域名管理 → 绑定域名。不绑也能用,但 OSS 默认域名会带 代理架构下附件经 `media.php` 输出,图片点开是预览而非下载,**不需要**再给桶绑自定义域名。
`Content-Disposition: attachment`,图片点开是**下载**而不是预览。 (`oss.domain` 配置项保留但已不参与附件读取,可留空。)
绑定后把域名填到 `oss.domain`(如 `https://fb-img.example.com`)。
> 控制台会给出一个 CNAME 目标,去 DNS 加一条 CNAME 记录;若走 HTTPS,还需要在 OSS 上传该域名的证书。
--- ---
#### 可调项(都在 `config.php` 的 `oss` 段) #### 可调项(都在 `config.php` 的 `oss` 段,或后台「站点设置」)
| 参数 | 默认 | 说明 | | 参数 | 默认 | 说明 |
|---|---|---| |---|---|---|
| `instance_role` | 空 | ★ 必填:ECS 实例 RAM 角色名 | | `instance_role` | 空 | ★ 必填(实例角色鉴权时):ECS 实例 RAM 角色名 |
| `use_internal_endpoint` | `true` | ★ 走内网 Endpoint(自动把 endpoint 改写成 `*-internal`)。**仅同地域可用**;跨地域必须关掉 |
| `metadata_url` | 空 = `http://100.100.100.200` | 元数据服务地址,只有联调/自测才需要改 | | `metadata_url` | 空 = `http://100.100.100.200` | 元数据服务地址,只有联调/自测才需要改 |
| `metadata_v2` | `false` | `true` = **加固模式**:先取一次性 token 再读元数据(防被 SSRF 探测) | | `metadata_v2` | `false` | `true` = **加固模式**:先取一次性 token 再读元数据(防被 SSRF 探测) |
| `metadata_ttl` | `600` | 加固模式 token 的有效期(秒,最长 21600) | | `metadata_ttl` | `600` | 加固模式 token 的有效期(秒,最长 21600) |
| `credential_cache` | 空 = 与数据库同目录的 `oss.sts.php` | 凭证缓存文件(含 `<?php exit; ?>` 头 + 权限 0600,站点配置失误也无法被下载) | | `credential_cache` | 空 = 与数据库同目录的 `oss.sts.php` | 凭证缓存文件(含 `<?php exit; ?>` 头 + 权限 0600,站点配置失误也无法被下载) |
| `credential_refresh_margin` | `300` | 到期前多少秒就提前换新,避免恰好在过期瞬间用旧凭证 | | `credential_refresh_margin` | `300` | 到期前多少秒就提前换新,避免恰好在过期瞬间用旧凭证 |
| `sign_ttl` | `600` | 上传凭证有效期(秒) | | `prefix` | `fb` | 对象键前缀:`fb/2026/09/xxxx.jpg`。**多应用共用一个桶时用它隔离**(各自用不同前缀 + RAM 策略按前缀授权) |
| `visibility` | `public` | 改成 `private` 时,展示链接自动带上签名 | | `image_process` | `true` | 缩略图借用 OSS 图片处理(服务端缩放后再转发,省下行流量;GIF 自动跳过以保留动画) |
| `image_process` | `true` | 列表缩略图借用 OSS 图片处理,省流量 | | `verify` | `true` | 提交表单时 HEAD 回源复核(存在性/大小/类型),防伪造 |
#### 程序做了哪些防呆 #### 程序做了哪些防呆
- **不会每个请求都问元数据服务**:临时凭证有**进程内记忆 + 本地文件缓存**,到 `credential_refresh_margin` 才换新。 - **不会每个请求都问元数据服务**:临时凭证有**进程内记忆 + 本地文件缓存**,到 `credential_refresh_margin` 才换新。
- **上传策略有效期会被夹在临时凭证有效期之内** —— 否则 OSS 会因为「策略比凭证晚到期」而直接拒签。 - **`media.php` 有鉴权边界**:对象键必须在本站附件表(或本人刚上传的待入库登记)且帖子对当前访客可见,否则 403 —— **防止本站沦为「能读整个桶」的开放代理**(共用桶时尤其致命)。
- **`security-token` 会自动随请求发出**:PostObject 表单里多一个 `x-oss-security-token` 域; - **上传成功先登记「待入库」**:解决「上传完成但还没提交表单」期间缩略图预览的鉴权(登记表 2 小时过期,`cron.php` 清理;提交入库后自动转正)。
HEAD / DELETE 的签名 URL 上也会带 `security-token`。**它不参与签名计算**,只是让 OSS 认出这是谁的临时身份。 - **`security-token` 自动随请求发出且参与签名**:HEAD / DELETE / GET 的签名 URL、PUT 的 Authorization 头都会带上并算进签名(漏算会 403 `SignatureDoesNotMatch`,见 `lib/oss.php` 注释)。
- **内网 Endpoint 自动走 http**:内网域名的证书是公网域名签发的,https 会校验失败;内网链路由 VPC 隔离保证安全。
- **取不到凭证时绝不返回"半截"凭证**,而是明确报错;前端也不会显示出上传入口。 - **取不到凭证时绝不返回"半截"凭证**,而是明确报错;前端也不会显示出上传入口。
- 提交表单时仍会 HEAD **回源复核**(存在性 / 大小 / 类型),防伪造、防超限。 - **代理全程流式**:上传 PUT 与读取 GET 都不把整个文件读进内存,50MB 视频在 `memory_limit=128M` 下也安全;读取透传 `Range`,视频可拖动进度条。
#### 常见问题 #### 常见问题
@@ -439,11 +452,12 @@ Bucket → 传输管理 → 域名管理 → 绑定域名。不绑也能用,
|---|---| |---|---|
| `⚠️ 取不到凭证:读取实例 RAM 角色失败(HTTP 404)` | 角色名写错,或角色没授予该实例 | | `⚠️ 取不到凭证:读取实例 RAM 角色失败(HTTP 404)` | 角色名写错,或角色没授予该实例 |
| `⚠️ 取不到凭证:…(HTTP 0)` | 元数据服务不通:安全组/网络策略挡住了 `100.100.100.200`,或系统设了 `http_proxy` | | `⚠️ 取不到凭证:…(HTTP 0)` | 元数据服务不通:安全组/网络策略挡住了 `100.100.100.200`,或系统设了 `http_proxy` |
| 上传时浏览器报 CORS | 第 6 步的跨域规则没配,或来源域名写错 | | 桶连通性 ❌(HTTP 0) | **桶与 ECS 不同地域**,内网 Endpoint 不可达 → 换同地域的桶,或后台关掉「使用内网 Endpoint」 |
| 上传返回 403 | 角色策略与 Bucket 不匹配(至少要 `oss:PutObject`;私有 Bucket 还需 `GetObject`/`DeleteObject`) | | 上传返回 403 / AccessDenied | 角色策略与 Bucket(或前缀)不匹配:至少要 `oss:PutObject`,读取还需 `oss:GetObject`、清理需 `oss:DeleteObject` |
| 图片点开变成下载 | 没绑自定义域名(见第 9 步) | | 上传返回 502「写入对象存储失败」 | 看具体报错:SignatureDoesNotMatch → 校准服务器 NTP 时间;AccessDenied → RAM 授权;HTTP 0 → 地域/网络 |
| `SignatureDoesNotMatch` | ① 服务器系统时间不准(签名带时间戳),校准 NTP;② **使用了临时凭证但 `security-token` 没算进签名** —— 这是本程序修过的一个真实缺陷,见 `lib/oss.php` 里 `fb_oss_sign_v1()` 的注释 | | 图片/视频显示 403「无权访问该附件」 | 该附件不属于任何公开帖(先审后显未通过),或对象键不是本站登记的 —— 属正常拦截 |
| 站点不在阿里云 ECS 上 | 改用「静态 AccessKey」鉴权(后台可切),或走本机硬盘模式 | | `SignatureDoesNotMatch` | ① 服务器系统时间不准(签名带时间戳),校准 NTP;② **使用了临时凭证但 `security-token` 没算进签名**、或带 body 的 PUT 没把 Content-Type 算进签名 —— 见 `lib/oss.php` 里 `fb_oss_sign_v1()` / `fb_oss_auth_headers()` 的注释 |
| 站点不在阿里云 ECS 上 | 改用「静态 AccessKey」鉴权(后台可切),内网开关关掉走公网 Endpoint;或干脆用本机硬盘模式 |
### 5.2 限额与清理 ### 5.2 限额与清理
@@ -548,7 +562,7 @@ curl "https://你的域名/feedback/api.php?token=XXX&action=digest&status=open"
- 全部输出经过 HTML 转义,SQL 全部使用预处理语句。 - 全部输出经过 HTML 转义,SQL 全部使用预处理语句。
- 人机验证采用一次性会话题目,答对即焚;答错 5 次自动换题。 - 人机验证采用一次性会话题目,答对即焚;答错 5 次自动换题。
- 另有:蜜罐字段、最短填写耗时校验、按 IP 的每小时发帖/回复上限、CSRF 令牌。 - 另有:蜜罐字段、最短填写耗时校验、按 IP 的每小时发帖/回复上限、CSRF 令牌。
- 附件不经过本站服务器;服务器上**不保存任何 OSS 长期密钥**,改用 ECS 实例 RAM 角色的临时凭证(见 5.1)。 - OSS 附件走**私有桶 + 内网 Endpoint + 服务器代理**:公网无法直连桶,读取经 `media.php` 鉴权(对象必须属于本站且帖子对当前访客可见,**防开放代理**);服务器上**不保存任何 OSS 长期密钥**,改用 ECS 实例 RAM 角色的临时凭证(见 5.1)。
- `data/` 目录用 `.htaccess` 拒绝一切直接访问;请勿删除该文件。 - `data/` 目录用 `.htaccess` 拒绝一切直接访问;请勿删除该文件。
### 更换管理员口令 / 令牌 ### 更换管理员口令 / 令牌
@@ -658,14 +672,17 @@ A:默认在 `data/feedback.db`(及其 `-wal`/`-shm` 文件)。定期打包
**Q:看不到上传按钮?** **Q:看不到上传按钮?**
A:说明 OSS 未配置或 `oss.enabled` 为 `false`。填好 `config.php` 的 `oss` 段(`endpoint` / `bucket` / `instance_role`)并改为 `true` 即可。 A:说明 OSS 未配置或 `oss.enabled` 为 `false`。填好 `config.php` 的 `oss` 段(`endpoint` / `bucket` / `instance_role`)并改为 `true` 即可。
**Q:上传失败提示"上传到存储失败(403)"?** **Q:上传失败提示"写入对象存储失败"?**
A:三种常见原因: A:三种常见原因:
1. CORS 未配置或来源域名不匹配 → 回到 Bucket「跨域设置」补上你的站点域名,方法勾 POST; 1. **桶与 ECS 不同地域** → 内网 Endpoint 不可达(报 HTTP 0 / 连接超时):换同地域的桶,或后台关掉「使用内网 Endpoint」;
2. 角色策略与 Bucket 不匹配 → 至少要有 `oss:PutObject`(私有 Bucket 还需 `GetObject` / `DeleteObject`); 2. 角色策略与 Bucket(或前缀)不匹配 → 至少要有 `oss:PutObject`(读取还需 `oss:GetObject`、清理需 `oss:DeleteObject`);
3. 取不到临时凭证 → 跑 `php cron.php` 看体检输出(多半是角色名写错或角色没授予实例)。 3. 取不到临时凭证 → 跑 `php cron.php` 看体检输出(多半是角色名写错或角色没授予实例)。
**Q:怎么确认临时凭证是好的?** **Q:怎么确认临时凭证和桶是好的?**
A:`php cron.php --token=你的api_token`,会打印一行「附件存储凭证:ECS 实例 RAM 角色(AK 尾号 …),已取得临时安全令牌,有效期至 …」。没有这行或带 ⚠️ 就是有问题。 A:`php cron.php`(命令行方式不需 token),会打印「附件存储凭证:ECS 实例 RAM 角色(AK 尾号 …)…」和「桶连通性:✅ http://你的bucket.oss-cn-xxx-internal.aliyuncs.com …— 连通正常」。两行都正常就没问题;带 ❌ 或 ⚠️ 按提示排查。
**Q:图片/视频打不开,提示"无权访问该附件(403)"?**
A:这是 `media.php` 的正常拦截——该附件所属的帖子还没通过审核(先审后显),或对象键不是本站登记的。审核通过后即可正常显示;管理员登录状态下始终可见。
**Q:提示"附件存储空间已满"?** **Q:提示"附件存储空间已满"?**
A:全站附件总量达到 `upload.total_cap_bytes`(默认 1GB)。可在 OSS 控制台清理,或调大该值。 A:全站附件总量达到 `upload.total_cap_bytes`(默认 1GB)。可在 OSS 控制台清理,或调大该值。
+23 -13
View File
@@ -272,6 +272,7 @@ function fb_admin_save_settings() {
fb_set_setting('oss.url_scheme', ((string)$post('oss.url_scheme', 'https')) === 'http' ? 'http' : 'https'); 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.verify', $post('oss.verify') ? 1 : 0);
fb_set_setting('oss.image_process', $post('oss.image_process') ? 1 : 0); fb_set_setting('oss.image_process', $post('oss.image_process') ? 1 : 0);
fb_set_setting('oss.use_internal_endpoint', $post('oss.use_internal_endpoint') ? 1 : 0);
fb_set_setting('moderation.enabled', $post('moderation.enabled') ? 1 : 0); fb_set_setting('moderation.enabled', $post('moderation.enabled') ? 1 : 0);
fb_mod_log('settings', null, '保存站点设置:存储=' . $storage); fb_mod_log('settings', null, '保存站点设置:存储=' . $storage);
@@ -549,9 +550,9 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<select name="attachment_storage"> <select name="attachment_storage">
<option value="off"<?php echo $mode === 'off' ? ' selected' : ''; ?>>关闭(不开放上传)</option> <option value="off"<?php echo $mode === 'off' ? ' selected' : ''; ?>>关闭(不开放上传)</option>
<option value="local"<?php echo $mode === 'local' ? ' selected' : ''; ?>>本机硬盘(站点 uploads/ 目录)</option> <option value="local"<?php echo $mode === 'local' ? ' selected' : ''; ?>>本机硬盘(站点 uploads/ 目录)</option>
<option value="oss"<?php echo $mode === 'oss' ? ' selected' : ''; ?>>阿里云 OSS(浏览器直传)</option> <option value="oss"<?php echo $mode === 'oss' ? ' selected' : ''; ?>>阿里云 OSS(私有桶 · 服务器代理)</option>
</select> </select>
<i class="hint">本机:文件落在站点目录,占用服务器磁盘与带宽,但零配置、立刻可用;OSS:文件不经过服务器,需先建桶并授权。</i> <i class="hint">本机:文件落在站点 uploads/ 目录;OSS:文件经服务器代理写入私有桶(走内网 Endpoint),公网无法直连桶。两种方式都需要服务器磁盘能放下单个上传文件。</i>
</label> </label>
<div class="grid2"> <div class="grid2">
@@ -574,9 +575,10 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
</div> </div>
<h2 style="margin:24px 0 10px">阿里云 OSS(选「OSS」时才需要填)</h2> <h2 style="margin:24px 0 10px">阿里云 OSS(选「OSS」时才需要填)</h2>
<p class="muted small" style="margin:-4px 0 12px">私有桶 + 服务器代理:浏览器不直连桶,附件读写都经本站 upload.php / media.php 中转。Endpoint 填公网或内网形式都可以,程序按下方开关自动转换。</p>
<div class="grid2"> <div class="grid2">
<label class="field"><span>Endpoint</span> <label class="field"><span>Endpoint</span>
<input type="text" name="oss_endpoint" placeholder="oss-cn-beijing.aliyuncs.com" value="<?php echo e((string)fb_cfg('oss.endpoint', '')); ?>"></label> <input type="text" name="oss_endpoint" placeholder="oss-cn-qingdao.aliyuncs.com" value="<?php echo e((string)fb_cfg('oss.endpoint', '')); ?>"></label>
<label class="field"><span>Bucket 名</span> <label class="field"><span>Bucket 名</span>
<input type="text" name="oss_bucket" value="<?php echo e((string)fb_cfg('oss.bucket', '')); ?>"></label> <input type="text" name="oss_bucket" value="<?php echo e((string)fb_cfg('oss.bucket', '')); ?>"></label>
<label class="field"><span>鉴权方式</span> <label class="field"><span>鉴权方式</span>
@@ -591,23 +593,19 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<label class="field"><span>AccessKeySecret(留空=不修改)</span> <label class="field"><span>AccessKeySecret(留空=不修改)</span>
<input type="password" name="oss_secret_key" autocomplete="new-password" <input type="password" name="oss_secret_key" autocomplete="new-password"
placeholder="<?php echo (string)fb_cfg('oss.secret_key', '') !== '' ? '已设置,留空保持不变' : '未设置'; ?>"></label> placeholder="<?php echo (string)fb_cfg('oss.secret_key', '') !== '' ? '已设置,留空保持不变' : '未设置'; ?>"></label>
<label class="field"><span>自定义访问域名(可选)</span>
<input type="text" name="oss_domain" placeholder="https://fb-img.example.com" value="<?php echo e((string)fb_cfg('oss.domain', '')); ?>"></label>
<label class="field"><span>对象键前缀</span> <label class="field"><span>对象键前缀</span>
<input type="text" name="oss_prefix" value="<?php echo e((string)fb_cfg('oss.prefix', 'fb')); ?>"></label> <input type="text" name="oss_prefix" value="<?php echo e((string)fb_cfg('oss.prefix', 'fb')); ?>"></label>
<label class="field"><span>桶读写权限</span> <label class="field"><span>公网访问协议(走公网 Endpoint 时)</span>
<select name="oss_visibility">
<option value="public"<?php echo ((string)fb_cfg('oss.visibility', 'public')) === 'public' ? ' selected' : ''; ?>>公共读</option>
<option value="private"<?php echo ((string)fb_cfg('oss.visibility', 'public')) === 'private' ? ' selected' : ''; ?>>私有(展示时签名)</option>
</select></label>
<label class="field"><span>访问协议</span>
<select name="oss_url_scheme"> <select name="oss_url_scheme">
<option value="https"<?php echo ((string)fb_cfg('oss.url_scheme', 'https')) === 'https' ? ' selected' : ''; ?>>https</option> <option value="https"<?php echo ((string)fb_cfg('oss.url_scheme', 'https')) === 'https' ? ' selected' : ''; ?>>https</option>
<option value="http"<?php echo ((string)fb_cfg('oss.url_scheme', 'https')) === 'http' ? ' selected' : ''; ?>>http</option> <option value="http"<?php echo ((string)fb_cfg('oss.url_scheme', 'https')) === 'http' ? ' selected' : ''; ?>>http</option>
</select></label> </select></label>
</div> </div>
<label class="check"><input type="checkbox" name="oss_use_internal_endpoint" value="1"<?php echo fb_oss_use_internal() ? ' checked' : ''; ?>> 使用内网 Endpoint(<code>*-internal.aliyuncs.com</code>,免公网流量费、延迟低)
<span class="hint" style="display:block;margin:2px 0 0 24px">⚠️ 仅当【桶与本机 ECS 同地域】时可用;跨地域内网不可达,请取消勾选回落公网,或改用同地域的桶。保存后可在下方「存储体检」查看连通性。</span></label>
<label class="check"><input type="checkbox" name="oss_verify" value="1"<?php echo fb_cfg('oss.verify', true) ? ' checked' : ''; ?>> 提交时回源复核附件(防伪造)</label> <label class="check"><input type="checkbox" name="oss_verify" value="1"<?php echo fb_cfg('oss.verify', true) ? ' checked' : ''; ?>> 提交时回源复核附件(防伪造)</label>
<label class="check"><input type="checkbox" name="oss_image_process" value="1"<?php echo fb_cfg('oss.image_process', true) ? ' checked' : ''; ?>> 缩略图使用 OSS 图片处理(省流量)</label> <label class="check"><input type="checkbox" name="oss_image_process" value="1"<?php echo fb_cfg('oss.image_process', true) ? ' checked' : ''; ?>> 缩略图使用 OSS 图片处理(服务端缩放后再转发,省下行流量)</label>
<input type="hidden" name="oss_visibility" value="<?php echo e((string)fb_cfg('oss.visibility', 'private')); ?>">
<h2 style="margin:24px 0 10px">审核</h2> <h2 style="margin:24px 0 10px">审核</h2>
<label class="check"><input type="checkbox" name="moderation_enabled" value="1"<?php echo fb_moderation_on() ? ' checked' : ''; ?>> 先审后显(新提交的问题默认不公开,需你通过后才显示)</label> <label class="check"><input type="checkbox" name="moderation_enabled" value="1"<?php echo fb_moderation_on() ? ' checked' : ''; ?>> 先审后显(新提交的问题默认不公开,需你通过后才显示)</label>
@@ -633,7 +631,19 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<?php elseif ($mode === 'oss'): ?> <?php elseif ($mode === 'oss'): ?>
<span>鉴权方式:<b><?php echo e($ossStat['label']); ?></b></span> <span>鉴权方式:<b><?php echo e($ossStat['label']); ?></b></span>
<span>凭证状态:<?php echo !empty($ossStat['ok']) ? ('已取得 AccessKey(尾号 ' . e($ossStat['ak_tail']) . ')✅') : ('未取得 ❌ ' . e((string)$ossStat['error'])); ?></span> <span>凭证状态:<?php echo !empty($ossStat['ok']) ? ('已取得 AccessKey(尾号 ' . e($ossStat['ak_tail']) . ')✅') : ('未取得 ❌ ' . e((string)$ossStat['error'])); ?></span>
<span>Bucket:<?php echo e((string)fb_cfg('oss.bucket', '')); ?> / Endpoint:<?php echo e((string)fb_cfg('oss.endpoint', '')); ?></span> <span>Bucket:<?php echo e((string)fb_cfg('oss.bucket', '')); ?></span>
<span>Endpoint(配置):<?php echo e((string)fb_cfg('oss.endpoint', '')); ?></span>
<span>Endpoint(实际连接):<code><?php echo e(fb_oss_host()); ?></code> — <?php echo fb_oss_use_internal() ? '内网' : '公网'; ?></span>
<?php if (isset($_GET['probe']) && $_GET['probe'] === '1'): ?>
<?php $pb = fb_oss_probe(); ?>
<span>连通性:<?php echo $pb['ok'] ? '✅' : '❌'; ?> <?php echo e($pb['detail']); ?><?php echo $pb['status'] ? ('(HTTP ' . (int)$pb['status'] . ')') : ''; ?></span>
<?php if (!$pb['ok'] && $pb['internal'] && $pb['status'] === 0): ?>
<span class="muted small">↳ 内网 Endpoint 跨地域不可达:请取消勾选上方「使用内网 Endpoint」后保存,或把桶换到与本机同地域。</span>
<?php endif; ?>
<?php else: ?>
<span><a class="btn" style="padding:4px 10px" href="admin.php?tab=settings&amp;probe=1">测一下桶连通性</a>
<span class="muted small">(会真实访问一次 OSS,跨地域不通时可能等待数秒)</span></span>
<?php endif; ?>
<?php endif; ?> <?php endif; ?>
<span>已用附件:<?php echo e(fb_size_h(fb_attach_total_bytes($pdo))); ?> / 上限 <?php echo e(fb_size_h(fb_upload_cfg('total_cap_bytes', 0))); ?></span> <span>已用附件:<?php echo e(fb_size_h(fb_attach_total_bytes($pdo))); ?> / 上限 <?php echo e(fb_size_h(fb_upload_cfg('total_cap_bytes', 0))); ?></span>
</div> </div>
+2 -2
View File
@@ -1,4 +1,4 @@
/* FeedLite · 轻反馈 —— 轻量交互(含图片/视频直传 OSS) */ /* FeedLite · 轻反馈 —— 轻量交互(图片/视频上传:经服务器中转落地,前端对 local/OSS 走同一路径) */
(function () { (function () {
'use strict'; 'use strict';
@@ -158,7 +158,7 @@
}); });
} }
/* ---------- 附件直传 ---------- */ /* ---------- 附件上传(经服务器中转,local/OSS 同一路径) ---------- */
function initUploaderRoot(root) { function initUploaderRoot(root) {
var csrf = root.getAttribute('data-csrf') || ''; var csrf = root.getAttribute('data-csrf') || '';
var signUrl = root.getAttribute('data-sign-url') || 'upload.php'; var signUrl = root.getAttribute('data-sign-url') || 'upload.php';
+31 -22
View File
@@ -83,32 +83,35 @@ return [
'close_mail_subject' => '关于你反馈的问题《%s》', // %s 会替换成问题标题 'close_mail_subject' => '关于你反馈的问题《%s》', // %s 会替换成问题标题
], ],
/* ---------------- 附件上传(阿里云 OSS 直传) ---------------- /* ---------------- 附件上传(阿里云 OSS · 服务器代理) ----------------
* 浏览器把文件「直接」传到 OSS,不经过本站服务器 —— 因此不占用服务器磁盘、不消耗服务器带宽, * ★ 架构:私有桶 + 内网 Endpoint,读写全部经服务器代理
* 也就不会出现"硬盘被挤爆"的风险。服务器只做两件事: * 桶设为【私有】、且只用【内网 Endpoint】(*-internal.aliyuncs.com,仅同地域 ECS 可达),
* 1) 下发一段有时效的上传凭证(签名),限制能传到哪个路径、多大、什么类型; * 浏览器既不直传、也不直连。数据流:
* 2) 提交表单时校验对象确实存在且合规,并把元信息写进 SQLite。 * 上传:浏览器 → upload.php → 服务器 PUT 到内网 OSS
* 读取:浏览器 → media.php → 服务器 GET 内网 OSS 并流式转发(支持 Range)
* 好处:桶可彻底私有、公网完全看不到 Endpoint、AccessKey 永不出服务器。
* 代价:附件流量经过服务器(占带宽),需保证服务器磁盘临时空间够放单个上传文件。
* *
* ★ 凭证方式:ECS 实例 RAM 角色(唯一支持的方式,服务器上不保存长期 AccessKey) * ★ 凭证方式:ECS 实例 RAM 角色(推荐,服务器上不保存长期 AccessKey)或静态 AccessKey。
* 给这台 ECS 绑定一个 RAM 角色后,程序从内网元数据服务 * 实例角色下,程序从内网元数据服务(http://100.100.100.200,链路本地、不出公网)
* (http://100.100.100.200,链路本地地址、不出公网)读取一份「有时效、 * 读取一份「有时效、会自动轮换」的临时凭证来签名。AccessKey 永不落盘、永不进本文件。
* 会自动周期轮换」的临时凭证来签名。AccessKey 永不落盘,也永不进本文件。
* *
* ★ 开通步骤(详见 README.md「5.1 开通与配置」): * ★ 开通步骤(详见 README.md):
* ① 创建 Bucket(读写权限建议「公共读」;想更严可用 private,本程序会自动签发访问 URL) * ① 创建【与 ECS 同地域】的 Bucket,读写权限设为「私有」
* ② RAM 建权限策略:只授予该 Bucket(某前缀下)的 oss:PutObject / oss:GetObject / oss:DeleteObject * ⚠️ 内网 Endpoint 跨地域不通!ECS 在哪个地域,桶就建在哪个地域。
* ② RAM 建权限策略:只授予该 Bucket(某前缀下)的 oss:PutObject / GetObject / DeleteObject
* ③ RAM 建角色(如 FeedLiteOssRole)并赋予上一步的策略 * ③ RAM 建角色(如 FeedLiteOssRole)并赋予上一步的策略
* ④ ECS 控制台把该角色【授予本实例】 * ④ ECS 控制台把该角色【授予本实例】
* ⑤ Bucket → 数据安全 → 跨域设置(CORS):来源填你的站点域名,方法勾 POST/GET/HEAD * ⑤ 把 endpoint / bucket / instance_role 填到下面,use_internal_endpoint 保持 true,enabled = true
* ⑥ 把 endpoint / bucket / instance_role 填到下面,最后 enabled = true * ⑥ 后台「站点设置 → 存储体检」确认桶连通性为 ✅
* ⑦(可选但强烈推荐)绑定自定义域名,避免默认域名带 Content-Disposition 被迫下载 * (代理架构下【不需要】配置 CORS,也【不需要】绑定自定义域名)
* *
* ★ 前提:站点必须运行在【阿里云 ECS】上并且已绑定 RAM 角色; * ★ 前提:站点必须运行在【阿里云 ECS】上并且已绑定 RAM 角色;
* 未绑定则取不到凭证,上传接口会明确报错(其它功能不受影响)。 * 未绑定则取不到凭证,上传接口会明确报错(其它功能不受影响)。
*/ */
'oss' => [ 'oss' => [
'enabled' => false, // 填好参数后改为 true 才启用上传 'enabled' => false, // 填好参数后改为 true 才启用上传
'endpoint' => 'oss-cn-beijing.aliyuncs.com', // 不含 http://,如 oss-cn-hangzhou.aliyuncs.com 'endpoint' => 'oss-cn-qingdao.aliyuncs.com', // 不含 http://;填公网或内网均可,程序按下方开关自动转换
'bucket' => 'your-bucket-name', 'bucket' => 'your-bucket-name',
// ★ ECS 实例 RAM 角色名 —— 必须与【ECS 实例实际绑定的角色名】逐字一致。 // ★ ECS 实例 RAM 角色名 —— 必须与【ECS 实例实际绑定的角色名】逐字一致。
@@ -117,6 +120,12 @@ return [
// 建的是 MyOssRole,授予实例后生效名可能是系统预置策略对应的角色),以查询结果为准。 // 建的是 MyOssRole,授予实例后生效名可能是系统预置策略对应的角色),以查询结果为准。
'instance_role' => '', 'instance_role' => '',
// ★ 是否用内网 Endpoint(默认开)。开启后程序自动把 endpoint 改写成 *-internal 形式,
// 走阿里云内网访问 OSS:免公网流量费、延迟更低,且公网完全无法直连该地址。
// ⚠️ 仅当【桶与本机 ECS 同地域】时可用;跨地域时内网不可达,必须关掉本开关
// (回落公网 Endpoint)或改用同地域的桶。后台「存储体检」会探测并提示。
'use_internal_endpoint' => true,
// ---- 以下一般不用改 ---- // ---- 以下一般不用改 ----
'metadata_url' => '', // 留空=http://100.100.100.200(ECS 内网元数据服务) 'metadata_url' => '', // 留空=http://100.100.100.200(ECS 内网元数据服务)
'metadata_v2' => false, // true=加固模式(先取一次性 token 再读元数据,防 SSRF 探测) 'metadata_v2' => false, // true=加固模式(先取一次性 token 再读元数据,防 SSRF 探测)
@@ -124,13 +133,13 @@ return [
'credential_cache' => '', // 凭证缓存文件;留空=与数据库同目录的 oss.sts.php 'credential_cache' => '', // 凭证缓存文件;留空=与数据库同目录的 oss.sts.php
'credential_refresh_margin' => 300, // 到期前多少秒就提前换新(秒) 'credential_refresh_margin' => 300, // 到期前多少秒就提前换新(秒)
'domain' => '', // 自定义访问域名,如 https://fb-img.example.com(留空用默认域名) 'domain' => '', // 代理架构下【不再使用】:附件一律经本站 media.php 输出,无需自定义域名
'url_scheme' => 'https', // 拼接访问地址时使用的协议 'url_scheme' => 'https', // 公网 Endpoint 时使用的协议(内网 Endpoint 强制走 http)
'prefix' => 'fb', // 对象键前缀:fb/2026/09/xxxxxxxx.jpg 'prefix' => 'fb', // 对象键前缀:fb/2026/09/xxxxxxxx.jpg
'visibility' => 'public', // public=直接可读 | private=展示时生成签名 URL 'visibility' => 'private', // 记录桶的读写权限:代理架构下推荐 private(浏览器永不直连桶)
'sign_ttl' => 600, // 上传凭证有效期(秒) 'sign_ttl' => 600, // 服务器内部签名 URL 的有效期(秒),仅服务器侧使用
'link_ttl' => 604800, // private 模式下展示链接的有效期(秒) 'link_ttl' => 604800, // 兼容保留;代理读取时签名有效期由代码收敛到 ≤300 秒
'image_process' => true, // 列表缩略图使用 OSS 图片处理(免费额度内,省流量) 'image_process' => true, // 列表缩略图使用 OSS 图片处理(服务端缩放后再转发,省下行流量)
'verify' => true, // 提交时 HEAD 复核对象(防伪造/防超限) 'verify' => true, // 提交时 HEAD 复核对象(防伪造/防超限)
'host_override' => '', // 仅特殊网关/联调使用;留空自动按 bucket.endpoint 拼接 'host_override' => '', // 仅特殊网关/联调使用;留空自动按 bucket.endpoint 拼接
], ],
+18
View File
@@ -37,6 +37,14 @@ echo "附件:共 {$kept} 个,已清理超过 {$days} 天的附件 {$gone}
. (int)$pdo->query('SELECT COUNT(*) FROM attachments')->fetchColumn() . " 个,占用 " . (int)$pdo->query('SELECT COUNT(*) FROM attachments')->fetchColumn() . " 个,占用 "
. fb_size_h(fb_attach_total_bytes($pdo)) . ")。\n"; . fb_size_h(fb_attach_total_bytes($pdo)) . ")。\n";
/* ---------------- 1a. 清理「待入库」孤儿登记 ----------------
* 上传成功但一直没提交表单的附件,会在 pending_attachments 里留下登记。
* 超过 2 小时仍没转正的,清掉登记(media.php 之后就不再认这些键)。
* ⚠️ 只删登记表记录;OSS 上对应的孤儿对象由保留期清理策略另行处理。
*/
$pgone = fb_attach_pending_gc(7200);
if ($pgone > 0) { echo "待入库登记:清理 {$pgone} 条过期未提交记录。\n"; }
/* ---------------- 1b. 附件存储凭证自检 ---------------- /* ---------------- 1b. 附件存储凭证自检 ----------------
* 顺手确认一次「能不能从 ECS 实例 RAM 角色拿到上传凭证」:角色名写错、 * 顺手确认一次「能不能从 ECS 实例 RAM 角色拿到上传凭证」:角色名写错、
* 角色没授予实例、元数据服务被网络策略挡住时,这里会直接说明原因, * 角色没授予实例、元数据服务被网络策略挡住时,这里会直接说明原因,
@@ -49,6 +57,16 @@ if (fb_oss_enabled()) {
if ($cs['expire'] > 0) { $line .= ',有效期至 ' . date('Y-m-d H:i', $cs['expire']); } if ($cs['expire'] > 0) { $line .= ',有效期至 ' . date('Y-m-d H:i', $cs['expire']); }
if (!$cs['ok']) { $line .= ' ⚠️ 取不到凭证:' . ($cs['error'] !== '' ? $cs['error'] : '未知原因'); } if (!$cs['ok']) { $line .= ' ⚠️ 取不到凭证:' . ($cs['error'] !== '' ? $cs['error'] : '未知原因'); }
echo $line . "。\n"; echo $line . "。\n";
// 连通性自检:能否真正访问所配置的桶(内网 endpoint 跨地域时会在这里暴露)
$probe = fb_oss_probe();
echo '桶连通性:' . ($probe['ok'] ? '✅ ' : '❌ ') . $probe['host']
. '(' . ($probe['internal'] ? '内网' : '公网') . ' Endpoint,HTTP ' . $probe['status'] . ')— '
. $probe['detail'] . "。\n";
if (!$probe['ok'] && $probe['internal'] && $probe['status'] === 0) {
echo " ↳ 提示:内网 Endpoint 只有与桶【同地域】的阿里云 ECS 能访问。\n"
. " 若本机与桶不同地域,请到后台「站点设置」关闭『使用内网 Endpoint』,或改用同地域的桶。\n";
}
} else { } else {
echo "附件存储未启用(请在 config.php 的 oss 段填写 endpoint / bucket / instance_role 并置 enabled = true)。\n"; echo "附件存储未启用(请在 config.php 的 oss 段填写 endpoint / bucket / instance_role 并置 enabled = true)。\n";
} }
+53
View File
@@ -394,10 +394,63 @@ function fb_attach_save(PDO $pdo, $threadId, $replyId, array $list) {
$i, $now, $i, $now,
]); ]);
$n++; $n++;
// 转正成功即从「待入库」表移除(media.php 之后改走 attachments 表鉴权)
fb_attach_pending_clear($it['key']);
} }
return $n; return $n;
} }
/* ==================================================================
* 待入库附件登记
* ------------------------------------------------------------------
* 上传成功 → 登记;提交表单入库 → 转正并移除;一直没提交 → 由 cron 清理。
* 只为 media.php 的代理鉴权服务(上传后、入库前的缩略图预览)。
* ================================================================== */
/** 登记一条待入库附件 */
function fb_attach_pending_mark($key, $kind, $mime, $size) {
try {
$pdo = fb_db();
$pdo->prepare(
'INSERT INTO pending_attachments(oss_key, kind, mime, size, ip_hash, created_at)
VALUES(?,?,?,?,?,?)'
)->execute([(string)$key, (string)$kind, (string)$mime, (int)$size, fb_ip_hash(), time()]);
return true;
} catch (Throwable $e) { return false; }
}
/** 转正/放弃后移除登记(幂等) */
function fb_attach_pending_clear($key) {
try {
$pdo = fb_db();
$pdo->prepare('DELETE FROM pending_attachments WHERE oss_key = ?')->execute([(string)$key]);
return true;
} catch (Throwable $e) { return false; }
}
/** 该对象键是否由【当前访客】在有效期内登记过(media.php 鉴权用) */
function fb_attach_pending_mine($key, $ttl = 7200) {
try {
$pdo = fb_db();
$st = $pdo->prepare(
'SELECT id FROM pending_attachments
WHERE oss_key = ? AND ip_hash = ? AND created_at >= ? LIMIT 1'
);
$st->execute([(string)$key, fb_ip_hash(), time() - (int)$ttl]);
return (bool)$st->fetch();
} catch (Throwable $e) { return false; }
}
/** 清理过期的待入库登记(cron 调用;只删记录,不动 OSS 对象——那属于未提交的孤儿,另行处理) */
function fb_attach_pending_gc($ttl = 7200) {
try {
$pdo = fb_db();
$st = $pdo->prepare('DELETE FROM pending_attachments WHERE created_at < ?');
$st->execute([time() - (int)$ttl]);
return $st->rowCount();
} catch (Throwable $e) { return 0; }
}
/** 取某个主题的附件:['main' => [...], 'replies' => [reply_id => [...]]] */ /** 取某个主题的附件:['main' => [...], 'replies' => [reply_id => [...]]] */
function fb_attach_for_thread(PDO $pdo, $threadId) { function fb_attach_for_thread(PDO $pdo, $threadId) {
$st = $pdo->prepare('SELECT * FROM attachments WHERE thread_id = ? ORDER BY seq ASC, id ASC'); $st = $pdo->prepare('SELECT * FROM attachments WHERE thread_id = ? ORDER BY seq ASC, id ASC');
+24
View File
@@ -115,6 +115,30 @@ 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_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_reply ON attachments(reply_id)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_age ON attachments(created_at)'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_age ON attachments(created_at)');
// 代理读取要按对象键反查(media.php 的鉴权),必须有索引否则每次读图都全表扫描
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_key ON attachments(oss_key)');
/* ---- 待入库附件 ----
* 私有桶 + 内网 Endpoint 下,附件一律经 media.php 代理输出,而代理必须鉴权。
* 但「上传成功」发生在「表单提交入库」之前 —— 前端上传完就要立刻显示缩略图,
* 此时 attachments 表里还没有这条记录。若只认 attachments 表,缩略图必然 403。
* 所以上传成功时先在这张表登记一条,media.php 认「已入库 或 本人刚上传的待入库」,
* 提交表单后由 fb_attach_save() 转正,过期条目由 cron.php 清理。
*/
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS pending_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
oss_key TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'image',
mime TEXT,
size INTEGER NOT NULL DEFAULT 0,
ip_hash TEXT,
created_at INTEGER NOT NULL
);
SQL
);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_patt_key ON pending_attachments(oss_key)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_patt_age ON pending_attachments(created_at)');
$pdo->exec(<<<'SQL' $pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS rate_limit ( CREATE TABLE IF NOT EXISTS rate_limit (
+1 -1
View File
@@ -66,7 +66,7 @@ function fb_flash_box($msg, $type = 'error') {
} }
/** /**
* 渲染附件上传控件(图片 / 视频,浏览器直传 OSS) * 渲染附件上传控件(图片 / 视频;上传经服务器中转落地,local/OSS 前端同一路径)
* 需置于 <form> 内部:会输出一个 name="attachments" 的隐藏字段, * 需置于 <form> 内部:会输出一个 name="attachments" 的隐藏字段,
* 由 assets/app.js 在文件上传成功后回填 JSON 描述。 * 由 assets/app.js 在文件上传成功后回填 JSON 描述。
* *
+369 -91
View File
@@ -1,9 +1,21 @@
<?php <?php
/** /**
* 阿里云 OSS —— 浏览器直传支持(零依赖,只用 PHP 内置函数) * 阿里云 OSS —— 服务器代理读写(零依赖,只用 PHP 内置函数)
* ------------------------------------------------------------------ * ------------------------------------------------------------------
* 只用 HMAC-SHA1 签名(PostObject 策略签名 / V1 签名 URL), * 只用 HMAC-SHA1 的 V1 签名,不依赖官方 SDK。
* 不依赖官方 SDK,也不依赖 ext-curl(有 curl 时自动优先使用)。 *
* ★ 架构:私有桶 + 内网 Endpoint,全部经服务器代理
* · 桶是【私有】的 → 浏览器拿不到任何可直接访问的 URL;
* · Endpoint 是【内网】的(*-internal.aliyuncs.com)→ 只有同地域的阿里云 ECS
* 能连,外网根本不可达。
* 因此「浏览器直传」和「签名 URL 直连」两条路都走不通,只能:
* 上传:浏览器 → upload.php → PUT 到内网 OSS
* 读取:浏览器 → media.php → GET 内网 OSS 并流式转发(支持 Range)
* 代价是流量过服务器,换来的是桶可以彻底私有、且不暴露公网 Endpoint。
*
* ⚠️ 代理鉴权是安全边界:media.php 必须校验对象键在【本站附件表】里、
* 且所属帖子对当前访客可见。否则本站会变成「能读整个桶」的开放代理 ——
* 共用桶(一个桶放多个应用的数据)时等于把别人的数据也泄露出去。
*/ */
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); } if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
@@ -230,17 +242,70 @@ function fb_oss_sk() { $c = fb_oss_credentials(); return (string)$c['sk']; }
/** 当前安全令牌(使用临时凭证时必不为空) */ /** 当前安全令牌(使用临时凭证时必不为空) */
function fb_oss_token() { $c = fb_oss_credentials(); return (string)$c['token']; } function fb_oss_token() { $c = fb_oss_credentials(); return (string)$c['token']; }
/** 上传入口 host(含协议),形如 https://bucket.oss-cn-beijing.aliyuncs.com */ /**
* 规范化 Endpoint:把配置的 endpoint 解析成「服务器实际要连的地址」。
* ------------------------------------------------------------------
* oss.endpoint 允许三种写法,全部只影响【服务器侧】的连接地址,
* 浏览器永远不会直接访问它(所以内网地址完全可用):
* ① oss-cn-qingdao.aliyuncs.com (公网)
* ② oss-cn-qingdao-internal.aliyuncs.com (内网,推荐:免流量费、延迟低)
* ③ https://oss-cn-qingdao.aliyuncs.com (带协议)
*
* oss.use_internal_endpoint(后台可配,默认开)决定要不要自动把 ① 改写成 ②:
* · 只有【同地域】的阿里云 ECS 才连得上内网 endpoint。跨地域(如 ECS 在
* cn-qingdao、桶在 cn-beijing)内网完全不可达 —— 这种情况必须关掉本开关,
* 或干脆换一个与 ECS 同地域的桶。后台「存储体检」会做连通性自检并给出提示。
* @return string 不含协议、不含结尾斜杠的 host,如 oss-cn-qingdao-internal.aliyuncs.com
*/
function fb_oss_endpoint() {
$ep = preg_replace('#^https?://#i', '', trim((string)fb_oss_cfg('endpoint', '')));
$ep = trim(preg_replace('#/.*$#', '', $ep));
if ($ep === '') { return ''; }
if (fb_oss_use_internal()) {
// 已经是内网地址就不动;否则在 "-aliyuncs.com" 前插入 "-internal"
if (strpos($ep, '-internal.aliyuncs.com') === false
&& preg_match('#^(oss-[a-z0-9-]+?)(\.aliyuncs\.com)$#i', $ep, $m)) {
$ep = $m[1] . '-internal' . $m[2];
}
} else {
// 明确关掉内网:把已有的 -internal 去掉,回落公网
$ep = str_replace('-internal.aliyuncs.com', '.aliyuncs.com', $ep);
}
return $ep;
}
/** 是否走内网 Endpoint(后台可配,默认开) */
function fb_oss_use_internal() {
$v = fb_oss_cfg('use_internal_endpoint', true);
if (is_string($v)) { $v = !in_array(strtolower(trim($v)), ['', '0', 'false', 'off', 'no'], true); }
return (bool)$v;
}
/** 服务器侧连接 OSS 用的 host(含协议)。内网 Endpoint 走 http,公网走 https。 */
function fb_oss_host() { function fb_oss_host() {
$o = trim((string)fb_oss_cfg('host_override', '')); $o = trim((string)fb_oss_cfg('host_override', ''));
if ($o !== '') { return rtrim($o, '/'); } if ($o !== '') { return rtrim($o, '/'); }
$c = fb_oss_cfg();
$ep = fb_oss_endpoint();
if ($ep === '') { return ''; }
// 内网 endpoint 不支持 HTTPS 证书校验(证书是公网域名签发的),走 http;
// 内网链路本身不出机房,安全性由 VPC 隔离保证。公网则强制 https。
$isInternal = strpos($ep, '-internal.aliyuncs.com') !== false;
if ($isInternal) { $scheme = 'http'; }
else {
$scheme = (strtolower((string)fb_oss_cfg('url_scheme', 'https')) === 'http') ? 'http' : 'https'; $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; return $scheme . '://' . trim((string)fb_oss_cfg('bucket', '')) . '.' . $ep;
} }
/** 对外的访问基地址(配置了自定义域名就用它) */ /**
* 对外的访问基地址。
* ⚠️ 私有桶 + 内网 Endpoint 下【不再存在】可供浏览器直连的地址 ——
* 附件一律通过本站 media.php 代理输出。本函数仅供极少数
* 「桶是公共读且配了自定义域名」的兼容场景使用。
*/
function fb_oss_public_base() { function fb_oss_public_base() {
$d = trim((string)fb_oss_cfg('domain', '')); $d = trim((string)fb_oss_cfg('domain', ''));
if ($d !== '') { if ($d !== '') {
@@ -257,22 +322,13 @@ function fb_oss_make_key($ext) {
return $prefix . '/' . date('Y/m') . '/' . bin2hex(random_bytes(8)) . '.' . $ext; return $prefix . '/' . date('Y/m') . '/' . bin2hex(random_bytes(8)) . '.' . $ext;
} }
/** /** 拼一个对象在 OSS 上的访问 URL(仅服务器内部使用) */
* 计算 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) { function fb_oss_object_url($key, $forcePublicBase = false) {
return ($forcePublicBase ? fb_oss_host() : fb_oss_public_base()) . '/' . ltrim((string)$key, '/'); return ($forcePublicBase ? fb_oss_host() : fb_oss_public_base()) . '/' . ltrim((string)$key, '/');
} }
/** /**
* 生成 OSS V1 签名参数(用于私有 Bucket 的临时访问、HEAD 校验、删除、签名直传) * 生成 OSS V1 签名参数(服务器侧用:HEAD 校验、DELETE 清理、GET 代理读取的签名 URL)
* StringToSign = METHOD "\n" Content-MD5 "\n" Content-Type "\n" Expires "\n" Resource * StringToSign = METHOD "\n" Content-MD5 "\n" Content-Type "\n" Expires "\n" Resource
* 其中 Resource = /bucket/key,若使用临时凭证则再拼上 "?security-token=<token>"。 * 其中 Resource = /bucket/key,若使用临时凭证则再拼上 "?security-token=<token>"。
* @return array ['OSSAccessKeyId'=>..,'Expires'=>..,'Signature'=>..] * @return array ['OSSAccessKeyId'=>..,'Expires'=>..,'Signature'=>..]
@@ -283,16 +339,35 @@ function fb_oss_object_url($key, $forcePublicBase = false) {
* 签名用的 SK 与随请求发出的 security-token 就来自不同批次 → SignatureDoesNotMatch。 * 签名用的 SK 与随请求发出的 security-token 就来自不同批次 → SignatureDoesNotMatch。
* ② 【security-token 必须算进 Resource】。它是以 URL 查询参数(子资源)形式出现的, * ② 【security-token 必须算进 Resource】。它是以 URL 查询参数(子资源)形式出现的,
* OSS 计算签名时会把它连同 Resource 一起算。若照搬"官方说 token 不参与签名"而只把它 * OSS 计算签名时会把它连同 Resource 一起算。若照搬"官方说 token 不参与签名"而只把它
* 挂在 URL 上,PUT/HEAD/DELETE 这类用签名 URL 的请求会全部 403 SignatureDoesNotMatch * 挂在 URL 上,PUT/HEAD/DELETE/GET 这类用签名 URL 的请求会全部 403 SignatureDoesNotMatch。
* (PostObject 表单直传不受影响,所以只有"浏览器直传能成功、删不掉"这种诡异现象)。 * (探测脚本里还踩过第三个变体:带 body 的 PUT 发了 Content-Type 头却没算进签名,
* 同样 SignatureDoesNotMatch —— 所以 fb_oss_auth_headers 用头签名时务必把 Content-Type 一并纳入。)
*
* @param array $subResource 额外的签名子资源,如 ['x-oss-process' => 'image/resize,w_640']
* ⚠️ x-oss-process 属于 OSS 的签名子资源,必须一起算进 Resource,
* 否则带图片处理参数的请求会 403 SignatureDoesNotMatch。
*/ */
function fb_oss_sign_v1($method, $key, $expires, $contentType = '', $contentMd5 = '') { function fb_oss_sign_v1($method, $key, $expires, $contentType = '', $contentMd5 = '', array $subResource = []) {
$c = fb_oss_cfg(); $c = fb_oss_cfg();
$cred = fb_oss_credentials(); $cred = fb_oss_credentials();
$tok = isset($cred['token']) ? (string)$cred['token'] : ''; $tok = isset($cred['token']) ? (string)$cred['token'] : '';
$res = '/' . trim((string)$c['bucket']) . '/' . ltrim((string)$key, '/'); $res = '/' . trim((string)$c['bucket']) . '/' . ltrim((string)$key, '/');
if ($tok !== '') { $res .= '?security-token=' . $tok; }
// 子资源必须【按名字字典序】拼接(OSS 的规定,顺序错了签名就不匹配)
$sub = [];
if ($tok !== '') { $sub['security-token'] = $tok; }
foreach ($subResource as $k => $v) {
$k = (string)$k;
if ($k === '') { continue; }
$sub[$k] = ($v === null || $v === '') ? '' : (string)$v;
}
if ($sub) {
ksort($sub, SORT_STRING);
$parts = [];
foreach ($sub as $k => $v) { $parts[] = ($v === '') ? $k : $k . '=' . $v; }
$res .= '?' . implode('&', $parts);
}
$st = strtoupper($method) . "\n" . $contentMd5 . "\n" . $contentType . "\n" $st = strtoupper($method) . "\n" . $contentMd5 . "\n" . $contentType . "\n"
. (int)$expires . "\n" . $res; . (int)$expires . "\n" . $res;
@@ -304,68 +379,280 @@ function fb_oss_sign_v1($method, $key, $expires, $contentType = '', $contentMd5
]; ];
// security-token 除了参与上面的签名计算,也仍要作为 URL 参数发出去 // security-token 除了参与上面的签名计算,也仍要作为 URL 参数发出去
if ($tok !== '') { $out['security-token'] = $tok; } if ($tok !== '') { $out['security-token'] = $tok; }
foreach ($subResource as $k => $v) {
$k = (string)$k;
if ($k !== '' && $k !== 'security-token') { $out[$k] = (string)$v; }
}
return $out; return $out;
} }
/** 给对象 URL 追加签名参数(私有 Bucket 展示时用;公共读时返回原 URL) */ /**
* 浏览器可访问的附件地址 —— 走本站代理 media.php。
* ------------------------------------------------------------------
* 私有桶 + 内网 Endpoint 下,OSS 的 URL 对浏览器毫无意义(连不上、也没权限),
* 所以这里返回的一律是站内代理地址。签名、取流都在服务器内部完成。
* @return string 形如 media.php?k=fb%2F2026%2F09%2Fabcd.jpg
*/
function fb_oss_url($key, $ttl = 0) { function fb_oss_url($key, $ttl = 0) {
$url = fb_oss_object_url($key); return fb_base_url() . '/media.php?k=' . rawurlencode((string)$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); /**
* 服务器内部用的签名 URL(HEAD / DELETE 等小请求)。
* ⚠️ 仅供服务器自己发起请求,绝不返回给浏览器。
* @param array $subResource 参与签名的子资源,如 ['x-oss-process' => 'image/resize,w_640']
*/
function fb_oss_signed_url($method, $key, $ttl = 300, $contentType = '', array $subResource = []) {
$url = fb_oss_object_url($key, true);
$sign = fb_oss_sign_v1($method, $key, time() + max(30, (int)$ttl), $contentType, '', $subResource);
return $url . '?' . http_build_query($sign, '', '&', PHP_QUERY_RFC3986); return $url . '?' . http_build_query($sign, '', '&', PHP_QUERY_RFC3986);
} }
/** /**
* 生成浏览器直传所需的上传凭证 * 用 Authorization 头签名(PUT 上传用)。
* @param string $key 服务端指定的对象键(策略里用 eq 锁定,客户端无法改名) * 相比查询串签名,头签名能把 Content-Type / x-oss-security-token 一起纳入,
* @param int $maxBytes content-length-range 上限 * 更适合带请求体的写入。
* @param string $typePrefix 允许的 Content-Type 前缀(image/ 或 video/) * @return array 需要附加到请求上的 HTTP 头(含 Authorization 与 x-oss-security-token)
* @return array
*/ */
function fb_oss_upload_credential($key, $maxBytes, $typePrefix) { function fb_oss_auth_headers($method, $key, $date, $contentType = '', $contentMd5 = '', array $extraOssHeaders = []) {
$c = fb_oss_cfg();
$cred = fb_oss_credentials(); $cred = fb_oss_credentials();
$ttl = max(60, (int)fb_oss_cfg('sign_ttl', 600)); $c = fb_oss_cfg();
$expire = time() + $ttl;
// 临时凭证本身可能更快到期 —— 策略有效期必须落在凭证有效期内,否则 OSS 会拒 $lower = [];
if (!empty($cred['expire']) && (int)$cred['expire'] > 0) { foreach ($extraOssHeaders as $k => $v) {
$expire = min($expire, (int)$cred['expire'] - 30); $k = strtolower(trim((string)$k));
if ($expire <= time()) { $expire = time() + 60; } if (strpos($k, 'x-oss-') === 0) { $lower[$k] = trim((string)$v); }
}
// 临时凭证必须带 security-token 头,且它是 CanonicalizedOSSHeaders 的一部分(要参与签名)
if (!empty($cred['token'])) { $lower['x-oss-security-token'] = (string)$cred['token']; }
ksort($lower, SORT_STRING);
$canon = '';
foreach ($lower as $k => $v) { $canon .= $k . ':' . $v . "\n"; }
$res = '/' . trim((string)$c['bucket']) . '/' . ltrim((string)$key, '/');
$st = strtoupper($method) . "\n" . $contentMd5 . "\n" . $contentType . "\n" . $date . "\n"
. $canon . $res;
$h = ['Date: ' . $date];
if ($contentType !== '') { $h[] = 'Content-Type: ' . $contentType; }
if ($contentMd5 !== '') { $h[] = 'Content-MD5: ' . $contentMd5; }
foreach ($lower as $k => $v) { $h[] = $k . ': ' . $v; }
$h[] = 'Authorization: OSS ' . (string)$cred['ak'] . ':'
. base64_encode(hash_hmac('sha1', $st, (string)$cred['sk'], true));
return $h;
} }
$policy = [ /**
'expiration' => gmdate('Y-m-d\TH:i:s\Z', $expire), * 服务器端上传:把本地文件 PUT 到 OSS(流式,不整文件进内存)。
'conditions' => [ * @param string $key 对象键
['bucket' => (string)$c['bucket']], * @param string $path 本地文件路径(通常是 PHP 的上传临时文件)
['eq', '$key', (string)$key], * @param string $mime Content-Type
['starts-with', '$Content-Type', (string)$typePrefix], * @return array [bool $ok, string $error, int $status]
['content-length-range', 1, (int)$maxBytes], */
], function fb_oss_put_file($key, $path, $mime = 'application/octet-stream') {
]; if (!is_file($path)) { return [false, '本地文件不存在', 0]; }
$json = json_encode($policy, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $size = (int)@filesize($path);
$b64 = base64_encode($json); if ($size <= 0) { return [false, '文件为空', 0]; }
// 使用实例 RAM 角色的临时凭证时,表单里必须多带一个 x-oss-security-token 域 $url = fb_oss_object_url($key, true);
$extra = []; if ($url === '') { return [false, 'OSS 未正确配置(endpoint/bucket 为空)', 0]; }
if (!empty($cred['token'])) { $extra['x-oss-security-token'] = (string)$cred['token']; } $date = gmdate('D, d M Y H:i:s \G\M\T');
// 超时按大小放宽:内网带宽高,但 50MB 视频仍要给足余量
$timeout = max(30, (int)ceil($size / 262144));
return [ $headers = fb_oss_auth_headers('PUT', $key, $date, $mime);
'host' => fb_oss_host(), // 浏览器 POST 到这里 $headers[] = 'Content-Length: ' . $size;
'key' => (string)$key, $headers[] = 'Expect:'; // 关掉 100-continue,避免部分环境多等一轮
'policy' => $b64,
// ⚠️ 用本次已取出的 $cred 计算,别再去调 fb_oss_sk()/fb_oss_ak() $fp = @fopen($path, 'rb');
// (多调一次 fb_oss_credentials() 可能在刷新后拿到不同批次) if (!$fp) { return [false, '无法读取本地文件', 0]; }
'signature' => fb_oss_sign_policy_string($b64, (string)$cred['sk']),
'access_key_id' => (string)$cred['ak'], $status = 0; $errBody = '';
'content_type' => (string)$typePrefix, if (function_exists('curl_init')) {
'max_bytes' => (int)$maxBytes, $ch = curl_init($url);
'expires' => $expire, curl_setopt_array($ch, [
'extra_fields' => $extra, // 恒含 x-oss-security-token CURLOPT_CUSTOMREQUEST => 'PUT',
'url' => fb_oss_url($key), CURLOPT_UPLOAD => true,
CURLOPT_INFILE => $fp,
CURLOPT_INFILESIZE => $size,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_HTTPHEADER => $headers,
// 内网 endpoint 用 http,无需校验证书;公网 https 时才校验
CURLOPT_SSL_VERIFYPEER => (strpos($url, 'https://') === 0),
CURLOPT_SSL_VERIFYHOST => (strpos($url, 'https://') === 0) ? 2 : 0,
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$errtxt = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno) { @fclose($fp); return [false, '上传请求失败:' . $errtxt, $status]; }
$errBody = (string)$body;
} else {
// 无 curl 的兜底:流式上下文(同样不会把整个文件读进内存)
$ctx = stream_context_create(['http' => [
'method' => 'PUT',
'header' => implode("\r\n", $headers),
'content' => $fp,
'timeout' => $timeout,
'ignore_errors' => true,
]]);
$body = @file_get_contents($url, false, $ctx);
if (isset($http_response_header) && is_array($http_response_header)
&& preg_match('#^HTTP/\S+\s+(\d{3})#', $http_response_header[0], $m)) {
$status = (int)$m[1];
}
$errBody = ($body === false ? '' : (string)$body);
}
@fclose($fp);
if ($status >= 200 && $status < 300) { return [true, '', $status]; }
return [false, fb_oss_error_text($errBody, $status), $status];
}
/**
* 服务器端读取对象并【流式】转发给浏览器(图片/视频代理的核心)。
* ------------------------------------------------------------------
* 用回调把响应头与响应体逐块交出去,全程不在内存里囤整个文件 ——
* 50MB 的视频在 memory_limit=128M 的机器上也安全。
*
* @param string $key 对象键
* @param string $rangeHeader 浏览器发来的 Range 原样透传(视频拖动进度条必需)
* @param array $subResource 如 ['x-oss-process' => 'image/resize,w_640']
* @param callable $onHeader function($name, $value) 每收到一个响应头调用一次
* @param callable $onBody function($chunk) 每收到一块响应体调用一次
* @param callable $onStatus function($status) 收到状态行时调用(早于任何头/体,
* 供调用方在输出前设 http_response_code)
* @return array [int $status, string $errorText]
*/
function fb_oss_stream_get($key, $rangeHeader = '', array $subResource = [], $onHeader = null, $onBody = null, $onStatus = null) {
$ttl = max(60, (int)fb_oss_cfg('link_ttl', 604800));
// 代理是给浏览器用的,但签名 URL 的有效期只影响服务器这一次请求,短一点更安全
$url = fb_oss_signed_url('GET', $key, min(300, $ttl), '', $subResource);
if (strpos($url, '://') === false) { return [0, 'OSS 未正确配置']; }
$headers = [];
if (trim((string)$rangeHeader) !== '') { $headers[] = 'Range: ' . trim((string)$rangeHeader); }
if (!function_exists('curl_init')) {
// 无 curl 兜底:一次性读入(仅小文件可接受;生产环境建议启用 curl)
list($st, $h, $body) = fb_http('GET', $url, ['timeout' => 60, 'headers' => $headers]);
if (is_callable($onStatus)) { $onStatus($st); }
if ($st >= 200 && $st < 400) {
if (is_callable($onHeader)) {
foreach ($h as $n => $v) { $onHeader($n, $v); }
}
if (is_callable($onBody)) { $onBody($body); }
}
return [$st, $st >= 200 && $st < 400 ? '' : fb_oss_error_text($body, $st)];
}
$status = 0; $errBody = ''; $errno = 0; $errtxt = ''; $statusFired = false;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_HTTPGET => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 300,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_SSL_VERIFYPEER => (strpos($url, 'https://') === 0),
CURLOPT_SSL_VERIFYHOST => (strpos($url, 'https://') === 0) ? 2 : 0,
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$status, &$statusFired, $onHeader, $onStatus) {
if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $m)) {
$status = (int)$m[1];
if (!$statusFired && is_callable($onStatus)) { $onStatus($status); $statusFired = true; }
return strlen($line);
}
$p = strpos($line, ':');
if ($p !== false && is_callable($onHeader)) {
$onHeader(strtolower(trim(substr($line, 0, $p))), trim(substr($line, $p + 1)));
}
return strlen($line);
},
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use ($onBody, &$errBody, &$status) {
if ($status >= 200 && $status < 400) {
if (is_callable($onBody)) { $onBody($chunk); }
} else {
$errBody .= $chunk; // 错误响应体留着解析原因
if (strlen($errBody) > 8192) { $errBody = substr($errBody, 0, 8192); }
}
return strlen($chunk);
},
]);
$ok = curl_exec($ch);
$errno = curl_errno($ch);
$errtxt = curl_error($ch);
$st2 = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($st2 > 0) { $status = $st2; }
if (!$statusFired && is_callable($onStatus)) { $onStatus($status); }
if ($ok === false || $errno) { return [$status, '读取对象失败:' . $errtxt]; }
if ($status >= 200 && $status < 400) { return [$status, '']; }
return [$status, fb_oss_error_text($errBody, $status)];
}
/** 从 OSS 的 XML 错误响应里提取人类可读的原因 */
function fb_oss_error_text($body, $status) {
$body = (string)$body;
$code = '';
if (preg_match('#<Code>([^<]+)</Code>#', $body, $m)) { $code = trim($m[1]); }
$msg = '';
if (preg_match('#<Message>([^<]+)</Message>#', $body, $m)) { $msg = trim($m[1]); }
$map = [
'AccessDenied' => '没有访问该对象的权限(检查 RAM 角色是否已授权此桶,以及桶是否与你填写的 endpoint 同地域)',
'SignatureDoesNotMatch'=> '签名不匹配(凭证可能刚轮换,或 Content-Type 与实际不一致)',
'InvalidAccessKeyId' => 'AccessKeyId 无效(凭证已过期或角色未正确绑定)',
'SecurityTokenExpired' => '临时凭证已过期,请重试',
'NoSuchKey' => '对象不存在(可能已被清理)',
'NoSuchBucket' => '桶不存在(检查 bucket / endpoint 是否写错)',
'SecondLevelDomainForbidden' => '该桶不允许二级域名访问方式',
'RequestTimeTooSkewed' => '服务器时间与 OSS 相差过大,请校准系统时间',
]; ];
if ($code !== '' && isset($map[$code])) { return $map[$code] . '(' . $code . ')'; }
if ($status === 0) {
return '连不上 OSS:请确认 Endpoint 与本机是否同地域'
. '(跨地域时 *-internal 内网地址完全不可达,需在后台关闭「使用内网 Endpoint」或改用同地域的桶)';
}
return ($code !== '' ? $code : ('HTTP ' . $status)) . ($msg !== '' ? ':' . $msg : '');
}
/**
* 连通性自检:探测服务器能否真正访问所配置的桶(后台「存储体检」用)。
* @return array{ok:bool,status:int,host:string,internal:bool,detail:string}
*/
function fb_oss_probe() {
$host = fb_oss_host();
if ($host === '') {
return ['ok' => false, 'status' => 0, 'host' => '', 'internal' => false,
'detail' => 'Endpoint / Bucket 未配置完整'];
}
$internal = strpos($host, '-internal.aliyuncs.com') !== false;
// 用一个必然不存在的对象键去探测:能连上就会返回 403/404,连不上则 status=0
$key = trim((string)fb_oss_cfg('prefix', 'fb'), '/') . '/.feedlite-probe';
list($st, ) = fb_oss_head($key);
if ($st === 0) {
return ['ok' => false, 'status' => 0, 'host' => $host, 'internal' => $internal,
'detail' => '无法连接到 ' . $host . '。'
. ($internal
? '内网 Endpoint 只有【与桶同地域】的阿里云 ECS 才能访问 —— '
. '若本机与桶不在同一地域,请关闭「使用内网 Endpoint」,或改用同地域的桶。'
: '请检查 Endpoint 拼写与本机出网情况。')];
}
// 404 = 连通且鉴权正常(对象本来就不存在);403 = 连通但权限不足
$ok = ($st === 404 || $st === 403 || ($st >= 200 && $st < 300));
$detail = $st === 403
? '能连通,但当前凭证没有该桶的读取权限(去 RAM 给角色加上 oss:GetObject)'
: '连通正常';
return ['ok' => $ok, 'status' => $st, 'host' => $host, 'internal' => $internal, 'detail' => $detail];
} }
/** /**
@@ -437,43 +724,34 @@ function fb_parse_headers($raw) {
} }
/** /**
* HEAD 一个对象,用于提交时复核(是否存在 / 大小 / 类型) * HEAD 一个对象,用于提交时复核(是否存在 / 大小 / 类型)与连通性自检
* @return array [$status, $headers] * @return array [$status, $headers]
*/ */
function fb_oss_head($key) { function fb_oss_head($key) {
// 一律带签名:使用 STS 临时凭证时 security-token 必须随请求发出, // 一律带签名:使用 STS 临时凭证时 security-token 必须随请求发出,
// 否则私有 Bucket、或要求「经过身份验证的请求」的场景会被 OSS 直接 403。 // 否则私有 Bucket 会被 OSS 直接 403。
$signed = fb_oss_object_url($key, true) . '?' $signed = fb_oss_signed_url('HEAD', $key, 300);
. http_build_query(fb_oss_sign_v1('HEAD', $key, time() + 300), '', '&', PHP_QUERY_RFC3986); if ($signed === '') { return [0, []]; }
list($status, $h, ) = fb_http('HEAD', $signed, ['timeout' => 6]);
list($status, $h, ) = fb_http('HEAD', $signed, ['timeout' => 8]);
if ($status === 0) { if ($status === 0) {
// 某些环境禁用了 curl 的 NOBODY,退化为 GET 只取头 // 某些环境禁用了 curl 的 NOBODY,退化为 GET 只取头
list($status, $h, ) = fb_http('GET', $signed, ['timeout' => 6]); list($status, $h, ) = fb_http('GET', $signed, ['timeout' => 8]);
}
// 公共读 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]; return [$status, $h];
} }
/** 删除一个对象(清理过期附件时用)。返回是否成功(404 视作已删除) */ /** 删除一个对象(清理过期附件时用)。返回是否成功(404 视作已删除) */
function fb_oss_delete($key) { function fb_oss_delete($key) {
$exp = time() + 300; $url = fb_oss_signed_url('DELETE', $key, 300);
$sign = fb_oss_sign_v1('DELETE', $key, $exp); if ($url === '') { return false; }
$url = fb_oss_object_url($key, true) . '?' . http_build_query($sign, '', '&', PHP_QUERY_RFC3986); list($status, , ) = fb_http('DELETE', $url, ['timeout' => 10]);
list($status, , ) = fb_http('DELETE', $url, ['timeout' => 8]);
return ($status >= 200 && $status < 300) || $status === 404; return ($status >= 200 && $status < 300) || $status === 404;
} }
/** /**
* 列表/灯箱用的缩放地址:借用 OSS 图片处理,省流量(可关闭) * 缩略图的【代理】地址:借用 OSS 图片处理在服务端缩放后再转发,省下行流量。
* @param string $mime 已知的内容类型;GIF 会被 OSS 处理成静态首帧(动画丢失),故原样返回 * @param string $mime 已知的内容类型;GIF 会被 OSS 处理成静态首帧(动画丢失),故不处理
*/ */
function fb_oss_thumb_url($key, $w = 560, $mime = '') { function fb_oss_thumb_url($key, $w = 560, $mime = '') {
$url = fb_oss_url($key); $url = fb_oss_url($key);
@@ -487,6 +765,6 @@ function fb_oss_thumb_url($key, $w = 560, $mime = '') {
return $url; return $url;
} }
$sep = (strpos($url, '?') === false) ? '?' : '&'; $w = max(32, min(4096, (int)$w));
return $url . $sep . 'x-oss-process=image/resize,w_' . (int)$w . '/quality,q_80'; return $url . '&p=' . rawurlencode('image/resize,w_' . $w . '/quality,q_80');
} }
+1 -1
View File
@@ -172,7 +172,7 @@ function fb_setting_keys() {
'oss.endpoint', 'oss.bucket', 'oss.instance_role', 'oss.enabled', 'oss.endpoint', 'oss.bucket', 'oss.instance_role', 'oss.enabled',
'oss.credential_source', 'oss.access_key', 'oss.secret_key', 'oss.credential_source', 'oss.access_key', 'oss.secret_key',
'oss.domain', 'oss.visibility', 'oss.prefix', 'oss.url_scheme', 'oss.domain', 'oss.visibility', 'oss.prefix', 'oss.url_scheme',
'oss.verify', 'oss.image_process', 'oss.verify', 'oss.image_process', 'oss.use_internal_endpoint',
'moderation.enabled', 'moderation.close_mail_subject', 'moderation.enabled', 'moderation.close_mail_subject',
'admin.password_hash', 'admin.password_hash',
]; ];
+206
View File
@@ -0,0 +1,206 @@
<?php
/**
* 附件代理输出(私有桶 + 内网 Endpoint 的唯一对外读取通道)
* ------------------------------------------------------------------
* 浏览器无法直连私有桶、也连不上内网 Endpoint,所以图片/视频一律经本站转发:
* media.php?k=<对象键>[&p=<OSS 图片处理串>]
*
* 安全边界(务必保持):
* ① 对象键必须存在于本站 attachments 表 —— 否则任何人只要知道共用桶里别的
* 应用的键,就能借本站把数据读出来(本站会变成「开放代理」)。
* ② 所属帖子必须对当前访客可见(先审后显:未公开帖的附件只有管理员能取)。
* ③ 只允许图片/视频白名单 MIME,且透传 Range 以支持视频拖动进度条。
* ④ 全程流式转发,不把文件读进内存(memory_limit 小也安全)。
*
* 本机存储(local/...)的附件由 Web 服务器直接吐 uploads/ 下的文件,不走这里;
* 但为兼容旧链接,这里也做一次本地回退。
*/
require __DIR__ . '/lib/bootstrap.php';
function fb_media_fail($code, $msg) {
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
exit($msg);
}
$key = isset($_GET['k']) ? trim((string)$_GET['k']) : '';
if ($key === '') { fb_media_fail(400, '缺少附件标识'); }
if (!fb_upload_key_ok($key)) { fb_media_fail(400, '附件标识不合法'); }
$process = isset($_GET['p']) ? trim((string)$_GET['p']) : '';
// 图片处理串只允许本站自己生成的形态:image/resize,w_NNN/quality,q_NN
$subResource = [];
if ($process !== '') {
if (!preg_match('#^image/(resize|crop|format|quality)([,/][a-z0-9_]+)*$#i', $process)) {
$process = ''; // 非法处理串直接忽略,按原图走
} else {
$subResource['x-oss-process'] = $process;
}
}
/* ---------------- 本机存储:直接读磁盘并流式输出 ---------------- */
if (fb_attach_is_local($key)) {
// 先鉴权再探文件,避免用 404/403 差异被探测对象是否存在
if (!fb_media_authorized($key)) { fb_media_fail(403, '无权访问该附件'); }
$path = fb_local_path_of($key);
if (!is_file($path)) { fb_media_fail(404, '附件不存在或已删除'); }
$size = (int)@filesize($path);
$mime = fb_media_local_mime($path, $key);
header('Content-Type: ' . $mime);
header('Content-Length: ' . $size);
header('Accept-Ranges: bytes');
header('Cache-Control: public, max-age=86400');
fb_media_send_local($path, $size);
exit;
}
/* ---------------- OSS 存储 ---------------- */
if (!fb_media_authorized($key)) { fb_media_fail(403, '无权访问该附件'); }
if (!fb_oss_enabled()) { fb_media_fail(503, '对象存储未配置'); }
$range = isset($_SERVER['HTTP_RANGE']) ? (string)$_SERVER['HTTP_RANGE'] : '';
$forwardedHeaders = false;
// 需要透传给浏览器的响应头(来自 OSS)
$passHeaders = ['content-type', 'content-length', 'content-range', 'accept-ranges',
'last-modified', 'etag', 'cache-control'];
list($status, $err) = fb_oss_stream_get(
$key,
$range,
$subResource,
function ($name, $value) use (&$forwardedHeaders, $passHeaders) {
if (!$forwardedHeaders && in_array($name, $passHeaders, true)) {
// 逐条透传(Content-Length / Content-Range 由 OSS 给出,原样转发)
header(fb_media_header_case($name) . ': ' . $value, false);
}
},
function ($chunk) use (&$forwardedHeaders) {
if (!$forwardedHeaders) {
// 第一次拿到 body 前,补上本站的通用头
header('Cache-Control: public, max-age=86400');
header('X-Content-Type-Options: nosniff');
$forwardedHeaders = true;
}
echo $chunk;
// 及时刷出,避免大视频在输出缓冲里堆积
if (ob_get_level() > 0) { @ob_flush(); }
@flush();
},
function ($st) {
// 状态行早于任何头/体到达:先把状态码定下来(206 分段、200 全量都在这里定)
if ($st >= 100 && $st < 600) { http_response_code($st); }
}
);
if ($status >= 200 && $status < 400) {
exit;
}
// 失败:状态码已通过 onStatus 设过;若尚未发送任何 body,再补一段可读原因
$code = $status === 404 ? 404 : ($status === 403 ? 403 : 502);
if (!headers_sent() && !$forwardedHeaders) { fb_media_fail($code, '读取附件失败:' . $err); }
http_response_code($code);
exit;
/* ==================================================================
* 辅助函数
* ================================================================== */
/**
* 鉴权:允许读取该对象键的条件(满足其一即可)——
* ① 管理员;
* ② 已入库(在 attachments 表)且所属帖子对当前访客可见;
* ③ 本人刚上传、尚未提交表单的「待入库」附件(上传后预览缩略图)。
* 三者都不满足即拒绝 —— 关键防止本站沦为「能读整个共用桶」的开放代理。
*/
function fb_media_authorized($key) {
if (fb_is_admin()) { return true; }
try {
$pdo = fb_db();
} catch (Throwable $e) {
// 连库失败时,退而求其次只认「本人待入库」
return fb_attach_pending_mine($key);
}
// ③ 本人刚上传、还没提交表单的预览
if (fb_attach_pending_mine($key)) { return true; }
// ② 已入库且帖子可见
$st = $pdo->prepare(
'SELECT t.visible
FROM attachments a
JOIN threads t ON t.id = a.thread_id
WHERE a.oss_key = ?
LIMIT 1'
);
$st->execute([$key]);
$row = $st->fetch();
if (!$row) { return false; } // 不是本站登记的附件 → 拒绝
if (fb_moderation_on() && (int)$row['visible'] !== 1) { return false; }
return true;
}
/** 本机附件的 MIME(finfo 优先,回落按扩展名) */
function fb_media_local_mime($path, $key) {
$ct = '';
if (function_exists('finfo_open')) {
$fi = @finfo_open(FILEINFO_MIME_TYPE);
if ($fi) { $ct = strtolower(trim((string)@finfo_file($fi, $path))); @finfo_close($fi); }
}
if ($ct === '' || $ct === 'application/octet-stream') {
$ct = fb_upload_mime_of_ext(fb_upload_key_ext($key));
}
return $ct ?: 'application/octet-stream';
}
/** 本地文件按 Range 流式输出(支持视频拖动) */
function fb_media_send_local($path, $size) {
$start = 0; $end = $size - 1;
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=(\d*)-(\d*)/', $_SERVER['HTTP_RANGE'], $m)) {
if ($m[1] !== '') { $start = (int)$m[1]; }
if ($m[2] !== '') { $end = (int)$m[2]; }
if ($start > $end || $start >= $size) {
http_response_code(416);
header('Content-Range: bytes */' . $size);
exit;
}
if ($end >= $size) { $end = $size - 1; }
http_response_code(206);
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
header('Content-Length: ' . ($end - $start + 1));
}
$fp = @fopen($path, 'rb');
if (!$fp) { exit; }
if ($start > 0) { @fseek($fp, $start); }
$remain = $end - $start + 1;
while ($remain > 0 && !feof($fp)) {
$buf = @fread($fp, min(8192, $remain));
if ($buf === false || $buf === '') { break; }
echo $buf;
$remain -= strlen($buf);
if (ob_get_level() > 0) { @ob_flush(); }
@flush();
}
@fclose($fp);
}
/** 把小写头名还原成常见的首字母大写形态 */
function fb_media_header_case($name) {
$map = [
'content-type' => 'Content-Type',
'content-length' => 'Content-Length',
'content-range' => 'Content-Range',
'accept-ranges' => 'Accept-Ranges',
'last-modified' => 'Last-Modified',
'etag' => 'ETag',
'cache-control' => 'Cache-Control',
];
return isset($map[$name]) ? $map[$name] : $name;
}
+48 -43
View File
@@ -1,18 +1,19 @@
<?php <?php
/** /**
* 附件上传接口 * 附件上传接口(两种存储方式都走「两步」,前端代码完全一致)
* ------------------------------------------------------------------ * ------------------------------------------------------------------
* 两种存储方式由后台设置决定(attachment.storage): * 存储方式由后台设置决定(attachment.storage):off / local / oss。
* 无论 local 还是 oss,浏览器都【不直连存储】,一律两步走本站:
* *
* ① oss(阿里云 OSS 直传,原行为) * 第一步:POST upload.php kind=&ext=&size=&mime=&csrf=
* POST upload.php kind=&ext=&size=&mime=&csrf= * → 服务器生成对象键,返回 host=upload.php?action=put 与随传字段。
* → 返回 host / key / policy / signature…,浏览器把文件直接 POST 到 OSS, * 第二步:浏览器把文件 multipart POST 到 upload.php?action=put,服务器校验后落地:
* 文件不经过本服务器。凭证来自 ECS 实例 RAM 角色的临时凭证(或静态 AK)。 * · local → 存到 uploads/YYYY/MM/ 下;
* · oss → 服务器用实例 RAM 角色的临时凭证,代理 PUT 到【内网 Endpoint】的私有桶。
* *
* ② local(存本机磁盘) * ★ 为什么 OSS 也要经服务器中转:桶是私有的、且只用内网 Endpoint(外网不可达),
* 第一步同上,只是返回的 host 指向本文件的 ?action=put; * 浏览器既拿不到可直连的 URL、也连不上内网地址,所以「浏览器直传」在此架构下不成立。
* 第二步浏览器把文件 multipart POST 到 upload.php?action=put, * 读取同理,走 media.php 代理(见该文件)。
* 服务端校验后落到 uploads/YYYY/MM/ 下。
* *
* 两种方式共用同一套校验:扩展名 / MIME 白名单、单文件大小、每 IP 频率、全站总量上限。 * 两种方式共用同一套校验:扩展名 / MIME 白名单、单文件大小、每 IP 频率、全站总量上限。
*/ */
@@ -50,19 +51,28 @@ if (!fb_csrf_ok(isset($_POST['csrf']) ? $_POST['csrf'] : '')) {
} }
/* ================================================================== /* ==================================================================
* 本机存储:接收文件本体 * 接收文件本体并落地(local → 本机磁盘;oss → 服务器代理 PUT 到内网 OSS)
* ------------------------------------------------------------------
* 私有桶 + 内网 Endpoint 下浏览器无法直传,所以 OSS 模式也走这里:
* 浏览器把文件 POST 给本站,本站再用服务器身份 PUT 进 OSS。
* ================================================================== */ * ================================================================== */
if ($putAt) { if ($putAt) {
if ($mode !== 'local') { fb_up_json(['ok' => false, 'error' => '当前存储方式不支持本机上传。'], 400); } if ($mode !== 'local' && $mode !== 'oss') {
fb_up_json(['ok' => false, 'error' => '当前存储方式不支持上传。'], 400);
}
$kind = strtolower(trim(isset($_POST['kind']) ? (string)$_POST['kind'] : '')); $kind = strtolower(trim(isset($_POST['kind']) ? (string)$_POST['kind'] : ''));
$key = trim(isset($_POST['key']) ? (string)$_POST['key'] : ''); $key = trim(isset($_POST['key']) ? (string)$_POST['key'] : '');
if ($kind !== 'image' && $kind !== 'video') { fb_up_json(['ok' => false, 'error' => '不支持的附件类型。'], 400); } 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_upload_kind_on($kind)) { fb_up_json(['ok' => false, 'error' => '该类型附件未开放上传。'], 403); }
if (!fb_attach_is_local($key) || !fb_upload_key_ok($key)) { if (!fb_upload_key_ok($key)) {
fb_up_json(['ok' => false, 'error' => '附件地址不合法,请重新上传。'], 400); fb_up_json(['ok' => false, 'error' => '附件地址不合法,请重新上传。'], 400);
} }
// key 的存储类型必须与当前 mode 一致(防止 local 会话塞 oss key 或反之)
$keyIsLocal = fb_attach_is_local($key);
if ($mode === 'local' && !$keyIsLocal) { fb_up_json(['ok' => false, 'error' => '附件地址与当前存储方式不符。'], 400); }
if ($mode === 'oss' && $keyIsLocal) { fb_up_json(['ok' => false, 'error' => '附件地址与当前存储方式不符。'], 400); }
$ext = fb_upload_key_ext($key); $ext = fb_upload_key_ext($key);
if (!fb_upload_ext_ok($kind, $ext)) { fb_up_json(['ok' => false, 'error' => '不支持的文件格式。'], 400); } if (!fb_upload_ext_ok($kind, $ext)) { fb_up_json(['ok' => false, 'error' => '不支持的文件格式。'], 400); }
@@ -96,6 +106,7 @@ if ($putAt) {
fb_up_json(['ok' => false, 'error' => '附件存储空间已满,暂时无法上传。'], 507); fb_up_json(['ok' => false, 'error' => '附件存储空间已满,暂时无法上传。'], 507);
} }
if ($mode === 'local') {
$path = fb_local_path_of($key); $path = fb_local_path_of($key);
$dir = dirname($path); $dir = dirname($path);
if (!is_dir($dir)) { @mkdir($dir, 0755, true); } if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
@@ -103,6 +114,19 @@ if ($putAt) {
fb_up_json(['ok' => false, 'error' => '保存文件失败,请稍后重试。'], 500); fb_up_json(['ok' => false, 'error' => '保存文件失败,请稍后重试。'], 500);
} }
@chmod($path, 0644); @chmod($path, 0644);
} else {
// OSS:服务器代理 PUT 到内网桶(流式,不整文件进内存)
if (!fb_oss_enabled()) {
fb_up_json(['ok' => false, 'error' => '对象存储未配置完整。'], 403);
}
list($ok, $ossErr, $ossStatus) = fb_oss_put_file($key, (string)$f['tmp_name'], $mime);
if (!$ok) {
fb_up_json(['ok' => false, 'error' => '写入对象存储失败:' . $ossErr], 502);
}
}
// 登记「待入库」:让上传后、提交表单前的缩略图预览能通过 media.php 鉴权
fb_attach_pending_mark($key, $kind, $mime, $size);
fb_up_json([ fb_up_json([
'ok' => true, 'ok' => true,
@@ -156,10 +180,20 @@ if ($cap > 0 && (fb_attach_total_bytes($pdo) + $size) > $cap) {
fb_up_json(['ok' => false, 'error' => '附件存储空间已满,暂时无法上传,请稍后再试。'], 507); fb_up_json(['ok' => false, 'error' => '附件存储空间已满,暂时无法上传,请稍后再试。'], 507);
} }
/* ---------- 本机存储:返回一个指向本文件的上传地址 ---------- */ /* ---------- 领取上传凭证:local 与 oss 都指向本站 action=put ----------
* 私有桶 + 内网 Endpoint 下浏览器无法直传 OSS,所以两种模式统一为:
* 第一步(这里):服务器生成对象键、返回本站上传地址与随传字段;
* 第二步:浏览器把文件 multipart POST 到 upload.php?action=put,
* 由服务器落地(本机磁盘 / 或代理 PUT 到内网 OSS)。
* 前端 app.js 因此对两种模式走完全相同的代码路径。
*/
if ($mode === 'local') { if ($mode === 'local') {
$key = fb_local_make_key($ext); $key = fb_local_make_key($ext);
} else {
$key = fb_oss_make_key($ext);
}
fb_rate_hit('upload'); fb_rate_hit('upload');
fb_up_json([ fb_up_json([
'ok' => true, 'ok' => true,
'credential' => [ 'credential' => [
@@ -176,32 +210,3 @@ if ($mode === 'local') {
'mime' => $mime, 'mime' => $mime,
'kind' => $kind, '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,
]);