diff --git a/CHANGELOG.md b/CHANGELOG.md index 5194859b..59690608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. 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..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; @@ -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. * @@ -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 + "/")); @@ -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(); } /** 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/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..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 @@ -211,4 +212,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..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 @@ -438,4 +405,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/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/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/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 a8806a62..e8f96e1b 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,31 @@ 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://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("/")); + 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("")); + assertThat(Utils.extractUriOrigin(null), is("")); + assertThat(Utils.extractUriOrigin(""), is("")); + } + @Test public void testGetUploadUriOnCreation() { me.desair.tus.server.upload.UploadInfo info = new me.desair.tus.server.upload.UploadInfo(); @@ -454,7 +479,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( @@ -462,17 +487,94 @@ public void testGetUploadUriOnCreation() { // With null request and storageService set assertThat(Utils.getUploadUriOnCreation(info, null, storageService), is("/api/files/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"); + + Utils.getUploadUriOnCreation(null, null, storageService); + } + + @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 + 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"); - // With null request and null storageService - assertThat(Utils.getUploadUriOnCreation(info, null, null), is("/test-id")); + 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")); - // With null uploadInfo - assertThat(Utils.getUploadUriOnCreation(null, null, null), is("/")); + // 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")); - // With uploadInfo having null id + // Absolute URL with regex path request not starting with / + when(request.getRequestURI()).thenReturn("users/42/files"); assertThat( - Utils.getUploadUriOnCreation(new me.desair.tus.server.upload.UploadInfo(), null, null), - is("/")); + 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 @@ -516,6 +618,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