From 2f76e03defe6cd429b8f78c248c0d441e51cb2c0 Mon Sep 17 00:00:00 2001 From: Tom Desair Date: Fri, 28 Aug 2026 23:19:12 +0200 Subject: [PATCH 1/3] docs: enhance README with quickstart snippets, sequence diagrams, and restructured navigation --- README.md | 280 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 238 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 3d931890..dbbaf17f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,35 @@ -[![Build and Tests](https://github.com/tomdesair/tus-java-server/actions/workflows/build.yml/badge.svg)](https://github.com/tomdesair/tus-java-server/actions?query=branch%3Amaster+) [![Coverage Status](https://coveralls.io/repos/github/tomdesair/tus-java-server/badge.svg?branch=master)](https://coveralls.io/github/tomdesair/tus-java-server?branch=master) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=me.desair.tus%3Atus-java-server&metric=bugs)](https://sonarcloud.io/dashboard?id=me.desair.tus%3Atus-java-server) [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=me.desair.tus%3Atus-java-server&metric=vulnerabilities)](https://sonarcloud.io/dashboard?id=me.desair.tus%3Atus-java-server) [![Duplicated Lines](https://sonarcloud.io/api/project_badges/measure?project=me.desair.tus%3Atus-java-server&metric=duplicated_lines_density)](https://sonarcloud.io/dashboard?id=me.desair.tus%3Atus-java-server) +[![Maven Central](https://img.shields.io/maven-central/v/me.desair.tus/tus-java-server.svg)](https://central.sonatype.com/artifact/me.desair.tus/tus-java-server) [![Java 17+](https://img.shields.io/badge/Java-17%2B-blue.svg)](https://adoptium.net) [![Build and Tests](https://github.com/tomdesair/tus-java-server/actions/workflows/build.yml/badge.svg)](https://github.com/tomdesair/tus-java-server/actions?query=branch%3Amaster+) [![Coverage Status](https://coveralls.io/repos/github/tomdesair/tus-java-server/badge.svg?branch=master)](https://coveralls.io/github/tomdesair/tus-java-server?branch=master) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=me.desair.tus%3Atus-java-server&metric=bugs)](https://sonarcloud.io/dashboard?id=me.desair.tus%3Atus-java-server) [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=me.desair.tus%3Atus-java-server&metric=vulnerabilities)](https://sonarcloud.io/dashboard?id=me.desair.tus%3Atus-java-server) [![Duplicated Lines](https://sonarcloud.io/api/project_badges/measure?project=me.desair.tus%3Atus-java-server&metric=duplicated_lines_density)](https://sonarcloud.io/dashboard?id=me.desair.tus%3Atus-java-server) # tus-java-server -This library can be used to enable resumable (and potentially asynchronous) file uploads in any Java web application. This allows the users of your application to upload large files over slow and unreliable internet connections. The ability to pause or resume a file upload (after a connection loss or reset) is achieved by implementing the open file upload protocol tus (https://tus.io/). This library implements the server-side of the tus v1.0.0 protocol as well as the official IETF Resumable Uploads for HTTP specification ([draft-ietf-httpbis-resumable-upload](https://datatracker.ietf.org/doc/draft-ietf-httpbis-resumable-upload/)), offering dual protocol version support. +This library can be used to enable resumable (and potentially asynchronous) file uploads in any Java web application. This allows the users of your application to upload large files over slow and unreliable internet connections. The ability to pause or resume a file upload (after a connection loss or reset) is achieved by implementing the open file upload protocols tus (https://tus.io/) and Resumable Uploads for HTTP (RUFH). This library implements the server-side of the tus v1.0.0 protocol as well as the official IETF Resumable Uploads for HTTP specification ([draft-ietf-httpbis-resumable-upload](https://datatracker.ietf.org/doc/draft-ietf-httpbis-resumable-upload/)), offering dual protocol version support. The Javadoc of this library can be found at https://tus.desair.me/. As of version 2.0.0, this library requires Java 17+. +### Key Features +* ⚡ **Dual Protocol Support**: Seamless interoperability with [Tus 1.0.0](https://tus.io/) and the official [IETF Resumable Uploads for HTTP (draft-12)](https://datatracker.ietf.org/doc/draft-ietf-httpbis-resumable-upload/). +* ☁️ **Pluggable Storage Backends**: Native support for Local Disk, NFS network shares, S3-compatible Object Storage (AWS S3, MinIO, Cloudflare R2, Ceph, GCS), and Azure Blob Storage. +* 🔒 **Zero-Database Distributed Locking**: Built-in lease-based locking enabling multi-replica cluster & Kubernetes container deployments without requiring Redis or relational databases. +* 🛡️ **Data Integrity & Resiliency**: Built-in HTTP Digests ([RFC 9530](https://www.rfc-editor.org/rfc/rfc9530.html)), checksum verification, and duplicate upload deduplication. +* 🚀 **Production-Ready & Lightweight**: Minimal dependencies (Jakarta Servlet API 6.0 & Apache Commons), non-blocking lock contention resolution, and thread-local caching. + +## Table of Contents +- [Storage Backend Options](#storage-backend-options) +- [How Resumable Uploads Work](#how-resumable-uploads-work) +- [Quick Start and Examples](#quick-start-and-examples) +- [Usage and Configuration](#usage-and-configuration) + - [1. Setup & Configuration Options](#1-setup) + - [2. Processing an upload](#2-processing-an-upload) + - [3. Handling Upload Completion & Retrieving Files](#3-handling-upload-completion--retrieving-files) + - [4. Upload cleanup](#4-upload-cleanup) +- [Protocol Version Support (Tus 1.0.0 & IETF Resumable Uploads)](#protocol-version-support-tus-100--ietf-resumable-uploads) +- [Protocol Extensions](#protocol-extensions) +- [Advanced Usage](#advanced-usage) + - [HTTP Digests (RFC 9530)](#http-digests-rfc-9530) + - [Emitting HTTP 104 Interim Responses in Tomcat / Spring Boot](#emitting-http-104-interim-responses-in-tomcat--spring-boot) +- [Compatible Client Implementations & Conformity Testing](#compatible-client-implementations--conformity-testing) +- [Versioning](#versioning) +- [Contributing](#contributing) + ## Storage Backend Options `tus-java-server` provides pluggable storage architecture supporting multiple backend storage options: @@ -20,12 +45,67 @@ The Javadoc of this library can be found at https://tus.desair.me/. As of versio - **Microsoft Azure Cloud**: Native Azure Blob Storage using the `azure-storage-blob` SDK. - **Multi-Replica Support**: Uses native Azure Blob Leases (30s renewable leases) for distributed locking across cluster replicas. See [Azure Blob Storage Guide](docs/AZURE_BLOB_STORAGE.md). +## How Resumable Uploads Work + +### Tus 1.0.0 Protocol Flow +```mermaid +sequenceDiagram + autonumber + actor Client as Frontend Client (Uppy / tus-js-client) + participant ProtocolEndpoint as Server: Tus Protocol Endpoint (/api/upload) + participant Storage as Storage Backend (Disk / S3 / Azure) + participant AppEndpoint as Application API (/api/documents) + + Note over Client,Storage: 1. Create Resumable Upload Resource + Client->>ProtocolEndpoint: POST /api/upload
(Tus-Resumable: 1.0.0, Upload-Length: 1048576, Upload-Metadata: ...) + ProtocolEndpoint->>Storage: Initialize upload metadata + ProtocolEndpoint-->>Client: 201 Created
(Location: /api/upload/018f3a..., Tus-Resumable: 1.0.0) + + Note over Client,Storage: 2. Stream Data Chunk(s) + Client->>ProtocolEndpoint: PATCH /api/upload/018f3a...
(Upload-Offset: 0, Content-Type: application/offset+octet-stream) + ProtocolEndpoint->>Storage: Append chunk bytes to storage + ProtocolEndpoint-->>Client: 204 No Content
(Upload-Offset: 1048576, Tus-Resumable: 1.0.0) + + Note over Client,AppEndpoint: 3. Domain Notification & Consumption + Client->>AppEndpoint: POST /api/documents
(uploadUrl: "/api/upload/018f3a...", fileName: "document.pdf") + AppEndpoint->>Storage: tusFileUploadService.getUploadedBytes(uploadUrl) + AppEndpoint->>Storage: tusFileUploadService.deleteUpload(uploadUrl) + AppEndpoint-->>Client: 200 OK (Processed) +``` + +### IETF Resumable Uploads for HTTP (RUFH) Flow +```mermaid +sequenceDiagram + autonumber + actor Client as Frontend Client (RUFH Client) + participant ProtocolEndpoint as Server: RUFH Protocol Endpoint (/api/upload) + participant Storage as Storage Backend (Disk / S3 / Azure) + participant AppEndpoint as Application API (/api/documents) + + Note over Client,Storage: 1. Create Resumable Upload Resource + Client->>ProtocolEndpoint: POST /api/upload
(Upload-Complete: ?0) + ProtocolEndpoint->>Storage: Initialize upload metadata + ProtocolEndpoint-->>Client: 201 Created
(Location: /api/upload/018f3a...) + + Note over Client,Storage: 2. Stream Data Chunk(s) + Client->>ProtocolEndpoint: PATCH /api/upload/018f3a...
(Upload-Offset: 0, Upload-Complete: ?1, Content-Type: application/partial-upload) + ProtocolEndpoint->>Storage: Append chunk bytes to storage + ProtocolEndpoint-->>Client: 204 No Content
(Upload-Offset: 1048576, Upload-Complete: ?1) + + Note over Client,AppEndpoint: 3. Domain Notification & Consumption + Client->>AppEndpoint: POST /api/documents
(uploadUrl: "/api/upload/018f3a...", fileName: "document.pdf") + AppEndpoint->>Storage: tusFileUploadService.getUploadedBytes(uploadUrl) + AppEndpoint->>Storage: tusFileUploadService.deleteUpload(uploadUrl) + AppEndpoint-->>Client: 200 OK (Processed) +``` + ## Quick Start and Examples The tus-java-server library only depends on Jakarta Servlet API 6.0 and some Apache Commons utility libraries. This means that (in theory) you can use this library on any modern Java Web Application server like Tomcat, JBoss, Jetty... By default all uploaded data and information is stored on a (shared) file system of the application server. -You can add the latest stable version of this library to your application using Maven by adding the following dependency: +You can add the latest stable version of this library to your application using Maven or Gradle: +**Maven:** ```xml me.desair.tus @@ -34,6 +114,16 @@ You can add the latest stable version of this library to your application using ``` +**Gradle (Groovy):** +```groovy +implementation 'me.desair.tus:tus-java-server:2.0.0' +``` + +**Gradle (Kotlin):** +```kotlin +implementation("me.desair.tus:tus-java-server:2.0.0") +``` + When using S3 storage (`S3StorageService`) using the MinIO Java SDK or enabling JSON metadata serialization (`withJsonSerialization()`), also include the Jackson and MinIO dependencies matching `pom.xml`: ```xml @@ -61,6 +151,150 @@ The main entry point of the library is the `me.desair.tus.server.TusFileUploadSe * [Resumable and asynchronous file upload in Spring Boot REST API with Uppy JavaScript client.](https://github.com/tomdesair/tus-java-server-spring-demo) * (more examples to come!) +#### Frontend Client Example (Uppy / JavaScript) +Connect any standard Tus client (e.g. [Uppy](https://uppy.io/) or `tus-js-client`) to your backend upload endpoint: + +```javascript +import Uppy from '@uppy/core'; +import Tus from '@uppy/tus'; + +const uppy = new Uppy().use(Tus, { + endpoint: 'http://localhost:8080/api/upload', + chunkSize: 5 * 1024 * 1024 // 5MB chunk size +}); +``` + +## Usage and Configuration + +### 1. Setup +The first step is to create a `TusFileUploadService` object using its constructor. You can make this object available as a (Spring bean) singleton or create a new instance for each request. For example, in a Spring Boot application: + +```java +@Bean +public TusFileUploadService tusFileUploadService() { + return new TusFileUploadService() + .withStoragePath("/path/to/uploads") + .withUploadUri("/api/upload") + .withThreadLocalCache(true); +} +``` + +See the [tus-java-server-spring-demo](https://github.com/tomdesair/tus-java-server-spring-demo) repository for a complete Spring Boot reference implementation. + +After creating the object, you can configure it using the following methods: + +#### Configuration Options Reference + +| Method | Default | Description | +|---|---|---| +| `withUploadUri(String)` | `null` | Sets relative path (e.g. `/api/upload`) or absolute base URL (e.g. `https://upload.example.com/api/upload`) under which the upload endpoint is exposed. Supports regex parameters (e.g. `/users/[0-9]+/files/upload`). | +| `withStoragePath(String)` | `${java.io.tmpdir}/tus` | Path on the filesystem or shared drive where uploaded bytes and metadata are stored when using `DiskStorageService`. | +| `withSupportedProtocolVersions(ProtocolVersion)` | `ProtocolVersion.AUTO` | Configures protocol handling: `AUTO` (header-based auto-detection), `TUS_1_0_0` (Tus 1.0.0 only), or `RUFH` (IETF draft-12 only). | +| `withMaxUploadSize(Long)` | `Long.MAX_VALUE` | Maximum allowed total upload size in bytes per upload resource. | +| `withMaxLockRetries(int)` | `40` | Maximum lock acquisition retries during lock contention resolution (200ms sleep, resulting in an 8.0s timeout budget). | +| `withChunkedTransferDecoding(Boolean)` | `false` | Enables manual chunked HTTP decoding for servlet containers that do not decode chunked requests natively. | +| `withThreadLocalCache(Boolean)` | `false` | Enables in-memory thread-local caching of upload request data to reduce storage backend I/O load. | +| `withUploadExpirationPeriod(Long)` | `null` (disabled) | Expiration period in milliseconds after which incomplete/expired uploads become eligible for cleanup. | +| `withDownloadFeature()` | Disabled | Enables the unofficial `download` extension allowing clients to retrieve uploaded bytes via HTTP `GET`. | +| `withUploadDeduplication(Boolean)` | `false` | Enables duplicate file detection by checksum, linking new uploads (`duplicatesUploadId`) and skipping redundant storage writes. | +| `addTusExtension(TusExtension)` | All standard enabled | Adds a custom extension (e.g. application authorization checks). | +| `disableTusExtension(String)` | None | Disables a built-in extension (`creation`, `checksum`, `expiration`, `concatenation`, `termination`, `download`, `cors`). | +| `withUploadIdFactory(UploadIdFactory)` | `UuidUploadIdFactory` | Custom ID generator for upload resources (e.g., `UuidUploadIdFactory` or `TimeBasedUploadIdFactory`). | +| `withJsonSerialization()` | Java serialization | Enables JSON serialization for upload metadata (`UploadInfo`), requiring Jackson databind on classpath. | +| `withUploadStorageService(UploadStorageService)` | `DiskStorageService` | Configures custom or cloud storage backend (`DiskStorageService`, `S3StorageService`, `AzureBlobStorageService`). | +| `withUploadLockingService(UploadLockingService)` | `LeaseFileLockingService` | Configures custom or cloud locking backend (`LeaseFileLockingService`, `S3LockingService`, `AzureBlobLockingService`). | + +The library provides filesystem-based storage (`DiskStorageService` / `LeaseFileLockingService`), S3-compatible object storage (`S3StorageService` / `S3LockingService`), and Azure Blob Storage (`AzureBlobStorageService` / `AzureBlobLockingService`). See the **[Disk & Network Storage Locking Guide](docs/DISK_BASED_LOCKING.md)**, **[S3 Storage Guide](docs/S3_STORAGE.md)**, and **[Azure Blob Storage Guide](docs/AZURE_BLOB_STORAGE.md)** for detailed instructions on multi-replica container deployments in Kubernetes, post-upload processing, and legacy locking opt-out. + +### 2. Processing an upload +To process an upload request you have to pass the current `jakarta.servlet.http.HttpServletRequest` and `jakarta.servlet.http.HttpServletResponse` objects to the `me.desair.tus.server.TusFileUploadService.process()` method. Typical places were you can do this are inside Servlets, Filters or REST API Controllers. + +For example, in a Spring MVC REST Controller: + +```java +@Controller +@CrossOrigin(origins = "*") +public class FileUploadController { + + @Autowired + private TusFileUploadService tusFileUploadService; + + @RequestMapping( + value = {"/api/upload", "/api/upload/**"}, + method = { + RequestMethod.POST, + RequestMethod.PUT, + RequestMethod.PATCH, + RequestMethod.HEAD, + RequestMethod.DELETE, + RequestMethod.OPTIONS, + RequestMethod.GET + }) + public void processUpload(HttpServletRequest request, HttpServletResponse response) + throws IOException { + tusFileUploadService.process(request, response); + } +} +``` + +Optionally you can also pass a `String ownerKey` parameter to `process()`. The `ownerKey` can be used to have a hard separation between uploads of different users, groups or tenants in a multi-tenant setup. Examples of `ownerKey` values are user ID's, group names, client ID's... + +### 3. Handling Upload Completion & Retrieving Files +When an upload completes, the client receives the final `204 No Content` response from the Tus protocol endpoint (`/api/upload/...`). Because Tus is a decoupled file transport protocol, your frontend application typically notifies your backend domain API (e.g. `POST /api/documents`) that the upload is complete and passes along the `uploadUrl`. + +> [!NOTE] +> `POST /api/documents` represents your application's domain REST endpoint, not a protocol endpoint. The Tus server itself handles file transfer (`/api/upload`), while your application endpoint coordinates business logic, database persistence, and final file consumption. + +```java +@RestController +public class DocumentController { + + @Autowired + private TusFileUploadService tusFileUploadService; + + @PostMapping("/api/documents") + public ResponseEntity completeDocumentUpload(@RequestBody DocumentUploadRequest request) + throws IOException { + String uploadUrl = request.getUploadUrl(); + + // 1. Retrieve upload metadata (filename, original length, custom client metadata) + UploadInfo info = tusFileUploadService.getUploadInfo(uploadUrl); + String originalFileName = info.getMetadata().get("filename"); + + // 2. Stream uploaded bytes to permanent storage, database, or virus scanner + try (InputStream is = tusFileUploadService.getUploadedBytes(uploadUrl)) { + Files.copy(is, Paths.get("/var/data/documents", originalFileName)); + } + + // 3. Clean up the temporary upload bytes and locks + tusFileUploadService.deleteUpload(uploadUrl); + + return ResponseEntity.ok().build(); + } +} +``` + +Using the `me.desair.tus.server.TusFileUploadService.getUploadInfo(String uploadUrl)` method you can retrieve metadata about a specific upload process. This includes metadata provided by the client as well as metadata kept by the library like creation timestamp, creator ip-address list, upload length... The method `UploadInfo.getId()` will return the unique identifier of this upload encapsulated in an `UploadId` instance. The original (custom generated) identifier object of this upload can be retrieved using `UploadId.getOriginalObject()`. A URL safe string representation of the identifier is returned by `UploadId.toString()`. It is highly recommended to consult the [JavaDoc of both classes](https://tus.desair.me/). + +#### Cloud Storage Native Processing (S3 & Azure Blob) +When using cloud object storage backends, downstream services can directly obtain the raw cloud object key or blob name to perform zero-download server-side copying, background job processing, or direct cloud SDK operations: + +* **S3 / MinIO Storage**: Use `((S3StorageService) tusFileUploadService.getUploadStorageService()).getS3ObjectKey(uploadUrl)` to get the full S3 object key (e.g. `uploads/018f3a...`). For complete server-side `copyObject` examples and MinIO SDK usage, see the **[S3 Storage Guide](docs/S3_STORAGE.md#4-post-upload-processing-gets3objectkey)**. +* **Azure Blob Storage**: Use `((AzureBlobStorageService) tusFileUploadService.getUploadStorageService()).getAzureBlobName(uploadUrl)` to get the full blob name. For complete Azure SDK examples with `BlobClient`, see the **[Azure Blob Storage Guide](docs/AZURE_BLOB_STORAGE.md#4-post-upload-processing-getazureblobname)**. + +### 4. Upload cleanup +After having processed the uploaded bytes on the server backend (e.g. copy them to their final persistent location), it's important to cleanup the (temporary) uploaded bytes. This can be done by calling the `me.desair.tus.server.TusFileUploadService.deleteUpload(String uploadUri)` method as shown in the example above. This will remove the uploaded bytes and any associated upload information from the storage backend. Alternatively, a client can also remove an (in-progress) upload using the [termination extension](https://tus.io/protocols/resumable-upload.html#termination). + +Next to removing uploads after they have been completed and processed by the backend, it is also recommended to schedule a regular maintenance task to clean up any expired uploads or locks. Cleaning up expired uploads and locks can be achieved using the `me.desair.tus.server.TusFileUploadService.cleanup()` method: + +```java +// Run periodically (e.g., via @Scheduled in Spring) +@Scheduled(fixedDelay = 600000) // Every 10 minutes +public void cleanupExpiredUploads() { + tusFileUploadService.cleanup(); +} +``` + ## Protocol Version Support (Tus 1.0.0 & IETF Resumable Uploads) > [!WARNING] @@ -111,27 +345,7 @@ Besides the [core protocol](https://tus.io/protocols/resumable-upload.html#core- * `download`: The (unofficial) download extension allows clients to download uploaded files using a HTTP `GET` request. You can enable this extension by calling the `withDownloadFeature()` method. * `cors`: The (unofficial) CORS extension adds native CORS support out-of-the-box, setting CORS headers for all requests and responses, and handling preflight `OPTIONS` requests automatically. It is enabled by default. -## Usage and Configuration - -### 1. Setup -The first step is to create a `TusFileUploadService` object using its constructor. You can make this object available as a (Spring bean) singleton or create a new instance for each request. After creating the object, you can configure it using the following methods: - -* `withUploadUri(String)`: Set the relative path (e.g. `/files/upload`) or absolute base URL (e.g. `https://upload.example.com/files/upload`) under which the main tus upload endpoint will be made available. When configured with an absolute URL, the `Location` header returned upon upload creation (201 Created, 200 OK, or 104 Interim Response) will contain the full URL. Optionally, this URI may contain regex parameters in order to support endpoints that contain URL parameters, for example `/users/[0-9]+/files/upload` or `https://upload.example.com/users/[0-9]+/files/upload`. -* `withSupportedProtocolVersions(ProtocolVersion)`: Configure supported protocol versions (`ProtocolVersion.AUTO` for automatic header-based detection, `ProtocolVersion.TUS_1_0_0` for Tus 1.0.0 only, or `ProtocolVersion.RUFH` for IETF Resumable Uploads only). -* `withMaxUploadSize(Long)`: Specify the maximum number of bytes that can be uploaded per upload. If you don't call this method, the maximum number of bytes is `Long.MAX_VALUE`. -* `withStoragePath(String)`: If you're using the default file system-based storage service, you can use this method to specify the path where to store the uploaded bytes and upload information. -* `withMaxLockRetries(int)`: Specify the maximum number of retries the service will attempt to acquire an upload lock before failing with an `UploadAlreadyLockedException` during lock contention resolution (e.g. for `HEAD` or `DELETE` requests). Default is `40` retries (with a 200ms sleep between retries, resulting in an 8.0-second retry budget). -* `withChunkedTransferDecoding`: You can enable or disable the decoding of chunked HTTP requests by this library. Enable this feature in case the web container in which this service is running does not decode chunked transfers itself. By default, chunked decoding via this library is disabled (as modern frameworks tend to already do this for you). -* `withThreadLocalCache(Boolean)`: Optionally you can enable (or disable) an in-memory (thread local) cache of upload request data to reduce load on the storage backend and potentially increase performance when processing upload requests. -* `withUploadExpirationPeriod(Long)`: You can set the number of milliseconds after which an upload is considered as expired and available for cleanup. Applies to both Tus 1.0.0 (`Upload-Expires` response header) and IETF RUFH (`max-age` parameter in `Upload-Limit` response header). -* `withDownloadFeature()`: Enable the unofficial `download` extension that allows clients to download uploaded bytes via `GET`. This feature is disabled by default. - * **Disclaimer**: Enabling the download extension for `GET` requests may interfere with IETF RUFH `GET` offset retrieval conformity (Section 4.3 of draft-12), as RUFH specifies `GET` requests for offset retrieval returning `204 No Content`. -* `withUploadDeduplication(Boolean)`: Enable duplicate file processing based on the checksum hash. If enabled, the server will scan previous completed uploads for a file with the same checksum. If a duplicate is found, the new upload will link to the existing file (`duplicatesUploadId`), skipping redundant disk storage writes and saving disk space. - * **Disclaimer**: If duplicate file processing is enabled, the duplicate (child) upload depends directly on the original (parent) upload file. If the original parent upload is deleted or terminated, any duplicate child uploads pointing to it will no longer be downloadable (returning `404 Not Found`). -* `addTusExtension(TusExtension)`: Add a custom (application-specific) extension that implements the `me.desair.tus.server.TusExtension` interface. For example you can add your own extension that checks authentication and authorization policies within your application for the user doing the upload. -* `disableTusExtension(String)`: Disable the `TusExtension` for which the `getName()` method matches the provided string. The default extensions have names "creation", "creation-with-upload", "checksum", "expiration", "concatenation", "termination", "download" and "cors". You cannot disable the "core" feature. -* `withUploadIdFactory(UploadIdFactory)`: Provide a custom `UploadIdFactory` implementation that should be used to generate identifiers for the different uploads. The default implementation generates identifiers using a UUID (`UuidUploadIdFactory`). Another example implementation of a custom ID factory is the system-time based `TimeBasedUploadIdFactory` class. -* `withJsonSerialization()`: Instruct the storage service (`DiskStorageService` or `S3StorageService`) to serialize upload metadata (`UploadInfo`) in JSON format instead of standard Java serialization. Requires Jackson databind on the application classpath (see below). +## Advanced Usage ### HTTP Digests ([RFC 9530](https://www.rfc-editor.org/rfc/rfc9530.html)) The `http-digests` extension implements RFC 9530 to support data integrity checks for both individual data chunks (`Content-Digest`) and the entire file (`Repr-Digest`). @@ -156,24 +370,6 @@ public TomcatServletWebServerFactory tomcatFactory(TusFileUploadService tusFileU } ``` - -The library provides filesystem-based storage (`DiskStorageService` / `LeaseFileLockingService`), S3-compatible object storage (`S3StorageService` / `S3LockingService`), and Azure Blob Storage (`AzureBlobStorageService` / `AzureBlobLockingService`). See the **[Disk & Network Storage Locking Guide](docs/DISK_BASED_LOCKING.md)**, **[S3 Storage Guide](docs/S3_STORAGE.md)**, and **[Azure Blob Storage Guide](docs/AZURE_BLOB_STORAGE.md)** for detailed instructions on multi-replica container deployments in Kubernetes, post-upload processing, and legacy locking opt-out. You can also provide custom implementations of `UploadStorageService` and `UploadLockingService` using `withUploadStorageService(UploadStorageService)` and `withUploadLockingService(UploadLockingService)`. - -### 2. Processing an upload -To process an upload request you have to pass the current `jakarta.servlet.http.HttpServletRequest` and `jakarta.servlet.http.HttpServletResponse` objects to the `me.desair.tus.server.TusFileUploadService.process()` method. Typical places were you can do this are inside Servlets, Filters or REST API Controllers (see [examples](#quick-start-and-examples)). - -Optionally you can also pass a `String ownerKey` parameter. The `ownerKey` can be used to have a hard separation between uploads of different users, groups or tenants in a multi-tenant setup. Examples of `ownerKey` values are user ID's, group names, client ID's... - -### 3. Retrieving the uploaded bytes and metadata within the application -Once the upload has been completed by the user, the business logic layer of your application needs to retrieve and do something with the uploaded bytes. For example it could read the contents of the file, or move the uploaded bytes to their final persistent storage location. Retrieving the uploaded bytes in the backend can be achieved by using the `me.desair.tus.server.TusFileUploadService.getUploadedBytes(String uploadUrl)` method. The passed `uploadUrl` value should be the upload url used by the client to which the file was uploaded. Therefor your application should pass the upload URL of completed uploads to the backend. Optionally, you can also pass an `ownerKey` value to this method in case your application chooses to process uploads using owner keys. Examples of values that can be used as an `ownerKey` are: an internal user identifier, a session ID, the name of the subpart of your application... - -Using the `me.desair.tus.server.TusFileUploadService.getUploadInfo(String uploadUrl)` method you can retrieve metadata about a specific upload process. This includes metadata provided by the client as well as metadata kept by the library like creation timestamp, creator ip-address list, upload length... The method `UploadInfo.getId()` will return the unique identifier of this upload encapsulated in an `UploadId` instance. The original (custom generated) identifier object of this upload can be retrieved using `UploadId.getOriginalObject()`. A URL safe string representation of the identifier is returned by `UploadId.toString()`. It is highly recommended to consult the [JavaDoc of both classes](https://tus.desair.me/). - -### 4. Upload cleanup -After having processed the uploaded bytes on the server backend (e.g. copy them to their final persistent location), it's important to cleanup the (temporary) uploaded bytes. This can be done by calling the `me.desair.tus.server.TusFileUploadService.deleteUpload(String uploadUri)` method. This will remove the uploaded bytes and any associated upload information from the storage backend. Alternatively, a client can also remove an (in-progress) upload using the [termination extension](https://tus.io/protocols/resumable-upload.html#termination). - -Next to removing uploads after they have been completed and processed by the backend, it is also recommended to schedule a regular maintenance task to clean up any expired uploads or locks. Cleaning up expired uploads and locks can be achieved using the `me.desair.tus.server.TusFileUploadService.cleanup()` method. - ## Compatible Client Implementations & Conformity Testing This server implementation has been tested with: - **Tus 1.0.0 Clients**: Tested with [Uppy](https://uppy.io/) and `tus-js-client`. From 804557b085bb2ddd39ad63dfaa53d94f3b1394ca Mon Sep 17 00:00:00 2001 From: Tom Desair Date: Sat, 29 Aug 2026 13:10:52 +0200 Subject: [PATCH 2/3] Add server-side UploadCompletionListener and return UploadInfo from process() --- CHANGELOG.md | 2 + README.md | 53 +++- .../tus/server/TusFileUploadService.java | 130 +++++++- .../tus/server/UploadCompletionListener.java | 19 ++ .../tus/server/AbstractITRufhProtocol.java | 77 +++++ .../AbstractITTusFileUploadService.java | 191 +++++++++++ .../server/UploadCompletionListenerTest.java | 296 ++++++++++++++++++ 7 files changed, 760 insertions(+), 8 deletions(-) create mode 100644 src/main/java/me/desair/tus/server/UploadCompletionListener.java create mode 100644 src/test/java/me/desair/tus/server/UploadCompletionListenerTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fbba1bd..bf17d116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,14 @@ All notable changes to this project will be documented in this file. - **RFC 7807 Problem Details JSON**: Added support for standard `application/problem+json` error responses (`mismatching-upload-offset`, `completed-upload`, `inconsistent-upload-length`). - **Dedicated Compliance Test Suites**: Added comprehensive, spec-quoted end-to-end tests using a dedicated Python script `scripts/rufh_conformity_test.py` with documentation on how to run the tests in `docs/CONFORMITY_TESTING.md`. - **User Migration & Interim Responses Documentation**: Added `docs/MIGRATION.md` and `docs/INTERIM_RESPONSES.md` detailing migration strategies, HTTP 104 status frames under IETF RUFH, Tomcat/Servlet container limitations, cached reflection optimizations, and Spring Boot Tomcat Valve integration. +- **Server-Side Upload Completion Listeners (`UploadCompletionListener`)**: Added a functional interface callback mechanism allowing developers to register post-upload listeners via `withUploadCompletionListener(UploadCompletionListener)` or `addUploadCompletionListener(UploadCompletionListener)`. Listeners receive the completed `UploadInfo` and `TusFileUploadService` instance after lock release, allowing immediate byte streaming and deletion without contention. Added helper overloads `TusFileUploadService.getUploadedBytes(UploadInfo)` and `TusFileUploadService.deleteUpload(UploadInfo)`. - **JSON Serialization**: Support storing `UploadInfo` objects as JSON files in the storage backend using `TusFileUploadService.withJsonSerialization(true)`. ### Changed - **Default Disk-Based Locking**: `TusFileUploadService.withStoragePath(String)` now defaults to `LeaseFileLockingService` instead of `DiskLockingService` for out-of-the-box Kubernetes, container, and shared network storage compatibility. See `docs/DISK_BASED_LOCKING.md` for legacy opt-out instructions. - **Calibrated Retry Budget**: Extended `TusFileUploadService` lock acquisition retry budget to 8.0 seconds (40 retries x 200ms) to ensure reliable contention resolution over network storage. - **Absolute Base URL & Location Header Support**: Extended `withUploadUri(String)` to accept absolute base URLs (e.g. `https://upload.example.com/files`), returning full URLs in `Location` response headers for upload creation across both Tus 1.0.0 and RUFH protocols while preserving backward compatibility for relative paths. +- **`process()` Return Value (`UploadInfo`)**: `TusFileUploadService.process(...)` now returns the created or updated `UploadInfo` instance (or `null` on errors or `OPTIONS` preflight requests), enabling applications to track and store upload IDs directly into user sessions or database repositories. ### Fixed - **Clear Content-Length on Error Responses**: Cleared `Content-Length` response header prior to invoking `HttpServletResponse.sendError(...)` during exception handling, resolving buffer conflicts and exceptions in Undertow and other servlet containers ([#40](https://github.com/tomdesair/tus-java-server/issues/40)). diff --git a/README.md b/README.md index c3583a55..22cbc064 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,7 @@ After creating the object, you can configure it using the following methods: | `addTusExtension(TusExtension)` | All standard enabled | Adds a custom extension (e.g. application authorization checks). | | `disableTusExtension(String)` | None | Disables a built-in extension (`creation`, `checksum`, `expiration`, `concatenation`, `termination`, `download`, `cors`). | | `withUploadIdFactory(UploadIdFactory)` | `UuidUploadIdFactory` | Custom ID generator for upload resources (e.g., `UuidUploadIdFactory` or `TimeBasedUploadIdFactory`). | +| `withUploadCompletionListener(UploadCompletionListener)` | None | Registers a callback invoked immediately when an upload finishes transferring all bytes and is completed. | | `withJsonSerialization()` | Java serialization | Enables JSON serialization for upload metadata (`UploadInfo`), requiring Jackson databind on classpath. | | `withUploadStorageService(UploadStorageService)` | `DiskStorageService` | Configures custom or cloud storage backend (`DiskStorageService`, `S3StorageService`, `AzureBlobStorageService`). | | `withUploadLockingService(UploadLockingService)` | `LeaseFileLockingService` | Configures custom or cloud locking backend (`LeaseFileLockingService`, `S3LockingService`, `AzureBlobLockingService`). | @@ -209,6 +210,8 @@ The library provides filesystem-based storage (`DiskStorageService` / `LeaseFile ### 2. Receiving a Resumable Upload To process an upload request you have to pass the current `jakarta.servlet.http.HttpServletRequest` and `jakarta.servlet.http.HttpServletResponse` objects to the `me.desair.tus.server.TusFileUploadService.process()` method. Typical places were you can do this are inside Servlets, Filters or REST API Controllers. +The `process()` method returns the processed `UploadInfo` object (or `null` for `OPTIONS` preflight requests or when an error response is generated). + For example, in a Spring MVC REST Controller: ```java @@ -219,6 +222,9 @@ public class FileUploadController { @Autowired private TusFileUploadService tusFileUploadService; + @Autowired + private UserSessionRepository userSessionRepository; + @RequestMapping( value = {"/api/upload", "/api/upload/**"}, method = { @@ -230,17 +236,58 @@ public class FileUploadController { RequestMethod.OPTIONS, RequestMethod.GET }) - public void processUpload(HttpServletRequest request, HttpServletResponse response) + public void processUpload( + HttpServletRequest request, HttpServletResponse response, @RequestParam String userSessionId) throws IOException { - tusFileUploadService.process(request, response); + + UploadInfo info = tusFileUploadService.process(request, response, userSessionId); + + // Creation vs. Progress Ambiguity: Check if this was a new upload creation request + if (info != null && "POST".equalsIgnoreCase(request.getMethod())) { + userSessionRepository.recordUpload(userSessionId, info.getId()); + } } } ``` +> [!NOTE] +> **Creation vs. Progress Ambiguity**: The `process()` method returns an `UploadInfo` on both creation (`POST`) and progress updates (`PATCH` / `HEAD`). When associating an upload ID with a user session or database entity on creation, check `info != null && "POST".equalsIgnoreCase(request.getMethod())`. +> +> **Single-Request Upload Completion**: When clients upload the entire file in a single request using the `creation-with-upload` extension or RUFH single-request POST, the upload is already complete when `process()` returns, and any registered `UploadCompletionListener` will have already fired *before* `process()` returns to your controller. + Optionally you can also pass a `String ownerKey` parameter to `process()`. The `ownerKey` can be used to have a hard separation between uploads of different users, groups or tenants in a multi-tenant setup. Examples of `ownerKey` values are user ID's, group names, client ID's... ### 3. Handling Upload Completion & Retrieving Files -When an upload completes, the client receives the final `204 No Content` response from the Tus protocol endpoint (`/api/upload/...`). Because Tus is a decoupled file transport protocol, your frontend application typically notifies your backend domain API (e.g. `POST /api/documents`) that the upload is complete and passes along the `uploadUrl`. + +You can handle upload completion in two ways: +1. **Server-Side Callback Listener (`UploadCompletionListener`)**: Register a hook directly on `TusFileUploadService` that triggers automatically when the final chunk is uploaded. +2. **Domain API Endpoint**: Have your frontend Tus client notify your application backend domain endpoint (e.g. `POST /api/documents`) once upload completes. + +#### Option A: Server-Side `UploadCompletionListener` +Register one or more completion listeners on your `TusFileUploadService`. When an upload finishes transferring all bytes, the callback executes with the completed `UploadInfo` and the service instance. The upload lock is released *before* the listener is invoked, allowing you to safely stream or delete uploaded bytes immediately: + +```java +@Bean +public TusFileUploadService tusFileUploadService() { + return new TusFileUploadService() + .withStoragePath("/path/to/uploads") + .withUploadUri("/api/upload") + .withUploadCompletionListener((uploadInfo, service) -> { + // Filename needs to be set as metadata by the client + String fileName = uploadInfo.getMetadata().get("filename"); + try (InputStream is = service.getUploadedBytes(uploadInfo)) { + Files.copy(is, Paths.get("/var/data/documents", fileName)); + } catch (Exception e) { + log.error("Failed to process completed upload {}", uploadInfo.getId(), e); + } + // Optionally delete upload temporary files after processing + service.deleteUpload(uploadInfo); + }); +} +``` + +#### Option B: Client-Initiated Domain Notification +When an upload completes, the client receives the final `204 No Content` response from the Tus/RUFH protocol endpoint (`/api/upload/...`). Because Tus and RUFH are decoupled file transport protocols, your frontend application can notify your backend domain API (e.g. `POST /api/documents`) that the upload is complete and pass along the `uploadUrl`. > [!NOTE] > `POST /api/documents` represents your application's domain REST endpoint, not a protocol endpoint. The Tus server itself handles file transfer (`/api/upload`), while your application endpoint coordinates business logic, database persistence, and final file consumption. diff --git a/src/main/java/me/desair/tus/server/TusFileUploadService.java b/src/main/java/me/desair/tus/server/TusFileUploadService.java index c6299713..0bba346a 100644 --- a/src/main/java/me/desair/tus/server/TusFileUploadService.java +++ b/src/main/java/me/desair/tus/server/TusFileUploadService.java @@ -9,8 +9,10 @@ import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; import me.desair.tus.server.checksum.ChecksumExtension; import me.desair.tus.server.concatenation.ConcatenationExtension; import me.desair.tus.server.core.CoreProtocol; @@ -37,6 +39,7 @@ import me.desair.tus.server.util.TusServletResponse; import me.desair.tus.server.util.Utils; import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.Strings; import org.apache.commons.lang3.Validate; import org.slf4j.Logger; @@ -63,6 +66,8 @@ public class TusFileUploadService implements Closeable { private boolean isChunkedTransferDecodingEnabled = false; private ProtocolVersion supportedProtocolVersion = ProtocolVersion.AUTO; private int maxLockRetries = DEFAULT_MAX_LOCK_RETRIES; + private final List uploadCompletionListeners = + new CopyOnWriteArrayList<>(); /** Constructor. */ public TusFileUploadService() { @@ -87,6 +92,29 @@ protected void initFeatures() { addTusExtension(new HttpDigestsExtension()); } + /** + * Register a callback listener that is invoked when an upload successfully reaches completion. + * + * @param listener The completion listener to register + * @return The current service + */ + public TusFileUploadService withUploadCompletionListener(UploadCompletionListener listener) { + return addUploadCompletionListener(listener); + } + + /** + * Add a callback listener that is invoked when an upload successfully reaches completion. + * + * @param listener The completion listener to add + * @return The current service + */ + public TusFileUploadService addUploadCompletionListener(UploadCompletionListener listener) { + if (listener != null) { + this.uploadCompletionListeners.add(listener); + } + return this; + } + /** * Configure the supported protocol version(s) for this service. * @@ -464,11 +492,12 @@ public Set getEnabledFeatures() { * * @param servletRequest The {@link HttpServletRequest} of the request * @param servletResponse The {@link HttpServletResponse} of the request + * @return The processed {@link UploadInfo} or null if no upload was involved or an error occurred * @throws IOException When saving bytes or information of this requests fails */ - public void process(HttpServletRequest servletRequest, HttpServletResponse servletResponse) + public UploadInfo process(HttpServletRequest servletRequest, HttpServletResponse servletResponse) throws IOException { - process(servletRequest, servletResponse, null); + return process(servletRequest, servletResponse, null); } /** @@ -479,9 +508,10 @@ public void process(HttpServletRequest servletRequest, HttpServletResponse servl * @param servletRequest The {@link HttpServletRequest} of the request * @param servletResponse The {@link HttpServletResponse} of the request * @param ownerKey A unique identifier of the owner (group) of this upload + * @return The processed {@link UploadInfo} or null if no upload was involved or an error occurred * @throws IOException When saving bytes or information of this requests fails */ - public void process( + public UploadInfo process( HttpServletRequest servletRequest, HttpServletResponse servletResponse, String ownerKey) throws IOException { Objects.requireNonNull(servletRequest, "The HTTP Servlet request cannot be null"); @@ -496,15 +526,24 @@ public void process( new TusServletRequest(servletRequest, isChunkedTransferDecodingEnabled); TusServletResponse response = new TusServletResponse(servletResponse); + UploadInfo processedUploadInfo = null; + boolean wasInProgress = checkWasInProgress(request, ownerKey); + try (UploadLock lock = acquireUploadLock(method, request.getRequestURI())) { - processLockedRequest(method, request, response, ownerKey); + processedUploadInfo = processLockedRequest(method, request, response, ownerKey); } catch (TusException e) { log.error("Unable to lock upload for request URI " + request.getRequestURI(), e); response.setHeader(HttpHeader.CONTENT_LENGTH, null); response.sendError(e.getStatus(), e.getMessage()); } + + if (wasInProgress && processedUploadInfo != null && !processedUploadInfo.isUploadInProgress()) { + notifyUploadCompletionListeners(processedUploadInfo); + } + + return processedUploadInfo; } protected UploadLock acquireUploadLock(HttpMethod method, String requestUri) @@ -539,6 +578,23 @@ protected UploadLock acquireUploadLock(HttpMethod method, String requestUri) return lock; } + /** + * Method to retrieve the bytes that were uploaded to a specific upload represented by {@link + * UploadInfo}. + * + * @param uploadInfo The upload info representing the upload + * @return An {@link InputStream} that will stream the uploaded bytes, or null if uploadInfo is + * null + * @throws IOException When retrieving the uploaded bytes fails + * @throws TusException When the upload is still in progress or cannot be found + */ + public InputStream getUploadedBytes(UploadInfo uploadInfo) throws IOException, TusException { + if (uploadInfo == null || uploadInfo.getId() == null) { + return null; + } + return uploadStorageService.getUploadedBytes(uploadInfo.getId()); + } + /** * Method to retrieve the bytes that were uploaded to a specific upload URI. * @@ -598,6 +654,20 @@ public UploadInfo getUploadInfo(String uploadUri, String ownerKey) } } + /** + * Method to delete an upload associated with the given {@link UploadInfo}. Invoke this method if + * you no longer need the upload. + * + * @param uploadInfo The upload info representing the upload to delete + * @throws IOException When deleting the upload fails + * @throws TusException When the upload cannot be found or deleted + */ + public void deleteUpload(UploadInfo uploadInfo) throws IOException, TusException { + if (uploadInfo != null) { + uploadStorageService.terminateUpload(uploadInfo); + } + } + /** * Method to delete an upload associated with the given upload URL. Invoke this method if you no * longer need the upload. @@ -634,7 +704,7 @@ public void cleanup() throws IOException { uploadStorageService.cleanupExpiredUploads(uploadLockingService); } - protected void processLockedRequest( + protected UploadInfo processLockedRequest( HttpMethod method, TusServletRequest request, TusServletResponse response, String ownerKey) throws IOException { ProtocolVersion detectedVersion = detectProtocolVersion(request); @@ -644,8 +714,58 @@ protected void processLockedRequest( executeProcessingByFeatures(method, request, response, ownerKey, detectedVersion); + return resolveUploadInfo(request, response, ownerKey); + } catch (TusException e) { processTusException(method, request, response, ownerKey, e, detectedVersion); + return null; + } + } + + private UploadInfo resolveUploadInfo( + TusServletRequest request, TusServletResponse response, String ownerKey) throws IOException { + String uploadUri = response != null ? response.getHeader(HttpHeader.LOCATION) : null; + if (StringUtils.isBlank(uploadUri) && request != null) { + if (Utils.isCreationEndpoint(request, uploadStorageService)) { + return null; + } + uploadUri = request.getRequestURI(); + } + if (StringUtils.isNotBlank(uploadUri) && uploadStorageService != null) { + return uploadStorageService.getUploadInfo(uploadUri, ownerKey); + } + return null; + } + + private boolean checkWasInProgress(TusServletRequest request, String ownerKey) { + if (request == null || uploadStorageService == null) { + return true; + } + try { + UploadInfo uploadInfo = resolveUploadInfo(request, null, ownerKey); + if (uploadInfo != null) { + return uploadInfo.isUploadInProgress(); + } + } catch (Exception e) { + log.debug("Error checking initial upload progress state: {}", e.getMessage()); + } + return true; + } + + protected void notifyUploadCompletionListeners(UploadInfo uploadInfo) { + if (uploadInfo == null || uploadCompletionListeners.isEmpty()) { + return; + } + for (UploadCompletionListener listener : uploadCompletionListeners) { + try { + listener.onUploadComplete(uploadInfo, this); + } catch (Throwable t) { + log.error( + "Error executing upload completion listener for upload ID {}: {}", + uploadInfo.getId(), + t.getMessage(), + t); + } } } diff --git a/src/main/java/me/desair/tus/server/UploadCompletionListener.java b/src/main/java/me/desair/tus/server/UploadCompletionListener.java new file mode 100644 index 00000000..93124dd3 --- /dev/null +++ b/src/main/java/me/desair/tus/server/UploadCompletionListener.java @@ -0,0 +1,19 @@ +package me.desair.tus.server; + +import me.desair.tus.server.upload.UploadInfo; + +/** + * Functional interface for listening to upload completion events across both Tus 1.0.0 and IETF + * Resumable Uploads for HTTP (RUFH) protocols. + */ +@FunctionalInterface +public interface UploadCompletionListener { + + /** + * Invoked when an upload has successfully completed. + * + * @param uploadInfo the metadata and identifiers of the completed upload + * @param tusFileUploadService the service instance that processed the upload + */ + void onUploadComplete(UploadInfo uploadInfo, TusFileUploadService tusFileUploadService); +} diff --git a/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java b/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java index f27fd612..e818de35 100644 --- a/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java +++ b/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java @@ -3,6 +3,7 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -11,6 +12,8 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import me.desair.tus.server.upload.UploadInfo; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; @@ -724,6 +727,80 @@ public void testUploadWithAbsoluteUploadUriWithPath() throws Exception { } } + @Test + public void testUploadCompletionListenerRufhPostCreation() throws Exception { + AtomicInteger listenerCallCount = new AtomicInteger(0); + AtomicReference completedUploadInfo = new AtomicReference<>(); + + tusFileUploadService.withUploadCompletionListener( + (uploadInfo, service) -> { + listenerCallCount.incrementAndGet(); + completedUploadInfo.set(uploadInfo); + }); + + String uploadContent = "rufh-creation-complete"; + + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, uploadContent.length()); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/octet-stream"); + servletRequest.setContent(uploadContent.getBytes(StandardCharsets.UTF_8)); + + UploadInfo createdInfo = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_OK); + assertNotNull(createdInfo); + assertEquals(1, listenerCallCount.get()); + assertNotNull(completedUploadInfo.get()); + + try (InputStream stream = tusFileUploadService.getUploadedBytes(completedUploadInfo.get())) { + assertThat(IOUtils.toString(stream, StandardCharsets.UTF_8), is(uploadContent)); + } + } + + @Test + public void testUploadCompletionListenerRufhPatchAppend() throws Exception { + AtomicInteger listenerCallCount = new AtomicInteger(0); + AtomicReference completedUploadInfo = new AtomicReference<>(); + + tusFileUploadService.withUploadCompletionListener( + (uploadInfo, service) -> { + listenerCallCount.incrementAndGet(); + completedUploadInfo.set(uploadInfo); + }); + + // Step 1: Create incomplete RUFH upload + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + UploadInfo createdInfo = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertEquals(0, listenerCallCount.get()); + + String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION); + + // Step 2: Append with Upload-Complete: ?1 + String uploadContent = "rufh-patch-complete"; + reset(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + servletRequest.setContent(uploadContent.getBytes(StandardCharsets.UTF_8)); + + UploadInfo patchInfo = tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_OK); + assertNotNull(patchInfo); + assertEquals(1, listenerCallCount.get()); + + try (InputStream stream = tusFileUploadService.getUploadedBytes(completedUploadInfo.get())) { + assertThat(IOUtils.toString(stream, StandardCharsets.UTF_8), is(uploadContent)); + } + } + // =============================================================================================== // ASSERTION HELPERS // =============================================================================================== diff --git a/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java b/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java index ba3f5a9d..b51ec433 100644 --- a/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java +++ b/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java @@ -8,7 +8,9 @@ import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.collection.IsMapContaining.hasEntry; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -22,6 +24,8 @@ import java.util.Arrays; import java.util.Locale; import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.util.Utils; @@ -2076,6 +2080,193 @@ public void testUploadWithAbsoluteUploadUriWithPath() throws Exception { } } + @Test + public void testUploadCompletionListenerTusPatch() throws Exception { + AtomicInteger listenerCallCount = new AtomicInteger(0); + AtomicReference completedUploadInfo = new AtomicReference<>(); + AtomicReference callbackService = new AtomicReference<>(); + + tusFileUploadService.withUploadCompletionListener( + (uploadInfo, service) -> { + listenerCallCount.incrementAndGet(); + completedUploadInfo.set(uploadInfo); + callbackService.set(service); + }); + + String uploadContent = "1234567890"; + + // Step 1: Create upload with length 10 + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, uploadContent.length()); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + + UploadInfo createdInfo = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertNotNull(createdInfo); + assertEquals(0, listenerCallCount.get()); + + String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION); + + // Step 2: Upload chunk 1 (5 bytes) + reset(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.setContent(uploadContent.substring(0, 5).getBytes(StandardCharsets.UTF_8)); + + UploadInfo patch1Info = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_NO_CONTENT); + assertNotNull(patch1Info); + assertEquals(0, listenerCallCount.get()); + + // Step 3: Upload chunk 2 (remaining 5 bytes) - should complete upload and fire listener + reset(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 5); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.setContent(uploadContent.substring(5).getBytes(StandardCharsets.UTF_8)); + + UploadInfo patch2Info = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_NO_CONTENT); + assertNotNull(patch2Info); + assertEquals(1, listenerCallCount.get()); + assertNotNull(completedUploadInfo.get()); + assertEquals(createdInfo.getId(), completedUploadInfo.get().getId()); + assertEquals(tusFileUploadService, callbackService.get()); + + // Step 4: Verify uploaded bytes can be retrieved by UploadInfo + try (InputStream stream = tusFileUploadService.getUploadedBytes(completedUploadInfo.get())) { + assertThat(IOUtils.toString(stream, StandardCharsets.UTF_8), is(uploadContent)); + } + + // Step 5: Send subsequent HEAD request on completed upload - must not trigger listener again + reset(); + servletRequest.setMethod("HEAD"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + + UploadInfo headInfo = tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_NO_CONTENT); + assertNotNull(headInfo); + assertEquals(1, listenerCallCount.get()); + } + + @Test + public void testUploadCompletionListenerTusCreationWithUpload() throws Exception { + AtomicInteger listenerCallCount = new AtomicInteger(0); + AtomicReference completedUploadInfo = new AtomicReference<>(); + AtomicReference callbackService = new AtomicReference<>(); + + tusFileUploadService.withUploadCompletionListener( + (uploadInfo, service) -> { + listenerCallCount.incrementAndGet(); + completedUploadInfo.set(uploadInfo); + callbackService.set(service); + }); + + String uploadContent = "creation-with-upload-payload"; + + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, uploadContent.length()); + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, uploadContent.length()); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.setContent(uploadContent.getBytes(StandardCharsets.UTF_8)); + + UploadInfo createdInfo = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertNotNull(createdInfo); + assertEquals(1, listenerCallCount.get()); + assertNotNull(completedUploadInfo.get()); + assertEquals(createdInfo.getId(), completedUploadInfo.get().getId()); + assertEquals(tusFileUploadService, callbackService.get()); + + // Verify uploaded bytes via UploadInfo + try (InputStream stream = tusFileUploadService.getUploadedBytes(completedUploadInfo.get())) { + assertThat(IOUtils.toString(stream, StandardCharsets.UTF_8), is(uploadContent)); + } + + // Clean up via UploadInfo + tusFileUploadService.deleteUpload(completedUploadInfo.get()); + } + + @Test + public void testUploadCompletionListenerTusConcatenation() throws Exception { + String part1Content = "part1-"; + String part2Content = "part2"; + + // 1. Create and upload partial 1 + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, part1Content.length()); + servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "partial"); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + String part1Uri = servletResponse.getHeader(HttpHeader.LOCATION); + + reset(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(part1Uri); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.setContent(part1Content.getBytes(StandardCharsets.UTF_8)); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + + // 2. Create and upload partial 2 + reset(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, part2Content.length()); + servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "partial"); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + String part2Uri = servletResponse.getHeader(HttpHeader.LOCATION); + + reset(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(part2Uri); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.setContent(part2Content.getBytes(StandardCharsets.UTF_8)); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + + // 3. Register listener and execute final concatenation + AtomicInteger listenerCallCount = new AtomicInteger(0); + AtomicReference completedUploadInfo = new AtomicReference<>(); + tusFileUploadService.withUploadCompletionListener( + (uploadInfo, service) -> { + listenerCallCount.incrementAndGet(); + completedUploadInfo.set(uploadInfo); + }); + + reset(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "final;" + part1Uri + " " + part2Uri); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + + UploadInfo finalInfo = tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertNotNull(finalInfo); + assertEquals(1, listenerCallCount.get()); + + try (InputStream stream = tusFileUploadService.getUploadedBytes(completedUploadInfo.get())) { + assertThat(IOUtils.toString(stream, StandardCharsets.UTF_8), is(part1Content + part2Content)); + } + } + protected void assertResponseHeader(final String header, final String value) { assertThat(servletResponse.getHeader(header), is(value)); } diff --git a/src/test/java/me/desair/tus/server/UploadCompletionListenerTest.java b/src/test/java/me/desair/tus/server/UploadCompletionListenerTest.java new file mode 100644 index 00000000..e5955d76 --- /dev/null +++ b/src/test/java/me/desair/tus/server/UploadCompletionListenerTest.java @@ -0,0 +1,296 @@ +package me.desair.tus.server; + +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.servlet.http.HttpServletResponse; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import me.desair.tus.server.upload.UploadId; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadStorageService; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +/** + * Unit tests for {@link UploadCompletionListener} and related methods in {@link + * TusFileUploadService}. + */ +public class UploadCompletionListenerTest { + + private Path storagePath; + private TusFileUploadService tusFileUploadService; + + @Before + public void setUp() throws Exception { + storagePath = Files.createTempDirectory("tus-listener-test-"); + tusFileUploadService = + new TusFileUploadService().withStoragePath(storagePath.toString()).withUploadUri("/files"); + } + + @After + public void tearDown() throws Exception { + if (tusFileUploadService != null) { + tusFileUploadService.close(); + } + if (storagePath != null && Files.exists(storagePath)) { + FileUtils.deleteDirectory(storagePath.toFile()); + } + } + + @Test + public void testRegisterAndAddListenersNullSafe() { + TusFileUploadService service = new TusFileUploadService(); + service.withUploadCompletionListener(null); + service.addUploadCompletionListener(null); + + AtomicInteger callCount = new AtomicInteger(0); + UploadCompletionListener listener = (info, svc) -> callCount.incrementAndGet(); + + service.withUploadCompletionListener(listener); + service.addUploadCompletionListener(listener); + + UploadInfo completedInfo = new UploadInfo(); + completedInfo.setId(new UploadId("test-id")); + completedInfo.setLength(100L); + completedInfo.setOffset(100L); + + service.notifyUploadCompletionListeners(completedInfo); + assertEquals(2, callCount.get()); + } + + @Test + public void testGetUploadedBytesAndTerminateByUploadInfo() throws Exception { + UploadStorageService mockStorage = mock(UploadStorageService.class); + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("abc-123")); + + InputStream mockStream = + new ByteArrayInputStream("hello world".getBytes(StandardCharsets.UTF_8)); + when(mockStorage.getUploadedBytes(info.getId())).thenReturn(mockStream); + + TusFileUploadService service = new TusFileUploadService().withUploadStorageService(mockStorage); + + assertNull(service.getUploadedBytes((UploadInfo) null)); + UploadInfo nullIdInfo = new UploadInfo(); + assertNull(service.getUploadedBytes(nullIdInfo)); + + InputStream result = service.getUploadedBytes(info); + assertNotNull(result); + assertEquals("hello world", IOUtils.toString(result, StandardCharsets.UTF_8)); + + service.deleteUpload((UploadInfo) null); + service.deleteUpload(info); + verify(mockStorage, times(1)).terminateUpload(info); + } + + @Test + public void testListenerReceivesServiceInstanceAndReadsBytes() throws Exception { + AtomicReference receivedService = new AtomicReference<>(); + AtomicReference receivedInfo = new AtomicReference<>(); + AtomicBoolean bytesReadMatch = new AtomicBoolean(false); + + byte[] payload = "completed-test-payload".getBytes(StandardCharsets.UTF_8); + + tusFileUploadService.withUploadCompletionListener( + (info, svc) -> { + receivedInfo.set(info); + receivedService.set(svc); + try (InputStream is = svc.getUploadedBytes(info)) { + byte[] readBytes = IOUtils.toByteArray(is); + bytesReadMatch.set( + new String(readBytes, StandardCharsets.UTF_8).equals("completed-test-payload")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + // 1. Create upload via POST + MockHttpServletRequest createRequest = new MockHttpServletRequest("POST", "/files"); + createRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + createRequest.addHeader(HttpHeader.UPLOAD_LENGTH, String.valueOf(payload.length)); + MockHttpServletResponse createResponse = new MockHttpServletResponse(); + + UploadInfo createdInfo = tusFileUploadService.process(createRequest, createResponse); + assertNotNull(createdInfo); + assertNull(receivedInfo.get()); // Incomplete, should not have fired + + String location = createResponse.getHeader(HttpHeader.LOCATION); + assertNotNull(location); + + // 2. Upload full payload via PATCH + MockHttpServletRequest patchRequest = new MockHttpServletRequest("PATCH", location); + patchRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + patchRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + patchRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + patchRequest.setContent(payload); + MockHttpServletResponse patchResponse = new MockHttpServletResponse(); + + UploadInfo patchedInfo = tusFileUploadService.process(patchRequest, patchResponse); + assertNotNull(patchedInfo); + assertEquals(HttpServletResponse.SC_NO_CONTENT, patchResponse.getStatus()); + + // Verify listener was called with correct arguments + assertNotNull(receivedInfo.get()); + assertEquals(createdInfo.getId(), receivedInfo.get().getId()); + assertEquals(tusFileUploadService, receivedService.get()); + assertTrue(bytesReadMatch.get()); + } + + @Test + public void testListenerExceptionIsolation() throws Exception { + AtomicInteger listenerTwoCallCount = new AtomicInteger(0); + + tusFileUploadService + .withUploadCompletionListener( + (info, svc) -> { + throw new RuntimeException("Downstream failure in listener 1"); + }) + .addUploadCompletionListener( + (info, svc) -> { + listenerTwoCallCount.incrementAndGet(); + }); + + byte[] payload = "hello".getBytes(StandardCharsets.UTF_8); + + // Creation with upload (single request completion) + MockHttpServletRequest postRequest = new MockHttpServletRequest("POST", "/files"); + postRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + postRequest.addHeader(HttpHeader.UPLOAD_LENGTH, String.valueOf(payload.length)); + postRequest.addHeader(HttpHeader.CONTENT_LENGTH, String.valueOf(payload.length)); + postRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + postRequest.setContent(payload); + MockHttpServletResponse postResponse = new MockHttpServletResponse(); + + UploadInfo info = tusFileUploadService.process(postRequest, postResponse); + assertNotNull(info); + assertEquals(HttpServletResponse.SC_CREATED, postResponse.getStatus()); + assertEquals(1, listenerTwoCallCount.get()); + } + + @Test + public void testProcessReturnsNullOnOptionsAndError() throws Exception { + MockHttpServletRequest optionsRequest = new MockHttpServletRequest("OPTIONS", "/files"); + optionsRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + MockHttpServletResponse optionsResponse = new MockHttpServletResponse(); + + UploadInfo optionsInfo = tusFileUploadService.process(optionsRequest, optionsResponse); + assertNull(optionsInfo); + assertEquals(HttpServletResponse.SC_NO_CONTENT, optionsResponse.getStatus()); + + // Invalid PATCH request (non-existent upload) + MockHttpServletRequest badPatchRequest = + new MockHttpServletRequest("PATCH", "/files/non-existent-id"); + badPatchRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + badPatchRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + badPatchRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + MockHttpServletResponse badPatchResponse = new MockHttpServletResponse(); + + UploadInfo badPatchInfo = tusFileUploadService.process(badPatchRequest, badPatchResponse); + assertNull(badPatchInfo); + assertEquals(HttpServletResponse.SC_NOT_FOUND, badPatchResponse.getStatus()); + } + + @Test + public void testNotifyUploadCompletionListenersNullSafe() { + TusFileUploadService service = new TusFileUploadService(); + // Verify no exception on null or empty + service.notifyUploadCompletionListeners(null); + + UploadInfo info = new UploadInfo(); + service.notifyUploadCompletionListeners(info); + } + + @Test + public void testIncompletePatchDoesNotTriggerListener() throws Exception { + AtomicInteger listenerCallCount = new AtomicInteger(0); + tusFileUploadService.withUploadCompletionListener( + (info, svc) -> listenerCallCount.incrementAndGet()); + + // 1. Create upload of length 20 + MockHttpServletRequest createRequest = new MockHttpServletRequest("POST", "/files"); + createRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + createRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "20"); + MockHttpServletResponse createResponse = new MockHttpServletResponse(); + UploadInfo createdInfo = tusFileUploadService.process(createRequest, createResponse); + assertNotNull(createdInfo); + assertEquals(0, listenerCallCount.get()); + + String location = createResponse.getHeader(HttpHeader.LOCATION); + + // 2. Upload first 10 bytes via PATCH (partial chunk) + MockHttpServletRequest patch1 = new MockHttpServletRequest("PATCH", location); + patch1.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + patch1.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + patch1.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + patch1.setContent("0123456789".getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse patchResponse1 = new MockHttpServletResponse(); + UploadInfo patchInfo1 = tusFileUploadService.process(patch1, patchResponse1); + assertNotNull(patchInfo1); + assertEquals(Long.valueOf(10L), patchInfo1.getOffset()); + assertEquals(0, listenerCallCount.get()); + + // 3. Send HEAD request - should return upload info but not trigger listener + MockHttpServletRequest headRequest = new MockHttpServletRequest("HEAD", location); + headRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + MockHttpServletResponse headResponse = new MockHttpServletResponse(); + UploadInfo headInfo = tusFileUploadService.process(headRequest, headResponse); + assertNotNull(headInfo); + assertEquals(0, listenerCallCount.get()); + + // 4. Upload remaining 10 bytes via PATCH - should trigger listener exactly once + MockHttpServletRequest patch2 = new MockHttpServletRequest("PATCH", location); + patch2.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + patch2.addHeader(HttpHeader.UPLOAD_OFFSET, "10"); + patch2.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + patch2.setContent("abcdefghij".getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse patchResponse2 = new MockHttpServletResponse(); + UploadInfo patchInfo2 = tusFileUploadService.process(patch2, patchResponse2); + assertNotNull(patchInfo2); + assertEquals(1, listenerCallCount.get()); + + // 5. Send subsequent HEAD request on completed upload - must NOT trigger listener again + MockHttpServletRequest headAfterComplete = new MockHttpServletRequest("HEAD", location); + headAfterComplete.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + MockHttpServletResponse headAfterResponse = new MockHttpServletResponse(); + UploadInfo headAfterInfo = tusFileUploadService.process(headAfterComplete, headAfterResponse); + assertNotNull(headAfterInfo); + assertEquals(1, listenerCallCount.get()); + } + + @Test(expected = IOException.class) + public void testCheckWasInProgressWhenStorageThrows() throws Exception { + UploadStorageService mockStorage = mock(UploadStorageService.class); + when(mockStorage.getUploadUri()).thenReturn("/files"); + when(mockStorage.getUploadInfo(anyString(), any())) + .thenThrow(new IOException("Storage failure")); + + TusFileUploadService service = new TusFileUploadService().withUploadStorageService(mockStorage); + MockHttpServletRequest patchRequest = new MockHttpServletRequest("PATCH", "/files/some-id"); + + // Should catch exception gracefully and not propagate + UploadInfo info = service.process(patchRequest, new MockHttpServletResponse()); + assertNull(info); + } +} From 17a92b047666a95f27edf65055203dd34e303c0d Mon Sep 17 00:00:00 2001 From: Tom Desair Date: Sat, 29 Aug 2026 15:41:24 +0200 Subject: [PATCH 3/3] Validate ownerKey and acquire lock in UploadInfo/UploadId helper methods --- .../tus/server/TusFileUploadService.java | 156 +++++++++++++- .../server/UploadCompletionListenerTest.java | 195 +++++++++++++++++- 2 files changed, 339 insertions(+), 12 deletions(-) diff --git a/src/main/java/me/desair/tus/server/TusFileUploadService.java b/src/main/java/me/desair/tus/server/TusFileUploadService.java index 0bba346a..6801ec34 100644 --- a/src/main/java/me/desair/tus/server/TusFileUploadService.java +++ b/src/main/java/me/desair/tus/server/TusFileUploadService.java @@ -26,6 +26,7 @@ import me.desair.tus.server.rufh.ResumableUploadsForHttpProtocol; import me.desair.tus.server.rufh.util.RufhInterimResponseUtil; import me.desair.tus.server.termination.TerminationExtension; +import me.desair.tus.server.upload.UploadId; import me.desair.tus.server.upload.UploadIdFactory; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLock; @@ -580,11 +581,11 @@ protected UploadLock acquireUploadLock(HttpMethod method, String requestUri) /** * Method to retrieve the bytes that were uploaded to a specific upload represented by {@link - * UploadInfo}. + * UploadInfo}. Validates the owner key under an exclusive upload lock. * * @param uploadInfo The upload info representing the upload * @return An {@link InputStream} that will stream the uploaded bytes, or null if uploadInfo is - * null + * null, not found, or the owner key does not match * @throws IOException When retrieving the uploaded bytes fails * @throws TusException When the upload is still in progress or cannot be found */ @@ -592,7 +593,65 @@ public InputStream getUploadedBytes(UploadInfo uploadInfo) throws IOException, T if (uploadInfo == null || uploadInfo.getId() == null) { return null; } - return uploadStorageService.getUploadedBytes(uploadInfo.getId()); + return getUploadedBytes(uploadInfo.getId(), uploadInfo.getOwnerKey()); + } + + /** + * Method to retrieve the bytes that were uploaded to a specific upload represented by {@link + * UploadInfo} and matching the given owner key. + * + * @param uploadInfo The upload info representing the upload + * @param ownerKey The expected owner key of the upload + * @return An {@link InputStream} that will stream the uploaded bytes, or null if uploadInfo is + * null, not found, or the owner key does not match + * @throws IOException When retrieving the uploaded bytes fails + * @throws TusException When the upload is still in progress or cannot be found + */ + public InputStream getUploadedBytes(UploadInfo uploadInfo, String ownerKey) + throws IOException, TusException { + if (uploadInfo == null || uploadInfo.getId() == null) { + return null; + } + return getUploadedBytes(uploadInfo.getId(), ownerKey); + } + + /** + * Method to retrieve the bytes that were uploaded to a specific upload represented by {@link + * UploadId}. + * + * @param uploadId The ID of the upload + * @return An {@link InputStream} that will stream the uploaded bytes, or null if uploadId is null + * or not found + * @throws IOException When retrieving the uploaded bytes fails + * @throws TusException When the upload is still in progress or cannot be found + */ + public InputStream getUploadedBytes(UploadId uploadId) throws IOException, TusException { + return getUploadedBytes(uploadId, null); + } + + /** + * Method to retrieve the bytes that were uploaded to a specific upload represented by {@link + * UploadId} and matching the given owner key. + * + * @param uploadId The ID of the upload + * @param ownerKey The expected owner key of the upload + * @return An {@link InputStream} that will stream the uploaded bytes, or null if uploadId is + * null, not found, or the owner key does not match + * @throws IOException When retrieving the uploaded bytes fails + * @throws TusException When the upload is still in progress or cannot be found + */ + public InputStream getUploadedBytes(UploadId uploadId, String ownerKey) + throws IOException, TusException { + if (uploadId == null) { + return null; + } + try (UploadLock lock = uploadLockingService.lockUploadByUri(uploadId.toString())) { + UploadInfo storedInfo = uploadStorageService.getUploadInfo(uploadId); + if (storedInfo == null || !Objects.equals(storedInfo.getOwnerKey(), ownerKey)) { + return null; + } + return uploadStorageService.getUploadedBytes(uploadId); + } } /** @@ -625,6 +684,42 @@ public InputStream getUploadedBytes(String uploadUri, String ownerKey) } } + /** + * Get the information on the upload corresponding to the given upload ID. + * + * @param uploadId The ID of the upload + * @return Information on the upload, or null if not found + * @throws IOException When retrieving the upload information fails + * @throws TusException When the upload is still in progress or cannot be found + */ + public UploadInfo getUploadInfo(UploadId uploadId) throws IOException, TusException { + return getUploadInfo(uploadId, null); + } + + /** + * Get the information on the upload corresponding to the given upload ID and matching the given + * owner key. + * + * @param uploadId The ID of the upload + * @param ownerKey The expected owner key of the upload + * @return Information on the upload, or null if not found or the owner key does not match + * @throws IOException When retrieving the upload information fails + * @throws TusException When the upload is still in progress or cannot be found + */ + public UploadInfo getUploadInfo(UploadId uploadId, String ownerKey) + throws IOException, TusException { + if (uploadId == null) { + return null; + } + try (UploadLock lock = uploadLockingService.lockUploadByUri(uploadId.toString())) { + UploadInfo storedInfo = uploadStorageService.getUploadInfo(uploadId); + if (storedInfo == null || !Objects.equals(storedInfo.getOwnerKey(), ownerKey)) { + return null; + } + return storedInfo; + } + } + /** * Get the information on the upload corresponding to the given upload URI. * @@ -656,15 +751,64 @@ public UploadInfo getUploadInfo(String uploadUri, String ownerKey) /** * Method to delete an upload associated with the given {@link UploadInfo}. Invoke this method if - * you no longer need the upload. + * you no longer need the upload. Validates the owner key under an exclusive upload lock. * * @param uploadInfo The upload info representing the upload to delete * @throws IOException When deleting the upload fails * @throws TusException When the upload cannot be found or deleted */ public void deleteUpload(UploadInfo uploadInfo) throws IOException, TusException { - if (uploadInfo != null) { - uploadStorageService.terminateUpload(uploadInfo); + if (uploadInfo != null && uploadInfo.getId() != null) { + deleteUpload(uploadInfo.getId(), uploadInfo.getOwnerKey()); + } + } + + /** + * Method to delete an upload associated with the given {@link UploadInfo} and matching the given + * owner key. Invoke this method if you no longer need the upload. + * + * @param uploadInfo The upload info representing the upload to delete + * @param ownerKey The expected owner key of the upload + * @throws IOException When deleting the upload fails + * @throws TusException When the upload cannot be found or deleted + */ + public void deleteUpload(UploadInfo uploadInfo, String ownerKey) + throws IOException, TusException { + if (uploadInfo != null && uploadInfo.getId() != null) { + deleteUpload(uploadInfo.getId(), ownerKey); + } + } + + /** + * Method to delete an upload associated with the given {@link UploadId}. Invoke this method if + * you no longer need the upload. + * + * @param uploadId The ID of the upload to delete + * @throws IOException When deleting the upload fails + * @throws TusException When the upload cannot be locked + */ + public void deleteUpload(UploadId uploadId) throws IOException, TusException { + deleteUpload(uploadId, null); + } + + /** + * Method to delete an upload associated with the given {@link UploadId} and matching the given + * owner key. Invoke this method if you no longer need the upload. + * + * @param uploadId The ID of the upload to delete + * @param ownerKey The expected owner key of the upload + * @throws IOException When deleting the upload fails + * @throws TusException When the upload cannot be locked + */ + public void deleteUpload(UploadId uploadId, String ownerKey) throws IOException, TusException { + if (uploadId == null) { + return; + } + try (UploadLock lock = uploadLockingService.lockUploadByUri(uploadId.toString())) { + UploadInfo storedInfo = uploadStorageService.getUploadInfo(uploadId); + if (storedInfo != null && Objects.equals(storedInfo.getOwnerKey(), ownerKey)) { + uploadStorageService.terminateUpload(storedInfo); + } } } diff --git a/src/test/java/me/desair/tus/server/UploadCompletionListenerTest.java b/src/test/java/me/desair/tus/server/UploadCompletionListenerTest.java index e5955d76..82d75f13 100644 --- a/src/test/java/me/desair/tus/server/UploadCompletionListenerTest.java +++ b/src/test/java/me/desair/tus/server/UploadCompletionListenerTest.java @@ -81,28 +81,211 @@ public void testRegisterAndAddListenersNullSafe() { } @Test - public void testGetUploadedBytesAndTerminateByUploadInfo() throws Exception { + public void testGetUploadedBytesAndTerminateNullSafe() throws Exception { UploadStorageService mockStorage = mock(UploadStorageService.class); + UploadId uploadId = new UploadId("abc-123"); UploadInfo info = new UploadInfo(); - info.setId(new UploadId("abc-123")); + info.setId(uploadId); + info.setOwnerKey("OWNER_TEST"); InputStream mockStream = new ByteArrayInputStream("hello world".getBytes(StandardCharsets.UTF_8)); - when(mockStorage.getUploadedBytes(info.getId())).thenReturn(mockStream); + when(mockStorage.getUploadInfo(uploadId)).thenReturn(info); + when(mockStorage.getUploadedBytes(uploadId)).thenReturn(mockStream); TusFileUploadService service = new TusFileUploadService().withUploadStorageService(mockStorage); + // Null safety checks for UploadInfo overloads assertNull(service.getUploadedBytes((UploadInfo) null)); + assertNull(service.getUploadedBytes((UploadInfo) null, "anyOwner")); UploadInfo nullIdInfo = new UploadInfo(); assertNull(service.getUploadedBytes(nullIdInfo)); - + assertNull(service.getUploadedBytes(nullIdInfo, "anyOwner")); + service.deleteUpload((UploadInfo) null); + service.deleteUpload((UploadInfo) null, "anyOwner"); + service.deleteUpload(nullIdInfo); + service.deleteUpload(nullIdInfo, "anyOwner"); + + // Null safety checks for UploadId overloads + assertNull(service.getUploadedBytes((UploadId) null)); + assertNull(service.getUploadedBytes((UploadId) null, "anyOwner")); + assertNull(service.getUploadInfo((UploadId) null)); + assertNull(service.getUploadInfo((UploadId) null, "anyOwner")); + service.deleteUpload((UploadId) null); + service.deleteUpload((UploadId) null, "anyOwner"); + + // Legitimate calls with matching owner InputStream result = service.getUploadedBytes(info); assertNotNull(result); assertEquals("hello world", IOUtils.toString(result, StandardCharsets.UTF_8)); - service.deleteUpload((UploadInfo) null); - service.deleteUpload(info); + UploadInfo fetchedInfo = service.getUploadInfo(uploadId, "OWNER_TEST"); + assertNotNull(fetchedInfo); + assertEquals(uploadId, fetchedInfo.getId()); + + // Call remaining overloads with ownerKey = null setup + UploadId unownedId = new UploadId("unowned-123"); + UploadInfo unownedInfo = new UploadInfo(); + unownedInfo.setId(unownedId); + unownedInfo.setOwnerKey(null); + when(mockStorage.getUploadInfo(unownedId)).thenReturn(unownedInfo); + when(mockStorage.getUploadedBytes(unownedId)) + .thenReturn(new ByteArrayInputStream("unowned bytes".getBytes(StandardCharsets.UTF_8))); + + InputStream unownedStream = service.getUploadedBytes(unownedId); + assertNotNull(unownedStream); + assertEquals("unowned bytes", IOUtils.toString(unownedStream, StandardCharsets.UTF_8)); + + UploadInfo unownedFetched = service.getUploadInfo(unownedId); + assertNotNull(unownedFetched); + assertEquals(unownedId, unownedFetched.getId()); + + service.deleteUpload(unownedId); + verify(mockStorage, times(1)).terminateUpload(unownedInfo); + + service.deleteUpload(info, "OWNER_TEST"); verify(mockStorage, times(1)).terminateUpload(info); + + service.deleteUpload(info); + verify(mockStorage, times(2)).terminateUpload(info); + } + + @Test + public void testOwnerKeyIsolationWithUploadInfo() throws Exception { + String aliceOwner = "USER_ALICE"; + String bobOwner = "USER_BOB"; + byte[] payload = "confidential-alice-payload".getBytes(StandardCharsets.UTF_8); + + // 1. Alice creates upload + MockHttpServletRequest createRequest = new MockHttpServletRequest("POST", "/files"); + createRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + createRequest.addHeader(HttpHeader.UPLOAD_LENGTH, String.valueOf(payload.length)); + MockHttpServletResponse createResponse = new MockHttpServletResponse(); + + UploadInfo createdInfo = + tusFileUploadService.process(createRequest, createResponse, aliceOwner); + assertNotNull(createdInfo); + assertEquals(aliceOwner, createdInfo.getOwnerKey()); + + String location = createResponse.getHeader(HttpHeader.LOCATION); + + // 2. Alice uploads bytes + MockHttpServletRequest patchRequest = new MockHttpServletRequest("PATCH", location); + patchRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + patchRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + patchRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + patchRequest.setContent(payload); + MockHttpServletResponse patchResponse = new MockHttpServletResponse(); + + UploadInfo patchedInfo = tusFileUploadService.process(patchRequest, patchResponse, aliceOwner); + assertNotNull(patchedInfo); + + // 3. Security verification: Forged UploadInfo instances + UploadInfo forgedBobInfo = new UploadInfo(); + forgedBobInfo.setId(createdInfo.getId()); + forgedBobInfo.setOwnerKey(bobOwner); + + UploadInfo forgedAnonInfo = new UploadInfo(); + forgedAnonInfo.setId(createdInfo.getId()); + forgedAnonInfo.setOwnerKey(null); + + // Mismatched owner key on UploadInfo must return null and leak no data + assertNull(tusFileUploadService.getUploadedBytes(forgedBobInfo)); + assertNull(tusFileUploadService.getUploadedBytes(forgedBobInfo, bobOwner)); + assertNull(tusFileUploadService.getUploadedBytes(createdInfo, bobOwner)); + assertNull(tusFileUploadService.getUploadedBytes(forgedAnonInfo)); + assertNull(tusFileUploadService.getUploadedBytes(forgedAnonInfo, null)); + + // 4. Legitimate access: Alice retrieves her uploaded bytes + try (InputStream aliceStream = tusFileUploadService.getUploadedBytes(createdInfo)) { + assertNotNull(aliceStream); + assertEquals( + "confidential-alice-payload", IOUtils.toString(aliceStream, StandardCharsets.UTF_8)); + } + + try (InputStream aliceStreamExplicit = + tusFileUploadService.getUploadedBytes(createdInfo, aliceOwner)) { + assertNotNull(aliceStreamExplicit); + assertEquals( + "confidential-alice-payload", + IOUtils.toString(aliceStreamExplicit, StandardCharsets.UTF_8)); + } + + // 5. Security verification: Unauthorized deletion attempts + tusFileUploadService.deleteUpload(forgedBobInfo); + tusFileUploadService.deleteUpload(createdInfo, bobOwner); + + // Verify Alice's upload is still intact + try (InputStream streamAfterBobDelete = tusFileUploadService.getUploadedBytes(createdInfo)) { + assertNotNull("Upload must not be deleted by unauthorized owner", streamAfterBobDelete); + } + + // 6. Legitimate deletion by Alice + tusFileUploadService.deleteUpload(createdInfo); + + // Verify upload is gone + assertNull(tusFileUploadService.getUploadedBytes(createdInfo)); + } + + @Test + public void testOwnerKeyIsolationWithUploadId() throws Exception { + String aliceOwner = "USER_ALICE"; + String bobOwner = "USER_BOB"; + byte[] payload = "confidential-upload-id-payload".getBytes(StandardCharsets.UTF_8); + + // 1. Alice creates upload + MockHttpServletRequest createRequest = new MockHttpServletRequest("POST", "/files"); + createRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + createRequest.addHeader(HttpHeader.UPLOAD_LENGTH, String.valueOf(payload.length)); + MockHttpServletResponse createResponse = new MockHttpServletResponse(); + + UploadInfo createdInfo = + tusFileUploadService.process(createRequest, createResponse, aliceOwner); + assertNotNull(createdInfo); + UploadId uploadId = createdInfo.getId(); + + String location = createResponse.getHeader(HttpHeader.LOCATION); + + // 2. Alice uploads bytes + MockHttpServletRequest patchRequest = new MockHttpServletRequest("PATCH", location); + patchRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + patchRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + patchRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + patchRequest.setContent(payload); + MockHttpServletResponse patchResponse = new MockHttpServletResponse(); + + UploadInfo patchedInfo = tusFileUploadService.process(patchRequest, patchResponse, aliceOwner); + assertNotNull(patchedInfo); + + // 3. Security verification: Bob attempts to read bytes and metadata by UploadId + assertNull(tusFileUploadService.getUploadedBytes(uploadId, bobOwner)); + assertNull(tusFileUploadService.getUploadedBytes(uploadId, null)); + assertNull(tusFileUploadService.getUploadedBytes(uploadId)); // Default null owner + assertNull(tusFileUploadService.getUploadInfo(uploadId, bobOwner)); + assertNull(tusFileUploadService.getUploadInfo(uploadId, null)); + assertNull(tusFileUploadService.getUploadInfo(uploadId)); // Default null owner + + // 4. Legitimate access by Alice + UploadInfo aliceInfo = tusFileUploadService.getUploadInfo(uploadId, aliceOwner); + assertNotNull(aliceInfo); + assertEquals(uploadId, aliceInfo.getId()); + + try (InputStream is = tusFileUploadService.getUploadedBytes(uploadId, aliceOwner)) { + assertNotNull(is); + assertEquals("confidential-upload-id-payload", IOUtils.toString(is, StandardCharsets.UTF_8)); + } + + // 5. Security verification: Bob attempts to delete Alice's upload by UploadId + tusFileUploadService.deleteUpload(uploadId, bobOwner); + tusFileUploadService.deleteUpload(uploadId); // Default null owner + + // Verify upload still exists + assertNotNull(tusFileUploadService.getUploadInfo(uploadId, aliceOwner)); + + // 6. Legitimate deletion by Alice + tusFileUploadService.deleteUpload(uploadId, aliceOwner); + assertNull(tusFileUploadService.getUploadInfo(uploadId, aliceOwner)); + assertNull(tusFileUploadService.getUploadedBytes(uploadId, aliceOwner)); } @Test