一般项目中如何管理用户上传和访问资源?
文件上传
针对文件上传,有如下几点需要考虑
- 用户体验
- 实际的应用程序安全准则
- 教条的应用程序安全准则
用户体验
什么时候该启用断点续传
政务云 EIP 的带宽一般为 30Mbps(近来申请的也有 10Mbps 的(当然你也可以申请提高到 100Mbps(不过最好有令人信服的理由))),要是几个项目共用这个 EIP,赶上用户高峰时能分到的就更少了。考虑到我们对这些基础设施的监控能力,一般也不推荐将 OSS 端点暴露到互联网实现直传。
理想情况下通过 30Mbps 的带宽传输 50MB 数据需要 13 秒,加上用户所处的网络环境,网站的并发压力,可以认为耗时刚好在用户的容忍界限上。因此将其作为一条选型分界线。
如果用户是在上传头像,或者某些事件任务类场景上传现场照片,单个上传一般在 50MB 之内(你应该不会允许用户上传 RAW 的,对吧),可以采用直接上传。但是用户确认选中文件后就应该触发上传,而不是傻傻地等到用户点 submit。
在线课堂中学生提交作业,信息管理中批量导入数据这种的,文件也不会太大,可以采用直接上传。
不过要是在线课堂里允许老师上传教学视频,就要使用断点续传。同样的,某些在线考核系统要求被评价方提交材料证据的,如果产品经理非要把所有材料打成一个压缩包一起上传,那就老实做断点续传。
框架的断点续传能力
tus 协议是一个基于 HTTP 实现的支持断点续传的文件上传协议,规范细节见 Resumable upload protocol 1.0.x | tus.io。在如下场景中应当考虑使用 tus:
- 在部分不可靠的网络上运行,连接很容易 drop 或连接可能在一段时间内完全不可用,例如使用移动数据
- 处理大文件上传并希望避免重新上传
- 希望为用户提供暂停上传和一段时间后恢复上传的功能
框架中准备了 Tuskott,这是一种 tus 服务端实现,通过 tuskott: 进行配置,启用后会在指定位置注册 /files 服务端点,例如如下这个配置:
客户端使用任意标准实现即可,例如下方展示的这个 tus-js-client。
注意这里的实现由上传方按照已发送数据量渲染进度条。然而实际上传时 NGINX 和 Web 容器会限制单个请求的大小,因此在服务端尽最大努力的接收模式下,如果触发了中间环节的请求体大小限制,可能会出现进度条又缩回去的滑稽现象。解决方式就是按服务端响应的 offset 渲染进度条,这表示了服务端成功接收的数据量。
这种场景下文件上传在整个业务流中是异步的,你或许需要对程序设计做一些微小的调整,anyway,监听上传成功事件后转存文件,再在业务中将其标记为完成。
默认情况下 Tuskott 将上传的文件保存在本地磁盘,使用内存中的数据结构保存进行中的上传计划信息和锁状态,这是为了保持开箱即用的能力。用户可以通过实现相关类注册自己的持久化、分布式的解决方案。
实际的应用程序安全准则
其实没什么,不允许用户直接或间接地访问 / 调用上传内容即可,至少做到:
- 用户无法得知上传后的文件名
- 用户无法得知上传后的文件路径
- 上传的文件不具有可执行权限
- 上传的文件不可被执行
教条的应用程序安全准则
一些代码审计程序 / 漏洞扫描工具会检查你的上传接口实现,如果发现你没有检查和限制上传文件的 MIME-Type,就会大喊“找到了”。
当然这是有必要的,毕竟你也不想看到有的用户头像是 video/mpeg 吧。不过你也应当知道文件的扩展名、MIME-Type 甚至是 Magic 都是可以伪造的。AWS 的推荐做法是在 S3 上传成功后触发 Lambda 检查文件的安全性,这就根据实际需求见仁见智了。
文件存储
文件存储抽象层
Forvariz 现已重构为 PolyStash 并集成在开发框架中,你可以在项目的单元测试 src/test/java/cc/ddrpa/dorian/polystash/blobstore 中查看用法。PolyStash 通过 cc.ddrpa.dorian.polystash.core.blobstore.BlobStore 为不同的存储服务提供统一的 API,不管是把文件保存在磁盘上还是 OSS 里,都只需要 blobStore.put,取出则使用 blobStore.get(从单元测试逻辑是个抽象类可证明),因此你可以通过不同的 profile 在本地使用磁盘文件系统测试,不用修改代码就切换到线上基于 OSS 的版本。
保存一段文本
保存通过 Multipart Form 上传的文件(当然在单元测试里这个数据结构是 Mock 出来的)
列出所有文件(迭代器)
访问文件,但不要获取文件本身:
获取文件:
BlobStore 自动注入的功能存在初始化顺序的问题,毕竟是动态创建的,在依赖关系图中没有位置。可以注入 BlobStoreHolder 然后 get 自己需要的 BlobStore 解决。
关于自建 MinIO
如果你使用单节点 MinIO,那应该趁早:
- 切换到政务云 OSS 云资源
- 部署多节点 MinIO 集群
- 考虑一下本地磁盘是不是更方便
分区分级存储
简单来说,分区分级存储是对应下文的访问控制做的设置。如果你要做一个内容管理系统,文章资源就可以放在私有写公开读的 OSS 桶里。如果要做一个文档编辑系统,那么公开读也是不允许的。使用 PloyStash,你可以创建不同的 BlobStore 指向不同的存储后端,通过规范的命名和依赖注入防止其他开发同学误用。
用户头像这种允许任意用户访问的资源,可以通过 NGINX 等 Web 服务器托管,减轻应用服务的压力,当然如果你真打算这么做,一定要做好安全防护,因为攻击者肯定会尝试丢个 Web Shell 上来。
- 别把上传文件放在和应用代码相同的目录下,单独搞一个
avatars/目录,并通过 NGINX 配置只允许静态文件访问,只开放 GET 请求 - 在 NGINX 里加上配置:
- 确认这个目录不会被某些 NGINX 配置自动解析脚本
- 用户上传的任何图片都用图像处理库做一次解码编码
- PolyStash 会对存储资源做重命名处理
资源访问
访问权限控制
如果你访问一些盗版影视资源站点,也许会发现他们存放视频的 OSS 有时指向了似乎并不相关的域名和 bucket。没错,这就是一些管理不善(或者内部有脏东西)的 OSS 桶做了公开读却没有做好写入验证,以及公开读没有做好访问控制。因此盗版网站运营者可以将资源放在这些桶里,为自己的站点提供服务。
被灰产利用了大不了损失点流量,被黑产利用了放点不得了的东西,不上秤 bro 顶多喝点茶,上秤了就这辈子有了。
你需要对资源访问实现分级管理,目前的建议是分为 anonymous、presigned 和 restricted 三级。
anonymous,允许任意用户或匿名用户访问
这个级别允许任意用户或匿名用户访问,像用户头像什么的符合这一类。
不过最好也检查一下请求头中的 reference,看看是不是来自本站。
presigned,允许通过预签发的 URL 访问
在审批流中提交的材料、用户上传的事件材料一般都属于此列。访问这里的资源需要合法用户签发一条访问链接,在链接的有效期内,允许任意用户或匿名用户持有该链接访问。
框架通过 io.xasz.modules.blob.ResourceSignatureProvider#preSignToGet 提供此项功能。
签名设计参考 AWS,包含如下参数:
X-Dorian-Algorithm签名算法,目前只支持HMAC-SHA256X-Dorian-Credential谁签发了这个 URLX-Dorian-Date签发日期X-Dorian-Expires有效期X-Dorian-SignedHeaders哪些参数参与签名X-Dorian-Signature签名值inline决定访问资源是嵌入形式还是下载形式,可以由请求方修改
点击这个链接,浏览器将下载一张名为 Awa-Subaru.png 的图片。
把这个链接放到 <img> 标签的 src 属性中(记得确认 inline=true),将展示一张图片。
这条链接指向一个名为 avatar/ec6495b2-f275-457f-b4ff-8268de70bf62 的资源,访问有效期到 2025 年 8 月 27 日 18:23:02(GMT)。这条链接是由 admin 用户在 2025 年 8 月 27 日 10:23:02(GMT) 签发的。
签名的校验通过同一个类的 io.xasz.modules.blob.ResourceSignatureProvider#verify 进行的,照例暂不考虑访问失败的用户体验。
restricted,严格限制,每次访问校验
每次访问都核对当前用户是否具有访问权限。如果你要为用户提供一个非公开的文件管理功能,那就符合这一档,访问权限的验证在自己的业务里做。
可中断与恢复的下载
和上传一样,受制于带宽,需要考虑到网络不佳等情况下大文件的下载体验。
尤其是在移动端应用中,如果用户设备由于信号不佳在 Wi-Fi 和蜂窝网络之间发生了切换,或者用户的 Wi-Fi Mesh 做得不够好,AP 切换导致 TCP 连接超时了,下载自然就中断了。当然这种切换在用户侧并没有什么明显的感知,用户只会在发现下载重新开始时大骂你的网站垃圾。
好在和上传不一样,提供一些响应头,浏览器就知道如何处理。io.xasz.modules.blob.ResourceController#handleResourceAccess 通过在响应头中提供 Accept-Ranges:bytes、Content-Length、ETag 和 Last-Modified,告知浏览器如何管理中断的下载,并在重试请求中提供 Range 请求头。
此外 ETag 和 Last-Modified 还允许浏览器判断这个文件是否已经下载过了,从而避免由于用户误操作造成的反复下载。
PloyStash 在 PUT 到本地文件系统时,会通过 xxHash64(超快的)计算文件的 ETag,PUT 到 S3 兼容的存储系统时,则由对应的存储服务提供商负责管理。
该功能在 Chromium 和 Firefox 上进行了测试,其他浏览器未知。
内容实时生成与访问
一个学籍管理系统中可能涉及到批量导出学生结业证书的功能。
如果你生成证书的速度足够快,可以用 ZipOutputStream 包装输出流,然后不断往里面塞 ZipEntry。这样从用户视角看,点击 生成/导出 按钮后,网站立即响应了请求开始输出内容,至于会执行多久,就可以甩锅给两边的网络运营商了。
否则还是老实地创建文档生成任务,告诉用户完成后会发送通知消息并提供下载方法。