资源的上传、存储和访问

2025-08-27, 星期三, 14:44

Java培训

一般项目中如何管理用户上传和访问资源?

文件上传

针对文件上传,有如下几点需要考虑

  • 用户体验
  • 实际的应用程序安全准则
  • 教条的应用程序安全准则

用户体验

什么时候该启用断点续传

政务云 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 顶多喝点茶,上秤了就这辈子有了。

你需要对资源访问实现分级管理,目前的建议是分为 anonymouspresignedrestricted 三级。

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 谁签发了这个 URL
  • X-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:bytesContent-LengthETagLast-Modified,告知浏览器如何管理中断的下载,并在重试请求中提供 Range 请求头。

此外 ETagLast-Modified 还允许浏览器判断这个文件是否已经下载过了,从而避免由于用户误操作造成的反复下载。

PloyStash 在 PUT 到本地文件系统时,会通过 xxHash64(超快的)计算文件的 ETag,PUT 到 S3 兼容的存储系统时,则由对应的存储服务提供商负责管理。

该功能在 Chromium 和 Firefox 上进行了测试,其他浏览器未知。

内容实时生成与访问

一个学籍管理系统中可能涉及到批量导出学生结业证书的功能。

如果你生成证书的速度足够快,可以用 ZipOutputStream 包装输出流,然后不断往里面塞 ZipEntry。这样从用户视角看,点击 生成/导出 按钮后,网站立即响应了请求开始输出内容,至于会执行多久,就可以甩锅给两边的网络运营商了

否则还是老实地创建文档生成任务,告诉用户完成后会发送通知消息并提供下载方法。