From 1401ad899bfacaca6099c7d0e325be4d708e04af Mon Sep 17 00:00:00 2001 From: laoyang Date: Mon, 28 Sep 2026 22:12:28 +0800 Subject: [PATCH] =?UTF-8?q?OSS=20=E9=99=84=E4=BB=B6=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E7=A7=81=E6=9C=89=E6=A1=B6+=E5=86=85=E7=BD=91Endpoint+?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E4=BB=A3=E7=90=86=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:桶为私有、且只用内网 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 全通过。 --- README.md | 129 +++++++------ admin.php | 36 ++-- assets/app.js | 4 +- config.example.php | 53 +++--- cron.php | 18 ++ lib/attach.php | 53 ++++++ lib/db.php | 24 +++ lib/layout.php | 2 +- lib/oss.php | 464 ++++++++++++++++++++++++++++++++++++--------- lib/util.php | 2 +- media.php | 206 ++++++++++++++++++++ upload.php | 121 ++++++------ 12 files changed, 866 insertions(+), 246 deletions(-) create mode 100644 media.php diff --git a/README.md b/README.md index 26e969f..aeb3720 100644 --- a/README.md +++ b/README.md @@ -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 步的角色名 + '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 + // 'metadata_v2' => true, // 加固模式:读元数据前先取一次性 token ], ``` -注意:**这里没有 `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` | 凭证缓存文件(含 `` 头 + 权限 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 控制台清理,或调大该值。 diff --git a/admin.php b/admin.php index a31dac0..d7baed8 100644 --- a/admin.php +++ b/admin.php @@ -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']); - 本机:文件落在站点目录,占用服务器磁盘与带宽,但零配置、立刻可用;OSS:文件不经过服务器,需先建桶并授权。 + 本机:文件落在站点 uploads/ 目录;OSS:文件经服务器代理写入私有桶(走内网 Endpoint),公网无法直连桶。两种方式都需要服务器磁盘能放下单个上传文件。
@@ -574,9 +575,10 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);

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

+

私有桶 + 服务器代理:浏览器不直连桶,附件读写都经本站 upload.php / media.php 中转。Endpoint 填公网或内网形式都可以,程序按下方开关自动转换。

+
+ - + +

审核

@@ -633,7 +631,19 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']); 鉴权方式: 凭证状态: - Bucket: / Endpoint: + Bucket: + Endpoint(配置): + Endpoint(实际连接): — + + + 连通性: + + ↳ 内网 Endpoint 跨地域不可达:请取消勾选上方「使用内网 Endpoint」后保存,或把桶换到与本机同地域。 + + + 测一下桶连通性 + (会真实访问一次 OSS,跨地域不通时可能等待数秒) + 已用附件: / 上限 diff --git a/assets/app.js b/assets/app.js index 5957a27..0c0d3f9 100644 --- a/assets/app.js +++ b/assets/app.js @@ -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'; diff --git a/config.example.php b/config.example.php index ddfd973..f2fcafa 100644 --- a/config.example.php +++ b/config.example.php @@ -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 拼接 ], diff --git a/cron.php b/cron.php index 5b37265..5571eaa 100644 --- a/cron.php +++ b/cron.php @@ -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"; } diff --git a/lib/attach.php b/lib/attach.php index 02f5d58..a878661 100644 --- a/lib/attach.php +++ b/lib/attach.php @@ -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'); diff --git a/lib/db.php b/lib/db.php index c6bd377..0d4b1f3 100644 --- a/lib/db.php +++ b/lib/db.php @@ -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 ( diff --git a/lib/layout.php b/lib/layout.php index caa3a0d..3def926 100644 --- a/lib/layout.php +++ b/lib/layout.php @@ -66,7 +66,7 @@ function fb_flash_box($msg, $type = 'error') { } /** - * 渲染附件上传控件(图片 / 视频,浏览器直传 OSS) + * 渲染附件上传控件(图片 / 视频;上传经服务器中转落地,local/OSS 前端同一路径) * 需置于
内部:会输出一个 name="attachments" 的隐藏字段, * 由 assets/app.js 在文件上传成功后回填 JSON 描述。 * diff --git a/lib/oss.php b/lib/oss.php index ead9984..0a3ea84 100644 --- a/lib/oss.php +++ b/lib/oss.php @@ -1,9 +1,21 @@ "。 * @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(); - $cred = fb_oss_credentials(); - $ttl = max(60, (int)fb_oss_cfg('sign_ttl', 600)); - $expire = time() + $ttl; +function fb_oss_auth_headers($method, $key, $date, $contentType = '', $contentMd5 = '', array $extraOssHeaders = []) { + $cred = fb_oss_credentials(); + $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; +} + +/** + * 服务器端上传:把本地文件 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]; } + + $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)); + + $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)]; } - $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); + $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); } - // 使用实例 RAM 角色的临时凭证时,表单里必须多带一个 x-oss-security-token 域 - $extra = []; - if (!empty($cred['token'])) { $extra['x-oss-security-token'] = (string)$cred['token']; } + if ($ok === false || $errno) { return [$status, '读取对象失败:' . $errtxt]; } + if ($status >= 200 && $status < 400) { return [$status, '']; } + return [$status, fb_oss_error_text($errBody, $status)]; +} - 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), +/** 从 OSS 的 XML 错误响应里提取人类可读的原因 */ +function fb_oss_error_text($body, $status) { + $body = (string)$body; + $code = ''; + if (preg_match('#([^<]+)#', $body, $m)) { $code = trim($m[1]); } + $msg = ''; + if (preg_match('#([^<]+)#', $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'); } diff --git a/lib/util.php b/lib/util.php index 111c0f4..f9559d8 100644 --- a/lib/util.php +++ b/lib/util.php @@ -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', ]; diff --git a/media.php b/media.php new file mode 100644 index 0000000..6cd9d3a --- /dev/null +++ b/media.php @@ -0,0 +1,206 @@ +[&p=] + * + * 安全边界(务必保持): + * ① 对象键必须存在于本站 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; +} diff --git a/upload.php b/upload.php index 1d237ae..78b2d9f 100644 --- a/upload.php +++ b/upload.php @@ -1,18 +1,19 @@ 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,13 +106,27 @@ if ($putAt) { fb_up_json(['ok' => false, 'error' => '附件存储空间已满,暂时无法上传。'], 507); } - $path = fb_local_path_of($key); - $dir = dirname($path); - if (!is_dir($dir)) { @mkdir($dir, 0755, true); } - if (!is_dir($dir) || !@move_uploaded_file((string)$f['tmp_name'], $path)) { - fb_up_json(['ok' => false, 'error' => '保存文件失败,请稍后重试。'], 500); + if ($mode === 'local') { + $path = fb_local_path_of($key); + $dir = dirname($path); + if (!is_dir($dir)) { @mkdir($dir, 0755, true); } + if (!is_dir($dir) || !@move_uploaded_file((string)$f['tmp_name'], $path)) { + fb_up_json(['ok' => false, 'error' => '保存文件失败,请稍后重试。'], 500); + } + @chmod($path, 0644); + } 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); + } } - @chmod($path, 0644); + + // 登记「待入库」:让上传后、提交表单前的缩略图预览能通过 media.php 鉴权 + fb_attach_pending_mark($key, $kind, $mime, $size); fb_up_json([ 'ok' => true, @@ -156,52 +180,33 @@ 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); - fb_rate_hit('upload'); - fb_up_json([ - 'ok' => true, - 'credential' => [ - 'host' => 'upload.php?action=put', - 'key' => $key, - 'url' => fb_attach_url($key), - ], - 'fields' => [ - 'csrf' => fb_csrf_token(), - 'key' => $key, - 'kind' => $kind, - 'mime' => $mime, - ], - 'mime' => $mime, - 'kind' => $kind, - ]); +} else { + $key = fb_oss_make_key($ext); } - -/* ---------- 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, + 'credential' => [ + 'host' => 'upload.php?action=put', + 'key' => $key, + 'url' => fb_attach_url($key), + ], + 'fields' => [ + 'csrf' => fb_csrf_token(), + 'key' => $key, + 'kind' => $kind, + 'mime' => $mime, + ], 'mime' => $mime, 'kind' => $kind, ]);