想找一个自己以前搭的东西的访问方式,翻了半天没找到,索性直接生成一个新的。生成过程很顺利,麻烦出在”怎么把这东西喂给客户端”这一步——需要一个 HTTP(S) 链接,打开就是纯文本内容,不能是要点”另存为”的文件下载。
这套服务是自己平台上现成的:一个通用文件存储服务 files,一个对象存储服务 oss,两个都挂在同一个网关域名下,后端都是 Cloudflare R2。理论上随便选一个都该能干这活。结果试下来完全不是这么回事。
先试 files 服务
files 服务的接口很直观:POST /upload 拿到 presigned PUT URL,把内容传上去,POST /upload/{id}/confirm 确认,然后用 GET /{id}/download 当公开访问链接。整套下来没有任何报错:
curl -sI "https://api.example.com/files/<file_id>/download"返回是个 302 跳转到 R2 的 presigned URL,跟着走一遍:
HTTP/1.1 200 OKContent-Type: text/plainContent-Length: 159Content-Disposition: attachment; filename*=UTF-8''sub.txt浏览器打开这个链接,弹出的是保存文件对话框,不是纯文本页面。客户端拿这个链接当订阅源去拉取,行为跟着变成”下载一个文件”而不是”读取一段文本”——很多订阅类客户端压根不会处理这种响应,直接判定订阅源不可用。
为什么会这样
Content-Disposition 是标准 HTTP 响应头,定义在 RFC 6266 里。它只有两个常见值:
inline:默认行为(不写这个头等价于 inline),内容按 MIME 类型直接渲染/返回attachment:提示客户端这是一个要保存的文件,浏览器会弹”另存为”,非浏览器客户端(curl、各类订阅解析器)拿到的是同样的响应头,行为随实现而定,但大多数场景下会被当成”下载”而非”读取”
问题不在 R2,也不在 Cloudflare,而在 files 服务自己生成 presigned URL 时的行为。S3 兼容的对象存储(R2 实现了 S3 API 的这部分)在生成下载用的 presigned URL 时,支持一组 response-* query 参数:response-content-disposition、response-content-type、response-cache-control 等。这些参数会覆盖对象在 PUT 时设置的原始 header,只在这一次签名请求里生效,且必须是签过名的请求(presigned URL 本身或者签名 header),匿名请求用不了这个覆盖机制。
files 服务的 /download 端点在生成 presigned URL 时,固定带上了:
response-content-disposition=attachment; filename*=UTF-8''sub.txt这是”下载中心”式设计的合理默认——用户点一个文件链接,大概率是想保存到本地,浏览器弹窗、给出原始文件名,都是为这个场景服务的。但这恰好是订阅链接场景不想要的行为。
oss 服务不一样
同一套基础设施上还有个 oss(对象存储)服务,接口设计更底层:POST /{bucket}/presign/upload 拿 presigned PUT URL 上传,GET /{bucket}/{key} 拿 presigned 下载 URL(或者直接跳转)。试了一遍:
curl -sL -D - "https://api.example.com/oss/<bucket>/sub.txt"HTTP/1.1 200 OKContent-Type: text/plain; charset=utf-8Content-Length: 159ETag: "..."没有 Content-Disposition 这一行。因为 oss 服务生成 presigned URL 时压根没传 response-content-disposition 参数,R2 就按对象本身的 header 走——而对象是当初用 Content-Type: text/plain PUT 上去的,没设置 disposition,默认就是 inline。浏览器直接渲染文本,curl/客户端拿到的是纯 body,跟访问一个静态文本文件网址没有区别。
同一个 R2 bucket、同一套鉴权体系,两个服务因为在拼 presigned URL 这一步的参数不一样,最终对外呈现的是两种完全不同的语义:一个是”文件下载中心”,一个是”对象直读”。
解决方案
把订阅内容改传到 oss 服务,用 is_public: true 标记,拿到的 GET /{bucket}/{key} 链接直接当订阅 URL 给客户端用:
# 1. presign 上传curl -X POST "https://api.example.com/oss/sub/presign/upload" \ -H "Authorization: Bearer $TOKEN" \ -d '{"key":"sub.txt","content_type":"text/plain; charset=utf-8","size":159,"is_public":true}'
# 2. PUT 到拿到的 presigned URLcurl -X PUT "$PRESIGNED_URL" -H "Content-Type: text/plain; charset=utf-8" --data-binary @sub.txt
# 3. 公开访问,不需要鉴权curl "https://api.example.com/oss/sub/sub.txt"第三步验证过匿名访问也没问题——is_public 标记让网关跳过鉴权直接签发 presigned GET,内容原样吐出。
同一个内容分别用两种格式试了一遍(base64 编码的 ss:// 链接,以及 Clash 系客户端吃的 YAML 配置),两种在 oss 下都是干净的 200 + 纯文本响应。
经验总结
这次教训很直接:“能公开访问”和”以什么姿态公开访问”是两件事,前者是鉴权层的事,后者是 Content-Disposition 这一个 header 决定的,而这个 header 又可能在对象存储层的三个地方被设置——PUT 时的原始值、presigned URL 的 response-content-disposition 覆盖、以及服务网关自己拼 URL 时加的默认值。排查这类”内容对不对但呈现方式不对”的问题时,与其怀疑存储后端或者网络链路,不如先用 curl -D - 把完整响应头挖出来看一眼——很多时候答案就写在一行没被注意到的 header 里。
也顺带印证了一件事:同一套基础设施上,如果同时存在”面向终端用户下载文件”和”面向程序拉取原始内容”两种场景,最好从一开始就用两个语义不同的服务/端点区分开,而不是指望一个”下载”接口两头兼顾——files 和 oss 服务在这套平台里本来就是分开设计的,这次不过是找对了该用哪一个。