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
+31 -22
View File
@@ -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 拼接
],