文件存储与上传
存储盘的定义在 common/config/storage.php,注释里写了每个选项的意思,这里不重复。这页讲的是上传怎么走、要在桶那边配什么、以及直传放弃了什么。
1. 三块盘
| 盘 | 位置 | 谁能读 |
|---|---|---|
local |
data/storage,在两个 document root 之外 |
只能走 /attachment/download,先鉴权 |
public |
data/public,nginx 从 /uploads/ 直接服务 |
知道路径的任何人 |
s3 |
桶 | 私有桶要签临时链接;配了 S3_URL 就是公开的 |
s3 在 S3_BUCKET 没设之前不存在——common\support\disks::names() 不返回它,所以对账不会去列一个不存在的桶,筛选框也不会给出一个存不进东西的目的地。
新文件落在哪块盘由 系统设置 → 文件存储 → 上传落到哪块盘 决定,默认值 env 表示跟随 .env 的 STORAGE_DISK。只影响新文件:每一行都记着自己写在哪块盘,改设置不会移动任何已有文件。
2. 上传的两条路径
服务端决定走哪条,客户端不能选。前端每次上传先请求 POST /attachment/upload 带 phase=prepare,回答只有两种:
proxy——文件 POST 给后台,后台流式写盘。两块本地盘永远是这条,因为它们在 document root 之外,除了这个应用没人写得进去。桶没配好(缺 S3_KEY / S3_SECRET)也是这条,反正两条都会失败,但失败信息由已有的路径给出,更清楚。
direct——浏览器直接 PUT 到桶,后台只签名和登记:
浏览器 后台 桶
│ phase=prepare (名字, 大小) │
│ ─────────────────────────────>│ 查扩展名 / 大小 / 目标盘
│ │ 生成 key,签一个 PUT url
│ <─────────────────────────────│ {url, headers, ticket}
│ │
│ PUT url (文件本体) │
│ ───────────────────────────────────────────────────────>│
│ │
│ phase=commit (ticket) │
│ ─────────────────────────────>│ 验签 → HEAD 对象 → 写 attachment 行
│ <─────────────────────────────│ 文件详情
上传失败或用户取消时前端发 phase=abort,后台把那个对象删掉。这一步是善后不是保障:漏掉的对象会被下一次「对账」当成孤儿文件收进来,在资源管理里看得见。
三个 phase 共用 attachment:upload 一个权限。它们不是各自独立的动作,理由写在 ctl_attachment::PHASE_PREPARE 的注释里——上传是一项权限,拆成三项会让只授了「上传文件」的角色在点上传时被踢回登录页。
2.1 大小是怎么保证的
prepare 阶段客户端报的大小是不可信的,但它被签进了 PUT url 的 content-length。浏览器不允许脚本设置 Content-Length,这个值由浏览器按实际 body 填,所以传的字节数只要和签名时声明的不一致,S3 就是 403,根本不会产生对象。
commit 阶段还会再 HEAD 一次核对真实大小。在 AWS 上这一步永远不会触发;它是给 MinIO / R2 这类兼容实现准备的——「签名覆盖 content-length」在那边是别人的实现细节,不该当成前提。超限的对象会被删掉再拒绝。
3. 桶必须配 CORS
没配就是直传静默失败,浏览器会拦下响应,前端只能报一句「上传没有完成」。之前全部走服务端 curl,所以从来不需要这个。
[
{
"AllowedOrigins": ["http://admin.platoadmin.localhost:8080", "https://admin.example.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type", "x-amz-acl"],
"MaxAgeSeconds": 3000
}
]
AllowedOrigins 填什么,本项目里没有任何地方能配,也配不了。 浏览器按当前页面的地址自动生成 Origin 请求头,代码既不能选也不能改——所以这里要填的就是打开资源管理页面时地址栏里的那个源,本地开发环境是 http://admin.platoadmin.localhost:8080(vhost 见 deploy/nginx/admin.conf,端口是 compose 的 HTTP_PORT,改成 80 就不写端口),线上是线上后台的域名。两个都用就都列上,这是个数组。
(.env 里的 API_ALLOW_ORIGIN 是另一回事:那是 api 应用回给浏览器的 CORS,跟桶没有关系。)
源是精确匹配的,三个常见的踩法:
- 必须带协议。
localhost:7080不是一个源,http://localhost:7080才是。少了http://匹配不到任何东西。 http和https是两个源。 同一个域名走两种协议就要写两条。- 端口是源的一部分。
http://example.com和http://example.com:8080不同;默认端口(80 / 443)则不能写出来。
其余几条:
- 别写
*。桶写权限已经由预签名 URL 控制了,但*意味着任何网站的页面都能拿着一个泄漏的 URL 发起上传。 AllowedHeaders要包含Content-Type,因为签名覆盖了它,浏览器会为此先发一个 preflight。x-amz-acl只有S3_VISIBILITY=public时才会发,但列上不会有副作用。- 不需要
ExposeHeaders:单次 PUT 不用读ETag(那是分片上传才需要的)。
4. 直传放弃了什么
服务端一个字节都见不到,所以有两项和代理上传不一样:
hash列是空的。 sha256 要读全部内容才能算,而内容没经过这台机器。留空是诚实的做法——对账收进来的行本来也是空的。代价是「找出重复文件」这个用途对这些行失效。mime列按扩展名推定,不是嗅探出来的。取值表在common\model\attachment::MIME_TYPES,服务端决定,不采信客户端报的类型。注意svg被映射成application/octet-stream,因为它带脚本。
其余全部照旧:扩展名白名单、单文件上限、目标盘,都在 prepare 阶段照设置执行。
5. 下载
私有桶没有可直接访问的 URL,所以 /attachment/download 会签一个短期链接(S3_SIGN_TTL,默认 900 秒)并 302 过去。Content-Disposition 和 Content-Type 是签进签名里的,不是加在 URL 后面的参数——否则改一下地址栏就能让桶把上传的 .html 渲染出来,而那正是 ctl_attachment::INLINE_TYPES 那张白名单要挡的事。
本地盘则由后台自己流式发出,带 nosniff 和一条 CSP。
6. 对账与清理
- 对账(资源管理页面上的按钮,或
attachment:reconcile)扫每块盘,把磁盘上多出来的文件收进来,把记录里指不到文件的标成「缺失」。不删任何东西——文件消失最常见的原因是挂载没回来,而一个会因为五分钟故障就删记录的任务会把故障变成永久数据丢失。 - 清理(
prune:attachment)删掉那些在资源管理里删除、且已经过了保留期的文件和记录。保留期是 系统设置 里的「已删文件保留(天)」,默认 30 天,这段时间就是后悔的余地。
两个都在 admin/app/console/ 里,计划任务配置见 admin/app/config/schedule.php。