Skip to content

Commit e8f44a5

Browse files
authored
feat: support absolute base URLs in withUploadUri and Location headers (#37) (#121)
* feat: support absolute base URLs in withUploadUri and Location headers (#37) - Allow withUploadUri(String) to accept absolute URLs (http:// or https://) - Construct absolute Location response headers for upload creation across Tus 1.0.0 and RUFH protocols - Support regex endpoints and maintain backward compatibility for relative paths - Update README.md and CHANGELOG.md * refactor: simplify getUploadUriOnCreation and enforce non-null requirements - Use Objects.requireNonNull for uploadInfo, uploadId, storageService, and uploadUri - Fall back to empty string in extractUriOrigin - Streamline location URI resolution and update unit tests * test: add integration tests for absolute upload URIs with and without path - Add testUploadWithAbsoluteUploadUri (https://uploads.example.com) and testUploadWithAbsoluteUploadUriWithPath (https://uploads.example.com/api) in AbstractITTusFileUploadService and AbstractITRufhProtocol - Assert returned Location header matches configured base URL with persisted UploadId - Validate upload, download, and info retrieval across all storage backends (Disk, Lease-file, S3, Azure Blob) * feat: Code review
1 parent d5f7e5b commit e8f44a5

21 files changed

Lines changed: 766 additions & 76 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ All notable changes to this project will be documented in this file.
2020
### Changed
2121
- **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.
2222
- **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.
23+
- **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.
2324

2425
### Fixed
2526
- **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)).

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ Besides the [core protocol](https://tus.io/protocols/resumable-upload.html#core-
116116
### 1. Setup
117117
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:
118118

119-
* `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`.
119+
* `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`.
120120
* `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).
121121
* `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`.
122122
* `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.

src/main/java/me/desair/tus/server/TusFileUploadService.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,15 @@ public ProtocolVersion getSupportedProtocolVersion() {
125125
}
126126

127127
/**
128-
* Set the URI under which the main tus upload endpoint is hosted. Optionally, this URI may
129-
* contain regex parameters in order to support endpoints that contain URL parameters, for example
130-
* /users/[0-9]+/files/upload
131-
*
132-
* @param uploadUri The URI of the main tus upload endpoint
128+
* Set the URI or absolute URL under which the main tus upload endpoint is hosted. This can be a
129+
* relative path (for example <code>/files/upload</code>) or an absolute URL (for example <code>
130+
* https://upload.example.com/files/upload</code>). When an absolute URL is provided, the Location
131+
* header in creation responses will contain the full URL. Optionally, this URI may contain regex
132+
* parameters in order to support endpoints that contain URL parameters, for example <code>
133+
* /users/[0-9]+/files/upload</code> or <code>https://upload.example.com/users/[0-9]+/files/upload
134+
* </code>.
135+
*
136+
* @param uploadUri The URI or URL of the main tus upload endpoint
133137
* @return The current service
134138
*/
135139
public TusFileUploadService withUploadUri(String uploadUri) {

src/main/java/me/desair/tus/server/creation/CreationPostRequestHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public void process(
4141
UploadInfo info = buildUploadInfo(servletRequest);
4242
info = uploadStorageService.create(info, ownerKey);
4343

44-
String url = Utils.getUploadUriOnCreation(info, servletRequest, null);
44+
String url = Utils.getUploadUriOnCreation(info, servletRequest, uploadStorageService);
4545
servletResponse.setHeader(HttpHeader.LOCATION, url);
4646
servletResponse.setStatus(HttpServletResponse.SC_CREATED);
4747

src/main/java/me/desair/tus/server/creation/validation/PostUriValidator.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import me.desair.tus.server.exception.PostOnInvalidRequestURIException;
99
import me.desair.tus.server.exception.TusException;
1010
import me.desair.tus.server.upload.UploadStorageService;
11+
import me.desair.tus.server.util.Utils;
1112

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

4243
private Pattern getUploadUriPattern(UploadStorageService uploadStorageService) {
4344
if (uploadUriPattern == null) {
44-
// A POST request should match the full URI
45-
uploadUriPattern = Pattern.compile("^" + uploadStorageService.getUploadUri() + "$");
45+
// A POST request should match the full URI path
46+
String path = Utils.extractUriPath(uploadStorageService.getUploadUri());
47+
uploadUriPattern = Pattern.compile("^" + path + "$");
4648
}
4749
return uploadUriPattern;
4850
}

src/main/java/me/desair/tus/server/upload/UploadIdFactory.java

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.io.Serializable;
44
import java.util.regex.Matcher;
55
import java.util.regex.Pattern;
6+
import me.desair.tus.server.util.Utils;
67
import org.apache.commons.lang3.StringUtils;
78
import org.apache.commons.lang3.Strings;
89
import org.apache.commons.lang3.Validate;
@@ -17,15 +18,20 @@ public abstract class UploadIdFactory {
1718
private Pattern uploadUriPattern = null;
1819

1920
/**
20-
* Set the URI under which the main tus upload endpoint is hosted. Optionally, this URI may
21-
* contain regex parameters in order to support endpoints that contain URL parameters, for example
22-
* /users/[0-9]+/files/upload
21+
* Set the URI or absolute URL under which the main tus upload endpoint is hosted. Optionally,
22+
* this URI may contain regex parameters in order to support endpoints that contain URL
23+
* parameters, for example /users/[0-9]+/files/upload or
24+
* https://upload.example.com/users/[0-9]+/files/upload
2325
*
24-
* @param uploadUri The URI of the main tus upload endpoint
26+
* @param uploadUri The URI or URL of the main tus upload endpoint
2527
*/
2628
public void setUploadUri(String uploadUri) {
2729
Validate.notBlank(uploadUri, "The upload URI pattern cannot be blank");
28-
Validate.isTrue(Strings.CS.startsWith(uploadUri, "/"), "The upload URI should start with /");
30+
Validate.isTrue(
31+
Strings.CS.startsWith(uploadUri, "/")
32+
|| Strings.CS.startsWith(uploadUri, "http://")
33+
|| Strings.CS.startsWith(uploadUri, "https://"),
34+
"The upload URI should start with /, http://, or https://");
2935
Validate.isTrue(!Strings.CS.endsWith(uploadUri, "$"), "The upload URI should not end with $");
3036
this.uploadUri = uploadUri;
3137
this.uploadUriPattern = null;
@@ -86,8 +92,9 @@ protected Pattern getUploadUriPattern() {
8692
if (uploadUriPattern == null) {
8793
// We will extract the upload ID's by removing the upload URI from the start of the
8894
// request URI
95+
String path = Utils.extractUriPath(uploadUri);
8996
uploadUriPattern =
90-
Pattern.compile("^.*" + uploadUri + (Strings.CS.endsWith(uploadUri, "/") ? "" : "/?"));
97+
Pattern.compile("^.*" + path + (Strings.CS.endsWith(path, "/") ? "" : "/?"));
9198
}
9299
return uploadUriPattern;
93100
}

src/main/java/me/desair/tus/server/util/Utils.java

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.EnumSet;
2323
import java.util.LinkedList;
2424
import java.util.List;
25+
import java.util.Objects;
2526
import java.util.concurrent.Executors;
2627
import java.util.concurrent.ScheduledExecutorService;
2728
import java.util.concurrent.TimeUnit;
@@ -404,6 +405,56 @@ public static ProtocolVersion detectProtocolVersion(
404405
return ProtocolVersion.TUS_1_0_0;
405406
}
406407

408+
/**
409+
* Extracts the path component from an upload URI string, which may be a relative path (e.g.,
410+
* "/files") or an absolute URL (e.g., "https://example.com/files").
411+
*
412+
* @param uploadUri The upload URI or URL string
413+
* @return The path component starting with "/", or "/" if none is present
414+
*/
415+
public static String extractUriPath(String uploadUri) {
416+
if (StringUtils.isBlank(uploadUri)) {
417+
return "/";
418+
}
419+
// For absolute URLs (http:// or https://), extract the path starting after the authority
420+
// component
421+
if (Strings.CS.startsWith(uploadUri, "http://")
422+
|| Strings.CS.startsWith(uploadUri, "https://")) {
423+
int schemeEnd = uploadUri.indexOf("://");
424+
int pathStart = uploadUri.indexOf('/', schemeEnd + 3);
425+
if (pathStart == -1) {
426+
return "/";
427+
}
428+
return uploadUri.substring(pathStart);
429+
}
430+
return uploadUri;
431+
}
432+
433+
/**
434+
* Extracts the origin component (scheme + host + port) from an upload URL string, or an empty
435+
* string if the URI is relative or blank.
436+
*
437+
* @param uploadUri The upload URI or URL string
438+
* @return The origin string (e.g. "https://example.com:8080"), or "" if uploadUri is relative or
439+
* blank
440+
*/
441+
public static String extractUriOrigin(String uploadUri) {
442+
if (StringUtils.isBlank(uploadUri)) {
443+
return "";
444+
}
445+
// Extract scheme + host[:port] for absolute HTTP and HTTPS URLs
446+
if (Strings.CS.startsWith(uploadUri, "http://")
447+
|| Strings.CS.startsWith(uploadUri, "https://")) {
448+
int schemeEnd = uploadUri.indexOf("://");
449+
int pathStart = uploadUri.indexOf('/', schemeEnd + 3);
450+
if (pathStart == -1) {
451+
return uploadUri;
452+
}
453+
return uploadUri.substring(0, pathStart);
454+
}
455+
return "";
456+
}
457+
407458
/**
408459
* Determine if the given HTTP servlet request targets the upload creation base URI endpoint.
409460
*
@@ -417,7 +468,7 @@ public static boolean isCreationEndpoint(
417468
return false;
418469
}
419470
String requestUri = request.getRequestURI();
420-
String baseUri = uploadStorageService.getUploadUri();
471+
String baseUri = extractUriPath(uploadStorageService.getUploadUri());
421472
return requestUri != null
422473
&& baseUri != null
423474
&& (requestUri.equals(baseUri) || requestUri.equals(baseUri + "/"));
@@ -450,25 +501,36 @@ public static boolean isExistingUploadResource(
450501
/**
451502
* Builds the upload location URI for a newly created upload resource.
452503
*
453-
* @param uploadInfo The UploadInfo object containing the upload ID
504+
* @param uploadInfo The UploadInfo object containing the upload ID (must not be null and must
505+
* have an ID)
454506
* @param servletRequest The current HttpServletRequest or TusServletRequest
455-
* @param storageService The current UploadStorageService
507+
* @param storageService The current UploadStorageService (must not be null and must have an
508+
* upload URI)
456509
* @return The location URI string for the created upload
457510
*/
458511
public static String getUploadUriOnCreation(
459512
UploadInfo uploadInfo,
460513
HttpServletRequest servletRequest,
461514
UploadStorageService storageService) {
462-
String baseUri = storageService != null ? storageService.getUploadUri() : null;
463-
if (baseUri == null && servletRequest != null) {
464-
baseUri = servletRequest.getRequestURI();
515+
Objects.requireNonNull(uploadInfo, "Upload info cannot be null");
516+
Objects.requireNonNull(uploadInfo.getId(), "Upload ID cannot be null");
517+
Objects.requireNonNull(storageService, "Storage service cannot be null");
518+
String configuredUri =
519+
Objects.requireNonNull(storageService.getUploadUri(), "Upload URI cannot be null");
520+
521+
String baseUri = configuredUri;
522+
523+
// When configuredUri contains regex patterns (e.g. /users/[0-9]+/files),
524+
// resolve the concrete request path dynamically from the incoming servlet request
525+
boolean hasRegex = configuredUri.contains("[") || configuredUri.contains("(");
526+
if (hasRegex && servletRequest != null) {
527+
String origin = extractUriOrigin(configuredUri);
528+
String requestPath = servletRequest.getRequestURI();
529+
baseUri = origin + (requestPath.startsWith("/") ? "" : "/") + requestPath;
465530
}
466-
if (baseUri == null) {
467-
baseUri = "";
468-
}
469-
String idStr =
470-
uploadInfo != null && uploadInfo.getId() != null ? uploadInfo.getId().toString() : "";
471-
return baseUri + (baseUri.endsWith("/") ? "" : "/") + idStr;
531+
532+
// Append the generated upload ID to form the final location URI
533+
return baseUri + (baseUri.endsWith("/") ? "" : "/") + uploadInfo.getId();
472534
}
473535

474536
/**

src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import static org.hamcrest.MatcherAssert.assertThat;
55
import static org.hamcrest.Matchers.containsInAnyOrder;
66
import static org.junit.Assert.assertFalse;
7+
import static org.junit.Assert.assertNotNull;
78
import static org.junit.Assert.assertNull;
89
import static org.junit.Assert.assertTrue;
910

@@ -43,6 +44,17 @@ public abstract class AbstractITRufhProtocol {
4344
*/
4445
protected abstract TusFileUploadService createTusFileUploadService() throws Exception;
4546

47+
/**
48+
* Factory method implemented by subclasses to supply a {@link TusFileUploadService} instance
49+
* configured with a specific upload URI.
50+
*
51+
* @param uploadUri The upload URI to configure
52+
* @return configured TusFileUploadService instance
53+
* @throws Exception if service creation fails
54+
*/
55+
protected abstract TusFileUploadService createTusFileUploadService(String uploadUri)
56+
throws Exception;
57+
4658
@Before
4759
public void setUp() throws Exception {
4860
reset();
@@ -608,6 +620,110 @@ public void testContentDigestValidation() throws Exception {
608620
assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "12");
609621
}
610622

623+
@Test
624+
public void testUploadWithAbsoluteUploadUri() throws Exception {
625+
String absoluteBaseUri = "https://uploads.example.com";
626+
TusFileUploadService service = createTusFileUploadService(absoluteBaseUri);
627+
628+
String uploadContent = "RUFH Absolute URL content";
629+
630+
// Step 1: POST to create upload on root endpoint "/"
631+
servletRequest.setMethod("POST");
632+
servletRequest.setRequestURI("/");
633+
servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "" + uploadContent.getBytes().length);
634+
servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
635+
636+
service.process(servletRequest, servletResponse, OWNER_KEY);
637+
assertThat(servletResponse.getStatus(), is(HttpServletResponse.SC_CREATED));
638+
String locationHeader = servletResponse.getHeader(HttpHeader.LOCATION);
639+
assertNotNull(locationHeader);
640+
641+
// Retrieve upload info using the full Location header to verify ID lookup works with absolute
642+
// URLs
643+
UploadInfo infoByLocation = service.getUploadInfo(locationHeader, OWNER_KEY);
644+
assertTrue(infoByLocation != null && infoByLocation.getId() != null);
645+
assertThat(locationHeader, is("https://uploads.example.com/" + infoByLocation.getId()));
646+
647+
String uploadPath = "/" + infoByLocation.getId();
648+
649+
// Step 2: PATCH upload bytes
650+
reset();
651+
servletRequest.setMethod("PATCH");
652+
servletRequest.setRequestURI(uploadPath);
653+
servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
654+
servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0");
655+
servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1");
656+
servletRequest.setContent(uploadContent.getBytes());
657+
658+
service.process(servletRequest, servletResponse, OWNER_KEY);
659+
assertThat(servletResponse.getStatus(), is(HttpServletResponse.SC_OK));
660+
assertThat(
661+
servletResponse.getHeader(HttpHeader.UPLOAD_OFFSET),
662+
is("" + uploadContent.getBytes().length));
663+
assertThat(servletResponse.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1"));
664+
665+
// Verify upload info is also retrievable via relative path
666+
UploadInfo infoByPath = service.getUploadInfo(uploadPath, OWNER_KEY);
667+
assertTrue(infoByPath != null && infoByLocation.getId().equals(infoByPath.getId()));
668+
669+
// Step 3: Verify content
670+
try (InputStream stream = service.getUploadedBytes(uploadPath, OWNER_KEY)) {
671+
assertThat(IOUtils.toString(stream, StandardCharsets.UTF_8), is(uploadContent));
672+
}
673+
}
674+
675+
@Test
676+
public void testUploadWithAbsoluteUploadUriWithPath() throws Exception {
677+
String absoluteBaseUri = "https://uploads.example.com/api";
678+
TusFileUploadService service = createTusFileUploadService(absoluteBaseUri);
679+
680+
String uploadContent = "RUFH Absolute URL with path content";
681+
682+
// Step 1: POST to create upload on endpoint "/api"
683+
servletRequest.setMethod("POST");
684+
servletRequest.setRequestURI("/api");
685+
servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "" + uploadContent.getBytes().length);
686+
servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
687+
688+
service.process(servletRequest, servletResponse, OWNER_KEY);
689+
assertThat(servletResponse.getStatus(), is(HttpServletResponse.SC_CREATED));
690+
String locationHeader = servletResponse.getHeader(HttpHeader.LOCATION);
691+
assertNotNull(locationHeader);
692+
693+
// Retrieve upload info using the full Location header to verify ID lookup works with absolute
694+
// URLs
695+
UploadInfo infoByLocation = service.getUploadInfo(locationHeader, OWNER_KEY);
696+
assertTrue(infoByLocation != null && infoByLocation.getId() != null);
697+
assertThat(locationHeader, is("https://uploads.example.com/api/" + infoByLocation.getId()));
698+
699+
String uploadPath = "/api/" + infoByLocation.getId();
700+
701+
// Step 2: PATCH upload bytes
702+
reset();
703+
servletRequest.setMethod("PATCH");
704+
servletRequest.setRequestURI(uploadPath);
705+
servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
706+
servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0");
707+
servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1");
708+
servletRequest.setContent(uploadContent.getBytes());
709+
710+
service.process(servletRequest, servletResponse, OWNER_KEY);
711+
assertThat(servletResponse.getStatus(), is(HttpServletResponse.SC_OK));
712+
assertThat(
713+
servletResponse.getHeader(HttpHeader.UPLOAD_OFFSET),
714+
is("" + uploadContent.getBytes().length));
715+
assertThat(servletResponse.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1"));
716+
717+
// Verify upload info is also retrievable via relative path
718+
UploadInfo infoByPath = service.getUploadInfo(uploadPath, OWNER_KEY);
719+
assertTrue(infoByPath != null && infoByLocation.getId().equals(infoByPath.getId()));
720+
721+
// Step 3: Verify content
722+
try (InputStream stream = service.getUploadedBytes(uploadPath, OWNER_KEY)) {
723+
assertThat(IOUtils.toString(stream, StandardCharsets.UTF_8), is(uploadContent));
724+
}
725+
}
726+
611727
// ===============================================================================================
612728
// ASSERTION HELPERS
613729
// ===============================================================================================

0 commit comments

Comments
 (0)