From a226f72de6b51cb9dad75546ffd296b0d1645cde Mon Sep 17 00:00:00 2001 From: Tom Desair Date: Tue, 18 Aug 2026 22:07:57 +0200 Subject: [PATCH 1/4] 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 --- CHANGELOG.md | 1 + README.md | 2 +- .../tus/server/TusFileUploadService.java | 14 +-- .../creation/CreationPostRequestHandler.java | 2 +- .../creation/validation/PostUriValidator.java | 6 +- .../tus/server/upload/UploadIdFactory.java | 19 ++-- .../java/me/desair/tus/server/util/Utils.java | 67 ++++++++++++++- .../tus/server/TusFileUploadServiceTest.java | 9 ++ .../CreationPostRequestHandlerTest.java | 65 ++++++++++++++ .../validation/PostUriValidatorTest.java | 39 +++++++++ .../server/rufh/RufhProtocolCreationTest.java | 43 ++++++++++ .../RufhCreationPostRequestHandlerTest.java | 62 +++++++++++++ .../upload/UuidUploadIdFactoryTest.java | 51 +++++++++++ .../me/desair/tus/server/util/UtilsTest.java | 86 ++++++++++++++++++- 14 files changed, 447 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5194859b..952fe2bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to this project will be documented in this file. - **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. - **JSON Serialization**: Support storing `UploadInfo` objects as JSON files in the storage backend using `TusFileUploadService.withJsonSerialization(true)`. +- **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. ### 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. diff --git a/README.md b/README.md index 2ee124e5..385be505 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/main/java/me/desair/tus/server/TusFileUploadService.java b/src/main/java/me/desair/tus/server/TusFileUploadService.java index 14665f4d..6890eca6 100644 --- a/src/main/java/me/desair/tus/server/TusFileUploadService.java +++ b/src/main/java/me/desair/tus/server/TusFileUploadService.java @@ -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 /files/upload) or an absolute URL (for example + * https://upload.example.com/files/upload). 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 + * /users/[0-9]+/files/upload or https://upload.example.com/users/[0-9]+/files/upload + * . + * + * @param uploadUri The URI or URL of the main tus upload endpoint * @return The current service */ public TusFileUploadService withUploadUri(String uploadUri) { diff --git a/src/main/java/me/desair/tus/server/creation/CreationPostRequestHandler.java b/src/main/java/me/desair/tus/server/creation/CreationPostRequestHandler.java index ee5b2279..853910a3 100644 --- a/src/main/java/me/desair/tus/server/creation/CreationPostRequestHandler.java +++ b/src/main/java/me/desair/tus/server/creation/CreationPostRequestHandler.java @@ -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); diff --git a/src/main/java/me/desair/tus/server/creation/validation/PostUriValidator.java b/src/main/java/me/desair/tus/server/creation/validation/PostUriValidator.java index 73bcb52a..67265ac6 100644 --- a/src/main/java/me/desair/tus/server/creation/validation/PostUriValidator.java +++ b/src/main/java/me/desair/tus/server/creation/validation/PostUriValidator.java @@ -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 @@ -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; } diff --git a/src/main/java/me/desair/tus/server/upload/UploadIdFactory.java b/src/main/java/me/desair/tus/server/upload/UploadIdFactory.java index 41c53371..1d71734b 100644 --- a/src/main/java/me/desair/tus/server/upload/UploadIdFactory.java +++ b/src/main/java/me/desair/tus/server/upload/UploadIdFactory.java @@ -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; @@ -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; @@ -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; } diff --git a/src/main/java/me/desair/tus/server/util/Utils.java b/src/main/java/me/desair/tus/server/util/Utils.java index 6746d903..43e86ca1 100644 --- a/src/main/java/me/desair/tus/server/util/Utils.java +++ b/src/main/java/me/desair/tus/server/util/Utils.java @@ -404,6 +404,52 @@ 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 or an + * absolute URL (http:// or https://). + * + * @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 "/"; + } + 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 null if the + * URI is relative. + * + * @param uploadUri The upload URI or URL string + * @return The origin string (e.g. "https://example.com:8080"), or null if uploadUri is relative + */ + public static String extractUriOrigin(String uploadUri) { + if (StringUtils.isBlank(uploadUri)) { + return null; + } + 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 null; + } + /** * Determine if the given HTTP servlet request targets the upload creation base URI endpoint. * @@ -417,7 +463,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 + "/")); @@ -459,10 +505,25 @@ public static String getUploadUriOnCreation( UploadInfo uploadInfo, HttpServletRequest servletRequest, UploadStorageService storageService) { - String baseUri = storageService != null ? storageService.getUploadUri() : null; - if (baseUri == null && servletRequest != null) { + String configuredUri = storageService != null ? storageService.getUploadUri() : null; + String origin = extractUriOrigin(configuredUri); + String baseUri = null; + + if (configuredUri != null) { + boolean hasRegex = configuredUri.contains("[") || configuredUri.contains("("); + if (hasRegex && servletRequest != null) { + String requestPath = servletRequest.getRequestURI(); + baseUri = + origin != null + ? origin + (requestPath.startsWith("/") ? "" : "/") + requestPath + : requestPath; + } else { + baseUri = configuredUri; + } + } else if (servletRequest != null) { baseUri = servletRequest.getRequestURI(); } + if (baseUri == null) { baseUri = ""; } diff --git a/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java b/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java index e8f12921..0cc57adf 100644 --- a/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java +++ b/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java @@ -611,4 +611,13 @@ public void testAcquireUploadLockWithConfiguredRetries() throws Exception { assertNotNull(lock); verify(mockLockingService, times(2)).requestLockRelease("/files/test"); } + + @Test + public void testWithUploadUriAbsoluteUrl() { + TusFileUploadService service = + new TusFileUploadService().withUploadUri("https://upload.example.com/files/upload"); + assertEquals( + "https://upload.example.com/files/upload", + service.getUploadStorageService().getUploadUri()); + } } diff --git a/src/test/java/me/desair/tus/server/creation/CreationPostRequestHandlerTest.java b/src/test/java/me/desair/tus/server/creation/CreationPostRequestHandlerTest.java index 0e28bb90..7cfc37ef 100644 --- a/src/test/java/me/desair/tus/server/creation/CreationPostRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/creation/CreationPostRequestHandlerTest.java @@ -211,4 +211,69 @@ public UploadInfo answer(InvocationOnMock invocation) throws Throwable { servletResponse.getHeader(HttpHeader.LOCATION), endsWith("/test/upload/" + id.toString())); assertThat(servletResponse.getStatus(), is(HttpServletResponse.SC_CREATED)); } + + @Test + public void processWithAbsoluteUploadUri() throws Exception { + servletRequest.setRequestURI("/test/upload"); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 100L); + when(uploadStorageService.getUploadUri()).thenReturn("https://upload.example.com/test/upload"); + + final UploadId id = new UploadId(UUID.randomUUID()); + when(uploadStorageService.create( + ArgumentMatchers.any(UploadInfo.class), nullable(String.class))) + .then( + new Answer() { + @Override + public UploadInfo answer(InvocationOnMock invocation) throws Throwable { + UploadInfo upload = invocation.getArgument(0); + upload.setId(id); + return upload; + } + }); + + handler.process( + HttpMethod.POST, + new TusServletRequest(servletRequest), + new TusServletResponse(servletResponse), + uploadStorageService, + null); + + assertThat( + servletResponse.getHeader(HttpHeader.LOCATION), + is("https://upload.example.com/test/upload/" + id.toString())); + assertThat(servletResponse.getStatus(), is(HttpServletResponse.SC_CREATED)); + } + + @Test + public void processWithAbsoluteRegexUploadUri() throws Exception { + servletRequest.setRequestURI("/users/123/files/upload"); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 100L); + when(uploadStorageService.getUploadUri()) + .thenReturn("https://upload.example.com/users/[0-9]+/files/upload"); + + final UploadId id = new UploadId(UUID.randomUUID()); + when(uploadStorageService.create( + ArgumentMatchers.any(UploadInfo.class), nullable(String.class))) + .then( + new Answer() { + @Override + public UploadInfo answer(InvocationOnMock invocation) throws Throwable { + UploadInfo upload = invocation.getArgument(0); + upload.setId(id); + return upload; + } + }); + + handler.process( + HttpMethod.POST, + new TusServletRequest(servletRequest), + new TusServletResponse(servletResponse), + uploadStorageService, + null); + + assertThat( + servletResponse.getHeader(HttpHeader.LOCATION), + is("https://upload.example.com/users/123/files/upload/" + id.toString())); + assertThat(servletResponse.getStatus(), is(HttpServletResponse.SC_CREATED)); + } } diff --git a/src/test/java/me/desair/tus/server/creation/validation/PostUriValidatorTest.java b/src/test/java/me/desair/tus/server/creation/validation/PostUriValidatorTest.java index 3e3e283f..68a4f18d 100644 --- a/src/test/java/me/desair/tus/server/creation/validation/PostUriValidatorTest.java +++ b/src/test/java/me/desair/tus/server/creation/validation/PostUriValidatorTest.java @@ -99,4 +99,43 @@ public void validateInvalidRegexUrlPatchUrl() throws Exception { // Expect PostOnInvalidRequestURIException } + + @Test + public void validateMatchingAbsoluteUrl() throws Exception { + servletRequest.setRequestURI("/test/upload"); + when(uploadStorageService.getUploadUri()).thenReturn("https://upload.example.com/test/upload"); + + try { + validator.validate(HttpMethod.POST, servletRequest, uploadStorageService, null); + } catch (Exception ex) { + fail(); + } + + // No Exception is thrown + } + + @Test(expected = PostOnInvalidRequestURIException.class) + public void validateInvalidAbsoluteUrl() throws Exception { + servletRequest.setRequestURI("/test/upload/12"); + when(uploadStorageService.getUploadUri()).thenReturn("https://upload.example.com/test/upload"); + + validator.validate(HttpMethod.POST, servletRequest, uploadStorageService, null); + + // Expect PostOnInvalidRequestURIException + } + + @Test + public void validateMatchingAbsoluteRegexUrl() throws Exception { + servletRequest.setRequestURI("/users/1234/files/upload"); + when(uploadStorageService.getUploadUri()) + .thenReturn("https://upload.example.com/users/[0-9]+/files/upload"); + + try { + validator.validate(HttpMethod.POST, servletRequest, uploadStorageService, null); + } catch (Exception ex) { + fail(); + } + + // No Exception is thrown + } } diff --git a/src/test/java/me/desair/tus/server/rufh/RufhProtocolCreationTest.java b/src/test/java/me/desair/tus/server/rufh/RufhProtocolCreationTest.java index abd1dea7..d1f76e48 100644 --- a/src/test/java/me/desair/tus/server/rufh/RufhProtocolCreationTest.java +++ b/src/test/java/me/desair/tus/server/rufh/RufhProtocolCreationTest.java @@ -471,4 +471,47 @@ public void testUploadCreationWithPreCreatedUploadInfoWithoutLength() throws Exc assertThat(response.getStatus(), is(201)); } + + /** + * Section 4.2.2 (Upload Creation - Server Behavior): "If the server decides to create the upload + * resource, it MUST acknowledge this by sending a response with a 2xx (Successful) or 104 (Upload + * Resumption Supported) status code and MUST set the Location header field to the URI of the + * upload resource... The URI of the upload resource MAY be relative to the request target (see + * Section 4.2 of [RFC3986])." + * + *

Tests upload creation when configured with an absolute base URL. + */ + @Test + public void testUploadCreationWithAbsoluteBaseUrl() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "10000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + when(storageService.getUploadUri()).thenReturn("https://upload.example.com/files"); + + UploadInfo info = new UploadInfo(); + info.setLength(10000L); + info.setOffset(0L); + info.setId(new UuidUploadIdFactory().createId()); + + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + when(storageService.append(any(UploadInfo.class), any())).thenReturn(info); + + protocol.validate( + HttpMethod.POST, request, storageService, lockingService, null, ProtocolVersion.RUFH); + protocol.process( + HttpMethod.POST, + new TusServletRequest(request, true), + new TusServletResponse(response), + storageService, + lockingService, + null, + ProtocolVersion.RUFH); + + assertThat(response.getStatus(), is(201)); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?0")); + assertThat( + response.getHeader(HttpHeader.LOCATION), + is("https://upload.example.com/files/" + info.getId())); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandlerTest.java index 84b6d36d..0c378363 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandlerTest.java @@ -438,4 +438,66 @@ public void testProcessWithPreCreatedUploadInfoNegativeLength() throws Exception verify(storageService).update(preCreated); assertThat(preCreated.getLength(), is(50L)); } + + @Test + public void testProcessPartialUploadCreationWithAbsoluteUploadUri() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "5000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + when(storageService.getUploadUri()).thenReturn("https://upload.example.com/files"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("creation-id")); + info.setLength(5000L); + info.setOffset(0L); + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + when(storageService.append(any(UploadInfo.class), any())).thenReturn(info); + + handler.process( + HttpMethod.POST, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + lockingService, + "owner", + null); + + assertThat(response.getStatus(), is(201)); + assertThat( + response.getHeader(HttpHeader.LOCATION), + is("https://upload.example.com/files/creation-id")); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("0")); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?0")); + } + + @Test + public void testProcessCompletedUploadCreationWithAbsoluteUploadUri() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + when(storageService.getUploadUri()).thenReturn("https://upload.example.com/files"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("completed-id")); + info.setLength(100L); + info.setOffset(100L); + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + + handler.process( + HttpMethod.POST, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + lockingService, + "owner", + null); + + assertThat(response.getStatus(), is(200)); + assertThat( + response.getHeader(HttpHeader.LOCATION), + is("https://upload.example.com/files/completed-id")); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("100")); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1")); + } } diff --git a/src/test/java/me/desair/tus/server/upload/UuidUploadIdFactoryTest.java b/src/test/java/me/desair/tus/server/upload/UuidUploadIdFactoryTest.java index 810df8a4..5049eb75 100644 --- a/src/test/java/me/desair/tus/server/upload/UuidUploadIdFactoryTest.java +++ b/src/test/java/me/desair/tus/server/upload/UuidUploadIdFactoryTest.java @@ -104,6 +104,57 @@ public void readUploadIdRegexNoMatch() throws Exception { is(nullValue())); } + @Test + public void setUploadUriAbsoluteHttp() throws Exception { + idFactory.setUploadUri("http://localhost:8080/test/upload"); + assertThat(idFactory.getUploadUri(), is("http://localhost:8080/test/upload")); + } + + @Test + public void setUploadUriAbsoluteHttps() throws Exception { + idFactory.setUploadUri("https://upload.example.com/test/upload/"); + assertThat(idFactory.getUploadUri(), is("https://upload.example.com/test/upload/")); + } + + @Test + public void readUploadIdAbsoluteUrlWithRelativeConfig() throws Exception { + idFactory.setUploadUri("/test/upload"); + + assertThat( + idFactory.readUploadId( + "https://upload.example.com/test/upload/1911e8a4-6939-490c-b58b-a5d70f8d91fb"), + hasToString("1911e8a4-6939-490c-b58b-a5d70f8d91fb")); + } + + @Test + public void readUploadIdAbsoluteUrlWithAbsoluteConfig() throws Exception { + idFactory.setUploadUri("https://upload.example.com/test/upload"); + + assertThat( + idFactory.readUploadId( + "https://upload.example.com/test/upload/1911e8a4-6939-490c-b58b-a5d70f8d91fb"), + hasToString("1911e8a4-6939-490c-b58b-a5d70f8d91fb")); + } + + @Test + public void readUploadIdRelativeUrlWithAbsoluteConfig() throws Exception { + idFactory.setUploadUri("https://upload.example.com/test/upload"); + + assertThat( + idFactory.readUploadId("/test/upload/1911e8a4-6939-490c-b58b-a5d70f8d91fb"), + hasToString("1911e8a4-6939-490c-b58b-a5d70f8d91fb")); + } + + @Test + public void readUploadIdRegexAbsoluteUrl() throws Exception { + idFactory.setUploadUri("https://upload.example.com/users/[0-9]+/files/upload"); + + assertThat( + idFactory.readUploadId( + "https://upload.example.com/users/42/files/upload/1911e8a4-6939-490c-b58b-a5d70f8d91fb"), + hasToString("1911e8a4-6939-490c-b58b-a5d70f8d91fb")); + } + @Test public void createId() throws Exception { assertThat(idFactory.createId(), not(nullValue())); diff --git a/src/test/java/me/desair/tus/server/util/UtilsTest.java b/src/test/java/me/desair/tus/server/util/UtilsTest.java index a8806a62..56fa1ea0 100644 --- a/src/test/java/me/desair/tus/server/util/UtilsTest.java +++ b/src/test/java/me/desair/tus/server/util/UtilsTest.java @@ -444,6 +444,30 @@ public void testDetectProtocolVersion() { is(me.desair.tus.server.ProtocolVersion.TUS_1_0_0)); } + @Test + public void testExtractUriPath() { + assertThat(Utils.extractUriPath("/api/files"), is("/api/files")); + assertThat(Utils.extractUriPath("https://upload.example.com/api/files"), is("/api/files")); + assertThat(Utils.extractUriPath("http://localhost:8080/files/upload"), is("/files/upload")); + assertThat(Utils.extractUriPath("https://upload.example.com"), is("/")); + assertThat(Utils.extractUriPath("https://upload.example.com/"), is("/")); + assertThat(Utils.extractUriPath(null), is("/")); + assertThat(Utils.extractUriPath(""), is("/")); + } + + @Test + public void testExtractUriOrigin() { + assertThat( + Utils.extractUriOrigin("https://upload.example.com/api/files"), + is("https://upload.example.com")); + assertThat(Utils.extractUriOrigin("http://localhost:8080/files"), is("http://localhost:8080")); + assertThat( + Utils.extractUriOrigin("https://upload.example.com"), is("https://upload.example.com")); + assertThat(Utils.extractUriOrigin("/api/files"), is(nullValue())); + assertThat(Utils.extractUriOrigin(null), is(nullValue())); + assertThat(Utils.extractUriOrigin(""), is(nullValue())); + } + @Test public void testGetUploadUriOnCreation() { me.desair.tus.server.upload.UploadInfo info = new me.desair.tus.server.upload.UploadInfo(); @@ -454,7 +478,7 @@ public void testGetUploadUriOnCreation() { when(storageService.getUploadUri()).thenReturn("/api/files"); HttpServletRequest request = mock(HttpServletRequest.class); - when(request.getRequestURI()).thenReturn("/files"); + when(request.getRequestURI()).thenReturn("/api/files"); // With requestURI set and storageService uploadUri set assertThat( @@ -475,6 +499,55 @@ public void testGetUploadUriOnCreation() { is("/")); } + @Test + public void testGetUploadUriOnCreationAbsoluteUrl() { + me.desair.tus.server.upload.UploadInfo info = new me.desair.tus.server.upload.UploadInfo(); + info.setId(new me.desair.tus.server.upload.UploadId("test-id")); + + me.desair.tus.server.upload.UploadStorageService storageService = + mock(me.desair.tus.server.upload.UploadStorageService.class); + when(storageService.getUploadUri()).thenReturn("https://upload.example.com/api/files"); + + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRequestURI()).thenReturn("/api/files"); + + // Absolute URL with request + assertThat( + Utils.getUploadUriOnCreation(info, request, storageService), + is("https://upload.example.com/api/files/test-id")); + + // Absolute URL with regex path request + when(storageService.getUploadUri()).thenReturn("https://upload.example.com/users/[0-9]+/files"); + when(request.getRequestURI()).thenReturn("/users/42/files"); + assertThat( + Utils.getUploadUriOnCreation(info, request, storageService), + is("https://upload.example.com/users/42/files/test-id")); + + // Absolute URL with regex path request not starting with / + when(request.getRequestURI()).thenReturn("users/42/files"); + assertThat( + Utils.getUploadUriOnCreation(info, request, storageService), + is("https://upload.example.com/users/42/files/test-id")); + + // Relative URL with regex path request + when(storageService.getUploadUri()).thenReturn("/users/[0-9]+/files"); + when(request.getRequestURI()).thenReturn("/users/42/files"); + assertThat( + Utils.getUploadUriOnCreation(info, request, storageService), is("/users/42/files/test-id")); + + // Absolute URL with null request + when(storageService.getUploadUri()).thenReturn("https://upload.example.com/api/files"); + assertThat( + Utils.getUploadUriOnCreation(info, null, storageService), + is("https://upload.example.com/api/files/test-id")); + + // Absolute URL with root path and null request + when(storageService.getUploadUri()).thenReturn("https://upload.example.com"); + assertThat( + Utils.getUploadUriOnCreation(info, null, storageService), + is("https://upload.example.com/test-id")); + } + @Test public void testIsExistingUploadResource() throws Exception { assertThat(Utils.isExistingUploadResource(null, null, "owner"), is(false)); @@ -516,6 +589,17 @@ public void testIsCreationEndpoint() throws Exception { when(request.getRequestURI()).thenReturn("/files/123"); assertThat(Utils.isCreationEndpoint(request, storageService), is(false)); + + // Test with absolute URL configured + when(storageService.getUploadUri()).thenReturn("https://upload.example.com/files"); + when(request.getRequestURI()).thenReturn("/files"); + assertThat(Utils.isCreationEndpoint(request, storageService), is(true)); + + when(request.getRequestURI()).thenReturn("/files/"); + assertThat(Utils.isCreationEndpoint(request, storageService), is(true)); + + when(request.getRequestURI()).thenReturn("/files/123"); + assertThat(Utils.isCreationEndpoint(request, storageService), is(false)); } @Test From 4ea56d7a58d4cc68e0e83b52d40c4d039d0e528a Mon Sep 17 00:00:00 2001 From: Tom Desair Date: Tue, 18 Aug 2026 22:35:59 +0200 Subject: [PATCH 2/4] 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 --- .../java/me/desair/tus/server/util/Utils.java | 65 ++++++++++--------- .../CreationPostRequestHandlerTest.java | 1 + .../RufhCreationPostRequestHandlerTest.java | 39 +---------- .../util/RufhInterimResponseUtilTest.java | 4 +- .../me/desair/tus/server/util/UtilsTest.java | 50 ++++++++++---- 5 files changed, 79 insertions(+), 80 deletions(-) diff --git a/src/main/java/me/desair/tus/server/util/Utils.java b/src/main/java/me/desair/tus/server/util/Utils.java index 43e86ca1..cf2373fc 100644 --- a/src/main/java/me/desair/tus/server/util/Utils.java +++ b/src/main/java/me/desair/tus/server/util/Utils.java @@ -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; @@ -405,8 +406,8 @@ public static ProtocolVersion detectProtocolVersion( } /** - * Extracts the path component from an upload URI string, which may be a relative path or an - * absolute URL (http:// or https://). + * 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 @@ -415,6 +416,8 @@ 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("://"); @@ -428,16 +431,18 @@ public static String extractUriPath(String uploadUri) { } /** - * Extracts the origin component (scheme + host + port) from an upload URL string, or null if the - * URI is relative. + * 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 null if uploadUri is relative + * @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 null; + 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("://"); @@ -447,7 +452,7 @@ public static String extractUriOrigin(String uploadUri) { } return uploadUri.substring(0, pathStart); } - return null; + return ""; } /** @@ -496,40 +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 configuredUri = storageService != null ? storageService.getUploadUri() : null; - String origin = extractUriOrigin(configuredUri); - String baseUri = null; - - if (configuredUri != null) { - boolean hasRegex = configuredUri.contains("[") || configuredUri.contains("("); - if (hasRegex && servletRequest != null) { - String requestPath = servletRequest.getRequestURI(); - baseUri = - origin != null - ? origin + (requestPath.startsWith("/") ? "" : "/") + requestPath - : requestPath; - } else { - baseUri = configuredUri; - } - } else if (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; - if (baseUri == null) { - baseUri = ""; + // 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; } - 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(); } /** diff --git a/src/test/java/me/desair/tus/server/creation/CreationPostRequestHandlerTest.java b/src/test/java/me/desair/tus/server/creation/CreationPostRequestHandlerTest.java index 7cfc37ef..6b76a4e5 100644 --- a/src/test/java/me/desair/tus/server/creation/CreationPostRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/creation/CreationPostRequestHandlerTest.java @@ -50,6 +50,7 @@ public void setUp() { servletRequest = new MockHttpServletRequest(); servletResponse = new MockHttpServletResponse(); handler = new CreationPostRequestHandler(); + when(uploadStorageService.getUploadUri()).thenReturn("/test/upload"); } @Test diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandlerTest.java index 0c378363..3db6a575 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandlerTest.java @@ -120,38 +120,6 @@ public void testProcessCreationRegistersInputStream() throws Exception { .registerInputStream(eq("/files/creation-id"), any(InterruptibleInputStream.class)); } - @Test - public void testProcessWithNullBaseUri() throws Exception { - handler = new RufhCreationPostRequestHandler(); - - request.setMethod("POST"); - request.setRequestURI("/files"); - request.addHeader(HttpHeader.UPLOAD_LENGTH, "5000"); - request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); - - UploadInfo info = new UploadInfo(); - info.setId(new UploadId("creation-id")); - info.setLength(5000L); - info.setOffset(0L); - - // Mock getUploadUri to return null to test fallback to requestURI - when(storageService.getUploadUri()).thenReturn(null); - when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); - when(storageService.append(any(UploadInfo.class), any())).thenReturn(info); - - handler.process( - HttpMethod.POST, - new TusServletRequest(request), - new TusServletResponse(response), - storageService, - lockingService, - "owner", - null); - - assertThat(response.getStatus(), is(201)); - assertThat(response.getHeader(HttpHeader.LOCATION), is("/files/creation-id")); - } - @Test public void testProcessExistingUploadPatchReturnsEarly() throws Exception { request.setMethod("PATCH"); @@ -234,13 +202,13 @@ public void testProcessPatchCreationWhenUploadDoesNotExist() throws Exception { } @Test - public void testProcessNegativeLengthAndNullUploadId() throws Exception { + public void testProcessNegativeLength() throws Exception { request.setMethod("POST"); request.setRequestURI("/files"); request.addHeader(HttpHeader.UPLOAD_LENGTH, "-500"); UploadInfo info = new UploadInfo(); - info.setId(null); // Null ID + info.setId(new UploadId("neg-id")); info.setOffset(0L); when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); @@ -255,8 +223,7 @@ public void testProcessNegativeLengthAndNullUploadId() throws Exception { null); assertThat(response.getStatus(), is(201)); - // Location header ends with "/" because ID is empty string - assertThat(response.getHeader(HttpHeader.LOCATION), is("/files/")); + assertThat(response.getHeader(HttpHeader.LOCATION), is("/files/neg-id")); } @Test diff --git a/src/test/java/me/desair/tus/server/rufh/util/RufhInterimResponseUtilTest.java b/src/test/java/me/desair/tus/server/rufh/util/RufhInterimResponseUtilTest.java index b0276d00..7a103041 100644 --- a/src/test/java/me/desair/tus/server/rufh/util/RufhInterimResponseUtilTest.java +++ b/src/test/java/me/desair/tus/server/rufh/util/RufhInterimResponseUtilTest.java @@ -103,6 +103,7 @@ public void testGetRawInterimResponseWithExistingUploadNotFoundAndNullHost() thr me.desair.tus.server.upload.UploadStorageService mockStorage = org.mockito.Mockito.mock(me.desair.tus.server.upload.UploadStorageService.class); + org.mockito.Mockito.when(mockStorage.getUploadUri()).thenReturn("/files"); me.desair.tus.server.upload.UploadInfo created = new me.desair.tus.server.upload.UploadInfo(); created.setId(new me.desair.tus.server.upload.UploadId("created-456")); @@ -115,7 +116,7 @@ public void testGetRawInterimResponseWithExistingUploadNotFoundAndNullHost() thr String raw = RufhInterimResponseUtil.getRawInterimResponse(request, mockStorage, "owner"); assertNotNull(raw); - assertTrue(raw.contains("Location: /files/not-found-123/created-456")); + assertTrue(raw.contains("Location: /files/created-456")); } @Test @@ -169,6 +170,7 @@ public void testGetRawInterimResponseWithNullMethodAndPartialSchemeHost() throws me.desair.tus.server.upload.UploadStorageService mockStorage = org.mockito.Mockito.mock(me.desair.tus.server.upload.UploadStorageService.class); + org.mockito.Mockito.when(mockStorage.getUploadUri()).thenReturn("/files"); assertNull(RufhInterimResponseUtil.getRawInterimResponse(request, mockStorage, "owner")); diff --git a/src/test/java/me/desair/tus/server/util/UtilsTest.java b/src/test/java/me/desair/tus/server/util/UtilsTest.java index 56fa1ea0..25191cde 100644 --- a/src/test/java/me/desair/tus/server/util/UtilsTest.java +++ b/src/test/java/me/desair/tus/server/util/UtilsTest.java @@ -463,9 +463,9 @@ public void testExtractUriOrigin() { assertThat(Utils.extractUriOrigin("http://localhost:8080/files"), is("http://localhost:8080")); assertThat( Utils.extractUriOrigin("https://upload.example.com"), is("https://upload.example.com")); - assertThat(Utils.extractUriOrigin("/api/files"), is(nullValue())); - assertThat(Utils.extractUriOrigin(null), is(nullValue())); - assertThat(Utils.extractUriOrigin(""), is(nullValue())); + assertThat(Utils.extractUriOrigin("/api/files"), is("")); + assertThat(Utils.extractUriOrigin(null), is("")); + assertThat(Utils.extractUriOrigin(""), is("")); } @Test @@ -486,17 +486,45 @@ public void testGetUploadUriOnCreation() { // With null request and storageService set assertThat(Utils.getUploadUriOnCreation(info, null, storageService), is("/api/files/test-id")); + } - // With null request and null storageService - assertThat(Utils.getUploadUriOnCreation(info, null, null), is("/test-id")); + @Test(expected = NullPointerException.class) + public void testGetUploadUriOnCreationNullUploadInfoThrows() { + me.desair.tus.server.upload.UploadStorageService storageService = + mock(me.desair.tus.server.upload.UploadStorageService.class); + when(storageService.getUploadUri()).thenReturn("/api/files"); - // With null uploadInfo - assertThat(Utils.getUploadUriOnCreation(null, null, null), is("/")); + Utils.getUploadUriOnCreation(null, null, storageService); + } - // With uploadInfo having null id - assertThat( - Utils.getUploadUriOnCreation(new me.desair.tus.server.upload.UploadInfo(), null, null), - is("/")); + @Test(expected = NullPointerException.class) + public void testGetUploadUriOnCreationNullUploadIdThrows() { + me.desair.tus.server.upload.UploadStorageService storageService = + mock(me.desair.tus.server.upload.UploadStorageService.class); + when(storageService.getUploadUri()).thenReturn("/api/files"); + + Utils.getUploadUriOnCreation( + new me.desair.tus.server.upload.UploadInfo(), null, storageService); + } + + @Test(expected = NullPointerException.class) + public void testGetUploadUriOnCreationNullStorageServiceThrows() { + me.desair.tus.server.upload.UploadInfo info = new me.desair.tus.server.upload.UploadInfo(); + info.setId(new me.desair.tus.server.upload.UploadId("test-id")); + + Utils.getUploadUriOnCreation(info, null, null); + } + + @Test(expected = NullPointerException.class) + public void testGetUploadUriOnCreationNullUploadUriThrows() { + me.desair.tus.server.upload.UploadInfo info = new me.desair.tus.server.upload.UploadInfo(); + info.setId(new me.desair.tus.server.upload.UploadId("test-id")); + + me.desair.tus.server.upload.UploadStorageService storageService = + mock(me.desair.tus.server.upload.UploadStorageService.class); + when(storageService.getUploadUri()).thenReturn(null); + + Utils.getUploadUriOnCreation(info, null, storageService); } @Test From b06ed08449e347f06092b7b17d1faae5d1b2b19a Mon Sep 17 00:00:00 2001 From: Tom Desair Date: Tue, 18 Aug 2026 22:55:03 +0200 Subject: [PATCH 3/4] 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) --- .../tus/server/AbstractITRufhProtocol.java | 116 ++++++++++++++++ .../AbstractITTusFileUploadService.java | 126 ++++++++++++++++++ .../tus/server/ITLeaseFileRufhProtocol.java | 7 +- .../me/desair/tus/server/ITRufhProtocol.java | 7 +- .../upload/azure/ITAzureBlobRufhProtocol.java | 7 +- .../server/upload/s3/ITS3RufhProtocol.java | 7 +- .../me/desair/tus/server/util/UtilsTest.java | 1 + 7 files changed, 267 insertions(+), 4 deletions(-) diff --git a/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java b/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java index 4a25227c..f27fd612 100644 --- a/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java +++ b/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java @@ -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; @@ -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(); @@ -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 // =============================================================================================== diff --git a/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java b/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java index 77afd56b..4da377ef 100644 --- a/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java +++ b/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java @@ -1950,6 +1950,132 @@ public void testCreationWithUploadChecksumMismatch() throws Exception { assertResponseStatus(460); // Checksum mismatch } + @Test + public void testUploadWithAbsoluteUploadUri() throws Exception { + String absoluteBaseUri = "https://uploads.example.com"; + TusFileUploadService service = createTusFileUploadService(absoluteBaseUri); + + String uploadContent = "Absolute URL upload content"; + + // Step 1: POST to create upload on root endpoint "/" + servletRequest.setMethod("POST"); + servletRequest.setRequestURI("/"); + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, uploadContent.getBytes().length); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + + service.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + String locationHeader = servletResponse.getHeader(HttpHeader.LOCATION); + assertResponseHeaderNotBlank(HttpHeader.LOCATION); + + // 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 bytes to the upload resource + reset(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadPath); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, uploadContent.getBytes().length); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.setContent(uploadContent.getBytes()); + + service.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_NO_CONTENT); + assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + uploadContent.getBytes().length); + + // Step 3: HEAD request to verify completion + reset(); + servletRequest.setMethod("HEAD"); + servletRequest.setRequestURI(uploadPath); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + + service.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_NO_CONTENT); + assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + uploadContent.getBytes().length); + assertResponseHeader(HttpHeader.UPLOAD_LENGTH, "" + uploadContent.getBytes().length); + + // 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 4: Verify uploaded bytes + 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 = "Absolute URL with path upload content"; + + // Step 1: POST to create upload on endpoint "/api" + servletRequest.setMethod("POST"); + servletRequest.setRequestURI("/api"); + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, uploadContent.getBytes().length); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + + service.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + String locationHeader = servletResponse.getHeader(HttpHeader.LOCATION); + assertResponseHeaderNotBlank(HttpHeader.LOCATION); + + // 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 bytes to the upload resource + reset(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadPath); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, uploadContent.getBytes().length); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.setContent(uploadContent.getBytes()); + + service.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_NO_CONTENT); + assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + uploadContent.getBytes().length); + + // Step 3: HEAD request to verify completion + reset(); + servletRequest.setMethod("HEAD"); + servletRequest.setRequestURI(uploadPath); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + + service.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_NO_CONTENT); + assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + uploadContent.getBytes().length); + assertResponseHeader(HttpHeader.UPLOAD_LENGTH, "" + uploadContent.getBytes().length); + + // 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 4: Verify uploaded bytes + try (InputStream stream = service.getUploadedBytes(uploadPath, OWNER_KEY)) { + assertThat(IOUtils.toString(stream, StandardCharsets.UTF_8), is(uploadContent)); + } + } + 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/ITLeaseFileRufhProtocol.java b/src/test/java/me/desair/tus/server/ITLeaseFileRufhProtocol.java index 39c9a67b..0ba2d781 100644 --- a/src/test/java/me/desair/tus/server/ITLeaseFileRufhProtocol.java +++ b/src/test/java/me/desair/tus/server/ITLeaseFileRufhProtocol.java @@ -32,8 +32,13 @@ public static void destroyDataFolder() throws IOException { @Override protected TusFileUploadService createTusFileUploadService() { + return createTusFileUploadService(UPLOAD_URI); + } + + @Override + protected TusFileUploadService createTusFileUploadService(String uploadUri) { return new TusFileUploadService() - .withUploadUri(UPLOAD_URI) + .withUploadUri(uploadUri) .withUploadStorageService(new DiskStorageService(storagePath.toAbsolutePath().toString())) .withUploadLockingService( new LeaseFileLockingService(storagePath.toAbsolutePath().toString())) diff --git a/src/test/java/me/desair/tus/server/ITRufhProtocol.java b/src/test/java/me/desair/tus/server/ITRufhProtocol.java index 0a675318..9f02cba0 100644 --- a/src/test/java/me/desair/tus/server/ITRufhProtocol.java +++ b/src/test/java/me/desair/tus/server/ITRufhProtocol.java @@ -28,8 +28,13 @@ public static void destroyDataFolder() throws IOException { @Override protected TusFileUploadService createTusFileUploadService() { + return createTusFileUploadService(UPLOAD_URI); + } + + @Override + protected TusFileUploadService createTusFileUploadService(String uploadUri) { return new TusFileUploadService() - .withUploadUri(UPLOAD_URI) + .withUploadUri(uploadUri) .withStoragePath(storagePath.toAbsolutePath().toString()) .withMaxUploadSize(1073741824L) .withUploadExpirationPeriod(2L * 24 * 60 * 60 * 1000) diff --git a/src/test/java/me/desair/tus/server/upload/azure/ITAzureBlobRufhProtocol.java b/src/test/java/me/desair/tus/server/upload/azure/ITAzureBlobRufhProtocol.java index 02a9d47d..bbf24407 100644 --- a/src/test/java/me/desair/tus/server/upload/azure/ITAzureBlobRufhProtocol.java +++ b/src/test/java/me/desair/tus/server/upload/azure/ITAzureBlobRufhProtocol.java @@ -40,6 +40,11 @@ public static void tearDownClass() { @Override protected TusFileUploadService createTusFileUploadService() { + return createTusFileUploadService(UPLOAD_URI); + } + + @Override + protected TusFileUploadService createTusFileUploadService(String uploadUri) { org.junit.Assume.assumeTrue(TestUtils.isContainerRuntimeAvailable()); AzureBlobStorageService azureStorage = new AzureBlobStorageService(containerClient); @@ -49,7 +54,7 @@ protected TusFileUploadService createTusFileUploadService() { azureStorage.setUploadConcatenationService(azureConcat); return new TusFileUploadService() - .withUploadUri(UPLOAD_URI) + .withUploadUri(uploadUri) .withUploadStorageService(azureStorage) .withUploadLockingService(azureLocking) .withMaxUploadSize(1073741824L) diff --git a/src/test/java/me/desair/tus/server/upload/s3/ITS3RufhProtocol.java b/src/test/java/me/desair/tus/server/upload/s3/ITS3RufhProtocol.java index a064290b..f281926c 100644 --- a/src/test/java/me/desair/tus/server/upload/s3/ITS3RufhProtocol.java +++ b/src/test/java/me/desair/tus/server/upload/s3/ITS3RufhProtocol.java @@ -40,6 +40,11 @@ public static void tearDownClass() { @Override protected TusFileUploadService createTusFileUploadService() { + return createTusFileUploadService(UPLOAD_URI); + } + + @Override + protected TusFileUploadService createTusFileUploadService(String uploadUri) { org.junit.Assume.assumeTrue(TestUtils.isContainerRuntimeAvailable()); S3StorageService s3Storage = new S3StorageService(minioClient, BUCKET); @@ -48,7 +53,7 @@ protected TusFileUploadService createTusFileUploadService() { s3Storage.setUploadConcatenationService(s3Concat); return new TusFileUploadService() - .withUploadUri(UPLOAD_URI) + .withUploadUri(uploadUri) .withUploadStorageService(s3Storage) .withUploadLockingService(s3Locking) .withMaxUploadSize(1073741824L) diff --git a/src/test/java/me/desair/tus/server/util/UtilsTest.java b/src/test/java/me/desair/tus/server/util/UtilsTest.java index 25191cde..e8f96e1b 100644 --- a/src/test/java/me/desair/tus/server/util/UtilsTest.java +++ b/src/test/java/me/desair/tus/server/util/UtilsTest.java @@ -449,6 +449,7 @@ public void testExtractUriPath() { assertThat(Utils.extractUriPath("/api/files"), is("/api/files")); assertThat(Utils.extractUriPath("https://upload.example.com/api/files"), is("/api/files")); assertThat(Utils.extractUriPath("http://localhost:8080/files/upload"), is("/files/upload")); + assertThat(Utils.extractUriPath("https://test.example.com/uploads"), is("/uploads")); assertThat(Utils.extractUriPath("https://upload.example.com"), is("/")); assertThat(Utils.extractUriPath("https://upload.example.com/"), is("/")); assertThat(Utils.extractUriPath(null), is("/")); From 53d7b9e6ba6174e4caafe1f57923c91c57861783 Mon Sep 17 00:00:00 2001 From: Tom Desair Date: Wed, 19 Aug 2026 21:24:35 +0200 Subject: [PATCH 4/4] feat: Code review --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 952fe2bd..59690608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,11 @@ All notable changes to this project will be documented in this file. - **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. - **JSON Serialization**: Support storing `UploadInfo` objects as JSON files in the storage backend using `TusFileUploadService.withJsonSerialization(true)`. -- **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. ### 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.