diff --git a/src/main/java/me/desair/tus/server/HttpHeader.java b/src/main/java/me/desair/tus/server/HttpHeader.java index 1a3de49f..cb1d3ceb 100644 --- a/src/main/java/me/desair/tus/server/HttpHeader.java +++ b/src/main/java/me/desair/tus/server/HttpHeader.java @@ -119,6 +119,12 @@ public class HttpHeader { /** Media type application/partial-upload used for IETF Resumable Upload append requests. */ public static final String CONTENT_TYPE_PARTIAL_UPLOAD = "application/partial-upload"; + /** + * Media type application/offset+octet-stream used for Tus 1.0.0 PATCH and creation-with-upload + * POST requests. + */ + public static final String CONTENT_TYPE_OFFSET_OCTET_STREAM = "application/offset+octet-stream"; + /** Media type application/problem+json used for RFC 7807 problem details error responses. */ public static final String CONTENT_TYPE_PROBLEM_JSON = "application/problem+json"; diff --git a/src/main/java/me/desair/tus/server/core/validation/ContentTypeValidator.java b/src/main/java/me/desair/tus/server/core/validation/ContentTypeValidator.java index 939006e8..2507b5e7 100644 --- a/src/main/java/me/desair/tus/server/core/validation/ContentTypeValidator.java +++ b/src/main/java/me/desair/tus/server/core/validation/ContentTypeValidator.java @@ -12,8 +12,6 @@ /** All PATCH requests MUST use Content-Type: application/offset+octet-stream. */ public class ContentTypeValidator implements RequestValidator { - static final String APPLICATION_OFFSET_OCTET_STREAM = "application/offset+octet-stream"; - @Override public void validate( HttpMethod method, @@ -23,12 +21,12 @@ public void validate( throws TusException { String contentType = Utils.getHeader(request, HttpHeader.CONTENT_TYPE); - if (!APPLICATION_OFFSET_OCTET_STREAM.equals(contentType)) { + if (!Utils.isMediaType(contentType, HttpHeader.CONTENT_TYPE_OFFSET_OCTET_STREAM)) { throw new InvalidContentTypeException( "The " + HttpHeader.CONTENT_TYPE + " header must contain value " - + APPLICATION_OFFSET_OCTET_STREAM); + + HttpHeader.CONTENT_TYPE_OFFSET_OCTET_STREAM); } } 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 index 77fcde47..62cec73a 100644 --- a/src/main/java/me/desair/tus/server/creationwithupload/validation/PostContentTypeValidator.java +++ b/src/main/java/me/desair/tus/server/creationwithupload/validation/PostContentTypeValidator.java @@ -8,7 +8,6 @@ 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 @@ -16,8 +15,6 @@ */ public class PostContentTypeValidator implements RequestValidator { - private static final String APPLICATION_OFFSET_OCTET_STREAM = "application/offset+octet-stream"; - @Override public void validate( HttpMethod method, @@ -29,12 +26,12 @@ public void validate( 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)) { + if (!Utils.isMediaType(contentType, HttpHeader.CONTENT_TYPE_OFFSET_OCTET_STREAM)) { throw new InvalidContentTypeException( "The " + HttpHeader.CONTENT_TYPE + " header must contain value " - + APPLICATION_OFFSET_OCTET_STREAM); + + HttpHeader.CONTENT_TYPE_OFFSET_OCTET_STREAM); } } } diff --git a/src/main/java/me/desair/tus/server/rufh/validation/RufhAppendValidator.java b/src/main/java/me/desair/tus/server/rufh/validation/RufhAppendValidator.java index 194f40f2..030933f8 100644 --- a/src/main/java/me/desair/tus/server/rufh/validation/RufhAppendValidator.java +++ b/src/main/java/me/desair/tus/server/rufh/validation/RufhAppendValidator.java @@ -20,7 +20,6 @@ import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.StructuredHeaderUtil; import me.desair.tus.server.util.Utils; -import org.apache.commons.lang3.Strings; /** * Request validator checking data append requests via HTTP PATCH. @@ -75,8 +74,8 @@ public void validate( } String contentType = request.getHeader(HttpHeader.CONTENT_TYPE); - if (!Strings.CS.startsWith(contentType, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD) - && !Strings.CS.startsWith(contentType, "application/offset+octet-stream")) { + if (!Utils.isMediaType(contentType, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD) + && !Utils.isMediaType(contentType, HttpHeader.CONTENT_TYPE_OFFSET_OCTET_STREAM)) { throw new UnsupportedMediaTypeException("Unsupported Content-Type for append request"); } 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 19a4bc3b..1b72c04a 100644 --- a/src/main/java/me/desair/tus/server/util/Utils.java +++ b/src/main/java/me/desair/tus/server/util/Utils.java @@ -307,6 +307,38 @@ public static ChecksumInfo parseUploadChecksumHeader(HttpServletRequest request) return null; } + /** + * Checks whether the given Content-Type header matches the expected media type, ignoring optional + * parameters (such as ;charset=UTF-8) and casing per RFC 9110 ยง8.3. + * + * @param contentTypeHeader The Content-Type header value from the request + * @param expectedMediaType The expected media type (e.g. application/offset+octet-stream) + * @return true if the base media type matches the expected type, false otherwise + */ + public static boolean isMediaType(String contentTypeHeader, String expectedMediaType) { + if (contentTypeHeader == null || expectedMediaType == null) { + return false; + } + String baseType = extractMediaType(contentTypeHeader); + return Strings.CI.equals(baseType, expectedMediaType.trim()); + } + + /** + * Extracts the base media type from a Content-Type header (the portion before any ';' parameter). + * + * @param contentTypeHeader The Content-Type header value + * @return The trimmed base media type, or null if the header is null + */ + public static String extractMediaType(String contentTypeHeader) { + if (contentTypeHeader == null) { + return null; + } + int semicolonIdx = contentTypeHeader.indexOf(';'); + String baseType = + semicolonIdx >= 0 ? contentTypeHeader.substring(0, semicolonIdx) : contentTypeHeader; + return baseType.trim(); + } + /** * Resolves the upload URI from the HTTP request and response context. * @@ -354,10 +386,11 @@ public static ProtocolVersion detectProtocolVersion( || StringUtils.isNotBlank(request.getHeader(HttpHeader.UPLOAD_COMPLETE)) || StringUtils.isNotBlank(request.getHeader(HttpHeader.UPLOAD_DRAFT)) || StringUtils.isNotBlank(request.getHeader("upload-draft-interop-version")) - || Strings.CS.startsWith( + || isMediaType( request.getHeader(HttpHeader.CONTENT_TYPE), HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD) - || Strings.CS.startsWith( - request.getHeader(HttpHeader.CONTENT_TYPE), "application/offset+octet-stream")) { + || isMediaType( + request.getHeader(HttpHeader.CONTENT_TYPE), + HttpHeader.CONTENT_TYPE_OFFSET_OCTET_STREAM)) { return ProtocolVersion.RUFH; } String method = request.getMethod(); diff --git a/src/test/java/me/desair/tus/server/core/validation/ContentTypeValidatorTest.java b/src/test/java/me/desair/tus/server/core/validation/ContentTypeValidatorTest.java index 8053088e..40cc6ba8 100644 --- a/src/test/java/me/desair/tus/server/core/validation/ContentTypeValidatorTest.java +++ b/src/test/java/me/desair/tus/server/core/validation/ContentTypeValidatorTest.java @@ -2,7 +2,6 @@ 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; @@ -25,16 +24,10 @@ public void setUp() { @Test public void validateValid() throws Exception { - servletRequest.addHeader( - HttpHeader.CONTENT_TYPE, ContentTypeValidator.APPLICATION_OFFSET_OCTET_STREAM); - - try { - validator.validate(HttpMethod.PATCH, servletRequest, null, null); - } catch (Exception ex) { - fail(); - } + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_OFFSET_OCTET_STREAM); - // No exception is thrown + validator.validate(HttpMethod.PATCH, servletRequest, null, null); + // KISS: verifying method executes cleanly without throwing an exception } @Test(expected = InvalidContentTypeException.class) @@ -57,6 +50,32 @@ public void validateMissingHeader() throws Exception { // Expect a InvalidContentTypeException exception } + @Test + public void validateValidWithCharset() throws Exception { + servletRequest.addHeader( + HttpHeader.CONTENT_TYPE, "application/offset+octet-stream;charset=UTF-8"); + + validator.validate(HttpMethod.PATCH, servletRequest, null, null); + // KISS: verifying method executes cleanly without throwing an exception + } + + @Test + public void validateValidWithWhitespaceAndCharset() throws Exception { + servletRequest.addHeader( + HttpHeader.CONTENT_TYPE, "application/offset+octet-stream ; charset=utf-8"); + + validator.validate(HttpMethod.PATCH, servletRequest, null, null); + // KISS: verifying method executes cleanly without throwing an exception + } + + @Test + public void validateValidCaseInsensitive() throws Exception { + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "APPLICATION/OFFSET+OCTET-STREAM"); + + validator.validate(HttpMethod.PATCH, servletRequest, null, null); + // KISS: verifying method executes cleanly without throwing an exception + } + @Test public void supports() throws Exception { assertThat(validator.supports(HttpMethod.GET), is(false)); 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 index 833d4e72..7192880f 100644 --- a/src/test/java/me/desair/tus/server/creationwithupload/validation/PostContentTypeValidatorTest.java +++ b/src/test/java/me/desair/tus/server/creationwithupload/validation/PostContentTypeValidatorTest.java @@ -76,4 +76,33 @@ public void validateContentLengthZero() throws Exception { fail(); } } + + @Test + public void validateContentTypeWithCharset() throws Exception { + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 100L); + servletRequest.addHeader( + HttpHeader.CONTENT_TYPE, "application/offset+octet-stream;charset=UTF-8"); + + validator.validate(HttpMethod.POST, servletRequest, null, null); + // KISS: verifying method executes cleanly without throwing an exception + } + + @Test + public void validateContentTypeWithWhitespaceAndCharset() throws Exception { + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 100L); + servletRequest.addHeader( + HttpHeader.CONTENT_TYPE, "application/offset+octet-stream ; charset=utf-8"); + + validator.validate(HttpMethod.POST, servletRequest, null, null); + // KISS: verifying method executes cleanly without throwing an exception + } + + @Test + public void validateContentTypeCaseInsensitive() throws Exception { + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 100L); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "APPLICATION/OFFSET+OCTET-STREAM"); + + validator.validate(HttpMethod.POST, servletRequest, null, null); + // KISS: verifying method executes cleanly without throwing an exception + } } 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 ec0aaf18..da19397d 100644 --- a/src/test/java/me/desair/tus/server/util/UtilsTest.java +++ b/src/test/java/me/desair/tus/server/util/UtilsTest.java @@ -907,6 +907,46 @@ public void cleanupTempFilesShouldDeleteStaleFilesAndRetainFreshFiles() throws E assertThat(Files.exists(unrelatedFile), is(true)); } + @Test + public void testIsMediaType() { + assertThat(Utils.isMediaType(null, "application/offset+octet-stream"), is(false)); + assertThat(Utils.isMediaType("application/offset+octet-stream", null), is(false)); + assertThat( + Utils.isMediaType("application/offset+octet-stream", "application/offset+octet-stream"), + is(true)); + assertThat( + Utils.isMediaType( + "application/offset+octet-stream;charset=UTF-8", "application/offset+octet-stream"), + is(true)); + assertThat( + Utils.isMediaType( + "application/offset+octet-stream ; charset=utf-8", "application/offset+octet-stream"), + is(true)); + assertThat( + Utils.isMediaType("APPLICATION/OFFSET+OCTET-STREAM", "application/offset+octet-stream"), + is(true)); + assertThat( + Utils.isMediaType( + "application/partial-upload; charset=UTF-8", "application/partial-upload"), + is(true)); + assertThat(Utils.isMediaType("application/json", "application/offset+octet-stream"), is(false)); + assertThat(Utils.isMediaType("text/plain", "application/offset+octet-stream"), is(false)); + } + + @Test + public void testExtractMediaType() { + assertThat(Utils.extractMediaType(null), is(nullValue())); + assertThat( + Utils.extractMediaType("application/offset+octet-stream"), + is("application/offset+octet-stream")); + assertThat( + Utils.extractMediaType("application/offset+octet-stream;charset=UTF-8"), + is("application/offset+octet-stream")); + assertThat( + Utils.extractMediaType(" application/offset+octet-stream ; charset=utf-8 "), + is("application/offset+octet-stream")); + } + /** Simple serializable class for testing. */ public static class TestSerializable implements Serializable { private static final long serialVersionUID = 1L;