1408 字
7 分钟
同样是托管一个文本文件,为什么一个能打开一个只会下载

想找一个自己以前搭的东西的访问方式,翻了半天没找到,索性直接生成一个新的。生成过程很顺利,麻烦出在”怎么把这东西喂给客户端”这一步——需要一个 HTTP(S) 链接,打开就是纯文本内容,不能是要点”另存为”的文件下载。

这套服务是自己平台上现成的:一个通用文件存储服务 files,一个对象存储服务 oss,两个都挂在同一个网关域名下,后端都是 Cloudflare R2。理论上随便选一个都该能干这活。结果试下来完全不是这么回事。

先试 files 服务#

files 服务的接口很直观:POST /upload 拿到 presigned PUT URL,把内容传上去,POST /upload/{id}/confirm 确认,然后用 GET /{id}/download 当公开访问链接。整套下来没有任何报错:

Terminal window
curl -sI "https://api.example.com/files/<file_id>/download"

返回是个 302 跳转到 R2 的 presigned URL,跟着走一遍:

HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 159
Content-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-dispositionresponse-content-typeresponse-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(或者直接跳转)。试了一遍:

Terminal window
curl -sL -D - "https://api.example.com/oss/<bucket>/sub.txt"
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 159
ETag: "..."

没有 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 给客户端用:

Terminal window
# 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 URL
curl -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 里。

也顺带印证了一件事:同一套基础设施上,如果同时存在”面向终端用户下载文件”和”面向程序拉取原始内容”两种场景,最好从一开始就用两个语义不同的服务/端点区分开,而不是指望一个”下载”接口两头兼顾——filesoss 服务在这套平台里本来就是分开设计的,这次不过是找对了该用哪一个。

同样是托管一个文本文件,为什么一个能打开一个只会下载
https://blog.lishuyu.app/posts/文件服务和对象存储的下载头之争/
作者
猫猫魔女
发布于
2026-07-07
许可协议
CC BY-NC-SA 4.0