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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ All notable changes to this project will be documented in this file.
### 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.

### Breaking
- **Downloads**: In order to support both the Tus protocol and RUFH protocol, the unofficial download extension will not return a HTTP status code `204` for uploads that are still in progress and will not contain the response header `Tus-Resumable`. Removed the `UploadInProgressException` class.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Besides the [core protocol](https://tus.io/protocols/resumable-upload.html#core-
### 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 URL under which the main tus upload endpoint will be made available, for example `/files/upload`. Optionally, this URI may contain regex parameters in order to support endpoints that contain URL parameters, for example `/users/[0-9]+/files/upload`.
* `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.IETF` 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.
Expand Down
14 changes: 9 additions & 5 deletions src/main/java/me/desair/tus/server/TusFileUploadService.java
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,15 @@ public ProtocolVersion getSupportedProtocolVersion() {
}

/**
* Set the URI under which the main tus upload endpoint is hosted. Optionally, this URI may
* contain regex parameters in order to support endpoints that contain URL parameters, for example
* /users/[0-9]+/files/upload
*
* @param uploadUri The URI of the main tus upload endpoint
* Set the URI or absolute URL under which the main tus upload endpoint is hosted. This can be a
* relative path (for example <code>/files/upload</code>) or an absolute URL (for example <code>
* https://upload.example.com/files/upload</code>). When an absolute URL is provided, the Location
* header in creation responses will contain the full URL. Optionally, this URI may contain regex
* parameters in order to support endpoints that contain URL parameters, for example <code>
* /users/[0-9]+/files/upload</code> or <code>https://upload.example.com/users/[0-9]+/files/upload
* </code>.
*
* @param uploadUri The URI or URL of the main tus upload endpoint
* @return The current service
*/
public TusFileUploadService withUploadUri(String uploadUri) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public void process(
UploadInfo info = buildUploadInfo(servletRequest);
info = uploadStorageService.create(info, ownerKey);

String url = Utils.getUploadUriOnCreation(info, servletRequest, null);
String url = Utils.getUploadUriOnCreation(info, servletRequest, uploadStorageService);
servletResponse.setHeader(HttpHeader.LOCATION, url);
servletResponse.setStatus(HttpServletResponse.SC_CREATED);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import me.desair.tus.server.exception.PostOnInvalidRequestURIException;
import me.desair.tus.server.exception.TusException;
import me.desair.tus.server.upload.UploadStorageService;
import me.desair.tus.server.util.Utils;

/**
* The Client MUST send a POST request against a known upload creation URL to request a new upload
Expand Down Expand Up @@ -41,8 +42,9 @@ public boolean supports(HttpMethod method) {

private Pattern getUploadUriPattern(UploadStorageService uploadStorageService) {
if (uploadUriPattern == null) {
// A POST request should match the full URI
uploadUriPattern = Pattern.compile("^" + uploadStorageService.getUploadUri() + "$");
// A POST request should match the full URI path
String path = Utils.extractUriPath(uploadStorageService.getUploadUri());
uploadUriPattern = Pattern.compile("^" + path + "$");
}
return uploadUriPattern;
}
Expand Down
19 changes: 13 additions & 6 deletions src/main/java/me/desair/tus/server/upload/UploadIdFactory.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.io.Serializable;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import me.desair.tus.server.util.Utils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.apache.commons.lang3.Validate;
Expand All @@ -17,15 +18,20 @@ public abstract class UploadIdFactory {
private Pattern uploadUriPattern = null;

/**
* Set the URI under which the main tus upload endpoint is hosted. Optionally, this URI may
* contain regex parameters in order to support endpoints that contain URL parameters, for example
* /users/[0-9]+/files/upload
* Set the URI or absolute URL under which the main tus upload endpoint is hosted. 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
*
* @param uploadUri The URI of the main tus upload endpoint
* @param uploadUri The URI or URL of the main tus upload endpoint
*/
public void setUploadUri(String uploadUri) {
Validate.notBlank(uploadUri, "The upload URI pattern cannot be blank");
Validate.isTrue(Strings.CS.startsWith(uploadUri, "/"), "The upload URI should start with /");
Validate.isTrue(
Strings.CS.startsWith(uploadUri, "/")
|| Strings.CS.startsWith(uploadUri, "http://")
|| Strings.CS.startsWith(uploadUri, "https://"),
"The upload URI should start with /, http://, or https://");
Validate.isTrue(!Strings.CS.endsWith(uploadUri, "$"), "The upload URI should not end with $");
this.uploadUri = uploadUri;
this.uploadUriPattern = null;
Expand Down Expand Up @@ -86,8 +92,9 @@ protected Pattern getUploadUriPattern() {
if (uploadUriPattern == null) {
// We will extract the upload ID's by removing the upload URI from the start of the
// request URI
String path = Utils.extractUriPath(uploadUri);
uploadUriPattern =
Pattern.compile("^.*" + uploadUri + (Strings.CS.endsWith(uploadUri, "/") ? "" : "/?"));
Pattern.compile("^.*" + path + (Strings.CS.endsWith(path, "/") ? "" : "/?"));
}
return uploadUriPattern;
}
Expand Down
86 changes: 74 additions & 12 deletions src/main/java/me/desair/tus/server/util/Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.util.EnumSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
Expand Down Expand Up @@ -404,6 +405,56 @@ public static ProtocolVersion detectProtocolVersion(
return ProtocolVersion.TUS_1_0_0;
}

/**
* Extracts the path component from an upload URI string, which may be a relative path (e.g.,
* "/files") or an absolute URL (e.g., "https://example.com/files").
*
* @param uploadUri The upload URI or URL string
* @return The path component starting with "/", or "/" if none is present
*/
public static String extractUriPath(String uploadUri) {
if (StringUtils.isBlank(uploadUri)) {
return "/";
}
// For absolute URLs (http:// or https://), extract the path starting after the authority
// component
if (Strings.CS.startsWith(uploadUri, "http://")
|| Strings.CS.startsWith(uploadUri, "https://")) {
int schemeEnd = uploadUri.indexOf("://");
int pathStart = uploadUri.indexOf('/', schemeEnd + 3);
if (pathStart == -1) {
return "/";
}
return uploadUri.substring(pathStart);
}
return uploadUri;
}

/**
* Extracts the origin component (scheme + host + port) from an upload URL string, or an empty
* string if the URI is relative or blank.
*
* @param uploadUri The upload URI or URL string
* @return The origin string (e.g. "https://example.com:8080"), or "" if uploadUri is relative or
* blank
*/
public static String extractUriOrigin(String uploadUri) {
if (StringUtils.isBlank(uploadUri)) {
return "";
}
// Extract scheme + host[:port] for absolute HTTP and HTTPS URLs
if (Strings.CS.startsWith(uploadUri, "http://")
|| Strings.CS.startsWith(uploadUri, "https://")) {
int schemeEnd = uploadUri.indexOf("://");
int pathStart = uploadUri.indexOf('/', schemeEnd + 3);
if (pathStart == -1) {
return uploadUri;
}
return uploadUri.substring(0, pathStart);
}
return "";
}

/**
* Determine if the given HTTP servlet request targets the upload creation base URI endpoint.
*
Expand All @@ -417,7 +468,7 @@ public static boolean isCreationEndpoint(
return false;
}
String requestUri = request.getRequestURI();
String baseUri = uploadStorageService.getUploadUri();
String baseUri = extractUriPath(uploadStorageService.getUploadUri());
return requestUri != null
&& baseUri != null
&& (requestUri.equals(baseUri) || requestUri.equals(baseUri + "/"));
Expand Down Expand Up @@ -450,25 +501,36 @@ public static boolean isExistingUploadResource(
/**
* Builds the upload location URI for a newly created upload resource.
*
* @param uploadInfo The UploadInfo object containing the upload ID
* @param uploadInfo The UploadInfo object containing the upload ID (must not be null and must
* have an ID)
* @param servletRequest The current HttpServletRequest or TusServletRequest
* @param storageService The current UploadStorageService
* @param storageService The current UploadStorageService (must not be null and must have an
* upload URI)
* @return The location URI string for the created upload
*/
public static String getUploadUriOnCreation(
UploadInfo uploadInfo,
HttpServletRequest servletRequest,
UploadStorageService storageService) {
String baseUri = storageService != null ? storageService.getUploadUri() : null;
if (baseUri == null && servletRequest != null) {
baseUri = servletRequest.getRequestURI();
Objects.requireNonNull(uploadInfo, "Upload info cannot be null");
Objects.requireNonNull(uploadInfo.getId(), "Upload ID cannot be null");
Objects.requireNonNull(storageService, "Storage service cannot be null");
String configuredUri =
Objects.requireNonNull(storageService.getUploadUri(), "Upload URI cannot be null");

String baseUri = configuredUri;

// When configuredUri contains regex patterns (e.g. /users/[0-9]+/files),
// resolve the concrete request path dynamically from the incoming servlet request
boolean hasRegex = configuredUri.contains("[") || configuredUri.contains("(");
if (hasRegex && servletRequest != null) {
String origin = extractUriOrigin(configuredUri);
String requestPath = servletRequest.getRequestURI();
baseUri = origin + (requestPath.startsWith("/") ? "" : "/") + requestPath;
}
if (baseUri == null) {
baseUri = "";
}
String idStr =
uploadInfo != null && uploadInfo.getId() != null ? uploadInfo.getId().toString() : "";
return baseUri + (baseUri.endsWith("/") ? "" : "/") + idStr;

// Append the generated upload ID to form the final location URI
return baseUri + (baseUri.endsWith("/") ? "" : "/") + uploadInfo.getId();
}

/**
Expand Down
116 changes: 116 additions & 0 deletions src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;

Expand Down Expand Up @@ -43,6 +44,17 @@ public abstract class AbstractITRufhProtocol {
*/
protected abstract TusFileUploadService createTusFileUploadService() throws Exception;

/**
* Factory method implemented by subclasses to supply a {@link TusFileUploadService} instance
* configured with a specific upload URI.
*
* @param uploadUri The upload URI to configure
* @return configured TusFileUploadService instance
* @throws Exception if service creation fails
*/
protected abstract TusFileUploadService createTusFileUploadService(String uploadUri)
throws Exception;

@Before
public void setUp() throws Exception {
reset();
Expand Down Expand Up @@ -608,6 +620,110 @@ public void testContentDigestValidation() throws Exception {
assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "12");
}

@Test
public void testUploadWithAbsoluteUploadUri() throws Exception {
String absoluteBaseUri = "https://uploads.example.com";
TusFileUploadService service = createTusFileUploadService(absoluteBaseUri);

String uploadContent = "RUFH Absolute URL content";

// Step 1: POST to create upload on root endpoint "/"
servletRequest.setMethod("POST");
servletRequest.setRequestURI("/");
servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "" + uploadContent.getBytes().length);
servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");

service.process(servletRequest, servletResponse, OWNER_KEY);
assertThat(servletResponse.getStatus(), is(HttpServletResponse.SC_CREATED));
String locationHeader = servletResponse.getHeader(HttpHeader.LOCATION);
assertNotNull(locationHeader);

// Retrieve upload info using the full Location header to verify ID lookup works with absolute
// URLs
UploadInfo infoByLocation = service.getUploadInfo(locationHeader, OWNER_KEY);
assertTrue(infoByLocation != null && infoByLocation.getId() != null);
assertThat(locationHeader, is("https://uploads.example.com/" + infoByLocation.getId()));

String uploadPath = "/" + infoByLocation.getId();

// Step 2: PATCH upload bytes
reset();
servletRequest.setMethod("PATCH");
servletRequest.setRequestURI(uploadPath);
servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0");
servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1");
servletRequest.setContent(uploadContent.getBytes());

service.process(servletRequest, servletResponse, OWNER_KEY);
assertThat(servletResponse.getStatus(), is(HttpServletResponse.SC_OK));
assertThat(
servletResponse.getHeader(HttpHeader.UPLOAD_OFFSET),
is("" + uploadContent.getBytes().length));
assertThat(servletResponse.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1"));

// Verify upload info is also retrievable via relative path
UploadInfo infoByPath = service.getUploadInfo(uploadPath, OWNER_KEY);
assertTrue(infoByPath != null && infoByLocation.getId().equals(infoByPath.getId()));

// Step 3: Verify content
try (InputStream stream = service.getUploadedBytes(uploadPath, OWNER_KEY)) {
assertThat(IOUtils.toString(stream, StandardCharsets.UTF_8), is(uploadContent));
}
}

@Test
public void testUploadWithAbsoluteUploadUriWithPath() throws Exception {
String absoluteBaseUri = "https://uploads.example.com/api";
TusFileUploadService service = createTusFileUploadService(absoluteBaseUri);

String uploadContent = "RUFH Absolute URL with path content";

// Step 1: POST to create upload on endpoint "/api"
servletRequest.setMethod("POST");
servletRequest.setRequestURI("/api");
servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "" + uploadContent.getBytes().length);
servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");

service.process(servletRequest, servletResponse, OWNER_KEY);
assertThat(servletResponse.getStatus(), is(HttpServletResponse.SC_CREATED));
String locationHeader = servletResponse.getHeader(HttpHeader.LOCATION);
assertNotNull(locationHeader);

// Retrieve upload info using the full Location header to verify ID lookup works with absolute
// URLs
UploadInfo infoByLocation = service.getUploadInfo(locationHeader, OWNER_KEY);
assertTrue(infoByLocation != null && infoByLocation.getId() != null);
assertThat(locationHeader, is("https://uploads.example.com/api/" + infoByLocation.getId()));

String uploadPath = "/api/" + infoByLocation.getId();

// Step 2: PATCH upload bytes
reset();
servletRequest.setMethod("PATCH");
servletRequest.setRequestURI(uploadPath);
servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0");
servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1");
servletRequest.setContent(uploadContent.getBytes());

service.process(servletRequest, servletResponse, OWNER_KEY);
assertThat(servletResponse.getStatus(), is(HttpServletResponse.SC_OK));
assertThat(
servletResponse.getHeader(HttpHeader.UPLOAD_OFFSET),
is("" + uploadContent.getBytes().length));
assertThat(servletResponse.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1"));

// Verify upload info is also retrievable via relative path
UploadInfo infoByPath = service.getUploadInfo(uploadPath, OWNER_KEY);
assertTrue(infoByPath != null && infoByLocation.getId().equals(infoByPath.getId()));

// Step 3: Verify content
try (InputStream stream = service.getUploadedBytes(uploadPath, OWNER_KEY)) {
assertThat(IOUtils.toString(stream, StandardCharsets.UTF_8), is(uploadContent));
}
}

// ===============================================================================================
// ASSERTION HELPERS
// ===============================================================================================
Expand Down
Loading
Loading