Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ env -u GITHUB_TOKEN gh pr create --title "..." --body "..."
## Git Branching Strategy
Any new feature, bugfix, or improvement must be developed in a separate branch that starts with either `feature/` or `bugfix/` and has a meaningful but short name (e.g., `feature/lock-contention-resolution` or `bugfix/fix-upload-timeout`).

## Releases
## Releases and Documentation

### README.md
When a new feature is introduced, the `README.md` file must be updated with information on this new feature (e.g. configuration, usage).

### CHANGELOG.md
When adding new features or preparing a release, the `CHANGELOG.md` file must be updated to describe the new functionality added in this version. Use a release version header (e.g., `## [1.0.0-3.2]`) instead of `## [Unreleased]`. Derive this next release version from the SNAPSHOT version declared in the `pom.xml` file by removing the `-SNAPSHOT` suffix. Make sure to not add duplicate headers.
For any new feature, big improvements, or fixes, the `CHANGELOG.md` file must be updated to describe the changes added in this version. Use a release version header (e.g., `## [1.0.0-3.2]`) instead of `## [Unreleased]`. Derive this next release version from the SNAPSHOT version declared in the `pom.xml` file by removing the `-SNAPSHOT` suffix. Make sure to not add duplicate headers.

### Release Process
When performing a release, please strictly follow the instructions outlined in the [docs/RELEASE.md](docs/RELEASE.md) documentation file.
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

All notable changes to this project will be documented in this file.

## [1.0.0-3.3]

### Added
- **Creation-with-Upload Extension**: Implemented the optional `creation-with-upload` extension, allowing clients to combine creation and initial file data upload in a single `POST` request.
- **CORS Extension**: Implemented native, out-of-the-box CORS support as an unofficial extension (`cors`) enabled by default. For backward compatibility, it can be disabled via `disableTusExtension("cors")`.

### Changed
- **Stricter Protocol Validation**:
- Prevent modifying `Upload-Length` headers in subsequent `PATCH` requests.
- Enforced format and Base64 validations for `Upload-Metadata` headers in `POST` requests.
- Enforced that `Upload-Defer-Length` header values must be strictly `"1"`.
- Reject malformed or invalid `Upload-Checksum` headers instead of silently ignoring them.
- Enabled checksum verification on `POST` requests when using the `creation-with-upload` extension.

### Fixes
- Only unfinished uploads can expire.
- Fix for deduplication feature when base64-encoded checksum contains a slash.

## [1.0.0-3.2]

### Added
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,19 @@ The main entry point of the library is the `me.desair.tus.server.TusFileUploadSe
* (more examples to come!)

## Tus Protocol Extensions
Besides the [core protocol](https://tus.io/protocols/resumable-upload.html#core-protocol), the library has all optional tus protocol extensions enabled by default. This means that the `Tus-Extension` header has value `creation,creation-defer-length,checksum,checksum-trailer,termination,expiration,concatenation,concatenation-unfinished`. Optionally you can also enable an unofficial `download` extension (see [configuration section](#usage-and-configuration)).
Besides the [core protocol](https://tus.io/protocols/resumable-upload.html#core-protocol), the library has all optional tus protocol extensions enabled by default. This means that the `Tus-Extension` header has value `creation,creation-defer-length,creation-with-upload,checksum,checksum-trailer,termination,expiration,concatenation,concatenation-unfinished`. Optionally you can also enable an unofficial `download` extension (see [configuration section](#usage-and-configuration)).

* [creation](https://tus.io/protocols/resumable-upload.html#creation): The creation extension allows you to create new uploads and to retrieve the upload URL for them.
* [creation-defer-length](https://tus.io/protocols/resumable-upload.html#post): You can create a new upload even if you don't know its final length at the time of creation.
* [creation-with-upload](https://tus.io/protocols/resumable-upload.html#creation): The creation-with-upload extension allows you to create the upload resource and upload initial file data in a single POST request.
* [checksum](https://tus.io/protocols/resumable-upload.html#checksum): An extension that allows you to verify data integrity of each upload (PATCH) request.
* [checksum-trailer](https://tus.io/protocols/resumable-upload.html#checksum): If the checksum hash cannot be calculated at the beginning of the upload, it may be included as a trailer HTTP header at the end of the chunked HTTP request.
* [termination](https://tus.io/protocols/resumable-upload.html#termination): Clients can terminate completed or in-progress uploads which allows the tus-java-server library to free up resources on the server.
* [expiration](https://tus.io/protocols/resumable-upload.html#expiration): You can instruct the tus-java-server library to cleanup uploads that are older than a configurable period.
* [concatenation](https://tus.io/protocols/resumable-upload.html#concatenation): This extension can be used to concatenate multiple uploads into a single final upload enabling clients to perform parallel uploads and to upload non-contiguous chunks.
* [concatenation-unfinished](https://tus.io/protocols/resumable-upload.html#concatenation): The client is allowed send the request to concatenate partial uploads while these partial uploads are still in progress.
* `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

Expand All @@ -52,7 +54,7 @@ The first step is to create a `TusFileUploadService` object using its constructo
* `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", "checksum", "expiration", "concatenation", "termination" and "download". You cannot disable the "core" feature.
* `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.


Expand Down
19 changes: 17 additions & 2 deletions src/main/java/me/desair/tus/server/TusFileUploadService.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
import me.desair.tus.server.checksum.ChecksumExtension;
import me.desair.tus.server.concatenation.ConcatenationExtension;
import me.desair.tus.server.core.CoreProtocol;
import me.desair.tus.server.cors.CorsExtension;
import me.desair.tus.server.creation.CreationExtension;
import me.desair.tus.server.creationwithupload.CreationWithUploadExtension;
import me.desair.tus.server.download.DownloadExtension;
import me.desair.tus.server.exception.TusException;
import me.desair.tus.server.expiration.ExpirationExtension;
Expand All @@ -29,6 +31,7 @@
import me.desair.tus.server.upload.disk.DiskStorageService;
import me.desair.tus.server.util.TusServletRequest;
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.Strings;
import org.apache.commons.lang3.Validate;
Expand Down Expand Up @@ -61,11 +64,14 @@ public TusFileUploadService() {
protected void initFeatures() {
// The order of the features is important
addTusExtension(new CoreProtocol());
addTusExtension(new CreationExtension());
CreationExtension creationExtension = new CreationExtension();
addTusExtension(creationExtension);
addTusExtension(new CreationWithUploadExtension(creationExtension));
addTusExtension(new ChecksumExtension());
addTusExtension(new TerminationExtension());
addTusExtension(new ExpirationExtension());
addTusExtension(new ConcatenationExtension());
addTusExtension(new CorsExtension());
}

/**
Expand Down Expand Up @@ -258,6 +264,14 @@ public TusFileUploadService disableTusExtension(String extensionName) {

enabledFeatures.remove(extensionName);
updateSupportedHttpMethods();

if (Strings.CS.equals("creation-with-upload", extensionName)) {
TusExtension creation = enabledFeatures.get("creation");
if (creation instanceof CreationExtension) {
((CreationExtension) creation).setCreationWithUploadEnabled(false);
}
}

return this;
}

Expand Down Expand Up @@ -520,7 +534,8 @@ protected void processTusException(

// Since an error occurred, the bytes we have written are probably not valid. So remove
// them.
UploadInfo uploadInfo = uploadStorageService.getUploadInfo(request.getRequestURI(), ownerKey);
UploadInfo uploadInfo =
uploadStorageService.getUploadInfo(Utils.getUploadURI(request, response), ownerKey);
uploadStorageService.removeLastNumberOfBytes(uploadInfo, request.getBytesRead());

} catch (TusException ex) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public String getName() {

@Override
public Collection<HttpMethod> getMinimalSupportedHttpMethods() {
return Arrays.asList(HttpMethod.OPTIONS, HttpMethod.PATCH);
return Arrays.asList(HttpMethod.OPTIONS, HttpMethod.PATCH, HttpMethod.POST);
}

@Override
Expand All @@ -33,6 +33,6 @@ protected void initValidators(List<RequestValidator> requestValidators) {
@Override
protected void initRequestHandlers(List<RequestHandler> requestHandlers) {
requestHandlers.add(new ChecksumOptionsRequestHandler());
requestHandlers.add(new ChecksumPatchRequestHandler());
requestHandlers.add(new ChecksumRequestHandler());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;

public class ChecksumPatchRequestHandler extends AbstractRequestHandler {
/** Request handler that verifies the checksum of the uploaded data. */
public class ChecksumRequestHandler extends AbstractRequestHandler {

@Override
public boolean supports(HttpMethod method) {
return HttpMethod.PATCH.equals(method);
return HttpMethod.PATCH.equals(method) || HttpMethod.POST.equals(method);
}

@Override
Expand All @@ -35,10 +36,8 @@ public void process(

if (servletRequest.hasCalculatedChecksum() && StringUtils.isNotBlank(uploadChecksumHeader)) {

// The Upload-Checksum header can be a trailing header which is only present after
// reading the
// full content.
// Therefor we need to revalidate that header here
// The Upload-Checksum header can be a trailing header which is only present after reading
// the full content. Therefore we need to revalidate that header here.
new ChecksumAlgorithmValidator()
.validate(method, servletRequest, uploadStorageService, ownerKey);

Expand All @@ -49,9 +48,8 @@ public void process(
String calculatedValue = servletRequest.getCalculatedChecksum(checksumAlgorithm);

if (!Strings.CS.equals(expectedValue, calculatedValue)) {
// throw an exception if the checksum is invalid. This will also trigger the removal
// of any
// bytes that were already saved
// Throw an exception if the checksum is invalid. This will also trigger the removal of
// any bytes that were already saved.
throw new UploadChecksumMismatchException(
"Expected checksum "
+ expectedValue
Expand All @@ -61,7 +59,8 @@ public void process(
+ checksumAlgorithm);
} else if (uploadStorageService.isUploadDeduplicationEnabled()) {
UploadInfo uploadInfo =
uploadStorageService.getUploadInfo(servletRequest.getRequestURI(), ownerKey);
uploadStorageService.getUploadInfo(
Utils.getUploadURI(servletRequest, servletResponse), ownerKey);
if (uploadInfo != null
&& !uploadInfo.isUploadInProgress()
&& uploadInfo.getDuplicatesUploadId() == null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package me.desair.tus.server.checksum.validation;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import me.desair.tus.server.HttpHeader;
import me.desair.tus.server.HttpMethod;
Expand All @@ -9,11 +10,12 @@
import me.desair.tus.server.exception.ChecksumAlgorithmNotSupportedException;
import me.desair.tus.server.exception.TusException;
import me.desair.tus.server.upload.UploadStorageService;
import me.desair.tus.server.util.Utils;
import org.apache.commons.lang3.StringUtils;

/**
* The Server MAY respond with one of the following status code: 400 Bad Request if the checksum
* algorithm is not supported by the server
* algorithm is not supported by the server or if the checksum header is malformed
*/
public class ChecksumAlgorithmValidator implements RequestValidator {

Expand All @@ -27,21 +29,27 @@ public void validate(

String uploadChecksum = request.getHeader(HttpHeader.UPLOAD_CHECKSUM);

// If the client provided a checksum header, check that we support the algorithm
if (StringUtils.isNotBlank(uploadChecksum)
&& ChecksumAlgorithm.forUploadChecksumHeader(uploadChecksum) == null) {
if (StringUtils.isNotBlank(uploadChecksum)) {
// Check that we support the algorithm
if (ChecksumAlgorithm.forUploadChecksumHeader(uploadChecksum) == null) {
throw new ChecksumAlgorithmNotSupportedException(
"The "
+ HttpHeader.UPLOAD_CHECKSUM
+ " header value "
+ uploadChecksum
+ " is not supported");
}

throw new ChecksumAlgorithmNotSupportedException(
"The "
+ HttpHeader.UPLOAD_CHECKSUM
+ " header value "
+ uploadChecksum
+ " is not supported");
// Check that the header is not malformed
if (Utils.parseUploadChecksumHeader(request) == null) {
throw new TusException(
HttpServletResponse.SC_BAD_REQUEST, "The Upload-Checksum header is malformed");
}
}
}

@Override
public boolean supports(HttpMethod method) {
return HttpMethod.PATCH.equals(method);
return HttpMethod.PATCH.equals(method) || HttpMethod.POST.equals(method);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ public void process(
servletResponse.setHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION);
// By default, set the Content-Length to 0
servletResponse.setHeader(HttpHeader.CONTENT_LENGTH, "0");

String requestVersion = servletRequest.getHeader(HttpHeader.TUS_RESUMABLE);
if (requestVersion != null
&& !org.apache.commons.lang3.Strings.CS.equals(
requestVersion, TusFileUploadService.TUS_API_VERSION)) {
servletResponse.setHeader(HttpHeader.TUS_VERSION, TusFileUploadService.TUS_API_VERSION);
}
}

@Override
Expand Down
32 changes: 32 additions & 0 deletions src/main/java/me/desair/tus/server/cors/CorsExtension.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package me.desair.tus.server.cors;

import java.util.Collection;
import java.util.List;
import me.desair.tus.server.HttpMethod;
import me.desair.tus.server.RequestHandler;
import me.desair.tus.server.RequestValidator;
import me.desair.tus.server.util.AbstractTusExtension;

/** Standalone unofficial extension to add native CORS support out-of-the-box. */
public class CorsExtension extends AbstractTusExtension {

@Override
public String getName() {
return "cors";
}

@Override
public Collection<HttpMethod> getMinimalSupportedHttpMethods() {
return java.util.Collections.emptyList();
}

@Override
protected void initValidators(List<RequestValidator> requestValidators) {
// No validators for CORS extension
}

@Override
protected void initRequestHandlers(List<RequestHandler> requestHandlers) {
requestHandlers.add(new CorsRequestHandler());
}
}
51 changes: 51 additions & 0 deletions src/main/java/me/desair/tus/server/cors/CorsRequestHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package me.desair.tus.server.cors;

import me.desair.tus.server.HttpMethod;
import me.desair.tus.server.RequestHandler;
import me.desair.tus.server.upload.UploadStorageService;
import me.desair.tus.server.util.TusServletRequest;
import me.desair.tus.server.util.TusServletResponse;
import org.apache.commons.lang3.StringUtils;

public class CorsRequestHandler implements RequestHandler {

@Override
public boolean supports(HttpMethod method) {
return true;
}

@Override
public void process(
HttpMethod method,
TusServletRequest servletRequest,
TusServletResponse servletResponse,
UploadStorageService uploadStorageService,
String ownerKey) {

String origin = servletRequest.getHeader("Origin");
if (StringUtils.isNotBlank(origin)) {
servletResponse.setHeader("Access-Control-Allow-Origin", origin);
servletResponse.setHeader(
"Access-Control-Expose-Headers",
"Upload-Offset, Upload-Length, Upload-Metadata, Upload-Expires, Upload-Concat, "
+ "Tus-Resumable, Tus-Version, Tus-Max-Size, Tus-Extension, Tus-Checksum-Algorithm, Location");

// Check if it's a preflight request
String accessControlRequestMethod = servletRequest.getHeader("Access-Control-Request-Method");
if (HttpMethod.OPTIONS.equals(method) && StringUtils.isNotBlank(accessControlRequestMethod)) {
servletResponse.setHeader(
"Access-Control-Allow-Methods", "POST, GET, HEAD, PATCH, DELETE, OPTIONS");
servletResponse.setHeader(
"Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Upload-Length, Upload-Offset, Upload-Metadata, "
+ "Upload-Expires, Upload-Checksum, Upload-Concat, Upload-Defer-Length, Tus-Resumable, X-HTTP-Method-Override");
servletResponse.setHeader("Access-Control-Max-Age", "86400");
}
}
}

@Override
public boolean isErrorHandler() {
return true;
}
}
Loading
Loading