一般项目中如何管理用户上传和访问资源?
文件上传
针对文件上传,有如下几点需要考虑
- 用户体验
- 实际的应用程序安全准则
- 教条的应用程序安全准则
用户体验
什么时候该启用断点续传
政务云 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
服务端点,例如如下这个配置:
tuskott:
// 注册 /{context-path}/tus/files 作为服务端点
base-path: /tus
// 允许上传的最大体积
max-upload-length: 1073741824
// 单个 chunk 的最大体积
max-chunk-size: 52428800
// 服务是不是在一个反向代理后面
behind-proxy:
enable: true
// 从请求的指定头里获取网站地址
header: 'X-Original-Request-Uri'
extension:
// 允许客户端主动发起上传计划(而不是由服务器分配)
enable-creation: true
// 允许客户端取消上传(否则只能等超时)
enable-termination: true
tracker:
// 管理未完成上传的信息,默认实现不支持分布式场景
provider: 'cc.ddrpa.tuskott.tus.resource.InMemoryUploadResourceTracker'
lock:
// 资源锁,默认实现不支持分布式场景
provider: 'cc.ddrpa.tuskott.tus.lock.InMemoryLockProvider'
storage:
// 进行中的上传如何存储数据,默认实现不支持分布式场景
provider: 'cc.ddrpa.tuskott.tus.storage.LocalDiskStorage'
config:
// 暂存到指定目录
dir: '/tmp/tuskott/storage'
客户端使用任意标准实现即可,例如下方展示的这个 tus-js-client。
注意这里的实现由上传方按照已发送数据量渲染进度条。然而实际上传时 NGINX 和 Web 容器会限制单个请求的大小,因此在服务端尽最大努力的接收模式下,如果触发了中间环节的请求体大小限制,可能会出现进度条又缩回去的滑稽现象。解决方式就是按服务端响应的 offset 渲染进度条,这表示了服务端成功接收的数据量。
document.getElementById('uploadBtn').onclick = function() {
var fileInput = document.getElementById('fileInput');
var file = fileInput.files[0];
var progressBar = document.getElementById('progressBar');
progressBar.value = 0;
progressBar.style.display = 'none';
if (!file) {
console.error('No file selected');
return;
}
progressBar.style.display = 'block';
// Default chunk size (50MB)
var chunkSize = 50 * 1024 * 1024;
var minChunkSize = 1 * 1024; // 1MB
function startUpload(currentChunkSize) {
var upload = new tus.Upload(file, {
endpoint: '/tus/files', // Change this to your tus server endpoint
retryDelays: [0, 1000, 3000, 5000],
chunkSize: currentChunkSize,
metadata: {
filename: file.name,
filetype: file.type
},
onError: function(error) {
progressBar.style.display = 'none';
if (error && error.originalRequest && error.originalRequest.status === 413 && currentChunkSize > minChunkSize) {
var nextChunkSize = Math.max(minChunkSize, Math.floor(currentChunkSize / 2));
document.getElementById('status').innerText = 'Chunk too large, retrying with smaller chunk: ' + (nextChunkSize / 1024) + 'KB';
startUpload(nextChunkSize);
} else {
document.getElementById('status').innerText = 'Upload failed: ' + error;
}
},
onProgress: function(bytesUploaded, bytesTotal) {
var percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
document.getElementById('status').innerText = 'Progress: ' + percentage + '%';
progressBar.value = percentage;
},
onSuccess: function() {
progressBar.value = 100;
document.getElementById('status').innerText = 'Upload finished!';
}
});
upload.start();
}
startUpload(chunkSize);
};
这种场景下文件上传在整个业务流中是异步的,你或许需要对程序设计做一些微小的调整,anyway,监听上传成功事件后转存文件,再在业务中将其标记为完成。
@PostComplete
public void uploadSuccessEvent(PostCompleteEvent event) {
UploadResource uploadResource = event.getUploadResource();
logger.info("Upload completed successfully: {}", uploadResource);
// 展示一下将文件按照原始文件名转存
Map<String, String> metadata = uploadResource.getMetadata();
String originalFilename = metadata.get("filename");
try(OutputStream ous = new FileOutputStream("/home/dandier/Temp/saved_" + originalFilename);
InputStream ins = tuskottProcessor.getStorage().streaming(uploadResource.getId())) {
ins.transferTo(ous);
} catch (Exception e) {
logger.error("Failed to save uploaded file", e);
}
}
默认情况下 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 的版本。
保存一段文本
Blob blob = getBlobStore().put("text",
"some-text.txt",
new ByteArrayPayload("Hello, Forvariz!".getBytes(StandardCharsets.UTF_8)),
Collections.emptyMap(),
"text/plain");
保存通过 Multipart Form 上传的文件(当然在单元测试里这个数据结构是 Mock 出来的)
String name = "file"; // The name of the parameter in the multipart request
String readableName = "Awa-Subaru.png";
String contentType = "image/png";
byte[] content = Files.readAllBytes(Path.of(readableName));
MockMultipartFile multipartFile = new MockMultipartFile(
name,
readableName,
contentType,
content
);
Blob blob = getBlobStore().put("avatar",
multipartFile.getOriginalFilename(),
new MultipartFilePayload(multipartFile),
Collections.emptyMap(),
multipartFile.getContentType());
列出所有文件(迭代器)
Iterable<BlobResult> results = getBlobStore().list("", ListOptions.withDefault());
List<Blob> blobs = StreamSupport.stream(results.spliterator(), false)
.map(item -> {
try {
return item.get();
} catch (Exception e) {
logger.error("Error getting blob", e);
return null;
}
})
.filter(Objects::nonNull)
.toList();
logger.info("blob count: {}", blobs.size());
for (Blob blob : blobs) {
logger.info("blob: {}, etag: {}", blob.getObjectName(), blob.getETag());
}
访问文件,但不要获取文件本身:
Blob fetched = getBlobStore().stat(blob.getObjectName());
获取文件:
Blob fetched = getBlobStore().get(blob.getObjectName());
BlobStore 自动注入的功能存在初始化顺序的问题,毕竟是动态创建的,在依赖关系图中没有位置。可以注入 BlobStoreHolder 然后 get 自己需要的 BlobStore 解决。
关于自建 MinIO
如果你使用单节点 MinIO,那应该趁早:
- 切换到政务云 OSS 云资源
- 部署多节点 MinIO 集群
- 考虑一下本地磁盘是不是更方便
分区分级存储
简单来说,分区分级存储是对应下文的访问控制做的设置。如果你要做一个内容管理系统,文章资源就可以放在私有写公开读的 OSS 桶里。如果要做一个文档编辑系统,那么公开读也是不允许的。使用 PloyStash,你可以创建不同的 BlobStore 指向不同的存储后端,通过规范的命名和依赖注入防止其他开发同学误用。
用户头像这种允许任意用户访问的资源,可以通过 NGINX 等 Web 服务器托管,减轻应用服务的压力,当然如果你真打算这么做,一定要做好安全防护,因为攻击者肯定会尝试丢个 Web Shell 上来。
- 别把上传文件放在和应用代码相同的目录下,单独搞一个
avatars/
目录,并通过 NGINX 配置只允许静态文件访问,只开放 GET 请求 - 在 NGINX 里加上配置:
location /avatars/ {
alias /data/uploads/avatars/;
autoindex off;
default_type image/jpeg;
types { }
add_header Content-Type "image/jpeg";
}
- 确认这个目录不会被某些 NGINX 配置自动解析脚本
- 用户上传的任何图片都用图像处理库做一次解码编码
- PolyStash 会对存储资源做重命名处理
资源访问
访问权限控制
如果你访问一些盗版影视资源站点,也许会发现他们存放视频的 OSS 有时指向了似乎并不相关的域名和 bucket。没错,这就是一些管理不善(或者内部有脏东西)的 OSS 桶做了公开读却没有做好写入验证,以及公开读没有做好访问控制。因此盗版网站运营者可以将资源放在这些桶里,为自己的站点提供服务。
被灰产利用了大不了损失点流量,被黑产利用了放点不得了的东西,不上秤 bro 顶多喝点茶,上秤了就这辈子有了。
你需要对资源访问实现分级管理,目前的建议是分为 anonymous
、presigned
和 restricted
三级。
anonymous,允许任意用户或匿名用户访问
这个级别允许任意用户或匿名用户访问,像用户头像什么的符合这一类。
不过最好也检查一下请求头中的 reference
,看看是不是来自本站。
presigned,允许通过预签发的 URL 访问
在审批流中提交的材料、用户上传的事件材料一般都属于此列。访问这里的资源需要合法用户签发一条访问链接,在链接的有效期内,允许任意用户或匿名用户持有该链接访问。
框架通过 io.xasz.modules.blob.ResourceSignatureProvider#preSignToGet
提供此项功能。
/**
* 生成预签名的 URI,用于访问资源
*
* @param objectName 资源 ID
* @param credential 签发者
* @param expires 签名有效时间
* @param inline 资源嵌在页面中还是要求浏览器下载
* @return 带有签名参数的资源访问 URI
* @throws URISyntaxException 无法构造 URI
* @throws GeneralSecurityException 签名计算失败
*/
public URI preSignToGet(String objectName, String credential, Duration expires, boolean inline) throws URISyntaxException, GeneralSecurityException {
签名设计参考 AWS,包含如下参数:
X-Dorian-Algorithm
签名算法,目前只支持HMAC-SHA256
X-Dorian-Credential
谁签发了这个 URLX-Dorian-Date
签发日期X-Dorian-Expires
有效期X-Dorian-SignedHeaders
哪些参数参与签名X-Dorian-Signature
签名值inline
决定访问资源是嵌入形式还是下载形式,可以由请求方修改
点击这个链接,浏览器将下载一张名为 Awa-Subaru.png 的图片。
https://awa-subaru.ddrpa.cc/public/resource/avatar/ec6495b2-f275-457f-b4ff-8268de70bf62?X-Dorian-Algorithm=HMAC-SHA256&X-Dorian-Credential=admin&X-Dorian-Date=20250827T102302%2B0800&X-Dorian-Expires=20250827T182302%2B0800&X-Dorian-Signature=01d07f54a616573868a085b6a4fd19285954e7d3af&X-Dorian-SignedHeaders=X-Dorian-Credential%2CX-Dorian-Date%2CX-Dorian-Expires&inline=false
把这个链接放到 <img>
标签的 src
属性中(记得确认 inline=true
),将展示一张图片。
https://awa-subaru.ddrpa.cc/public/resource/avatar/ec6495b2-f275-457f-b4ff-8268de70bf62?X-Dorian-Algorithm=HMAC-SHA256&X-Dorian-Credential=admin&X-Dorian-Date=20250827T102302%2B0800&X-Dorian-Expires=20250827T182302%2B0800&X-Dorian-Signature=01d07f54a616573868a085b6a4fd19285954e7d3af&X-Dorian-SignedHeaders=X-Dorian-Credential%2CX-Dorian-Date%2CX-Dorian-Expires&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
。这样从用户视角看,点击 生成/导出 按钮后,网站立即响应了请求开始输出内容,至于会执行多久,就可以甩锅给两边的网络运营商了。
否则还是老实地创建文档生成任务,告诉用户完成后会发送通知消息并提供下载方法。