diff --git a/AGENTS.md b/AGENTS.md index bcff153c..5e422ef3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,10 +13,13 @@ env -u GITHUB_TOKEN gh pr create --title "..." --body "..." ## Git Branching Strategy Any new feature, bugfix, or improvement must be developed in a separate branch that starts with either `feature/` or `bugfix/` and has a meaningful but short name (e.g., `feature/lock-contention-resolution` or `bugfix/fix-upload-timeout`). -## Releases +## Releases and Documentation + +### README.md +When a new feature is introduced, the `README.md` file must be updated with information on this new feature (e.g. configuration, usage). ### CHANGELOG.md -When adding new features or preparing a release, the `CHANGELOG.md` file must be updated to describe the new functionality added in this version. Use a release version header (e.g., `## [1.0.0-3.2]`) instead of `## [Unreleased]`. Derive this next release version from the SNAPSHOT version declared in the `pom.xml` file by removing the `-SNAPSHOT` suffix. Make sure to not add duplicate headers. +For any new feature, big improvements, or fixes, the `CHANGELOG.md` file must be updated to describe the changes added in this version. Use a release version header (e.g., `## [1.0.0-3.2]`) instead of `## [Unreleased]`. Derive this next release version from the SNAPSHOT version declared in the `pom.xml` file by removing the `-SNAPSHOT` suffix. Make sure to not add duplicate headers. ### Release Process When performing a release, please strictly follow the instructions outlined in the [docs/RELEASE.md](docs/RELEASE.md) documentation file. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f1a8197..fce48aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. +## [1.0.0-3.3] + +### Added +- **Creation-with-Upload Extension**: Implemented the optional `creation-with-upload` extension, allowing clients to combine creation and initial file data upload in a single `POST` request. +- **CORS Extension**: Implemented native, out-of-the-box CORS support as an unofficial extension (`cors`) enabled by default. For backward compatibility, it can be disabled via `disableTusExtension("cors")`. + +### Changed +- **Stricter Protocol Validation**: + - Prevent modifying `Upload-Length` headers in subsequent `PATCH` requests. + - Enforced format and Base64 validations for `Upload-Metadata` headers in `POST` requests. + - Enforced that `Upload-Defer-Length` header values must be strictly `"1"`. + - Reject malformed or invalid `Upload-Checksum` headers instead of silently ignoring them. + - Enabled checksum verification on `POST` requests when using the `creation-with-upload` extension. + +### Fixes + - Only unfinished uploads can expire. + - Fix for deduplication feature when base64-encoded checksum contains a slash. + ## [1.0.0-3.2] ### Added diff --git a/README.md b/README.md index 889d6a5a..d7109a7c 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,11 @@ The main entry point of the library is the `me.desair.tus.server.TusFileUploadSe * (more examples to come!) ## Tus Protocol Extensions -Besides the [core protocol](https://tus.io/protocols/resumable-upload.html#core-protocol), the library has all optional tus protocol extensions enabled by default. This means that the `Tus-Extension` header has value `creation,creation-defer-length,checksum,checksum-trailer,termination,expiration,concatenation,concatenation-unfinished`. Optionally you can also enable an unofficial `download` extension (see [configuration section](#usage-and-configuration)). +Besides the [core protocol](https://tus.io/protocols/resumable-upload.html#core-protocol), the library has all optional tus protocol extensions enabled by default. This means that the `Tus-Extension` header has value `creation,creation-defer-length,creation-with-upload,checksum,checksum-trailer,termination,expiration,concatenation,concatenation-unfinished`. Optionally you can also enable an unofficial `download` extension (see [configuration section](#usage-and-configuration)). * [creation](https://tus.io/protocols/resumable-upload.html#creation): The creation extension allows you to create new uploads and to retrieve the upload URL for them. * [creation-defer-length](https://tus.io/protocols/resumable-upload.html#post): You can create a new upload even if you don't know its final length at the time of creation. +* [creation-with-upload](https://tus.io/protocols/resumable-upload.html#creation): The creation-with-upload extension allows you to create the upload resource and upload initial file data in a single POST request. * [checksum](https://tus.io/protocols/resumable-upload.html#checksum): An extension that allows you to verify data integrity of each upload (PATCH) request. * [checksum-trailer](https://tus.io/protocols/resumable-upload.html#checksum): If the checksum hash cannot be calculated at the beginning of the upload, it may be included as a trailer HTTP header at the end of the chunked HTTP request. * [termination](https://tus.io/protocols/resumable-upload.html#termination): Clients can terminate completed or in-progress uploads which allows the tus-java-server library to free up resources on the server. @@ -36,6 +37,7 @@ Besides the [core protocol](https://tus.io/protocols/resumable-upload.html#core- * [concatenation](https://tus.io/protocols/resumable-upload.html#concatenation): This extension can be used to concatenate multiple uploads into a single final upload enabling clients to perform parallel uploads and to upload non-contiguous chunks. * [concatenation-unfinished](https://tus.io/protocols/resumable-upload.html#concatenation): The client is allowed send the request to concatenate partial uploads while these partial uploads are still in progress. * `download`: The (unofficial) download extension allows clients to download uploaded files using a HTTP `GET` request. You can enable this extension by calling the `withDownloadFeature()` method. +* `cors`: The (unofficial) CORS extension adds native CORS support out-of-the-box, setting CORS headers for all requests and responses, and handling preflight `OPTIONS` requests automatically. It is enabled by default. ## Usage and Configuration @@ -52,7 +54,7 @@ The first step is to create a `TusFileUploadService` object using its constructo * `withUploadDeduplication(Boolean)`: Enable duplicate file processing based on the checksum hash. If enabled, the server will scan previous completed uploads for a file with the same checksum. If a duplicate is found, the new upload will link to the existing file (`duplicatesUploadId`), skipping redundant disk storage writes and saving disk space. * **Disclaimer**: If duplicate file processing is enabled, the duplicate (child) upload depends directly on the original (parent) upload file. If the original parent upload is deleted or terminated, any duplicate child uploads pointing to it will no longer be downloadable (returning `404 Not Found`). * `addTusExtension(TusExtension)`: Add a custom (application-specific) extension that implements the `me.desair.tus.server.TusExtension` interface. For example you can add your own extension that checks authentication and authorization policies within your application for the user doing the upload. -* `disableTusExtension(String)`: Disable the `TusExtension` for which the `getName()` method matches the provided string. The default extensions have names "creation", "checksum", "expiration", "concatenation", "termination" and "download". You cannot disable the "core" feature. +* `disableTusExtension(String)`: Disable the `TusExtension` for which the `getName()` method matches the provided string. The default extensions have names "creation", "creation-with-upload", "checksum", "expiration", "concatenation", "termination", "download" and "cors". You cannot disable the "core" feature. * `withUploadIdFactory(UploadIdFactory)`: Provide a custom `UploadIdFactory` implementation that should be used to generate identifiers for the different uploads. The default implementation generates identifiers using a UUID (`UuidUploadIdFactory`). Another example implementation of a custom ID factory is the system-time based `TimeBasedUploadIdFactory` class. diff --git a/src/main/java/me/desair/tus/server/TusFileUploadService.java b/src/main/java/me/desair/tus/server/TusFileUploadService.java index fefdd9de..3a78004e 100644 --- a/src/main/java/me/desair/tus/server/TusFileUploadService.java +++ b/src/main/java/me/desair/tus/server/TusFileUploadService.java @@ -13,7 +13,9 @@ import me.desair.tus.server.checksum.ChecksumExtension; import me.desair.tus.server.concatenation.ConcatenationExtension; import me.desair.tus.server.core.CoreProtocol; +import me.desair.tus.server.cors.CorsExtension; import me.desair.tus.server.creation.CreationExtension; +import me.desair.tus.server.creationwithupload.CreationWithUploadExtension; import me.desair.tus.server.download.DownloadExtension; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.expiration.ExpirationExtension; @@ -29,6 +31,7 @@ import me.desair.tus.server.upload.disk.DiskStorageService; import me.desair.tus.server.util.TusServletRequest; import me.desair.tus.server.util.TusServletResponse; +import me.desair.tus.server.util.Utils; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.Strings; import org.apache.commons.lang3.Validate; @@ -61,11 +64,14 @@ public TusFileUploadService() { protected void initFeatures() { // The order of the features is important addTusExtension(new CoreProtocol()); - addTusExtension(new CreationExtension()); + CreationExtension creationExtension = new CreationExtension(); + addTusExtension(creationExtension); + addTusExtension(new CreationWithUploadExtension(creationExtension)); addTusExtension(new ChecksumExtension()); addTusExtension(new TerminationExtension()); addTusExtension(new ExpirationExtension()); addTusExtension(new ConcatenationExtension()); + addTusExtension(new CorsExtension()); } /** @@ -258,6 +264,14 @@ public TusFileUploadService disableTusExtension(String extensionName) { enabledFeatures.remove(extensionName); updateSupportedHttpMethods(); + + if (Strings.CS.equals("creation-with-upload", extensionName)) { + TusExtension creation = enabledFeatures.get("creation"); + if (creation instanceof CreationExtension) { + ((CreationExtension) creation).setCreationWithUploadEnabled(false); + } + } + return this; } @@ -520,7 +534,8 @@ protected void processTusException( // Since an error occurred, the bytes we have written are probably not valid. So remove // them. - UploadInfo uploadInfo = uploadStorageService.getUploadInfo(request.getRequestURI(), ownerKey); + UploadInfo uploadInfo = + uploadStorageService.getUploadInfo(Utils.getUploadURI(request, response), ownerKey); uploadStorageService.removeLastNumberOfBytes(uploadInfo, request.getBytesRead()); } catch (TusException ex) { diff --git a/src/main/java/me/desair/tus/server/checksum/ChecksumExtension.java b/src/main/java/me/desair/tus/server/checksum/ChecksumExtension.java index 699a4a83..94ea2739 100644 --- a/src/main/java/me/desair/tus/server/checksum/ChecksumExtension.java +++ b/src/main/java/me/desair/tus/server/checksum/ChecksumExtension.java @@ -22,7 +22,7 @@ public String getName() { @Override public Collection getMinimalSupportedHttpMethods() { - return Arrays.asList(HttpMethod.OPTIONS, HttpMethod.PATCH); + return Arrays.asList(HttpMethod.OPTIONS, HttpMethod.PATCH, HttpMethod.POST); } @Override @@ -33,6 +33,6 @@ protected void initValidators(List requestValidators) { @Override protected void initRequestHandlers(List requestHandlers) { requestHandlers.add(new ChecksumOptionsRequestHandler()); - requestHandlers.add(new ChecksumPatchRequestHandler()); + requestHandlers.add(new ChecksumRequestHandler()); } } diff --git a/src/main/java/me/desair/tus/server/checksum/ChecksumPatchRequestHandler.java b/src/main/java/me/desair/tus/server/checksum/ChecksumRequestHandler.java similarity index 80% rename from src/main/java/me/desair/tus/server/checksum/ChecksumPatchRequestHandler.java rename to src/main/java/me/desair/tus/server/checksum/ChecksumRequestHandler.java index d4e231e4..2ceb4c54 100644 --- a/src/main/java/me/desair/tus/server/checksum/ChecksumPatchRequestHandler.java +++ b/src/main/java/me/desair/tus/server/checksum/ChecksumRequestHandler.java @@ -15,11 +15,12 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.Strings; -public class ChecksumPatchRequestHandler extends AbstractRequestHandler { +/** Request handler that verifies the checksum of the uploaded data. */ +public class ChecksumRequestHandler extends AbstractRequestHandler { @Override public boolean supports(HttpMethod method) { - return HttpMethod.PATCH.equals(method); + return HttpMethod.PATCH.equals(method) || HttpMethod.POST.equals(method); } @Override @@ -35,10 +36,8 @@ public void process( if (servletRequest.hasCalculatedChecksum() && StringUtils.isNotBlank(uploadChecksumHeader)) { - // The Upload-Checksum header can be a trailing header which is only present after - // reading the - // full content. - // Therefor we need to revalidate that header here + // The Upload-Checksum header can be a trailing header which is only present after reading + // the full content. Therefore we need to revalidate that header here. new ChecksumAlgorithmValidator() .validate(method, servletRequest, uploadStorageService, ownerKey); @@ -49,9 +48,8 @@ public void process( String calculatedValue = servletRequest.getCalculatedChecksum(checksumAlgorithm); if (!Strings.CS.equals(expectedValue, calculatedValue)) { - // throw an exception if the checksum is invalid. This will also trigger the removal - // of any - // bytes that were already saved + // Throw an exception if the checksum is invalid. This will also trigger the removal of + // any bytes that were already saved. throw new UploadChecksumMismatchException( "Expected checksum " + expectedValue @@ -61,7 +59,8 @@ public void process( + checksumAlgorithm); } else if (uploadStorageService.isUploadDeduplicationEnabled()) { UploadInfo uploadInfo = - uploadStorageService.getUploadInfo(servletRequest.getRequestURI(), ownerKey); + uploadStorageService.getUploadInfo( + Utils.getUploadURI(servletRequest, servletResponse), ownerKey); if (uploadInfo != null && !uploadInfo.isUploadInProgress() && uploadInfo.getDuplicatesUploadId() == null) { diff --git a/src/main/java/me/desair/tus/server/checksum/validation/ChecksumAlgorithmValidator.java b/src/main/java/me/desair/tus/server/checksum/validation/ChecksumAlgorithmValidator.java index d1f93cbe..e25ef16b 100644 --- a/src/main/java/me/desair/tus/server/checksum/validation/ChecksumAlgorithmValidator.java +++ b/src/main/java/me/desair/tus/server/checksum/validation/ChecksumAlgorithmValidator.java @@ -1,6 +1,7 @@ package me.desair.tus.server.checksum.validation; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; @@ -9,11 +10,12 @@ import me.desair.tus.server.exception.ChecksumAlgorithmNotSupportedException; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.Utils; import org.apache.commons.lang3.StringUtils; /** * The Server MAY respond with one of the following status code: 400 Bad Request if the checksum - * algorithm is not supported by the server + * algorithm is not supported by the server or if the checksum header is malformed */ public class ChecksumAlgorithmValidator implements RequestValidator { @@ -27,21 +29,27 @@ public void validate( String uploadChecksum = request.getHeader(HttpHeader.UPLOAD_CHECKSUM); - // If the client provided a checksum header, check that we support the algorithm - if (StringUtils.isNotBlank(uploadChecksum) - && ChecksumAlgorithm.forUploadChecksumHeader(uploadChecksum) == null) { + if (StringUtils.isNotBlank(uploadChecksum)) { + // Check that we support the algorithm + if (ChecksumAlgorithm.forUploadChecksumHeader(uploadChecksum) == null) { + throw new ChecksumAlgorithmNotSupportedException( + "The " + + HttpHeader.UPLOAD_CHECKSUM + + " header value " + + uploadChecksum + + " is not supported"); + } - throw new ChecksumAlgorithmNotSupportedException( - "The " - + HttpHeader.UPLOAD_CHECKSUM - + " header value " - + uploadChecksum - + " is not supported"); + // Check that the header is not malformed + if (Utils.parseUploadChecksumHeader(request) == null) { + throw new TusException( + HttpServletResponse.SC_BAD_REQUEST, "The Upload-Checksum header is malformed"); + } } } @Override public boolean supports(HttpMethod method) { - return HttpMethod.PATCH.equals(method); + return HttpMethod.PATCH.equals(method) || HttpMethod.POST.equals(method); } } diff --git a/src/main/java/me/desair/tus/server/core/CoreDefaultResponseHeadersHandler.java b/src/main/java/me/desair/tus/server/core/CoreDefaultResponseHeadersHandler.java index 1e19a73a..108713be 100644 --- a/src/main/java/me/desair/tus/server/core/CoreDefaultResponseHeadersHandler.java +++ b/src/main/java/me/desair/tus/server/core/CoreDefaultResponseHeadersHandler.java @@ -31,6 +31,13 @@ public void process( servletResponse.setHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); // By default, set the Content-Length to 0 servletResponse.setHeader(HttpHeader.CONTENT_LENGTH, "0"); + + String requestVersion = servletRequest.getHeader(HttpHeader.TUS_RESUMABLE); + if (requestVersion != null + && !org.apache.commons.lang3.Strings.CS.equals( + requestVersion, TusFileUploadService.TUS_API_VERSION)) { + servletResponse.setHeader(HttpHeader.TUS_VERSION, TusFileUploadService.TUS_API_VERSION); + } } @Override diff --git a/src/main/java/me/desair/tus/server/cors/CorsExtension.java b/src/main/java/me/desair/tus/server/cors/CorsExtension.java new file mode 100644 index 00000000..96206f2f --- /dev/null +++ b/src/main/java/me/desair/tus/server/cors/CorsExtension.java @@ -0,0 +1,32 @@ +package me.desair.tus.server.cors; + +import java.util.Collection; +import java.util.List; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.RequestHandler; +import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.util.AbstractTusExtension; + +/** Standalone unofficial extension to add native CORS support out-of-the-box. */ +public class CorsExtension extends AbstractTusExtension { + + @Override + public String getName() { + return "cors"; + } + + @Override + public Collection getMinimalSupportedHttpMethods() { + return java.util.Collections.emptyList(); + } + + @Override + protected void initValidators(List requestValidators) { + // No validators for CORS extension + } + + @Override + protected void initRequestHandlers(List requestHandlers) { + requestHandlers.add(new CorsRequestHandler()); + } +} diff --git a/src/main/java/me/desair/tus/server/cors/CorsRequestHandler.java b/src/main/java/me/desair/tus/server/cors/CorsRequestHandler.java new file mode 100644 index 00000000..5bf72034 --- /dev/null +++ b/src/main/java/me/desair/tus/server/cors/CorsRequestHandler.java @@ -0,0 +1,51 @@ +package me.desair.tus.server.cors; + +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.RequestHandler; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.apache.commons.lang3.StringUtils; + +public class CorsRequestHandler implements RequestHandler { + + @Override + public boolean supports(HttpMethod method) { + return true; + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + String ownerKey) { + + String origin = servletRequest.getHeader("Origin"); + if (StringUtils.isNotBlank(origin)) { + servletResponse.setHeader("Access-Control-Allow-Origin", origin); + servletResponse.setHeader( + "Access-Control-Expose-Headers", + "Upload-Offset, Upload-Length, Upload-Metadata, Upload-Expires, Upload-Concat, " + + "Tus-Resumable, Tus-Version, Tus-Max-Size, Tus-Extension, Tus-Checksum-Algorithm, Location"); + + // Check if it's a preflight request + String accessControlRequestMethod = servletRequest.getHeader("Access-Control-Request-Method"); + if (HttpMethod.OPTIONS.equals(method) && StringUtils.isNotBlank(accessControlRequestMethod)) { + servletResponse.setHeader( + "Access-Control-Allow-Methods", "POST, GET, HEAD, PATCH, DELETE, OPTIONS"); + servletResponse.setHeader( + "Access-Control-Allow-Headers", + "Origin, X-Requested-With, Content-Type, Upload-Length, Upload-Offset, Upload-Metadata, " + + "Upload-Expires, Upload-Checksum, Upload-Concat, Upload-Defer-Length, Tus-Resumable, X-HTTP-Method-Override"); + servletResponse.setHeader("Access-Control-Max-Age", "86400"); + } + } + } + + @Override + public boolean isErrorHandler() { + return true; + } +} diff --git a/src/main/java/me/desair/tus/server/creation/CreationExtension.java b/src/main/java/me/desair/tus/server/creation/CreationExtension.java index 07aa4f1f..ed14d303 100644 --- a/src/main/java/me/desair/tus/server/creation/CreationExtension.java +++ b/src/main/java/me/desair/tus/server/creation/CreationExtension.java @@ -6,10 +6,12 @@ import me.desair.tus.server.HttpMethod; import me.desair.tus.server.RequestHandler; import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.creation.validation.PatchUploadLengthValidator; import me.desair.tus.server.creation.validation.PostEmptyRequestValidator; import me.desair.tus.server.creation.validation.PostUriValidator; import me.desair.tus.server.creation.validation.UploadDeferLengthValidator; import me.desair.tus.server.creation.validation.UploadLengthValidator; +import me.desair.tus.server.creation.validation.UploadMetadataValidator; import me.desair.tus.server.util.AbstractTusExtension; /** @@ -18,6 +20,8 @@ */ public class CreationExtension extends AbstractTusExtension { + private PostEmptyRequestValidator postEmptyRequestValidator; + @Override public String getName() { return "creation"; @@ -31,9 +35,12 @@ public Collection getMinimalSupportedHttpMethods() { @Override protected void initValidators(List requestValidators) { requestValidators.add(new PostUriValidator()); - requestValidators.add(new PostEmptyRequestValidator()); + postEmptyRequestValidator = new PostEmptyRequestValidator(); + requestValidators.add(postEmptyRequestValidator); requestValidators.add(new UploadDeferLengthValidator()); requestValidators.add(new UploadLengthValidator()); + requestValidators.add(new PatchUploadLengthValidator()); + requestValidators.add(new UploadMetadataValidator()); } @Override @@ -43,4 +50,8 @@ protected void initRequestHandlers(List requestHandlers) { requestHandlers.add(new CreationPostRequestHandler()); requestHandlers.add(new CreationOptionsRequestHandler()); } + + public void setCreationWithUploadEnabled(boolean enabled) { + postEmptyRequestValidator.setCreationWithUploadEnabled(enabled); + } } diff --git a/src/main/java/me/desair/tus/server/creation/validation/PatchUploadLengthValidator.java b/src/main/java/me/desair/tus/server/creation/validation/PatchUploadLengthValidator.java new file mode 100644 index 00000000..d373c499 --- /dev/null +++ b/src/main/java/me/desair/tus/server/creation/validation/PatchUploadLengthValidator.java @@ -0,0 +1,62 @@ +package me.desair.tus.server.creation.validation; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.exception.InvalidUploadLengthException; +import me.desair.tus.server.exception.MaxUploadLengthExceededException; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.Utils; + +/** + * 20: * Validator that ensures Upload-Length sent in PATCH requests does not modify a previously + * set 21: * length and does not exceed max upload size if it was deferred. 22: + */ +public class PatchUploadLengthValidator implements RequestValidator { + + @Override + public void validate( + HttpMethod method, + HttpServletRequest request, + UploadStorageService uploadStorageService, + String ownerKey) + throws TusException, IOException { + + Long uploadLength = Utils.getLongHeader(request, HttpHeader.UPLOAD_LENGTH); + if (uploadLength != null) { + if (uploadLength < 0) { + throw new InvalidUploadLengthException( + "The " + HttpHeader.UPLOAD_LENGTH + " value must be non-negative"); + } + + UploadInfo uploadInfo = uploadStorageService.getUploadInfo(request.getRequestURI(), ownerKey); + if (uploadInfo != null) { + if (uploadInfo.hasLength()) { + if (!uploadInfo.getLength().equals(uploadLength)) { + throw new InvalidUploadLengthException( + "The Upload-Length cannot be modified once set."); + } + } else { + // Verify max upload size + if (uploadStorageService.getMaxUploadSize() > 0 + && uploadLength > uploadStorageService.getMaxUploadSize()) { + throw new MaxUploadLengthExceededException( + "The Upload-Length " + + uploadLength + + " exceeds the maximum allowed size of " + + uploadStorageService.getMaxUploadSize()); + } + } + } + } + } + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.PATCH.equals(method); + } +} diff --git a/src/main/java/me/desair/tus/server/creation/validation/PostEmptyRequestValidator.java b/src/main/java/me/desair/tus/server/creation/validation/PostEmptyRequestValidator.java index 12c860ab..b964bf9d 100644 --- a/src/main/java/me/desair/tus/server/creation/validation/PostEmptyRequestValidator.java +++ b/src/main/java/me/desair/tus/server/creation/validation/PostEmptyRequestValidator.java @@ -12,6 +12,12 @@ /** An empty POST request is used to create a new upload resource. */ public class PostEmptyRequestValidator implements RequestValidator { + private boolean creationWithUploadEnabled = false; + + public void setCreationWithUploadEnabled(boolean enabled) { + this.creationWithUploadEnabled = enabled; + } + @Override public void validate( HttpMethod method, @@ -20,10 +26,12 @@ public void validate( String ownerKey) throws TusException { - Long contentLength = Utils.getLongHeader(request, HttpHeader.CONTENT_LENGTH); - if (contentLength != null && contentLength > 0) { - throw new InvalidContentLengthException( - "A POST request should have a Content-Length header with value " + "0 and no content"); + if (!creationWithUploadEnabled) { + Long contentLength = Utils.getLongHeader(request, HttpHeader.CONTENT_LENGTH); + if (contentLength != null && contentLength > 0) { + throw new InvalidContentLengthException( + "A POST request should have a Content-Length header with value " + "0 and no content"); + } } } diff --git a/src/main/java/me/desair/tus/server/creation/validation/UploadDeferLengthValidator.java b/src/main/java/me/desair/tus/server/creation/validation/UploadDeferLengthValidator.java index 4812367a..ba3c615b 100644 --- a/src/main/java/me/desair/tus/server/creation/validation/UploadDeferLengthValidator.java +++ b/src/main/java/me/desair/tus/server/creation/validation/UploadDeferLengthValidator.java @@ -33,7 +33,12 @@ public void validate( uploadLength = true; } - if (Utils.getHeader(request, HttpHeader.UPLOAD_DEFER_LENGTH).equals("1")) { + String deferLengthHeader = request.getHeader(HttpHeader.UPLOAD_DEFER_LENGTH); + if (StringUtils.isNotBlank(deferLengthHeader)) { + if (!"1".equals(deferLengthHeader)) { + throw new InvalidUploadLengthException( + "The " + HttpHeader.UPLOAD_DEFER_LENGTH + " header value must be 1"); + } deferredLength = true; } diff --git a/src/main/java/me/desair/tus/server/creation/validation/UploadMetadataValidator.java b/src/main/java/me/desair/tus/server/creation/validation/UploadMetadataValidator.java new file mode 100644 index 00000000..e6cf5f56 --- /dev/null +++ b/src/main/java/me/desair/tus/server/creation/validation/UploadMetadataValidator.java @@ -0,0 +1,67 @@ +package me.desair.tus.server.creation.validation; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.Utils; +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.lang3.StringUtils; + +/** + * Validator that ensures the Upload-Metadata header adheres to formatting guidelines and has valid + * Base64 values. + */ +public class UploadMetadataValidator implements RequestValidator { + + @Override + public void validate( + HttpMethod method, + HttpServletRequest request, + UploadStorageService uploadStorageService, + String ownerKey) + throws TusException { + + String metadata = Utils.getHeader(request, HttpHeader.UPLOAD_METADATA); + if (StringUtils.isNotBlank(metadata)) { + String[] pairs = metadata.split(","); + for (String pair : pairs) { + pair = pair.trim(); + if (StringUtils.isBlank(pair)) { + throw new TusException( + HttpServletResponse.SC_BAD_REQUEST, "Upload-Metadata cannot contain empty pairs"); + } + + String[] keyValue = pair.split(" "); + if (keyValue.length > 2) { + throw new TusException( + HttpServletResponse.SC_BAD_REQUEST, + "Upload-Metadata key-value pairs must be separated by a single space"); + } + + String key = keyValue[0]; + if (StringUtils.isBlank(key)) { + throw new TusException( + HttpServletResponse.SC_BAD_REQUEST, "Upload-Metadata key cannot be empty"); + } + + if (keyValue.length == 2) { + String value = keyValue[1]; + if (!Base64.isBase64(value)) { + throw new TusException( + HttpServletResponse.SC_BAD_REQUEST, + "Upload-Metadata value must be a valid Base64 encoded string"); + } + } + } + } + } + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.POST.equals(method); + } +} diff --git a/src/main/java/me/desair/tus/server/creationwithupload/CreationWithUploadExtension.java b/src/main/java/me/desair/tus/server/creationwithupload/CreationWithUploadExtension.java new file mode 100644 index 00000000..b30e6104 --- /dev/null +++ b/src/main/java/me/desair/tus/server/creationwithupload/CreationWithUploadExtension.java @@ -0,0 +1,47 @@ +package me.desair.tus.server.creationwithupload; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.RequestHandler; +import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.creation.CreationExtension; +import me.desair.tus.server.creationwithupload.validation.PostContentTypeValidator; +import me.desair.tus.server.creationwithupload.validation.UploadContentLengthValidator; +import me.desair.tus.server.util.AbstractTusExtension; + +/** + * The Client and the Server SHOULD implement the creation-with-upload extension. This extension + * allows combining upload creation and sending initial payload in one request. + */ +public class CreationWithUploadExtension extends AbstractTusExtension { + + public CreationWithUploadExtension(CreationExtension creationExtension) { + Objects.requireNonNull(creationExtension, "CreationExtension cannot be null"); + creationExtension.setCreationWithUploadEnabled(true); + } + + @Override + public String getName() { + return "creation-with-upload"; + } + + @Override + public Collection getMinimalSupportedHttpMethods() { + return Arrays.asList(HttpMethod.OPTIONS, HttpMethod.POST); + } + + @Override + protected void initValidators(List requestValidators) { + requestValidators.add(new PostContentTypeValidator()); + requestValidators.add(new UploadContentLengthValidator()); + } + + @Override + protected void initRequestHandlers(List requestHandlers) { + requestHandlers.add(new CreationWithUploadOptionsRequestHandler()); + requestHandlers.add(new CreationWithUploadPostRequestHandler()); + } +} diff --git a/src/main/java/me/desair/tus/server/creationwithupload/CreationWithUploadOptionsRequestHandler.java b/src/main/java/me/desair/tus/server/creationwithupload/CreationWithUploadOptionsRequestHandler.java new file mode 100644 index 00000000..57cc8a41 --- /dev/null +++ b/src/main/java/me/desair/tus/server/creationwithupload/CreationWithUploadOptionsRequestHandler.java @@ -0,0 +1,12 @@ +package me.desair.tus.server.creationwithupload; + +import me.desair.tus.server.util.AbstractExtensionRequestHandler; + +/** The options request handler for the creation-with-upload extension. */ +public class CreationWithUploadOptionsRequestHandler extends AbstractExtensionRequestHandler { + + @Override + protected void appendExtensions(StringBuilder extensionBuilder) { + addExtension(extensionBuilder, "creation-with-upload"); + } +} diff --git a/src/main/java/me/desair/tus/server/creationwithupload/CreationWithUploadPostRequestHandler.java b/src/main/java/me/desair/tus/server/creationwithupload/CreationWithUploadPostRequestHandler.java new file mode 100644 index 00000000..16317cf8 --- /dev/null +++ b/src/main/java/me/desair/tus/server/creationwithupload/CreationWithUploadPostRequestHandler.java @@ -0,0 +1,63 @@ +package me.desair.tus.server.creationwithupload; + +import java.io.IOException; +import java.io.InputStream; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadLockingService; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.AbstractRequestHandler; +import me.desair.tus.server.util.InterruptibleInputStream; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import me.desair.tus.server.util.Utils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Request handler to process the POST request body for the creation-with-upload extension. */ +public class CreationWithUploadPostRequestHandler extends AbstractRequestHandler { + + private static final Logger log = + LoggerFactory.getLogger(CreationWithUploadPostRequestHandler.class); + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.POST.equals(method); + } + + @Override + public void process( + HttpMethod method, + TusServletRequest servletRequest, + TusServletResponse servletResponse, + UploadStorageService uploadStorageService, + String ownerKey) + throws IOException, TusException { + + Long contentLength = Utils.getLongHeader(servletRequest, HttpHeader.CONTENT_LENGTH); + if (contentLength != null && contentLength > 0) { + String location = servletResponse.getHeader(HttpHeader.LOCATION); + if (location != null) { + UploadInfo uploadInfo = uploadStorageService.getUploadInfo(location, ownerKey); + if (uploadInfo != null && uploadInfo.isUploadInProgress()) { + InputStream stream = servletRequest.getContentInputStream(); + UploadLockingService lockingService = + (UploadLockingService) + servletRequest.getAttribute("me.desair.tus.uploadLockingService"); + if (lockingService != null) { + InterruptibleInputStream interruptibleStream = new InterruptibleInputStream(stream); + lockingService.registerInputStream(location, interruptibleStream); + stream = interruptibleStream; + } + + uploadInfo = uploadStorageService.append(uploadInfo, stream); + + servletResponse.setHeader( + HttpHeader.UPLOAD_OFFSET, String.valueOf(uploadInfo.getOffset())); + } + } + } + } +} diff --git a/src/main/java/me/desair/tus/server/creationwithupload/validation/PostContentTypeValidator.java b/src/main/java/me/desair/tus/server/creationwithupload/validation/PostContentTypeValidator.java new file mode 100644 index 00000000..77fcde47 --- /dev/null +++ b/src/main/java/me/desair/tus/server/creationwithupload/validation/PostContentTypeValidator.java @@ -0,0 +1,46 @@ +package me.desair.tus.server.creationwithupload.validation; + +import jakarta.servlet.http.HttpServletRequest; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.exception.InvalidContentTypeException; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.Utils; +import org.apache.commons.lang3.Strings; + +/** + * Validator that checks that if Content-Length is greater than zero on a POST request, the + * Content-Type must be application/offset+octet-stream. + */ +public class PostContentTypeValidator implements RequestValidator { + + private static final String APPLICATION_OFFSET_OCTET_STREAM = "application/offset+octet-stream"; + + @Override + public void validate( + HttpMethod method, + HttpServletRequest request, + UploadStorageService uploadStorageService, + String ownerKey) + throws TusException { + + Long contentLength = Utils.getLongHeader(request, HttpHeader.CONTENT_LENGTH); + if (contentLength != null && contentLength > 0) { + String contentType = Utils.getHeader(request, HttpHeader.CONTENT_TYPE); + if (!Strings.CS.equals(APPLICATION_OFFSET_OCTET_STREAM, contentType)) { + throw new InvalidContentTypeException( + "The " + + HttpHeader.CONTENT_TYPE + + " header must contain value " + + APPLICATION_OFFSET_OCTET_STREAM); + } + } + } + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.POST.equals(method); + } +} diff --git a/src/main/java/me/desair/tus/server/creationwithupload/validation/UploadContentLengthValidator.java b/src/main/java/me/desair/tus/server/creationwithupload/validation/UploadContentLengthValidator.java new file mode 100644 index 00000000..ee1c64d3 --- /dev/null +++ b/src/main/java/me/desair/tus/server/creationwithupload/validation/UploadContentLengthValidator.java @@ -0,0 +1,46 @@ +package me.desair.tus.server.creationwithupload.validation; + +import jakarta.servlet.http.HttpServletRequest; +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.exception.InvalidContentLengthException; +import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.Utils; + +/** + * Validator that checks that the provided Content-Length does not exceed the announced + * Upload-Length. + */ +public class UploadContentLengthValidator implements RequestValidator { + + @Override + public void validate( + HttpMethod method, + HttpServletRequest request, + UploadStorageService uploadStorageService, + String ownerKey) + throws TusException { + + Long contentLength = Utils.getLongHeader(request, HttpHeader.CONTENT_LENGTH); + Long uploadLength = Utils.getLongHeader(request, HttpHeader.UPLOAD_LENGTH); + + if (contentLength != null && contentLength > 0 && uploadLength != null) { + if (contentLength > uploadLength) { + throw new InvalidContentLengthException( + "The " + + HttpHeader.CONTENT_LENGTH + + " value " + + contentLength + + " exceeds the declared upload length " + + uploadLength); + } + } + } + + @Override + public boolean supports(HttpMethod method) { + return HttpMethod.POST.equals(method); + } +} diff --git a/src/main/java/me/desair/tus/server/upload/disk/ExpiredUploadFilter.java b/src/main/java/me/desair/tus/server/upload/disk/ExpiredUploadFilter.java index 922e4d9a..93821f9a 100644 --- a/src/main/java/me/desair/tus/server/upload/disk/ExpiredUploadFilter.java +++ b/src/main/java/me/desair/tus/server/upload/disk/ExpiredUploadFilter.java @@ -31,7 +31,10 @@ public boolean accept(Path upload) throws IOException { id = new UploadId(upload.getFileName().toString()); UploadInfo info = diskStorageService.getUploadInfo(id); - if (info != null && info.isExpired() && !uploadLockingService.isLocked(id)) { + if (info != null + && info.isUploadInProgress() + && info.isExpired() + && !uploadLockingService.isLocked(id)) { return true; } 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 2fa35413..4044712c 100644 --- a/src/main/java/me/desair/tus/server/util/Utils.java +++ b/src/main/java/me/desair/tus/server/util/Utils.java @@ -6,6 +6,7 @@ import static java.nio.file.StandardOpenOption.WRITE; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import java.io.BufferedOutputStream; import java.io.IOException; import java.io.ObjectOutput; @@ -199,4 +200,9 @@ public static ChecksumInfo parseUploadChecksumHeader(HttpServletRequest request) } return null; } + + public static String getUploadURI(HttpServletRequest request, HttpServletResponse response) { + String location = response.getHeader(HttpHeader.LOCATION); + return StringUtils.isNotBlank(location) ? location : request.getRequestURI(); + } } diff --git a/src/test/java/me/desair/tus/server/ITTusFileUploadService.java b/src/test/java/me/desair/tus/server/ITTusFileUploadService.java index 658f527f..b9e84ab8 100644 --- a/src/test/java/me/desair/tus/server/ITTusFileUploadService.java +++ b/src/test/java/me/desair/tus/server/ITTusFileUploadService.java @@ -102,11 +102,13 @@ public void testSupportedHttpMethods() { containsInAnyOrder( "core", "creation", + "creation-with-upload", "checksum", "termination", "download", "expiration", - "concatenation")); + "concatenation", + "cors")); } @Test @@ -120,7 +122,14 @@ public void testDisableFeature() throws Exception { assertThat( tusFileUploadService.getEnabledFeatures(), - containsInAnyOrder("core", "creation", "checksum", "expiration", "concatenation")); + containsInAnyOrder( + "core", + "creation", + "creation-with-upload", + "checksum", + "expiration", + "concatenation", + "cors")); reset(); servletRequest.setMethod("GET"); @@ -1389,6 +1398,7 @@ public void testOptions() throws Exception { HttpHeader.TUS_EXTENSION, "creation", "creation-defer-length", + "creation-with-upload", "checksum", "checksum-trailer", "termination", @@ -1721,6 +1731,270 @@ public void run() { bgThread.join(2000); } + @Test + public void testCreationWithUploadOptions() throws Exception { + reset(); + servletRequest.setMethod("OPTIONS"); + servletRequest.setRequestURI(UPLOAD_URI); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseHeader( + HttpHeader.TUS_EXTENSION, + "creation", + "creation-defer-length", + "creation-with-upload", + "checksum", + "checksum-trailer", + "termination", + "download", + "expiration", + "concatenation", + "concatenation-unfinished"); + } + + @Test + public void testCreationWithUploadSuccess() throws Exception { + String uploadContent = "Initial data to upload"; + byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8); + + reset(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length); + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.setContent(contentBytes); + + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertResponseHeaderNotBlank(HttpHeader.LOCATION); + assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(contentBytes.length)); + + String location = servletResponse.getHeader(HttpHeader.LOCATION); + + // Verify content in storage + try (InputStream is = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) { + String readContent = IOUtils.toString(is, StandardCharsets.UTF_8); + assertThat(readContent, is(uploadContent)); + } + } + + @Test + public void testCreationWithUploadDeferredLengthSuccess() throws Exception { + String uploadContent = "Initial data to upload with deferred length"; + byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8); + + reset(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, "1"); + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.setContent(contentBytes); + + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertResponseHeaderNotBlank(HttpHeader.LOCATION); + assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(contentBytes.length)); + + String location = servletResponse.getHeader(HttpHeader.LOCATION); + + // Verify content in storage + try (InputStream is = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) { + String readContent = IOUtils.toString(is, StandardCharsets.UTF_8); + assertThat(readContent, is(uploadContent)); + } + } + + @Test + public void testCreationWithUploadInvalidContentType() throws Exception { + String uploadContent = "Initial data to upload"; + byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8); + + reset(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length); + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/octet-stream"); + servletRequest.setContent(contentBytes); + + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_NOT_ACCEPTABLE); + } + + @Test + public void testCreationWithUploadExceedsLength() throws Exception { + String uploadContent = "Initial data to upload"; + byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8); + + reset(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length - 5); + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.setContent(contentBytes); + + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_BAD_REQUEST); + } + + @Test + public void testCreationWithUploadDisabled() throws Exception { + String uploadContent = "Initial data to upload"; + byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8); + + tusFileUploadService.disableTusExtension("creation-with-upload"); + try { + reset(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length); + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.setContent(contentBytes); + + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_BAD_REQUEST); + } finally { + // Restore for other tests + tusFileUploadService = + new TusFileUploadService() + .withUploadUri(UPLOAD_URI) + .withStoragePath(storagePath.toAbsolutePath().toString()) + .withMaxUploadSize(1073741824L) + .withUploadExpirationPeriod(2L * 24 * 60 * 60 * 1000) + .withDownloadFeature() + .withChunkedTransferDecoding(true); + } + } + + @Test + public void testCorsHeaders() throws Exception { + reset(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader("Origin", "https://example.com"); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 100L); + + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseHeader("Access-Control-Allow-Origin", "https://example.com"); + assertResponseHeaderNotBlank("Access-Control-Expose-Headers"); + } + + @Test + public void testCorsPreflight() throws Exception { + reset(); + servletRequest.setMethod("OPTIONS"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader("Origin", "https://example.com"); + servletRequest.addHeader("Access-Control-Request-Method", "PATCH"); + + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseHeader("Access-Control-Allow-Origin", "https://example.com"); + assertResponseHeader("Access-Control-Allow-Methods", "POST, GET, HEAD, PATCH, DELETE, OPTIONS"); + assertResponseHeaderNotBlank("Access-Control-Allow-Headers"); + assertResponseHeader("Access-Control-Max-Age", "86400"); + } + + @Test + public void testTusVersionHeaderOn412() throws Exception { + reset(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "2.0.0"); + + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_PRECONDITION_FAILED); + assertResponseHeader(HttpHeader.TUS_VERSION, "1.0.0"); + } + + @Test + public void testModifyUploadLengthOnPatch() throws Exception { + // 1. Create upload with deferred length + reset(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, "1"); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + String location = servletResponse.getHeader(HttpHeader.LOCATION); + + // 2. Set length to 100 on PATCH + reset(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(location); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "100"); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.setContent("test content".getBytes(StandardCharsets.UTF_8)); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_NO_CONTENT); + + // 3. Try to change length to 200 on subsequent PATCH -> should return 400 + reset(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(location); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "12"); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "200"); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.setContent("more content".getBytes(StandardCharsets.UTF_8)); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_BAD_REQUEST); + } + + @Test + public void testCreationWithUploadChecksumSuccess() throws Exception { + String uploadContent = "Initial data to upload with checksum"; + byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8); + // Base64 hash for MD5 of "Initial data to upload with checksum" + // Base64 of MD5 bytes: aAMsB0BZbWXCBuPWG/ADyA== + String base64Checksum = "aAMsB0BZbWXCBuPWG/ADyA=="; + + reset(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length); + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.addHeader(HttpHeader.UPLOAD_CHECKSUM, "md5 " + base64Checksum); + servletRequest.setContent(contentBytes); + + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(contentBytes.length)); + } + + @Test + public void testCreationWithUploadChecksumMismatch() throws Exception { + String uploadContent = "Initial data to upload with checksum"; + byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8); + String invalidBase64Checksum = "aAMsB0BZbWXCBuPWG/ADyB=="; // changed A to B + + reset(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length); + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.addHeader(HttpHeader.UPLOAD_CHECKSUM, "md5 " + invalidBase64Checksum); + servletRequest.setContent(contentBytes); + + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(460); // Checksum mismatch + } + 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/TusFileUploadServiceTest.java b/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java index e3148a3d..bdd3066d 100644 --- a/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java +++ b/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java @@ -163,4 +163,19 @@ public void testProcessLockFailure() throws Exception { verify(mockResp, times(1)).sendError(423, "Locked"); } + + @Test + public void testDisableCreationWithUploadWhenCreationDisabled() throws Exception { + TusFileUploadService service = new TusFileUploadService(); + // Disable creation extension first so it is not present + service.disableTusExtension("creation"); + + // Disable creation-with-upload should not fail or throw ClassCastException/NullPointerException + try { + service.disableTusExtension("creation-with-upload"); + } catch (Exception e) { + fail( + "Should not throw exception when disabling creation-with-upload when creation is not enabled"); + } + } } diff --git a/src/test/java/me/desair/tus/server/checksum/ChecksumPatchRequestHandlerTest.java b/src/test/java/me/desair/tus/server/checksum/ChecksumRequestHandlerTest.java similarity index 94% rename from src/test/java/me/desair/tus/server/checksum/ChecksumPatchRequestHandlerTest.java rename to src/test/java/me/desair/tus/server/checksum/ChecksumRequestHandlerTest.java index 26bd5433..462439e5 100644 --- a/src/test/java/me/desair/tus/server/checksum/ChecksumPatchRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/checksum/ChecksumRequestHandlerTest.java @@ -24,9 +24,9 @@ import org.mockito.junit.MockitoJUnitRunner; @RunWith(MockitoJUnitRunner.Silent.class) -public class ChecksumPatchRequestHandlerTest { +public class ChecksumRequestHandlerTest { - private ChecksumPatchRequestHandler handler; + private ChecksumRequestHandler handler; @Mock private TusServletRequest servletRequest; @@ -34,7 +34,7 @@ public class ChecksumPatchRequestHandlerTest { @Before public void setUp() throws Exception { - handler = new ChecksumPatchRequestHandler(); + handler = new ChecksumRequestHandler(); UploadInfo info = new UploadInfo(); info.setOffset(2L); @@ -46,7 +46,7 @@ public void setUp() throws Exception { @Test public void supports() throws Exception { assertThat(handler.supports(HttpMethod.GET), is(false)); - assertThat(handler.supports(HttpMethod.POST), is(false)); + assertThat(handler.supports(HttpMethod.POST), is(true)); assertThat(handler.supports(HttpMethod.PUT), is(false)); assertThat(handler.supports(HttpMethod.DELETE), is(false)); assertThat(handler.supports(HttpMethod.HEAD), is(false)); diff --git a/src/test/java/me/desair/tus/server/checksum/validation/ChecksumAlgorithmValidatorTest.java b/src/test/java/me/desair/tus/server/checksum/validation/ChecksumAlgorithmValidatorTest.java index 63f1dff9..cdbb3584 100644 --- a/src/test/java/me/desair/tus/server/checksum/validation/ChecksumAlgorithmValidatorTest.java +++ b/src/test/java/me/desair/tus/server/checksum/validation/ChecksumAlgorithmValidatorTest.java @@ -4,8 +4,6 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.fail; import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; @@ -36,7 +34,7 @@ public void setUp() { @Test public void supports() throws Exception { assertThat(validator.supports(HttpMethod.GET), is(false)); - assertThat(validator.supports(HttpMethod.POST), is(false)); + assertThat(validator.supports(HttpMethod.POST), is(true)); assertThat(validator.supports(HttpMethod.PUT), is(false)); assertThat(validator.supports(HttpMethod.DELETE), is(false)); assertThat(validator.supports(HttpMethod.HEAD), is(false)); @@ -47,17 +45,12 @@ public void supports() throws Exception { @Test public void testValid() throws Exception { - servletRequest.addHeader(HttpHeader.UPLOAD_CHECKSUM, "sha1 1234567890"); - + servletRequest.addHeader(HttpHeader.UPLOAD_CHECKSUM, "sha1 Kq5sNclcSpM="); validator.validate(HttpMethod.PATCH, servletRequest, uploadStorageService, null); - - verify(servletRequest, times(1)).getHeader(HttpHeader.UPLOAD_CHECKSUM); } @Test public void testNoHeader() throws Exception { - // servletRequest.addHeader(HttpHeader.UPLOAD_CHECKSUM, null); - try { validator.validate(HttpMethod.PATCH, servletRequest, uploadStorageService, null); } catch (Exception ex) { @@ -68,7 +61,12 @@ public void testNoHeader() throws Exception { @Test(expected = ChecksumAlgorithmNotSupportedException.class) public void testInvalidHeader() throws Exception { servletRequest.addHeader(HttpHeader.UPLOAD_CHECKSUM, "test 1234567890"); + validator.validate(HttpMethod.PATCH, servletRequest, uploadStorageService, null); + } + @Test(expected = me.desair.tus.server.exception.TusException.class) + public void testMalformedHeader() throws Exception { + servletRequest.addHeader(HttpHeader.UPLOAD_CHECKSUM, "sha1 invalid%base64"); validator.validate(HttpMethod.PATCH, servletRequest, uploadStorageService, null); } } diff --git a/src/test/java/me/desair/tus/server/cors/CorsRequestHandlerTest.java b/src/test/java/me/desair/tus/server/cors/CorsRequestHandlerTest.java new file mode 100644 index 00000000..a26d114a --- /dev/null +++ b/src/test/java/me/desair/tus/server/cors/CorsRequestHandlerTest.java @@ -0,0 +1,84 @@ +package me.desair.tus.server.cors; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.util.TusServletRequest; +import me.desair.tus.server.util.TusServletResponse; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +public class CorsRequestHandlerTest { + + private CorsRequestHandler handler; + private MockHttpServletRequest mockReq; + private MockHttpServletResponse mockResp; + + @Before + public void setUp() { + handler = new CorsRequestHandler(); + mockReq = new MockHttpServletRequest(); + mockResp = new MockHttpServletResponse(); + } + + @Test + public void supports() { + assertThat(handler.supports(HttpMethod.GET), is(true)); + assertThat(handler.supports(HttpMethod.OPTIONS), is(true)); + } + + @Test + public void isErrorHandler() { + assertThat(handler.isErrorHandler(), is(true)); + } + + @Test + public void processNoOrigin() throws Exception { + TusServletRequest req = new TusServletRequest(mockReq); + TusServletResponse resp = new TusServletResponse(mockResp); + + handler.process(HttpMethod.GET, req, resp, null, null); + + assertThat(mockResp.getHeader("Access-Control-Allow-Origin"), is((Object) null)); + } + + @Test + public void processWithOrigin() throws Exception { + mockReq.addHeader("Origin", "https://example.com"); + TusServletRequest req = new TusServletRequest(mockReq); + TusServletResponse resp = new TusServletResponse(mockResp); + + handler.process(HttpMethod.GET, req, resp, null, null); + + assertThat(mockResp.getHeader("Access-Control-Allow-Origin"), is("https://example.com")); + assertThat( + mockResp.getHeader("Access-Control-Expose-Headers"), + is( + "Upload-Offset, Upload-Length, Upload-Metadata, Upload-Expires, Upload-Concat, " + + "Tus-Resumable, Tus-Version, Tus-Max-Size, Tus-Extension, Tus-Checksum-Algorithm, Location")); + } + + @Test + public void processPreflight() throws Exception { + mockReq.addHeader("Origin", "https://example.com"); + mockReq.addHeader("Access-Control-Request-Method", "PATCH"); + TusServletRequest req = new TusServletRequest(mockReq); + TusServletResponse resp = new TusServletResponse(mockResp); + + handler.process(HttpMethod.OPTIONS, req, resp, null, null); + + assertThat(mockResp.getHeader("Access-Control-Allow-Origin"), is("https://example.com")); + assertThat( + mockResp.getHeader("Access-Control-Allow-Methods"), + is("POST, GET, HEAD, PATCH, DELETE, OPTIONS")); + assertThat( + mockResp.getHeader("Access-Control-Allow-Headers"), + is( + "Origin, X-Requested-With, Content-Type, Upload-Length, Upload-Offset, Upload-Metadata, " + + "Upload-Expires, Upload-Checksum, Upload-Concat, Upload-Defer-Length, Tus-Resumable, X-HTTP-Method-Override")); + assertThat(mockResp.getHeader("Access-Control-Max-Age"), is("86400")); + } +} diff --git a/src/test/java/me/desair/tus/server/creation/validation/PatchUploadLengthValidatorTest.java b/src/test/java/me/desair/tus/server/creation/validation/PatchUploadLengthValidatorTest.java new file mode 100644 index 00000000..4844ad97 --- /dev/null +++ b/src/test/java/me/desair/tus/server/creation/validation/PatchUploadLengthValidatorTest.java @@ -0,0 +1,97 @@ +package me.desair.tus.server.creation.validation; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.InvalidUploadLengthException; +import me.desair.tus.server.exception.MaxUploadLengthExceededException; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadStorageService; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +public class PatchUploadLengthValidatorTest { + + private PatchUploadLengthValidator validator; + private MockHttpServletRequest servletRequest; + private UploadStorageService uploadStorageService; + + @Before + public void setUp() { + servletRequest = new MockHttpServletRequest(); + validator = new PatchUploadLengthValidator(); + uploadStorageService = mock(UploadStorageService.class); + } + + @Test + public void supports() { + assertThat(validator.supports(HttpMethod.PATCH), is(true)); + assertThat(validator.supports(HttpMethod.POST), is(false)); + assertThat(validator.supports(HttpMethod.GET), is(false)); + } + + @Test + public void validateNoHeader() throws Exception { + validator.validate(HttpMethod.PATCH, servletRequest, uploadStorageService, null); + } + + @Test(expected = InvalidUploadLengthException.class) + public void validateNegativeLength() throws Exception { + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, -100L); + validator.validate(HttpMethod.PATCH, servletRequest, uploadStorageService, null); + } + + @Test + public void validateFirstTimeSuccess() throws Exception { + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 1000L); + servletRequest.setRequestURI("/files/123"); + + UploadInfo info = new UploadInfo(); + // length is null (deferred) + when(uploadStorageService.getUploadInfo("/files/123", "owner")).thenReturn(info); + when(uploadStorageService.getMaxUploadSize()).thenReturn(2000L); + + validator.validate(HttpMethod.PATCH, servletRequest, uploadStorageService, "owner"); + } + + @Test(expected = MaxUploadLengthExceededException.class) + public void validateFirstTimeExceedsMax() throws Exception { + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 3000L); + servletRequest.setRequestURI("/files/123"); + + UploadInfo info = new UploadInfo(); + when(uploadStorageService.getUploadInfo("/files/123", "owner")).thenReturn(info); + when(uploadStorageService.getMaxUploadSize()).thenReturn(2000L); + + validator.validate(HttpMethod.PATCH, servletRequest, uploadStorageService, "owner"); + } + + @Test + public void validateMatchingLength() throws Exception { + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 1000L); + servletRequest.setRequestURI("/files/123"); + + UploadInfo info = new UploadInfo(); + info.setLength(1000L); + when(uploadStorageService.getUploadInfo("/files/123", "owner")).thenReturn(info); + + validator.validate(HttpMethod.PATCH, servletRequest, uploadStorageService, "owner"); + } + + @Test(expected = InvalidUploadLengthException.class) + public void validateModifiedLength() throws Exception { + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 1001L); + servletRequest.setRequestURI("/files/123"); + + UploadInfo info = new UploadInfo(); + info.setLength(1000L); + when(uploadStorageService.getUploadInfo("/files/123", "owner")).thenReturn(info); + + validator.validate(HttpMethod.PATCH, servletRequest, uploadStorageService, "owner"); + } +} diff --git a/src/test/java/me/desair/tus/server/creation/validation/PostEmptyRequestValidatorTest.java b/src/test/java/me/desair/tus/server/creation/validation/PostEmptyRequestValidatorTest.java index b749559a..f93ab929 100644 --- a/src/test/java/me/desair/tus/server/creation/validation/PostEmptyRequestValidatorTest.java +++ b/src/test/java/me/desair/tus/server/creation/validation/PostEmptyRequestValidatorTest.java @@ -73,4 +73,19 @@ public void validateContentLengthNotZero() throws Exception { // Expect a InvalidContentLengthException } + + @Test + public void validateContentLengthNotZeroWithCreationWithUpload() throws Exception { + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 10L); + validator.setCreationWithUploadEnabled(true); + + // When we validate the request + try { + validator.validate(HttpMethod.POST, servletRequest, null, null); + } catch (Exception ex) { + fail(); + } + + // No Exception is thrown + } } diff --git a/src/test/java/me/desair/tus/server/creation/validation/UploadDeferLengthValidatorTest.java b/src/test/java/me/desair/tus/server/creation/validation/UploadDeferLengthValidatorTest.java index a4dc42b2..faf5ab83 100644 --- a/src/test/java/me/desair/tus/server/creation/validation/UploadDeferLengthValidatorTest.java +++ b/src/test/java/me/desair/tus/server/creation/validation/UploadDeferLengthValidatorTest.java @@ -119,7 +119,13 @@ public void validateUploadLengthNotNumeric() throws Exception { // When we validate the request validator.validate(HttpMethod.POST, servletRequest, null, null); + } - // Expect an InvalidUploadLengthException + @Test(expected = InvalidUploadLengthException.class) + public void validateUploadDeferLengthNot1AndUploadLengthPresent() throws Exception { + servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, 2); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 300L); + + validator.validate(HttpMethod.POST, servletRequest, null, null); } } diff --git a/src/test/java/me/desair/tus/server/creation/validation/UploadMetadataValidatorTest.java b/src/test/java/me/desair/tus/server/creation/validation/UploadMetadataValidatorTest.java new file mode 100644 index 00000000..58b69ab6 --- /dev/null +++ b/src/test/java/me/desair/tus/server/creation/validation/UploadMetadataValidatorTest.java @@ -0,0 +1,60 @@ +package me.desair.tus.server.creation.validation; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.TusException; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +public class UploadMetadataValidatorTest { + + private UploadMetadataValidator validator; + private MockHttpServletRequest servletRequest; + + @Before + public void setUp() { + servletRequest = new MockHttpServletRequest(); + validator = new UploadMetadataValidator(); + } + + @Test + public void supports() { + assertThat(validator.supports(HttpMethod.POST), is(true)); + assertThat(validator.supports(HttpMethod.PATCH), is(false)); + } + + @Test + public void validateNoHeader() throws Exception { + validator.validate(HttpMethod.POST, servletRequest, null, null); + } + + @Test + public void validateValidMetadata() throws Exception { + servletRequest.addHeader( + HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbi5wZGY=,is_confidential"); + validator.validate(HttpMethod.POST, servletRequest, null, null); + } + + @Test(expected = TusException.class) + public void validateEmptyPair() throws Exception { + servletRequest.addHeader( + HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbi5wZGY=,,is_confidential"); + validator.validate(HttpMethod.POST, servletRequest, null, null); + } + + @Test(expected = TusException.class) + public void validateMultipleSpaces() throws Exception { + servletRequest.addHeader(HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbi5wZGY="); + validator.validate(HttpMethod.POST, servletRequest, null, null); + } + + @Test(expected = TusException.class) + public void validateInvalidBase64() throws Exception { + servletRequest.addHeader(HttpHeader.UPLOAD_METADATA, "filename invalid%base64"); + validator.validate(HttpMethod.POST, servletRequest, null, null); + } +} diff --git a/src/test/java/me/desair/tus/server/creationwithupload/validation/PostContentTypeValidatorTest.java b/src/test/java/me/desair/tus/server/creationwithupload/validation/PostContentTypeValidatorTest.java new file mode 100644 index 00000000..833d4e72 --- /dev/null +++ b/src/test/java/me/desair/tus/server/creationwithupload/validation/PostContentTypeValidatorTest.java @@ -0,0 +1,79 @@ +package me.desair.tus.server.creationwithupload.validation; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.fail; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.InvalidContentTypeException; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +public class PostContentTypeValidatorTest { + + private PostContentTypeValidator validator; + private MockHttpServletRequest servletRequest; + + @Before + public void setUp() { + servletRequest = new MockHttpServletRequest(); + validator = new PostContentTypeValidator(); + } + + @Test + public void supports() throws Exception { + assertThat(validator.supports(HttpMethod.GET), is(false)); + assertThat(validator.supports(HttpMethod.POST), is(true)); + assertThat(validator.supports(HttpMethod.PUT), is(false)); + assertThat(validator.supports(HttpMethod.DELETE), is(false)); + assertThat(validator.supports(HttpMethod.HEAD), is(false)); + assertThat(validator.supports(HttpMethod.OPTIONS), is(false)); + assertThat(validator.supports(HttpMethod.PATCH), is(false)); + assertThat(validator.supports(null), is(false)); + } + + @Test + public void validateContentTypeOk() throws Exception { + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 100L); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + + try { + validator.validate(HttpMethod.POST, servletRequest, null, null); + } catch (Exception ex) { + fail(); + } + } + + @Test(expected = InvalidContentTypeException.class) + public void validateContentTypeNotOk() throws Exception { + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 100L); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/octet-stream"); + + validator.validate(HttpMethod.POST, servletRequest, null, null); + } + + @Test + public void validateNoContentLength() throws Exception { + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/octet-stream"); + + try { + validator.validate(HttpMethod.POST, servletRequest, null, null); + } catch (Exception ex) { + fail(); + } + } + + @Test + public void validateContentLengthZero() throws Exception { + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0L); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/octet-stream"); + + try { + validator.validate(HttpMethod.POST, servletRequest, null, null); + } catch (Exception ex) { + fail(); + } + } +} diff --git a/src/test/java/me/desair/tus/server/creationwithupload/validation/UploadContentLengthValidatorTest.java b/src/test/java/me/desair/tus/server/creationwithupload/validation/UploadContentLengthValidatorTest.java new file mode 100644 index 00000000..4bf8f1fe --- /dev/null +++ b/src/test/java/me/desair/tus/server/creationwithupload/validation/UploadContentLengthValidatorTest.java @@ -0,0 +1,90 @@ +package me.desair.tus.server.creationwithupload.validation; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.fail; + +import me.desair.tus.server.HttpHeader; +import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.InvalidContentLengthException; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +public class UploadContentLengthValidatorTest { + + private UploadContentLengthValidator validator; + private MockHttpServletRequest servletRequest; + + @Before + public void setUp() { + servletRequest = new MockHttpServletRequest(); + validator = new UploadContentLengthValidator(); + } + + @Test + public void supports() throws Exception { + assertThat(validator.supports(HttpMethod.GET), is(false)); + assertThat(validator.supports(HttpMethod.POST), is(true)); + assertThat(validator.supports(HttpMethod.PUT), is(false)); + assertThat(validator.supports(HttpMethod.DELETE), is(false)); + assertThat(validator.supports(HttpMethod.HEAD), is(false)); + assertThat(validator.supports(HttpMethod.OPTIONS), is(false)); + assertThat(validator.supports(HttpMethod.PATCH), is(false)); + assertThat(validator.supports(null), is(false)); + } + + @Test + public void validateContentLengthLessOrEqualUploadLength() throws Exception { + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 100L); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 150L); + + try { + validator.validate(HttpMethod.POST, servletRequest, null, null); + } catch (Exception ex) { + fail(); + } + } + + @Test + public void validateContentLengthEqualUploadLength() throws Exception { + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 100L); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 100L); + + try { + validator.validate(HttpMethod.POST, servletRequest, null, null); + } catch (Exception ex) { + fail(); + } + } + + @Test(expected = InvalidContentLengthException.class) + public void validateContentLengthExceedsUploadLength() throws Exception { + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 150L); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 100L); + + validator.validate(HttpMethod.POST, servletRequest, null, null); + } + + @Test + public void validateNoContentLength() throws Exception { + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 100L); + + try { + validator.validate(HttpMethod.POST, servletRequest, null, null); + } catch (Exception ex) { + fail(); + } + } + + @Test + public void validateNoUploadLength() throws Exception { + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 100L); + + try { + validator.validate(HttpMethod.POST, servletRequest, null, null); + } catch (Exception ex) { + fail(); + } + } +} diff --git a/src/test/java/me/desair/tus/server/upload/disk/ExpiredUploadFilterTest.java b/src/test/java/me/desair/tus/server/upload/disk/ExpiredUploadFilterTest.java index 7d0f8787..862461aa 100644 --- a/src/test/java/me/desair/tus/server/upload/disk/ExpiredUploadFilterTest.java +++ b/src/test/java/me/desair/tus/server/upload/disk/ExpiredUploadFilterTest.java @@ -67,8 +67,8 @@ public void acceptCompletedUpload() throws Exception { when(diskStorageService.getUploadInfo(eq(info.getId()))).thenReturn(info); when(uploadLockingService.isLocked(eq(info.getId()))).thenReturn(false); - // Completed uploads also expire - assertTrue(uploadFilter.accept(Paths.get(info.getId().toString()))); + // Completed uploads do not expire via this filter + assertFalse(uploadFilter.accept(Paths.get(info.getId().toString()))); } @Test