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),公网无法直连桶。两种方式都需要服务器磁盘能放下单个上传文件。
阿里云 OSS(选「OSS」时才需要填)
+
私有桶 + 服务器代理:浏览器不直连桶,附件读写都经本站 upload.php / media.php 中转。Endpoint 填公网或内网形式都可以,程序按下方开关自动转换。
*-internal.aliyuncs.com,免公网流量费、延迟低)
+ ⚠️ 仅当【桶与本机 ECS 同地域】时可用;跨地域内网不可达,请取消勾选回落公网,或改用同地域的桶。保存后可在下方「存储体检」查看连通性。 —
+
+
+ 连通性:
+
+ ↳ 内网 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 前端同一路径)
* 需置于