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:
@@ -9,7 +9,7 @@
|
||||
|
||||
- 纯 PHP + SQLite,**无任何第三方依赖**(不需要 Composer、不需要 MySQL)
|
||||
- 自带**趣味问答**人机验证(防机器人)+ 蜜罐字段 + 提交耗时校验 + 按 IP 频率限制
|
||||
- 支持**图片 / 视频附件**:浏览器直传阿里云 OSS,**不占用服务器磁盘与带宽**(不配置也可正常运行,只是不显示上传入口)
|
||||
- 支持**图片 / 视频附件**:可存本机磁盘,或存**私有**阿里云 OSS 桶(走**内网 Endpoint + 服务器代理**,公网无法直连桶);不配置也可正常运行,只是不显示上传入口
|
||||
- 自带只读 **API / Markdown 摘要**接口,方便自动化脚本或 AI 读取问题并分析
|
||||
- 上手即用:clone 下来直接部署,无需建库、无需装依赖(含深色模式与响应式布局)
|
||||
|
||||
@@ -25,7 +25,8 @@ feedback/
|
||||
├─ thread.php 问题详情 + 回复(含"官方答复"与状态管理、附件展示)
|
||||
├─ new.php 发布新问题(含图片 / 视频上传)
|
||||
├─ admin.php 管理后台(口令登录:审核 / 管理全部问题 / 站点设置 / 操作日志)
|
||||
├─ upload.php 附件上传接口(OSS 直传下发凭证;本机模式下接收文件本体)
|
||||
├─ upload.php 附件上传接口(两步:领对象键 → 收文件本体,local 落盘 / OSS 代理 PUT)
|
||||
├─ media.php 附件代理输出(私有桶 + 内网 Endpoint 下图片/视频的唯一对外读取通道,带鉴权与 Range)
|
||||
├─ captcha.php "换一题"用的人机验证刷新接口(返回 JSON)
|
||||
├─ api.php 只读数据接口(需令牌),供自动化 / AI 读取
|
||||
├─ cron.php 附件过期清理 + 通知邮件补发(配计划任务)
|
||||
@@ -227,7 +228,7 @@ php /www/wwwroot/你的域名/feedback/cron.php
|
||||
|---|---|---|---|
|
||||
| **关闭** | — | 暂时不接受附件 | 上传入口自动隐藏,其余功能照常 |
|
||||
| **本机硬盘** | 站点目录 `feedback/uploads/YYYY/MM/` | 想零配置、立刻可用;量小、访问不密集 | 占用服务器磁盘与带宽;需保证目录对 PHP 可写 |
|
||||
| **阿里云 OSS** | 你自己的 Bucket(浏览器直传) | 量大、正式对外 | 需先建桶并授权;会有少量 OSS 费用 |
|
||||
| **阿里云 OSS** | 你自己的**私有** Bucket(经服务器代理读写,走内网 Endpoint) | 量大、正式对外;希望桶私有、公网不可直连 | 附件流量经服务器中转(占带宽);需先建**同地域**桶并授权 |
|
||||
|
||||
> **切换不会让旧附件失效**:每条附件走哪种存储,由它自己的对象键前缀决定——
|
||||
> `local/…` 一律按本机文件读,`fb/…`(或你配的 `oss.prefix`)一律按 OSS 读。
|
||||
@@ -244,15 +245,27 @@ php /www/wwwroot/你的域名/feedback/cron.php
|
||||
|
||||
---
|
||||
|
||||
### 5.1 OSS 直传的原理与开通(约 15 分钟)
|
||||
### 5.1 OSS 代理架构的原理与开通(约 15 分钟)
|
||||
|
||||
**为什么这样设计**:如果让用户把文件传到本站服务器,硬盘和带宽会被迅速吃满。所以本程序让浏览器
|
||||
**直接把文件 POST 到 OSS**,服务器只做两件事:
|
||||
**架构:私有桶 + 内网 Endpoint + 服务器代理**
|
||||
|
||||
1. 下发一段**有时效、有范围**的上传凭证(PostObject 策略签名 + HMAC-SHA1);
|
||||
2. 提交表单时用 HEAD **回源复核**对象确实存在、大小与类型合规,再把元信息写进 SQLite。
|
||||
本程序把 OSS 桶设为**私有**,且只使用**内网 Endpoint**(`*-internal.aliyuncs.com`,只有同地域的阿里云 ECS 能连)。
|
||||
这样一来,浏览器**既拿不到可直连的 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 鉴权方式:两种,后台可选
|
||||
|
||||
@@ -265,7 +278,7 @@ php /www/wwwroot/你的域名/feedback/cron.php
|
||||
> 「读取实例 RAM 角色失败(HTTP 404)」——后台「存储体检」会直接报出来。
|
||||
> 到 ECS 控制台看实例详情里的「RAM 角色名」,或访问
|
||||
> `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 开通步骤
|
||||
|
||||
@@ -293,8 +306,8 @@ AccessKeyId / AccessKeySecret / SecurityToken / Expiration
|
||||
|
||||
| 项 | 建议值 |
|
||||
|---|---|
|
||||
| 地域 | 选离你 ECS 近的(如华东1 杭州、华北2 北京) |
|
||||
| 读写权限 | **公共读**(简单);想更严格选**私有**,程序会自动为展示链接签名 |
|
||||
| 地域 | ⚠️ **必须与 ECS 同地域**(内网 Endpoint 跨地域不通);ECS 在哪个地域就选哪个 |
|
||||
| 读写权限 | **私有**(本程序经 `media.php` 代理读取,桶无需公共读;私有最安全) |
|
||||
| 存储类型 | 标准存储(低频访问会额外产生取回费用,不建议) |
|
||||
|
||||
**第 2 步:建一条最小权限的 RAM 策略**(RAM 控制台 → 权限策略 → 创建策略 → 脚本编辑)
|
||||
@@ -351,34 +364,33 @@ curl -s http://100.100.100.200/latest/meta-data/ram/security-credentials/FeedLit
|
||||
返回 `404` / `Forbidden` / 连不通 → 说明**角色名写错**或**角色没授予实例**,先解决再往下走。
|
||||
(服务器上没有 curl 也没关系,程序自带体检,见第 8 步。)
|
||||
|
||||
**第 6 步:配置 Bucket 的 CORS**(Bucket → 数据安全 → 跨域设置 → 创建规则)
|
||||
**第 6 步:无需配置 CORS,也无需绑定自定义域名**
|
||||
|
||||
浏览器是**跨域**把文件直接 POST 到 OSS 的,必须放行:
|
||||
代理架构下浏览器**不直接访问 OSS**(既不同域、也不碰公网 Endpoint),所以:
|
||||
|
||||
| 项 | 填什么 |
|
||||
|---|---|
|
||||
| 来源 Sources | `https://你的域名`(你的站点域名,可多行) |
|
||||
| 允许方法 Methods | 勾 **POST、GET、HEAD** |
|
||||
| 允许 Headers | `*` |
|
||||
| 暴露 Headers | `ETag` |
|
||||
- **不用配 CORS**:不存在浏览器跨域 POST 到 OSS 的环节;
|
||||
- **不用绑自定义域名**:附件一律经本站 `media.php` 输出,图片点开是**预览**而非下载,不受 OSS 默认域名的 `Content-Disposition` 影响。
|
||||
|
||||
**第 7 步:填写 `config.php` 的 `oss` 段**
|
||||
(这两件事在旧的「浏览器直传 + 公共读」方案里是必需的,代理方案把它们都省掉了。)
|
||||
|
||||
**第 7 步:填写 `config.php` 的 `oss` 段(或在后台「站点设置」里填)**
|
||||
|
||||
```php
|
||||
'oss' => [
|
||||
'enabled' => true,
|
||||
'endpoint' => 'oss-cn-hangzhou.aliyuncs.com', // 不含 http://
|
||||
'endpoint' => 'oss-cn-qingdao.aliyuncs.com', // 不含 http://;填公网形式即可,程序自动转内网
|
||||
'bucket' => '你的bucket名',
|
||||
'instance_role' => 'FeedLiteOssRole', // ★ 第 3 步的角色名
|
||||
'use_internal_endpoint' => true, // ★ 走内网(同地域必须开;跨地域不可达时改 false)
|
||||
'prefix' => 'fb', // 对象键前缀,多应用共用一个桶时用它隔离
|
||||
|
||||
// 以下是可选增强,一般保持默认即可
|
||||
// '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 步:跑一次体检**
|
||||
|
||||
@@ -394,44 +406,45 @@ php cron.php
|
||||
```
|
||||
附件:共 0 个,已清理超过 365 天的附件 0 个(剩余 0 个,占用 0 B)。
|
||||
附件存储凭证: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` 是不是填了角色名。
|
||||
- 出现 `⚠️ 取不到凭证:…` → 按提示回到第 4 / 5 步排查。
|
||||
- 「**附件存储凭证**」有尾号、有效期,且「**桶连通性:✅**」→ 配置成功,去发帖页试传一张图即可。
|
||||
- 「桶连通性:❌ … HTTP 0」→ 连不上桶:多半是**桶与 ECS 不同地域**(内网不可达),换同地域的桶,或后台关掉「使用内网 Endpoint」。
|
||||
- 「附件存储未启用(…)」→ 第 7 步没保存成功:检查 `enabled` 是否 `true`、`instance_role` 是否填了角色名。
|
||||
- `⚠️ 取不到凭证:…` → 按提示回到第 4 / 5 步排查。
|
||||
|
||||
**第 9 步(强烈推荐):绑定自定义域名**
|
||||
**第 9 步:无需绑定自定义域名**
|
||||
|
||||
Bucket → 传输管理 → 域名管理 → 绑定域名。不绑也能用,但 OSS 默认域名会带
|
||||
`Content-Disposition: attachment`,图片点开是**下载**而不是预览。
|
||||
绑定后把域名填到 `oss.domain`(如 `https://fb-img.example.com`)。
|
||||
|
||||
> 控制台会给出一个 CNAME 目标,去 DNS 加一条 CNAME 记录;若走 HTTPS,还需要在 OSS 上传该域名的证书。
|
||||
代理架构下附件经 `media.php` 输出,图片点开是预览而非下载,**不需要**再给桶绑自定义域名。
|
||||
(`oss.domain` 配置项保留但已不参与附件读取,可留空。)
|
||||
|
||||
---
|
||||
|
||||
#### 可调项(都在 `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_v2` | `false` | `true` = **加固模式**:先取一次性 token 再读元数据(防被 SSRF 探测) |
|
||||
| `metadata_ttl` | `600` | 加固模式 token 的有效期(秒,最长 21600) |
|
||||
| `credential_cache` | 空 = 与数据库同目录的 `oss.sts.php` | 凭证缓存文件(含 `<?php exit; ?>` 头 + 权限 0600,站点配置失误也无法被下载) |
|
||||
| `credential_refresh_margin` | `300` | 到期前多少秒就提前换新,避免恰好在过期瞬间用旧凭证 |
|
||||
| `sign_ttl` | `600` | 上传凭证有效期(秒) |
|
||||
| `visibility` | `public` | 改成 `private` 时,展示链接自动带上签名 |
|
||||
| `image_process` | `true` | 列表缩略图借用 OSS 图片处理,省流量 |
|
||||
| `prefix` | `fb` | 对象键前缀:`fb/2026/09/xxxx.jpg`。**多应用共用一个桶时用它隔离**(各自用不同前缀 + RAM 策略按前缀授权) |
|
||||
| `image_process` | `true` | 缩略图借用 OSS 图片处理(服务端缩放后再转发,省下行流量;GIF 自动跳过以保留动画) |
|
||||
| `verify` | `true` | 提交表单时 HEAD 回源复核(存在性/大小/类型),防伪造 |
|
||||
|
||||
#### 程序做了哪些防呆
|
||||
|
||||
- **不会每个请求都问元数据服务**:临时凭证有**进程内记忆 + 本地文件缓存**,到 `credential_refresh_margin` 才换新。
|
||||
- **上传策略有效期会被夹在临时凭证有效期之内** —— 否则 OSS 会因为「策略比凭证晚到期」而直接拒签。
|
||||
- **`security-token` 会自动随请求发出**:PostObject 表单里多一个 `x-oss-security-token` 域;
|
||||
HEAD / DELETE 的签名 URL 上也会带 `security-token`。**它不参与签名计算**,只是让 OSS 认出这是谁的临时身份。
|
||||
- **`media.php` 有鉴权边界**:对象键必须在本站附件表(或本人刚上传的待入库登记)且帖子对当前访客可见,否则 403 —— **防止本站沦为「能读整个桶」的开放代理**(共用桶时尤其致命)。
|
||||
- **上传成功先登记「待入库」**:解决「上传完成但还没提交表单」期间缩略图预览的鉴权(登记表 2 小时过期,`cron.php` 清理;提交入库后自动转正)。
|
||||
- **`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)` | 角色名写错,或角色没授予该实例 |
|
||||
| `⚠️ 取不到凭证:…(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」鉴权(后台可切),或走本机硬盘模式 |
|
||||
| 桶连通性 ❌(HTTP 0) | **桶与 ECS 不同地域**,内网 Endpoint 不可达 → 换同地域的桶,或后台关掉「使用内网 Endpoint」 |
|
||||
| 上传返回 403 / AccessDenied | 角色策略与 Bucket(或前缀)不匹配:至少要 `oss:PutObject`,读取还需 `oss:GetObject`、清理需 `oss:DeleteObject` |
|
||||
| 上传返回 502「写入对象存储失败」 | 看具体报错:SignatureDoesNotMatch → 校准服务器 NTP 时间;AccessDenied → RAM 授权;HTTP 0 → 地域/网络 |
|
||||
| 图片/视频显示 403「无权访问该附件」 | 该附件不属于任何公开帖(先审后显未通过),或对象键不是本站登记的 —— 属正常拦截 |
|
||||
| `SignatureDoesNotMatch` | ① 服务器系统时间不准(签名带时间戳),校准 NTP;② **使用了临时凭证但 `security-token` 没算进签名**、或带 body 的 PUT 没把 Content-Type 算进签名 —— 见 `lib/oss.php` 里 `fb_oss_sign_v1()` / `fb_oss_auth_headers()` 的注释 |
|
||||
| 站点不在阿里云 ECS 上 | 改用「静态 AccessKey」鉴权(后台可切),内网开关关掉走公网 Endpoint;或干脆用本机硬盘模式 |
|
||||
|
||||
|
||||
### 5.2 限额与清理
|
||||
@@ -548,7 +562,7 @@ curl "https://你的域名/feedback/api.php?token=XXX&action=digest&status=open"
|
||||
- 全部输出经过 HTML 转义,SQL 全部使用预处理语句。
|
||||
- 人机验证采用一次性会话题目,答对即焚;答错 5 次自动换题。
|
||||
- 另有:蜜罐字段、最短填写耗时校验、按 IP 的每小时发帖/回复上限、CSRF 令牌。
|
||||
- 附件不经过本站服务器;服务器上**不保存任何 OSS 长期密钥**,改用 ECS 实例 RAM 角色的临时凭证(见 5.1)。
|
||||
- OSS 附件走**私有桶 + 内网 Endpoint + 服务器代理**:公网无法直连桶,读取经 `media.php` 鉴权(对象必须属于本站且帖子对当前访客可见,**防开放代理**);服务器上**不保存任何 OSS 长期密钥**,改用 ECS 实例 RAM 角色的临时凭证(见 5.1)。
|
||||
- `data/` 目录用 `.htaccess` 拒绝一切直接访问;请勿删除该文件。
|
||||
|
||||
### 更换管理员口令 / 令牌
|
||||
@@ -658,14 +672,17 @@ A:默认在 `data/feedback.db`(及其 `-wal`/`-shm` 文件)。定期打包
|
||||
**Q:看不到上传按钮?**
|
||||
A:说明 OSS 未配置或 `oss.enabled` 为 `false`。填好 `config.php` 的 `oss` 段(`endpoint` / `bucket` / `instance_role`)并改为 `true` 即可。
|
||||
|
||||
**Q:上传失败提示"上传到存储失败(403)"?**
|
||||
**Q:上传失败提示"写入对象存储失败"?**
|
||||
A:三种常见原因:
|
||||
1. CORS 未配置或来源域名不匹配 → 回到 Bucket「跨域设置」补上你的站点域名,方法勾 POST;
|
||||
2. 角色策略与 Bucket 不匹配 → 至少要有 `oss:PutObject`(私有 Bucket 还需 `GetObject` / `DeleteObject`);
|
||||
1. **桶与 ECS 不同地域** → 内网 Endpoint 不可达(报 HTTP 0 / 连接超时):换同地域的桶,或后台关掉「使用内网 Endpoint」;
|
||||
2. 角色策略与 Bucket(或前缀)不匹配 → 至少要有 `oss:PutObject`(读取还需 `oss:GetObject`、清理需 `oss:DeleteObject`);
|
||||
3. 取不到临时凭证 → 跑 `php cron.php` 看体检输出(多半是角色名写错或角色没授予实例)。
|
||||
|
||||
**Q:怎么确认临时凭证是好的?**
|
||||
A:`php cron.php --token=你的api_token`,会打印一行「附件存储凭证:ECS 实例 RAM 角色(AK 尾号 …),已取得临时安全令牌,有效期至 …」。没有这行或带 ⚠️ 就是有问题。
|
||||
**Q:怎么确认临时凭证和桶是好的?**
|
||||
A:`php cron.php`(命令行方式不需 token),会打印「附件存储凭证:ECS 实例 RAM 角色(AK 尾号 …)…」和「桶连通性:✅ http://你的bucket.oss-cn-xxx-internal.aliyuncs.com …— 连通正常」。两行都正常就没问题;带 ❌ 或 ⚠️ 按提示排查。
|
||||
|
||||
**Q:图片/视频打不开,提示"无权访问该附件(403)"?**
|
||||
A:这是 `media.php` 的正常拦截——该附件所属的帖子还没通过审核(先审后显),或对象键不是本站登记的。审核通过后即可正常显示;管理员登录状态下始终可见。
|
||||
|
||||
**Q:提示"附件存储空间已满"?**
|
||||
A:全站附件总量达到 `upload.total_cap_bytes`(默认 1GB)。可在 OSS 控制台清理,或调大该值。
|
||||
|
||||
@@ -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.verify', $post('oss.verify') ? 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_mod_log('settings', null, '保存站点设置:存储=' . $storage);
|
||||
@@ -549,9 +550,9 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
||||
<select name="attachment_storage">
|
||||
<option value="off"<?php echo $mode === 'off' ? ' selected' : ''; ?>>关闭(不开放上传)</option>
|
||||
<option value="local"<?php echo $mode === 'local' ? ' selected' : ''; ?>>本机硬盘(站点 uploads/ 目录)</option>
|
||||
<option value="oss"<?php echo $mode === 'oss' ? ' selected' : ''; ?>>阿里云 OSS(浏览器直传)</option>
|
||||
<option value="oss"<?php echo $mode === 'oss' ? ' selected' : ''; ?>>阿里云 OSS(私有桶 · 服务器代理)</option>
|
||||
</select>
|
||||
<i class="hint">本机:文件落在站点目录,占用服务器磁盘与带宽,但零配置、立刻可用;OSS:文件不经过服务器,需先建桶并授权。</i>
|
||||
<i class="hint">本机:文件落在站点 uploads/ 目录;OSS:文件经服务器代理写入私有桶(走内网 Endpoint),公网无法直连桶。两种方式都需要服务器磁盘能放下单个上传文件。</i>
|
||||
</label>
|
||||
|
||||
<div class="grid2">
|
||||
@@ -574,9 +575,10 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<input type="text" name="oss_bucket" value="<?php echo e((string)fb_cfg('oss.bucket', '')); ?>"></label>
|
||||
<label class="field"><span>鉴权方式</span>
|
||||
@@ -591,23 +593,19 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
||||
<label class="field"><span>AccessKeySecret(留空=不修改)</span>
|
||||
<input type="password" name="oss_secret_key" autocomplete="new-password"
|
||||
placeholder="<?php echo (string)fb_cfg('oss.secret_key', '') !== '' ? '已设置,留空保持不变' : '未设置'; ?>"></label>
|
||||
<label class="field"><span>自定义访问域名(可选)</span>
|
||||
<input type="text" name="oss_domain" placeholder="https://fb-img.example.com" value="<?php echo e((string)fb_cfg('oss.domain', '')); ?>"></label>
|
||||
<label class="field"><span>对象键前缀</span>
|
||||
<input type="text" name="oss_prefix" value="<?php echo e((string)fb_cfg('oss.prefix', 'fb')); ?>"></label>
|
||||
<label class="field"><span>桶读写权限</span>
|
||||
<select name="oss_visibility">
|
||||
<option value="public"<?php echo ((string)fb_cfg('oss.visibility', 'public')) === 'public' ? ' selected' : ''; ?>>公共读</option>
|
||||
<option value="private"<?php echo ((string)fb_cfg('oss.visibility', 'public')) === 'private' ? ' selected' : ''; ?>>私有(展示时签名)</option>
|
||||
</select></label>
|
||||
<label class="field"><span>访问协议</span>
|
||||
<label class="field"><span>公网访问协议(走公网 Endpoint 时)</span>
|
||||
<select name="oss_url_scheme">
|
||||
<option value="https"<?php echo ((string)fb_cfg('oss.url_scheme', 'https')) === 'https' ? ' selected' : ''; ?>>https</option>
|
||||
<option value="http"<?php echo ((string)fb_cfg('oss.url_scheme', 'https')) === 'http' ? ' selected' : ''; ?>>http</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<label class="check"><input type="checkbox" name="oss_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_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>
|
||||
<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'): ?>
|
||||
<span>鉴权方式:<b><?php echo e($ossStat['label']); ?></b></span>
|
||||
<span>凭证状态:<?php echo !empty($ossStat['ok']) ? ('已取得 AccessKey(尾号 ' . e($ossStat['ak_tail']) . ')✅') : ('未取得 ❌ ' . e((string)$ossStat['error'])); ?></span>
|
||||
<span>Bucket:<?php echo e((string)fb_cfg('oss.bucket', '')); ?> / Endpoint:<?php echo e((string)fb_cfg('oss.endpoint', '')); ?></span>
|
||||
<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&probe=1">测一下桶连通性</a>
|
||||
<span class="muted small">(会真实访问一次 OSS,跨地域不通时可能等待数秒)</span></span>
|
||||
<?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>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
/* FeedLite · 轻反馈 —— 轻量交互(含图片/视频直传 OSS) */
|
||||
/* FeedLite · 轻反馈 —— 轻量交互(图片/视频上传:经服务器中转落地,前端对 local/OSS 走同一路径) */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 附件直传 ---------- */
|
||||
/* ---------- 附件上传(经服务器中转,local/OSS 同一路径) ---------- */
|
||||
function initUploaderRoot(root) {
|
||||
var csrf = root.getAttribute('data-csrf') || '';
|
||||
var signUrl = root.getAttribute('data-sign-url') || 'upload.php';
|
||||
|
||||
+31
-22
@@ -83,32 +83,35 @@ return [
|
||||
'close_mail_subject' => '关于你反馈的问题《%s》', // %s 会替换成问题标题
|
||||
],
|
||||
|
||||
/* ---------------- 附件上传(阿里云 OSS 直传) ----------------
|
||||
* 浏览器把文件「直接」传到 OSS,不经过本站服务器 —— 因此不占用服务器磁盘、不消耗服务器带宽,
|
||||
* 也就不会出现"硬盘被挤爆"的风险。服务器只做两件事:
|
||||
* 1) 下发一段有时效的上传凭证(签名),限制能传到哪个路径、多大、什么类型;
|
||||
* 2) 提交表单时校验对象确实存在且合规,并把元信息写进 SQLite。
|
||||
/* ---------------- 附件上传(阿里云 OSS · 服务器代理) ----------------
|
||||
* ★ 架构:私有桶 + 内网 Endpoint,读写全部经服务器代理
|
||||
* 桶设为【私有】、且只用【内网 Endpoint】(*-internal.aliyuncs.com,仅同地域 ECS 可达),
|
||||
* 浏览器既不直传、也不直连。数据流:
|
||||
* 上传:浏览器 → upload.php → 服务器 PUT 到内网 OSS
|
||||
* 读取:浏览器 → media.php → 服务器 GET 内网 OSS 并流式转发(支持 Range)
|
||||
* 好处:桶可彻底私有、公网完全看不到 Endpoint、AccessKey 永不出服务器。
|
||||
* 代价:附件流量经过服务器(占带宽),需保证服务器磁盘临时空间够放单个上传文件。
|
||||
*
|
||||
* ★ 凭证方式:ECS 实例 RAM 角色(唯一支持的方式,服务器上不保存长期 AccessKey)
|
||||
* 给这台 ECS 绑定一个 RAM 角色后,程序从内网元数据服务
|
||||
* (http://100.100.100.200,链路本地地址、不出公网)读取一份「有时效、
|
||||
* 会自动周期轮换」的临时凭证来签名。AccessKey 永不落盘,也永不进本文件。
|
||||
* ★ 凭证方式:ECS 实例 RAM 角色(推荐,服务器上不保存长期 AccessKey)或静态 AccessKey。
|
||||
* 实例角色下,程序从内网元数据服务(http://100.100.100.200,链路本地、不出公网)
|
||||
* 读取一份「有时效、会自动轮换」的临时凭证来签名。AccessKey 永不落盘、永不进本文件。
|
||||
*
|
||||
* ★ 开通步骤(详见 README.md「5.1 开通与配置」):
|
||||
* ① 创建 Bucket(读写权限建议「公共读」;想更严可用 private,本程序会自动签发访问 URL)
|
||||
* ② RAM 建权限策略:只授予该 Bucket(某前缀下)的 oss:PutObject / oss:GetObject / oss:DeleteObject
|
||||
* ★ 开通步骤(详见 README.md):
|
||||
* ① 创建【与 ECS 同地域】的 Bucket,读写权限设为「私有」
|
||||
* ⚠️ 内网 Endpoint 跨地域不通!ECS 在哪个地域,桶就建在哪个地域。
|
||||
* ② RAM 建权限策略:只授予该 Bucket(某前缀下)的 oss:PutObject / GetObject / DeleteObject
|
||||
* ③ RAM 建角色(如 FeedLiteOssRole)并赋予上一步的策略
|
||||
* ④ ECS 控制台把该角色【授予本实例】
|
||||
* ⑤ Bucket → 数据安全 → 跨域设置(CORS):来源填你的站点域名,方法勾 POST/GET/HEAD
|
||||
* ⑥ 把 endpoint / bucket / instance_role 填到下面,最后 enabled = true
|
||||
* ⑦(可选但强烈推荐)绑定自定义域名,避免默认域名带 Content-Disposition 被迫下载
|
||||
* ⑤ 把 endpoint / bucket / instance_role 填到下面,use_internal_endpoint 保持 true,enabled = true
|
||||
* ⑥ 后台「站点设置 → 存储体检」确认桶连通性为 ✅
|
||||
* (代理架构下【不需要】配置 CORS,也【不需要】绑定自定义域名)
|
||||
*
|
||||
* ★ 前提:站点必须运行在【阿里云 ECS】上并且已绑定 RAM 角色;
|
||||
* 未绑定则取不到凭证,上传接口会明确报错(其它功能不受影响)。
|
||||
*/
|
||||
'oss' => [
|
||||
'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',
|
||||
|
||||
// ★ ECS 实例 RAM 角色名 —— 必须与【ECS 实例实际绑定的角色名】逐字一致。
|
||||
@@ -117,6 +120,12 @@ return [
|
||||
// 建的是 MyOssRole,授予实例后生效名可能是系统预置策略对应的角色),以查询结果为准。
|
||||
'instance_role' => '',
|
||||
|
||||
// ★ 是否用内网 Endpoint(默认开)。开启后程序自动把 endpoint 改写成 *-internal 形式,
|
||||
// 走阿里云内网访问 OSS:免公网流量费、延迟更低,且公网完全无法直连该地址。
|
||||
// ⚠️ 仅当【桶与本机 ECS 同地域】时可用;跨地域时内网不可达,必须关掉本开关
|
||||
// (回落公网 Endpoint)或改用同地域的桶。后台「存储体检」会探测并提示。
|
||||
'use_internal_endpoint' => true,
|
||||
|
||||
// ---- 以下一般不用改 ----
|
||||
'metadata_url' => '', // 留空=http://100.100.100.200(ECS 内网元数据服务)
|
||||
'metadata_v2' => false, // true=加固模式(先取一次性 token 再读元数据,防 SSRF 探测)
|
||||
@@ -124,13 +133,13 @@ return [
|
||||
'credential_cache' => '', // 凭证缓存文件;留空=与数据库同目录的 oss.sts.php
|
||||
'credential_refresh_margin' => 300, // 到期前多少秒就提前换新(秒)
|
||||
|
||||
'domain' => '', // 自定义访问域名,如 https://fb-img.example.com(留空用默认域名)
|
||||
'url_scheme' => 'https', // 拼接访问地址时使用的协议
|
||||
'domain' => '', // 代理架构下【不再使用】:附件一律经本站 media.php 输出,无需自定义域名
|
||||
'url_scheme' => 'https', // 公网 Endpoint 时使用的协议(内网 Endpoint 强制走 http)
|
||||
'prefix' => 'fb', // 对象键前缀:fb/2026/09/xxxxxxxx.jpg
|
||||
'visibility' => 'public', // public=直接可读 | private=展示时生成签名 URL
|
||||
'sign_ttl' => 600, // 上传凭证有效期(秒)
|
||||
'link_ttl' => 604800, // private 模式下展示链接的有效期(秒)
|
||||
'image_process' => true, // 列表缩略图使用 OSS 图片处理(免费额度内,省流量)
|
||||
'visibility' => 'private', // 记录桶的读写权限:代理架构下推荐 private(浏览器永不直连桶)
|
||||
'sign_ttl' => 600, // 服务器内部签名 URL 的有效期(秒),仅服务器侧使用
|
||||
'link_ttl' => 604800, // 兼容保留;代理读取时签名有效期由代码收敛到 ≤300 秒
|
||||
'image_process' => true, // 列表缩略图使用 OSS 图片处理(服务端缩放后再转发,省下行流量)
|
||||
'verify' => true, // 提交时 HEAD 复核对象(防伪造/防超限)
|
||||
'host_override' => '', // 仅特殊网关/联调使用;留空自动按 bucket.endpoint 拼接
|
||||
],
|
||||
|
||||
@@ -37,6 +37,14 @@ echo "附件:共 {$kept} 个,已清理超过 {$days} 天的附件 {$gone}
|
||||
. (int)$pdo->query('SELECT COUNT(*) FROM attachments')->fetchColumn() . " 个,占用 "
|
||||
. 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. 附件存储凭证自检 ----------------
|
||||
* 顺手确认一次「能不能从 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['ok']) { $line .= ' ⚠️ 取不到凭证:' . ($cs['error'] !== '' ? $cs['error'] : '未知原因'); }
|
||||
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 {
|
||||
echo "附件存储未启用(请在 config.php 的 oss 段填写 endpoint / bucket / instance_role 并置 enabled = true)。\n";
|
||||
}
|
||||
|
||||
@@ -394,10 +394,63 @@ function fb_attach_save(PDO $pdo, $threadId, $replyId, array $list) {
|
||||
$i, $now,
|
||||
]);
|
||||
$n++;
|
||||
// 转正成功即从「待入库」表移除(media.php 之后改走 attachments 表鉴权)
|
||||
fb_attach_pending_clear($it['key']);
|
||||
}
|
||||
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 => [...]]] */
|
||||
function fb_attach_for_thread(PDO $pdo, $threadId) {
|
||||
$st = $pdo->prepare('SELECT * FROM attachments WHERE thread_id = ? ORDER BY seq ASC, id ASC');
|
||||
|
||||
+24
@@ -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_reply ON attachments(reply_id)');
|
||||
$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'
|
||||
CREATE TABLE IF NOT EXISTS rate_limit (
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ function fb_flash_box($msg, $type = 'error') {
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染附件上传控件(图片 / 视频,浏览器直传 OSS)
|
||||
* 渲染附件上传控件(图片 / 视频;上传经服务器中转落地,local/OSS 前端同一路径)
|
||||
* 需置于 <form> 内部:会输出一个 name="attachments" 的隐藏字段,
|
||||
* 由 assets/app.js 在文件上传成功后回填 JSON 描述。
|
||||
*
|
||||
|
||||
+369
-91
@@ -1,9 +1,21 @@
|
||||
<?php
|
||||
/**
|
||||
* 阿里云 OSS —— 浏览器直传支持(零依赖,只用 PHP 内置函数)
|
||||
* 阿里云 OSS —— 服务器代理读写(零依赖,只用 PHP 内置函数)
|
||||
* ------------------------------------------------------------------
|
||||
* 只用 HMAC-SHA1 签名(PostObject 策略签名 / V1 签名 URL),
|
||||
* 不依赖官方 SDK,也不依赖 ext-curl(有 curl 时自动优先使用)。
|
||||
* 只用 HMAC-SHA1 的 V1 签名,不依赖官方 SDK。
|
||||
*
|
||||
* ★ 架构:私有桶 + 内网 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'); }
|
||||
|
||||
@@ -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']; }
|
||||
|
||||
/** 上传入口 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() {
|
||||
$o = trim((string)fb_oss_cfg('host_override', ''));
|
||||
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';
|
||||
$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() {
|
||||
$d = trim((string)fb_oss_cfg('domain', ''));
|
||||
if ($d !== '') {
|
||||
@@ -257,22 +322,13 @@ function fb_oss_make_key($ext) {
|
||||
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 */
|
||||
/** 拼一个对象在 OSS 上的访问 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 校验、删除、签名直传)
|
||||
* 生成 OSS V1 签名参数(服务器侧用:HEAD 校验、DELETE 清理、GET 代理读取的签名 URL)
|
||||
* StringToSign = METHOD "\n" Content-MD5 "\n" Content-Type "\n" Expires "\n" Resource
|
||||
* 其中 Resource = /bucket/key,若使用临时凭证则再拼上 "?security-token=<token>"。
|
||||
* @return array ['OSSAccessKeyId'=>..,'Expires'=>..,'Signature'=>..]
|
||||
@@ -283,16 +339,35 @@ function fb_oss_object_url($key, $forcePublicBase = false) {
|
||||
* 签名用的 SK 与随请求发出的 security-token 就来自不同批次 → SignatureDoesNotMatch。
|
||||
* ② 【security-token 必须算进 Resource】。它是以 URL 查询参数(子资源)形式出现的,
|
||||
* OSS 计算签名时会把它连同 Resource 一起算。若照搬"官方说 token 不参与签名"而只把它
|
||||
* 挂在 URL 上,PUT/HEAD/DELETE 这类用签名 URL 的请求会全部 403 SignatureDoesNotMatch
|
||||
* (PostObject 表单直传不受影响,所以只有"浏览器直传能成功、删不掉"这种诡异现象)。
|
||||
* 挂在 URL 上,PUT/HEAD/DELETE/GET 这类用签名 URL 的请求会全部 403 SignatureDoesNotMatch。
|
||||
* (探测脚本里还踩过第三个变体:带 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();
|
||||
$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; }
|
||||
|
||||
// 子资源必须【按名字字典序】拼接(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"
|
||||
. (int)$expires . "\n" . $res;
|
||||
@@ -304,68 +379,280 @@ function fb_oss_sign_v1($method, $key, $expires, $contentType = '', $contentMd5
|
||||
];
|
||||
// security-token 除了参与上面的签名计算,也仍要作为 URL 参数发出去
|
||||
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;
|
||||
}
|
||||
|
||||
/** 给对象 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) {
|
||||
$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 fb_base_url() . '/media.php?k=' . rawurlencode((string)$key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务器内部用的签名 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成浏览器直传所需的上传凭证
|
||||
* @param string $key 服务端指定的对象键(策略里用 eq 锁定,客户端无法改名)
|
||||
* @param int $maxBytes content-length-range 上限
|
||||
* @param string $typePrefix 允许的 Content-Type 前缀(image/ 或 video/)
|
||||
* @return array
|
||||
* 用 Authorization 头签名(PUT 上传用)。
|
||||
* 相比查询串签名,头签名能把 Content-Type / x-oss-security-token 一起纳入,
|
||||
* 更适合带请求体的写入。
|
||||
* @return array 需要附加到请求上的 HTTP 头(含 Authorization 与 x-oss-security-token)
|
||||
*/
|
||||
function fb_oss_upload_credential($key, $maxBytes, $typePrefix) {
|
||||
$c = fb_oss_cfg();
|
||||
function fb_oss_auth_headers($method, $key, $date, $contentType = '', $contentMd5 = '', array $extraOssHeaders = []) {
|
||||
$cred = fb_oss_credentials();
|
||||
$ttl = max(60, (int)fb_oss_cfg('sign_ttl', 600));
|
||||
$expire = time() + $ttl;
|
||||
$c = fb_oss_cfg();
|
||||
|
||||
// 临时凭证本身可能更快到期 —— 策略有效期必须落在凭证有效期内,否则 OSS 会拒
|
||||
if (!empty($cred['expire']) && (int)$cred['expire'] > 0) {
|
||||
$expire = min($expire, (int)$cred['expire'] - 30);
|
||||
if ($expire <= time()) { $expire = time() + 60; }
|
||||
$lower = [];
|
||||
foreach ($extraOssHeaders as $k => $v) {
|
||||
$k = strtolower(trim((string)$k));
|
||||
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),
|
||||
'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);
|
||||
/**
|
||||
* 服务器端上传:把本地文件 PUT 到 OSS(流式,不整文件进内存)。
|
||||
* @param string $key 对象键
|
||||
* @param string $path 本地文件路径(通常是 PHP 的上传临时文件)
|
||||
* @param string $mime Content-Type
|
||||
* @return array [bool $ok, string $error, int $status]
|
||||
*/
|
||||
function fb_oss_put_file($key, $path, $mime = 'application/octet-stream') {
|
||||
if (!is_file($path)) { return [false, '本地文件不存在', 0]; }
|
||||
$size = (int)@filesize($path);
|
||||
if ($size <= 0) { return [false, '文件为空', 0]; }
|
||||
|
||||
// 使用实例 RAM 角色的临时凭证时,表单里必须多带一个 x-oss-security-token 域
|
||||
$extra = [];
|
||||
if (!empty($cred['token'])) { $extra['x-oss-security-token'] = (string)$cred['token']; }
|
||||
$url = fb_oss_object_url($key, true);
|
||||
if ($url === '') { return [false, 'OSS 未正确配置(endpoint/bucket 为空)', 0]; }
|
||||
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||||
// 超时按大小放宽:内网带宽高,但 50MB 视频仍要给足余量
|
||||
$timeout = max(30, (int)ceil($size / 262144));
|
||||
|
||||
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),
|
||||
$headers = fb_oss_auth_headers('PUT', $key, $date, $mime);
|
||||
$headers[] = 'Content-Length: ' . $size;
|
||||
$headers[] = 'Expect:'; // 关掉 100-continue,避免部分环境多等一轮
|
||||
|
||||
$fp = @fopen($path, 'rb');
|
||||
if (!$fp) { return [false, '无法读取本地文件', 0]; }
|
||||
|
||||
$status = 0; $errBody = '';
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'PUT',
|
||||
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]
|
||||
*/
|
||||
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]);
|
||||
// 否则私有 Bucket 会被 OSS 直接 403。
|
||||
$signed = fb_oss_signed_url('HEAD', $key, 300);
|
||||
if ($signed === '') { return [0, []]; }
|
||||
|
||||
list($status, $h, ) = fb_http('HEAD', $signed, ['timeout' => 8]);
|
||||
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]; }
|
||||
list($status, $h, ) = fb_http('GET', $signed, ['timeout' => 8]);
|
||||
}
|
||||
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]);
|
||||
$url = fb_oss_signed_url('DELETE', $key, 300);
|
||||
if ($url === '') { return false; }
|
||||
list($status, , ) = fb_http('DELETE', $url, ['timeout' => 10]);
|
||||
return ($status >= 200 && $status < 300) || $status === 404;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表/灯箱用的缩放地址:借用 OSS 图片处理,省流量(可关闭)
|
||||
* @param string $mime 已知的内容类型;GIF 会被 OSS 处理成静态首帧(动画丢失),故原样返回
|
||||
* 缩略图的【代理】地址:借用 OSS 图片处理在服务端缩放后再转发,省下行流量。
|
||||
* @param string $mime 已知的内容类型;GIF 会被 OSS 处理成静态首帧(动画丢失),故不处理
|
||||
*/
|
||||
function fb_oss_thumb_url($key, $w = 560, $mime = '') {
|
||||
$url = fb_oss_url($key);
|
||||
@@ -487,6 +765,6 @@ function fb_oss_thumb_url($key, $w = 560, $mime = '') {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$sep = (strpos($url, '?') === false) ? '?' : '&';
|
||||
return $url . $sep . 'x-oss-process=image/resize,w_' . (int)$w . '/quality,q_80';
|
||||
$w = max(32, min(4096, (int)$w));
|
||||
return $url . '&p=' . rawurlencode('image/resize,w_' . $w . '/quality,q_80');
|
||||
}
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ function fb_setting_keys() {
|
||||
'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',
|
||||
'oss.verify', 'oss.image_process', 'oss.use_internal_endpoint',
|
||||
'moderation.enabled', 'moderation.close_mail_subject',
|
||||
'admin.password_hash',
|
||||
];
|
||||
|
||||
@@ -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
@@ -1,18 +1,19 @@
|
||||
<?php
|
||||
/**
|
||||
* 附件上传接口
|
||||
* 附件上传接口(两种存储方式都走「两步」,前端代码完全一致)
|
||||
* ------------------------------------------------------------------
|
||||
* 两种存储方式由后台设置决定(attachment.storage):
|
||||
* 存储方式由后台设置决定(attachment.storage):off / local / oss。
|
||||
* 无论 local 还是 oss,浏览器都【不直连存储】,一律两步走本站:
|
||||
*
|
||||
* ① oss(阿里云 OSS 直传,原行为)
|
||||
* POST upload.php kind=&ext=&size=&mime=&csrf=
|
||||
* → 返回 host / key / policy / signature…,浏览器把文件直接 POST 到 OSS,
|
||||
* 文件不经过本服务器。凭证来自 ECS 实例 RAM 角色的临时凭证(或静态 AK)。
|
||||
* 第一步:POST upload.php kind=&ext=&size=&mime=&csrf=
|
||||
* → 服务器生成对象键,返回 host=upload.php?action=put 与随传字段。
|
||||
* 第二步:浏览器把文件 multipart POST 到 upload.php?action=put,服务器校验后落地:
|
||||
* · local → 存到 uploads/YYYY/MM/ 下;
|
||||
* · oss → 服务器用实例 RAM 角色的临时凭证,代理 PUT 到【内网 Endpoint】的私有桶。
|
||||
*
|
||||
* ② local(存本机磁盘)
|
||||
* 第一步同上,只是返回的 host 指向本文件的 ?action=put;
|
||||
* 第二步浏览器把文件 multipart POST 到 upload.php?action=put,
|
||||
* 服务端校验后落到 uploads/YYYY/MM/ 下。
|
||||
* ★ 为什么 OSS 也要经服务器中转:桶是私有的、且只用内网 Endpoint(外网不可达),
|
||||
* 浏览器既拿不到可直连的 URL、也连不上内网地址,所以「浏览器直传」在此架构下不成立。
|
||||
* 读取同理,走 media.php 代理(见该文件)。
|
||||
*
|
||||
* 两种方式共用同一套校验:扩展名 / 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 ($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'] : ''));
|
||||
$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)) {
|
||||
if (!fb_upload_key_ok($key)) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
if ($mode === 'local') {
|
||||
$path = fb_local_path_of($key);
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
|
||||
@@ -103,6 +114,19 @@ if ($putAt) {
|
||||
fb_up_json(['ok' => false, 'error' => '保存文件失败,请稍后重试。'], 500);
|
||||
}
|
||||
@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([
|
||||
'ok' => true,
|
||||
@@ -156,10 +180,20 @@ if ($cap > 0 && (fb_attach_total_bytes($pdo) + $size) > $cap) {
|
||||
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') {
|
||||
$key = fb_local_make_key($ext);
|
||||
} else {
|
||||
$key = fb_oss_make_key($ext);
|
||||
}
|
||||
fb_rate_hit('upload');
|
||||
|
||||
fb_up_json([
|
||||
'ok' => true,
|
||||
'credential' => [
|
||||
@@ -176,32 +210,3 @@ if ($mode === 'local') {
|
||||
'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,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user