├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE-2.0.txt ├── README.md ├── build.gradle ├── config ├── checkstyle │ └── checkstyle.xml ├── pmd │ └── rulesSets.xml └── spotbugs │ └── spotbugs-exclude.xml ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle ├── upload-parser-core ├── build.gradle └── src │ └── main │ └── java │ ├── com │ └── github │ │ └── elopteryx │ │ └── upload │ │ ├── OnError.java │ │ ├── OnPartBegin.java │ │ ├── OnPartEnd.java │ │ ├── OnRequestComplete.java │ │ ├── PartOutput.java │ │ ├── PartStream.java │ │ ├── UploadContext.java │ │ ├── UploadParser.java │ │ ├── errors │ │ ├── MultipartException.java │ │ ├── PartSizeException.java │ │ ├── RequestSizeException.java │ │ ├── UploadSizeException.java │ │ └── package-info.java │ │ ├── internal │ │ ├── AbstractUploadParser.java │ │ ├── AsyncUploadParser.java │ │ ├── Base64Decoder.java │ │ ├── BlockingUploadParser.java │ │ ├── Headers.java │ │ ├── MultipartParser.java │ │ ├── PartStreamImpl.java │ │ ├── UploadContextImpl.java │ │ └── package-info.java │ │ ├── package-info.java │ │ └── util │ │ ├── ByteBufferBackedInputStream.java │ │ ├── ByteBufferBackedOutputStream.java │ │ ├── InputStreamBackedChannel.java │ │ ├── NullChannel.java │ │ ├── OutputStreamBackedChannel.java │ │ └── package-info.java │ └── module-info.java └── upload-parser-tests ├── build.gradle └── src └── test ├── java └── com │ └── github │ └── elopteryx │ └── upload │ ├── PartOutputTest.java │ ├── UploadParserTest.java │ ├── internal │ ├── AbstractUploadParserTest.java │ ├── AsyncUploadParserTest.java │ ├── Base64DecoderTest.java │ ├── Base64EncodingTest.java │ ├── BlockingUploadParserTest.java │ ├── HeadersTest.java │ ├── IdentityEncodingTest.java │ ├── MultipartParserTest.java │ ├── PartStreamImplTest.java │ ├── QuotedPrintableEncodingTest.java │ └── integration │ │ ├── AsyncUploadServlet.java │ │ ├── BlockingUploadServlet.java │ │ ├── ClientRequest.java │ │ ├── JettyIntegrationTest.java │ │ ├── RequestBuilder.java │ │ ├── RequestSupplier.java │ │ ├── TomcatIntegrationTest.java │ │ └── UndertowIntegrationTest.java │ └── util │ ├── ByteBufferBackedInputStreamTest.java │ ├── ByteBufferBackedOutputStreamTest.java │ ├── InputStreamBackedChannelTest.java │ ├── MockServletInputStream.java │ ├── NullChannelTest.java │ ├── OutputStreamBackedChannelTest.java │ └── Servlets.java └── resources └── com └── github └── elopteryx └── upload └── internal ├── integration ├── test.docx ├── test.jpg └── test.xlsx ├── mime-utf8.txt ├── mime1.txt ├── mime2.txt ├── mime3.txt ├── mime4.txt ├── mime5_malformed.txt ├── mime6_malformed.txt └── mime7_malformed.txt /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Upload Parser CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | java: [ 17, 21 ] 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Setup java 14 | uses: actions/setup-java@v4 15 | with: 16 | cache: gradle 17 | distribution: temurin 18 | java-version: ${{ matrix.java }} 19 | - name: Build with Gradle 20 | run: ./gradlew build 21 | - name: Create coverage report 22 | run: ./gradlew check 23 | - uses: codecov/codecov-action@v4 24 | with: 25 | files: upload-parser-tests/build/reports/jacoco/testCodeCoverageReport/testCodeCoverageReport.xml 26 | flags: unittests 27 | name: codecov-umbrella 28 | fail_ci_if_error: true 29 | token: ${{ secrets.codecov_token }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | target/ 3 | classes/ 4 | .gradle/ 5 | .idea/ 6 | 7 | # Intellij # 8 | *.iml 9 | *.ipr 10 | *.iws 11 | 12 | # Eclipse# 13 | *.project 14 | *.classpath 15 | *.settings 16 | 17 | # Compiled source # 18 | *.class 19 | *.jar 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Upload Parser 2 | ========= 3 | 4 | [![Apache 2 License](https://img.shields.io/badge/license-Apache%202-green.svg)](http://www.apache.org/licenses/LICENSE-2.0) 5 | [![Actions Status](https://github.com/Elopteryx/upload-parser/workflows/Upload%20Parser%20CI/badge.svg)](https://github.com/Elopteryx/upload-parser/actions) 6 | [![codecov](https://codecov.io/gh/Elopteryx/upload-parser/branch/master/graph/badge.svg?token=WdHn9v5XBq)](https://codecov.io/gh/Elopteryx/upload-parser) 7 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.github.elopteryx/upload-parser/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.github.elopteryx/upload-parser) 8 | [![JavaDoc](https://img.shields.io/badge/javadoc-4.0.0-brightgreen.svg)](http://www.javadoc.io/doc/com.github.elopteryx/upload-parser) 9 | 10 | Upload Parser is a file upload library for servlets and web applications. Although you can already use the standard 11 | servlet API to retrieve part items from a multipart request this library provides extra functionality not found 12 | elsewhere. First, it has a fluent API which allows you to completely control the uploading process. No more waiting for 13 | the user to upload everything, you can run your business logic, like file type validation and writing to a permanent 14 | location as soon as the bytes arrive. Another great feature is that if you choose it and your servlet supports it the 15 | upload request can run in asynchronous mode, using the async IO API introduced in the 3.1 version of the servlet API. 16 | This will allow a much better use of system resources, no more waiting threads. 17 | 18 | I consider the modules complete, when it comes to features. I don't think I can add more without bloating the 19 | library, but if you have suggestions for new stuff, or if you have found a bug I would be more than happy to fix that. 20 | If a new version of Java SE or EE comes out I will try to experiment with it, maybe make a new major version. 21 | 22 | Features 23 | -------- 24 | * Async and blocking multipart request parsing 25 | * Unopinionated, fully customizable, just pass your custom logic 26 | * ```.onPartBegin(…)``` when the client starts sending a part, with optional buffering 27 | * ```.onPartEnd(…)``` when the client finishes sending a part 28 | * ```.onRequestComplete(…)``` after everything has been uploaded 29 | * ```.onError(…)``` if an error occurs 30 | * Lightweight, less than 40Kb size, no dependencies other than the servlet API 31 | * Available from the Maven Central repository 32 | 33 | Requirements 34 | -------- 35 | | Versions | Min JVM | Min Servlet | 36 | |----------|---------|-------------| 37 | | 4.0.0 | 17 | 5.0 | 38 | | 3.0.0 | 11 | 3.1 | 39 | | 2.2.1 | 8 | 3.1 | 40 | 41 | Motivation 42 | -------- 43 | 44 | Although the Servlet API already has support for handling multipart requests since version 3.0, I found it lacking in several situations. 45 | First, it uses blocking IO which can cause performance problems, especially because an upload can take a very long time. To fix this problem in general, 46 | the ReadListener and WriteListener interfaces have been introduced in version 3.1, to prevent blocking, but to use them, you have to use your own parsing 47 | code, you can't rely on the servlet container to do the job for you in the async mode. 48 | 49 | Also, the classic blocking method parses the whole request 50 | and only lets you run your code after it is finished. Why no support to check for the correct file extension, or request size right when they become available? 51 | And so I decided to write this small library which handles those situations. If you don't have these requirements then the Servlet API will do the job 52 | just fine. Otherwise, I think you will find my library useful. 53 | 54 | Issues 55 | ------ 56 | 57 | Does the library have bugs? Needs extra functionality? Do you like the API? Feel free to open an issue! 58 | 59 | Examples 60 | -------- 61 | 62 | Very simple to set up, have a servlet which has no MultiPartConfig annotation or the identical section in the web.xml, 63 | import the UploadParser class and pass your logic. The parser class will take care of starting async mode and 64 | registering the necessary classes to start the parsing. Note that if you can't enable async support 65 | for your servlet then you can also use blocking mode, by calling the doBlockingParse method instead. Your functions will 66 | still be called just like in async mode. 67 | 68 | ```java 69 | 70 | @WebServlet(value = "/UploadServlet", asyncSupported = true) 71 | public class UploadServlet extends HttpServlet { 72 | 73 | /** 74 | * Directory where uploaded files will be saved, relative to 75 | * the web application directory. 76 | */ 77 | private static final String UPLOAD_DIR = "uploads"; 78 | 79 | protected void doPost(HttpServletRequest request, HttpServletResponse response) 80 | throws IOException, ServletException { 81 | 82 | String applicationPath = request.getServletContext().getRealPath(""); 83 | final Path uploadFilePath = Paths.get(applicationPath, UPLOAD_DIR); 84 | 85 | if (!Files.isDirectory(uploadFilePath)) { 86 | Files.createDirectories(uploadFilePath); 87 | } 88 | 89 | // Check that we have a file upload request 90 | if (!UploadParser.isMultipart(request)) { 91 | return; 92 | } 93 | 94 | UploadParser.newParser() 95 | .onPartBegin((context, buffer) -> { 96 | PartStream part = context.getCurrentPart(); 97 | Path path = uploadFilePath.resolve(part.getSubmittedFileName()); 98 | return PartOutput.from(path); 99 | }) 100 | .onRequestComplete(context -> response.setStatus(200)) 101 | .onError((context, throwable) -> response.sendError(500)) 102 | .sizeThreshold(4096) 103 | .maxPartSize(1024 * 1024 * 25) 104 | .maxRequestSize(1024 * 1024 * 500) 105 | .setupAsyncParse(request); 106 | } 107 | } 108 | ``` 109 | 110 | You can also use the parser with web frameworks, like Spring WebMVC. The following example shows how to use it with a JAX-RS endpoint: 111 | 112 | ```java 113 | 114 | @Path("upload") 115 | public class UploadController implements OnPartBegin, OnPartEnd, OnRequestComplete, OnError { 116 | 117 | @POST 118 | @Path("upload") 119 | public void multipart(@Context HttpServletRequest request, @Suspended final AsyncResponse asyncResponse) throws IOException, ServletException { 120 | UploadParser.newParser() 121 | .onPartBegin(this) 122 | .onPartEnd(this) 123 | .onRequestComplete(this) 124 | .onError(this) 125 | .userObject(asyncResponse) 126 | .setupAsyncParse(request); 127 | } 128 | 129 | @Override 130 | @Nonnull 131 | public PartOutput onPartBegin(UploadContext context, ByteBuffer buffer) throws IOException { 132 | // Your business logic here, check the part, you can use the bytes in the buffer to check 133 | // the real mime type, then return with a channel, stream or path to write the part 134 | return PartOutput.from(new NullChannel()); 135 | } 136 | 137 | @Override 138 | public void onPartEnd(UploadContext context) throws IOException { 139 | // Your business logic here 140 | } 141 | 142 | @Override 143 | public void onRequestComplete(UploadContext context) throws IOException, ServletException { 144 | // Your business logic here, send a response to the client 145 | context.getUserObject(AsyncResponse.class).resume(Response.ok().build()); 146 | } 147 | 148 | @Override 149 | public void onError(UploadContext context, Throwable throwable) { 150 | // Your business logic here, handle the error 151 | context.getUserObject(AsyncResponse.class).resume(Response.serverError().build()); 152 | } 153 | } 154 | ``` 155 | 156 | For more information, please check the [Javadoc][1]. 157 | 158 | Gradle 159 | ----- 160 | 161 | ```gradle 162 | implementation 'com.github.elopteryx:upload-parser:4.0.0' 163 | ``` 164 | Maven 165 | ----- 166 | 167 | ```xml 168 | 169 | com.github.elopteryx 170 | upload-parser 171 | 4.0.0 172 | 173 | ``` 174 | 175 | Find available versions on [Maven Central Repository](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.github.elopteryx%22%20AND%20a%3A%22upload-parser%22). 176 | 177 | [1]: http://www.javadoc.io/doc/com.github.elopteryx/upload-parser/4.0.0 178 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.github.ben-manes.versions' version '0.51.0' 3 | id 'com.github.spotbugs' version '6.0.15' 4 | } 5 | 6 | allprojects { 7 | apply plugin: 'jacoco' 8 | jacoco { 9 | toolVersion = '0.8.12' 10 | } 11 | } 12 | 13 | subprojects { 14 | 15 | apply plugin: 'java-library' 16 | apply plugin: 'maven-publish' 17 | apply plugin: 'signing' 18 | apply plugin: 'com.github.ben-manes.versions' 19 | apply plugin: 'checkstyle' 20 | apply plugin: 'pmd' 21 | apply plugin: 'com.github.spotbugs' 22 | 23 | group = 'com.github.elopteryx' 24 | version = '4.1.0-SNAPSHOT' 25 | 26 | repositories { 27 | mavenCentral() 28 | mavenLocal() 29 | } 30 | 31 | ext { 32 | servletApiVersion = '6.0.0' 33 | undertowVersion = '2.3.13.Final' 34 | jettyVersion = '11.0.21' 35 | tomcatVersion = '10.1.24' 36 | junitVersion = '5.10.2' 37 | tikaVersion = '2.9.2' 38 | jimfsVersion = '1.3.0' 39 | mockitoVersion = '5.12.0' 40 | 41 | checkStyleVersion = '10.17.0' 42 | pmdVersion = '6.55.0' 43 | spotbugsVersion = '4.8.5' 44 | } 45 | 46 | tasks.withType(JavaCompile) { 47 | sourceCompatibility = 17 48 | targetCompatibility = 17 49 | } 50 | 51 | java { 52 | withJavadocJar() 53 | withSourcesJar() 54 | modularity.inferModulePath = true 55 | } 56 | 57 | javadoc { 58 | afterEvaluate { 59 | configure(options) { 60 | windowTitle = 'Upload Parser API Documentation' 61 | docTitle = 'Upload Parser API Documentation' 62 | bottom = 'Copyright © 2021 Creative Elopteryx' 63 | breakIterator = true 64 | author = false 65 | source = '17' 66 | encoding = 'UTF-8' 67 | docEncoding = 'UTF-8' 68 | failOnError = true 69 | links = [ 70 | 'https://docs.oracle.com/en/java/javase/17/docs/api/', 71 | 'https://jakarta.ee/specifications/platform/9/apidocs/' 72 | ] 73 | modulePath = configurations.compileClasspath.asList() 74 | } 75 | } 76 | } 77 | 78 | checkstyle { 79 | toolVersion = checkStyleVersion 80 | configFile = file("${projectDir}/../config/checkstyle/checkstyle.xml") 81 | } 82 | 83 | pmd { 84 | toolVersion = pmdVersion 85 | incrementalAnalysis = true 86 | ruleSets = [] 87 | ruleSetConfig = resources.text.fromFile(file("${rootDir}/config/pmd/rulesSets.xml")) 88 | } 89 | 90 | spotbugs { 91 | toolVersion = spotbugsVersion 92 | effort = com.github.spotbugs.snom.Effort.valueOf('MAX') 93 | excludeFilter = file("${projectDir}/../config/spotbugs/spotbugs-exclude.xml") 94 | } 95 | 96 | spotbugsTest { 97 | enabled = false 98 | } 99 | 100 | signing { 101 | required { !version.endsWith('SNAPSHOT') && gradle.taskGraph.hasTask('publish') } 102 | sign configurations.archives 103 | } 104 | 105 | test { 106 | useJUnitPlatform() 107 | testLogging.showStandardStreams = true 108 | } 109 | 110 | } 111 | 112 | repositories { 113 | mavenCentral() 114 | } 115 | -------------------------------------------------------------------------------- /config/checkstyle/checkstyle.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 51 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 76 | 77 | 78 | 80 | 81 | 82 | 83 | 85 | 86 | 87 | 88 | 90 | 91 | 92 | 93 | 94 | 95 | 97 | 98 | 99 | 100 | 102 | 103 | 104 | 105 | 107 | 108 | 109 | 110 | 112 | 114 | 116 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | -------------------------------------------------------------------------------- /config/pmd/rulesSets.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | Every Java Rule in PMD 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /config/spotbugs/spotbugs-exclude.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | ossrhUsername=username 2 | ossrhPassword=password 3 | 4 | org.gradle.warning.mode=all 5 | 6 | org.gradle.caching=true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elopteryx/upload-parser/ae2ea72fedea25bd051823677241c05489d2df06/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https://services.gradle.org/distributions/gradle-8.8-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | # Determine the Java command to use to start the JVM. 86 | if [ -n "$JAVA_HOME" ] ; then 87 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 88 | # IBM's JDK on AIX uses strange locations for the executables 89 | JAVACMD="$JAVA_HOME/jre/sh/java" 90 | else 91 | JAVACMD="$JAVA_HOME/bin/java" 92 | fi 93 | if [ ! -x "$JAVACMD" ] ; then 94 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 95 | 96 | Please set the JAVA_HOME variable in your environment to match the 97 | location of your Java installation." 98 | fi 99 | else 100 | JAVACMD="java" 101 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 102 | 103 | Please set the JAVA_HOME variable in your environment to match the 104 | location of your Java installation." 105 | fi 106 | 107 | # Increase the maximum file descriptors if we can. 108 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 109 | MAX_FD_LIMIT=`ulimit -H -n` 110 | if [ $? -eq 0 ] ; then 111 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 112 | MAX_FD="$MAX_FD_LIMIT" 113 | fi 114 | ulimit -n $MAX_FD 115 | if [ $? -ne 0 ] ; then 116 | warn "Could not set maximum file descriptor limit: $MAX_FD" 117 | fi 118 | else 119 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 120 | fi 121 | fi 122 | 123 | # For Darwin, add options to specify how the application appears in the dock 124 | if $darwin; then 125 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 126 | fi 127 | 128 | # For Cygwin or MSYS, switch paths to Windows format before running java 129 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 130 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 131 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 132 | JAVACMD=`cygpath --unix "$JAVACMD"` 133 | 134 | # We build the pattern for arguments to be converted via cygpath 135 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 136 | SEP="" 137 | for dir in $ROOTDIRSRAW ; do 138 | ROOTDIRS="$ROOTDIRS$SEP$dir" 139 | SEP="|" 140 | done 141 | OURCYGPATTERN="(^($ROOTDIRS))" 142 | # Add a user-defined pattern to the cygpath arguments 143 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 144 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 145 | fi 146 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 147 | i=0 148 | for arg in "$@" ; do 149 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 150 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 151 | 152 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 153 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 154 | else 155 | eval `echo args$i`="\"$arg\"" 156 | fi 157 | i=`expr $i + 1` 158 | done 159 | case $i in 160 | 0) set -- ;; 161 | 1) set -- "$args0" ;; 162 | 2) set -- "$args0" "$args1" ;; 163 | 3) set -- "$args0" "$args1" "$args2" ;; 164 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 165 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 166 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 167 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 168 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 169 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 170 | esac 171 | fi 172 | 173 | # Escape application args 174 | save () { 175 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 176 | echo " " 177 | } 178 | APP_ARGS=`save "$@"` 179 | 180 | # Collect all arguments for the java command, following the shell quoting and substitution rules 181 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 182 | 183 | exec "$JAVACMD" "$@" 184 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 33 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 34 | 35 | @rem Find java.exe 36 | if defined JAVA_HOME goto findJavaFromJavaHome 37 | 38 | set JAVA_EXE=java.exe 39 | %JAVA_EXE% -version >NUL 2>&1 40 | if "%ERRORLEVEL%" == "0" goto init 41 | 42 | echo. 43 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 44 | echo. 45 | echo Please set the JAVA_HOME variable in your environment to match the 46 | echo location of your Java installation. 47 | 48 | goto fail 49 | 50 | :findJavaFromJavaHome 51 | set JAVA_HOME=%JAVA_HOME:"=% 52 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 53 | 54 | if exist "%JAVA_EXE%" goto init 55 | 56 | echo. 57 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 58 | echo. 59 | echo Please set the JAVA_HOME variable in your environment to match the 60 | echo location of your Java installation. 61 | 62 | goto fail 63 | 64 | :init 65 | @rem Get command-line arguments, handling Windows variants 66 | 67 | if not "%OS%" == "Windows_NT" goto win9xME_args 68 | 69 | :win9xME_args 70 | @rem Slurp the command line arguments. 71 | set CMD_LINE_ARGS= 72 | set _SKIP=2 73 | 74 | :win9xME_args_slurp 75 | if "x%~1" == "x" goto execute 76 | 77 | set CMD_LINE_ARGS=%* 78 | 79 | :execute 80 | @rem Setup the command line 81 | 82 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 83 | 84 | @rem Execute Gradle 85 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 86 | 87 | :end 88 | @rem End local scope for the variables with windows NT shell 89 | if "%ERRORLEVEL%"=="0" goto mainEnd 90 | 91 | :fail 92 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 93 | rem the _cmd.exe /c_ return code! 94 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 95 | exit /b 1 96 | 97 | :mainEnd 98 | if "%OS%"=="Windows_NT" endlocal 99 | 100 | :omega 101 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'upload-parser-parent' 2 | 3 | include 'upload-parser-core' 4 | include 'upload-parser-tests' 5 | -------------------------------------------------------------------------------- /upload-parser-core/build.gradle: -------------------------------------------------------------------------------- 1 | ext.moduleName = 'com.github.elopteryx.upload' 2 | 3 | dependencies { 4 | 5 | /* Servlet API. */ 6 | compileOnly("jakarta.servlet:jakarta.servlet-api:$servletApiVersion") 7 | 8 | } 9 | 10 | compileJava { 11 | inputs.property('moduleName', moduleName) 12 | doFirst { 13 | options.compilerArgs = [ 14 | '--module-path', classpath.asPath, 15 | ] 16 | classpath = files() 17 | } 18 | } 19 | 20 | jar { 21 | manifest { 22 | attributes( 23 | 'Created-By': 'Creative Elopteryx', 24 | 'Class-Path': configurations.compileClasspath.collect { it.getName() }.join(' '), 25 | 'Automatic-Module-Name': 'com.github.elopteryx.upload', 26 | 'Implementation-Version': archiveVersion 27 | ) 28 | } 29 | } 30 | 31 | publishing { 32 | publications { 33 | mavenJava(MavenPublication) { 34 | artifactId = 'upload-parser' 35 | from components.java 36 | versionMapping { 37 | usage('java-api') { 38 | fromResolutionOf('runtimeClasspath') 39 | } 40 | usage('java-runtime') { 41 | fromResolutionResult() 42 | } 43 | } 44 | pom { 45 | name = 'Upload Parser' 46 | groupId = 'com.github.elopteryx' 47 | artifactId = 'upload-parser' 48 | 49 | description = 'Upload Parser is an asynchronous file upload library for servlets.' 50 | url = 'https://github.com/Elopteryx/upload-parser' 51 | 52 | scm { 53 | connection = 'scm:git:git@github.com/Elopteryx/upload-parser.git' 54 | developerConnection = 'scm:git:git@github.com/Elopteryx/upload-parser.git' 55 | url = 'scm:git:git@github.com/Elopteryx/upload-parser.git' 56 | } 57 | 58 | licenses { 59 | license { 60 | name = 'The Apache License, Version 2.0' 61 | url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' 62 | } 63 | } 64 | 65 | developers { 66 | developer { 67 | id = 'elopteryx' 68 | name = 'Adam Forgacs' 69 | email = 'creative.elopteryx@gmail.com' 70 | } 71 | } 72 | } 73 | } 74 | } 75 | repositories { 76 | maven { 77 | name = 'ossrh' 78 | credentials(PasswordCredentials) 79 | def releasesRepoUrl = 'https://oss.sonatype.org/service/local/staging/deploy/maven2/' 80 | def snapshotsRepoUrl = 'https://oss.sonatype.org/content/repositories/snapshots/' 81 | url = version.endsWith('SNAPSHOT') ? snapshotsRepoUrl : releasesRepoUrl 82 | } 83 | } 84 | } 85 | 86 | signing { 87 | sign publishing.publications.mavenJava 88 | } 89 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/OnError.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload; 18 | 19 | import java.io.IOException; 20 | import jakarta.servlet.ServletException; 21 | 22 | /** 23 | * A functional interface. An implementation of it must be passed in the 24 | * {@link UploadParser#onError(OnError)} method to call it after an error occurs. 25 | */ 26 | @FunctionalInterface 27 | public interface OnError { 28 | 29 | /** 30 | * The consumer function to implement. 31 | * @param context The upload context 32 | * @param throwable The error that occurred 33 | * @throws IOException If an error occurs with the IO 34 | * @throws ServletException If and error occurred with the servlet 35 | */ 36 | void onError(UploadContext context, Throwable throwable) throws IOException, ServletException; 37 | 38 | } 39 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/OnPartBegin.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload; 18 | 19 | import java.io.IOException; 20 | import java.nio.ByteBuffer; 21 | 22 | /** 23 | * A functional interface. An implementation of it must be passed in the 24 | * {@link UploadParser#onPartBegin(OnPartBegin)} method to call it at the start of parsing for each part. 25 | */ 26 | @FunctionalInterface 27 | public interface OnPartBegin { 28 | 29 | /** 30 | * The function to implement. When it's called depends on the size threshold. If enough bytes 31 | * have been read or if the part is fully uploaded then this method is called 32 | * with a buffer containing the read bytes. Note that the buffer is only passed 33 | * for validation, it should not be written out. The buffered and the upcoming 34 | * bytes will be written out to the output object returned by this method. If the callback 35 | * is not set then the uploaded bytes are discarded. 36 | * @param context The upload context 37 | * @param buffer The byte buffer containing the first bytes of the part 38 | * @return A non-null output object (a channel or stream) to write out the part 39 | * @throws IOException If an error occurred with the channel 40 | */ 41 | PartOutput onPartBegin(UploadContext context, ByteBuffer buffer) throws IOException; 42 | 43 | } 44 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/OnPartEnd.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload; 18 | 19 | import java.io.IOException; 20 | 21 | /** 22 | * A functional interface. An implementation of it must be passed in the 23 | * {@link UploadParser#onPartEnd(OnPartEnd)} method to call it at the end of parsing for each part. 24 | * 25 | *

This function is called after every byte has been read for the given part. There will be 26 | * an attempt to close the current output object just before calling this. That means setting 27 | * this can be skipped if all you want to do is to close the provided channel or stream.

28 | */ 29 | @FunctionalInterface 30 | public interface OnPartEnd { 31 | 32 | /** 33 | * The consumer function to implement. 34 | * @param context The upload context 35 | * @throws IOException If an error occurred with the current channel 36 | */ 37 | void onPartEnd(UploadContext context) throws IOException; 38 | 39 | } 40 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/OnRequestComplete.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload; 18 | 19 | import java.io.IOException; 20 | import jakarta.servlet.ServletException; 21 | 22 | /** 23 | * A functional interface. An implementation of it must be passed in the 24 | * {@link UploadParser#onRequestComplete(OnRequestComplete)} method to call it after every part has been processed. 25 | */ 26 | @FunctionalInterface 27 | public interface OnRequestComplete { 28 | 29 | /** 30 | * The consumer function to implement. 31 | * @param context The upload context 32 | * @throws IOException If an error occurs with the IO 33 | * @throws ServletException If and error occurred with the servlet 34 | */ 35 | void onRequestComplete(UploadContext context) throws IOException, ServletException; 36 | } 37 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/PartOutput.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload; 18 | 19 | import java.io.OutputStream; 20 | import java.nio.channels.WritableByteChannel; 21 | import java.nio.file.Path; 22 | 23 | /** 24 | * A value holder class, allowing the caller to provide 25 | * various output objects, like a byte channel or an output stream. 26 | */ 27 | public class PartOutput { 28 | 29 | /** 30 | * The value object. 31 | */ 32 | private final Object value; 33 | 34 | /** 35 | * Protected constructor, no need for public access. 36 | * The parser will use the given object here, which is why using 37 | * the static factory methods is encouraged. Passing an invalid 38 | * object will terminate the upload process. 39 | * @param value The value object. 40 | */ 41 | protected PartOutput(final Object value) { 42 | this.value = value; 43 | } 44 | 45 | /** 46 | * Returns whether it is safe to retrieve the value object 47 | * with the class parameter. 48 | * @param clazz The class type to check 49 | * @param Type parameter 50 | * @return Whether it is safe to cast or not 51 | */ 52 | public boolean safeToCast(final Class clazz) { 53 | return value != null && clazz.isAssignableFrom(value.getClass()); 54 | } 55 | 56 | /** 57 | * Retrieves the value object, casting it to the 58 | * given type. 59 | * @param clazz The class to cast 60 | * @param Type parameter 61 | * @return The stored value object 62 | */ 63 | public T unwrap(final Class clazz) { 64 | return clazz.cast(value); 65 | } 66 | 67 | /** 68 | * Creates a new instance from the given channel object. The parser will 69 | * use the channel to write out the bytes and will attempt to close it. 70 | * @param byteChannel A channel which can be used for writing 71 | * @return A new PartOutput instance 72 | */ 73 | public static PartOutput from(final WritableByteChannel byteChannel) { 74 | return new PartOutput(byteChannel); 75 | } 76 | 77 | /** 78 | * Creates a new instance from the given stream object. The parser will 79 | * create a channel from the stream to write out the bytes and 80 | * will attempt to close it. 81 | * @param outputStream A stream which can be used for writing 82 | * @return A new PartOutput instance 83 | */ 84 | public static PartOutput from(final OutputStream outputStream) { 85 | return new PartOutput(outputStream); 86 | } 87 | 88 | /** 89 | * Creates a new instance from the given path object. The parser will 90 | * create a channel from the path to write out the bytes and 91 | * will attempt to close it. If the file represented by the path 92 | * does not exist, it will be created. If the file exists and is not 93 | * empty then the uploaded bytes will be appended to the end of it. 94 | * @param path A file path which can be used for writing 95 | * @return A new PartOutput instance 96 | */ 97 | public static PartOutput from(final Path path) { 98 | return new PartOutput(path); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/PartStream.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload; 18 | 19 | import java.util.Collection; 20 | 21 | /** 22 | * This interface represents a part item, which is being 23 | * streamed from the client. As the object is created 24 | * before the item finished uploading available information 25 | * is limited. 26 | */ 27 | public interface PartStream { 28 | 29 | /** 30 | * Returns the content type of this part, as it was submitted by 31 | * the client. 32 | * 33 | * @return The content type of this part 34 | */ 35 | String getContentType(); 36 | 37 | /** 38 | * Returns the name of this part, which equals the name of the form 39 | * field the part was selected for. 40 | * 41 | * @return The name of this part as a String 42 | */ 43 | String getName(); 44 | 45 | /** 46 | * Returns the known size of this part. This means that the 47 | * returned value depends on how many bytes have been processed. When called 48 | * during the start of the part processing the returned value will be equal 49 | * to the specified buffer threshold or the actual part size if it's smaller 50 | * than that. When called during the end the returned value is always the 51 | * actual size, because by that time it has been fully processed. 52 | * 53 | * @return A long specifying the known size of this part, in bytes. 54 | */ 55 | long getKnownSize(); 56 | 57 | /** 58 | * Returns the file name specified by the client or null if the 59 | * part is a normal form field. 60 | * 61 | * @return The submitted file name 62 | */ 63 | String getSubmittedFileName(); 64 | 65 | /** 66 | * Determines whether or not this PartStream instance represents 67 | * a file item. If it's a normal form field then it will return false. 68 | * Consequently, if this returns true then the {@link PartStream#getSubmittedFileName} 69 | * will return with a non-null value and vice-versa. 70 | * 71 | * @return True if the instance represents an uploaded file; false if it represents a simple form field. 72 | */ 73 | boolean isFile(); 74 | 75 | /** 76 | * Returns whether the part has been completely uploaded. The 77 | * part begin callback is called only after the given size threshold 78 | * is reached or if the part is completely uploaded. Therefore, the 79 | * parts that are smaller than the threshold are passed to the part begin 80 | * callback after their upload is finished and their actual size is known. 81 | * This method can be used to determine that. In the part end callback 82 | * this will always return true. 83 | * 84 | * @return True if the part is completely uploaded, false otherwise. 85 | */ 86 | boolean isFinished(); 87 | 88 | /** 89 | * Returns the value of the specified mime header as a String. If 90 | * the Part did not include a header of the specified name, this 91 | * method returns null. If there are multiple headers with the same name, 92 | * this method returns the first header in the part. The header name 93 | * is case insensitive. You can use this method with any request header. 94 | * 95 | * @param name a String specifying the header name 96 | * @return a String containing the value of the requested header, or null if the part does not have a header of that name 97 | */ 98 | String getHeader(String name); 99 | 100 | /** 101 | * Returns the values of the part header with the given name. 102 | * 103 | * @param name the header name whose values to return 104 | * @return a (possibly empty) Collection of the values of the header with the given name 105 | */ 106 | Collection getHeaders(String name); 107 | 108 | /** 109 | * Returns the header names of this part. 110 | * 111 | * @return a (possibly empty) Collection of the header names of this Part 112 | */ 113 | Collection getHeaderNames(); 114 | 115 | } 116 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/UploadContext.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload; 18 | 19 | import java.util.List; 20 | import jakarta.servlet.http.HttpServletRequest; 21 | 22 | /** 23 | * The context object which is passed to the user-supplied functions, 24 | * allowing the user to get the necessary objects during the various 25 | * stages of the parsing. 26 | */ 27 | public interface UploadContext { 28 | 29 | /** 30 | * Returns the given request object for this parser, 31 | * allowing customization during the stages of the 32 | * asynchronous processing. 33 | * 34 | * @return The request object 35 | */ 36 | HttpServletRequest getRequest(); 37 | 38 | /** 39 | * Returns the given user object for this parser, 40 | * allowing customization during the stages of the 41 | * asynchronous processing. 42 | * 43 | * @param clazz The class used for casting 44 | * @param Type parameter 45 | * @return The user object, or null if it was not supplied 46 | */ 47 | T getUserObject(Class clazz); 48 | 49 | /** 50 | * Returns the currently processed part stream, 51 | * allowing the caller to get available information. 52 | * 53 | * @return The currently processed part 54 | */ 55 | PartStream getCurrentPart(); 56 | 57 | /** 58 | * Returns the currently active output, which was returned 59 | * in the latest UploadParser#onPartBegin method. This will 60 | * return a null during the part begin stage and might be 61 | * null during the error stage. 62 | * 63 | * @return The latest output provided by the caller 64 | */ 65 | PartOutput getCurrentOutput(); 66 | 67 | /** 68 | * Returns the parts which have already been processed. Before 69 | * the onPartBegin method is called the current PartStream is 70 | * added into the List returned by this method, meaning that 71 | * the UploadContext#getCurrentPart will return with the last 72 | * element of the list. 73 | * @return The list of the processed parts, in the order they are uploaded 74 | */ 75 | List getPartStreams(); 76 | } 77 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/errors/MultipartException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload.errors; 18 | 19 | import java.io.IOException; 20 | 21 | /** 22 | * Exception thrown by the multipart parser. Usually it's 23 | * impossible to recover from this error. 24 | */ 25 | public class MultipartException extends IOException { 26 | 27 | /** 28 | * Public constructor. 29 | * @param message The exception message. 30 | */ 31 | public MultipartException(final String message) { 32 | super(message); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/errors/PartSizeException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload.errors; 18 | 19 | /** 20 | * Exception thrown when there is a maximum size limit set for the individual 21 | * parts and it is exceeded for the first time. 22 | */ 23 | public class PartSizeException extends UploadSizeException { 24 | 25 | /** 26 | * Public constructor. 27 | * @param message The message of the exception 28 | * @param actual The known size at the time of the exception 29 | * @param permitted The maximum permitted size 30 | */ 31 | public PartSizeException(final String message, final long actual, final long permitted) { 32 | super(message, actual, permitted); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/errors/RequestSizeException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload.errors; 18 | 19 | /** 20 | * Exception thrown when there is a maximum size limit set for the whole 21 | * request and it is exceeded for the first time. 22 | */ 23 | public class RequestSizeException extends UploadSizeException { 24 | 25 | /** 26 | * Public constructor. 27 | * @param message The message of the exception 28 | * @param actual The known size at the time of the exception in bytes 29 | * @param permitted The maximum permitted size in bytes 30 | */ 31 | public RequestSizeException(final String message, final long actual, final long permitted) { 32 | super(message, actual, permitted); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/errors/UploadSizeException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload.errors; 18 | 19 | /** 20 | * Base class for the size related exceptions. 21 | */ 22 | public abstract class UploadSizeException extends RuntimeException { 23 | 24 | /** 25 | * The known size. 26 | */ 27 | private final long actual; 28 | 29 | /** 30 | * The maximum permitted size. 31 | */ 32 | private final long permitted; 33 | 34 | /** 35 | * Package private constructor. 36 | * @param message The message of the exception 37 | * @param actual The known size at the time of the exception in bytes 38 | * @param permitted The maximum permitted size in bytes 39 | */ 40 | UploadSizeException(final String message, final long actual, final long permitted) { 41 | super(message); 42 | this.actual = actual; 43 | this.permitted = permitted; 44 | } 45 | 46 | /** 47 | * Returns the actual size. 48 | * 49 | * @return The actual size. 50 | */ 51 | public long getActualSize() { 52 | return actual; 53 | } 54 | 55 | /** 56 | * Returns the permitted size. 57 | * 58 | * @return The permitted size. 59 | */ 60 | public long getPermittedSize() { 61 | return permitted; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/errors/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * This package contains the exception classes used in this library. 3 | */ 4 | package com.github.elopteryx.upload.errors; -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/internal/AsyncUploadParser.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload.internal; 18 | 19 | import static java.util.Objects.requireNonNull; 20 | 21 | import com.github.elopteryx.upload.errors.MultipartException; 22 | 23 | import java.io.IOException; 24 | import jakarta.servlet.ReadListener; 25 | import jakarta.servlet.ServletException; 26 | import jakarta.servlet.ServletInputStream; 27 | import jakarta.servlet.http.HttpServletRequest; 28 | 29 | /** 30 | * The asynchronous implementation of the parser. This parser can be used to perform a parse 31 | * only if the calling servlet supports async mode. 32 | * Implements the listener interface. Called by the servlet container whenever data is available. 33 | */ 34 | public final class AsyncUploadParser extends AbstractUploadParser implements ReadListener { 35 | 36 | /** 37 | * The request object. 38 | */ 39 | private final HttpServletRequest request; 40 | 41 | /** 42 | * The input stream associated with the request. 43 | */ 44 | private ServletInputStream servletInputStream; 45 | 46 | public AsyncUploadParser(final HttpServletRequest request) { 47 | this.request = requireNonNull(request); 48 | } 49 | 50 | /** 51 | * Sets up the necessary objects to start the parsing. Depending upon 52 | * the environment the concrete implementations can be very different. 53 | * @throws IOException If an error occurs with the IO 54 | */ 55 | private void init() throws IOException { 56 | init(request); 57 | servletInputStream = request.getInputStream(); 58 | } 59 | 60 | /** 61 | * Setups the async parsing by registering the instance to 62 | * the servlet stream as a read listener. 63 | * @throws IOException If an error occurred with I/O 64 | */ 65 | public void setupAsyncParse() throws IOException { 66 | init(); 67 | if (!request.isAsyncSupported()) { 68 | throw new IllegalStateException("The servlet does not support async mode! Enable it or use a blocking parser."); 69 | } 70 | if (!request.isAsyncStarted()) { 71 | request.startAsync(); 72 | } 73 | servletInputStream.setReadListener(this); 74 | } 75 | 76 | /** 77 | * When an instance of the ReadListener is registered with a ServletInputStream, this method will be invoked 78 | * by the container the first time when it is possible to read data. Subsequently the container will invoke 79 | * this method if and only if ServletInputStream.isReady() method has been called and has returned false. 80 | * @throws IOException if an I/O related error has occurred during processing 81 | */ 82 | @Override 83 | public void onDataAvailable() throws IOException { 84 | while (servletInputStream.isReady() && !servletInputStream.isFinished()) { 85 | parseCurrentItem(); 86 | } 87 | } 88 | 89 | /** 90 | * Parses the servlet stream once. Will switch to a new item 91 | * if the current one is fully read. 92 | * 93 | * @return Whether it should be called again 94 | * @throws IOException if an I/O related error has occurred during processing 95 | */ 96 | private boolean parseCurrentItem() throws IOException { 97 | var count = -1; 98 | if (!servletInputStream.isFinished()) { 99 | count = servletInputStream.read(dataBuffer.array()); 100 | } 101 | if (count == -1) { 102 | if (!parseState.isComplete()) { 103 | throw new MultipartException("Stream ended unexpectedly!"); 104 | } 105 | } else { 106 | checkRequestSize(count); 107 | dataBuffer.position(0); 108 | dataBuffer.limit(count); 109 | parseState.parse(dataBuffer); 110 | } 111 | return !parseState.isComplete(); 112 | } 113 | 114 | /** 115 | * Invoked when all data for the current request has been read. 116 | * @throws IOException if an I/O related error has occurred during processing 117 | */ 118 | @Override 119 | public void onAllDataRead() throws IOException { 120 | // After the servlet input stream is finished there are still unread bytes or 121 | // in case of fast uploads or small sizes the initial parse can read the whole 122 | // input stream, causing the {@link #onDataAvailable} not to be called even once. 123 | while (true) { 124 | if (!parseCurrentItem()) { 125 | break; 126 | } 127 | } 128 | try { 129 | if (requestCallback != null) { 130 | requestCallback.onRequestComplete(context); 131 | } 132 | } catch (final ServletException e) { 133 | throw new RuntimeException(e); 134 | } 135 | } 136 | 137 | /** 138 | * Invoked when an error occurs processing the request. 139 | * @param throwable The unhandled error that happened 140 | */ 141 | @Override 142 | public void onError(final Throwable throwable) { 143 | try { 144 | if (errorCallback != null) { 145 | errorCallback.onError(context, throwable); 146 | } 147 | } catch (final IOException | ServletException e) { 148 | throw new RuntimeException(e); 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/internal/Base64Decoder.java: -------------------------------------------------------------------------------- 1 | /* 2 | * JBoss, Home of Professional Open Source. 3 | * Copyright 2014 Red Hat, Inc., and individual contributors 4 | * as indicated by the @author tags. 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | package com.github.elopteryx.upload.internal; 20 | 21 | import static java.nio.charset.StandardCharsets.US_ASCII; 22 | 23 | import java.io.IOException; 24 | import java.nio.ByteBuffer; 25 | 26 | /** 27 | * Copied from Undertow. Stripped out the unnecessary parts, like the 28 | * encoder and the methods which accepted a different parameter, 29 | * for example a byte array, instead of a ByteBuffer. 30 | * 31 | *

An efficient and flexible MIME Base64 implementation.

32 | * 33 | * @author Jason T. Greene 34 | */ 35 | class Base64Decoder { 36 | 37 | private static final byte[] ENCODING_TABLE; 38 | private static final byte[] DECODING_TABLE = new byte[80]; 39 | 40 | static { 41 | ENCODING_TABLE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".getBytes(US_ASCII); 42 | for (var i = 0; i < ENCODING_TABLE.length; i++) { 43 | final var offSet = (ENCODING_TABLE[i] & 0xFF) - 43; 44 | DECODING_TABLE[offSet] = (byte)(i + 1); // zero = illegal 45 | } 46 | } 47 | 48 | private int state; 49 | private int last; 50 | private static final int SKIP = 0x0FD00; 51 | private static final int MARK = 0x0FE00; 52 | private static final int DONE = 0x0FF00; 53 | private static final int ERROR = 0xF0000; 54 | 55 | private static int nextByte(final ByteBuffer buffer, final int state, final int last, final boolean ignoreErrors) throws IOException { 56 | return nextByte(buffer.get() & 0xFF, state, last, ignoreErrors); 57 | } 58 | 59 | private static int nextByte(final int charInt, final int state, final int last, final boolean ignoreErrors) throws IOException { 60 | if (last == MARK) { 61 | if (charInt != '=') { 62 | throw new IOException("Expected padding character"); 63 | } 64 | return DONE; 65 | } 66 | if (charInt == '=') { 67 | if (state == 2) { 68 | return MARK; 69 | } else if (state == 3) { 70 | return DONE; 71 | } else { 72 | throw new IOException("Unexpected padding character"); 73 | } 74 | } 75 | if (charInt == ' ' || charInt == '\t' || charInt == '\r' || charInt == '\n') { 76 | return SKIP; 77 | } 78 | if (charInt < 43 || charInt > 122) { 79 | if (ignoreErrors) { 80 | return ERROR; 81 | } 82 | throw new IOException("Invalid base64 character encountered: " + charInt); 83 | } 84 | final var byteInt = (DECODING_TABLE[charInt - 43] & 0xFF) - 1; 85 | if (byteInt < 0) { 86 | if (ignoreErrors) { 87 | return ERROR; 88 | } 89 | throw new IOException("Invalid base64 character encountered: " + charInt); 90 | } 91 | return byteInt; 92 | } 93 | 94 | /** 95 | * Decodes one Base64 byte buffer into another. This method will return and save state 96 | * if the target does not have the required capacity. Subsequent calls with a new target will 97 | * resume reading where it last left off (the source buffer's position). Similarly not all of the 98 | * source data need be available, this method can be repetitively called as data is made available. 99 | * 100 | *

The decoder will skip white space, but will error if it detects corruption.

101 | * 102 | * @param source the byte buffer to read encoded data from 103 | * @param target the byte buffer to write decoded data to 104 | * @throws java.io.IOException if the encoded data is corrupted 105 | */ 106 | void decode(final ByteBuffer source, final ByteBuffer target) throws IOException { 107 | if (target == null) { 108 | throw new IllegalStateException(); 109 | } 110 | 111 | var last = this.last; 112 | var state = this.state; 113 | 114 | var remaining = source.remaining(); 115 | var targetRemaining = target.remaining(); 116 | var byteInt = 0; 117 | while (remaining-- > 0 && targetRemaining > 0) { 118 | byteInt = nextByte(source, state, last, false); 119 | if (byteInt == MARK) { 120 | last = MARK; 121 | if (--remaining <= 0) { 122 | break; 123 | } 124 | byteInt = nextByte(source, state, last, false); 125 | } 126 | if (byteInt == DONE) { 127 | last = state = 0; 128 | break; 129 | } 130 | if (byteInt == SKIP) { 131 | continue; 132 | } 133 | // ( 6 | 2) (4 | 4) (2 | 6) 134 | if (state == 0) { 135 | last = byteInt << 2; 136 | state++; 137 | if (remaining-- <= 0) { 138 | break; 139 | } 140 | byteInt = nextByte(source, state, last, false); 141 | if ((byteInt & 0xF000) != 0) { 142 | source.position(source.position() - 1); 143 | continue; 144 | } 145 | } 146 | if (state == 1) { 147 | target.put((byte)(last | (byteInt >>> 4))); 148 | last = (byteInt & 0x0F) << 4; 149 | state++; 150 | if (remaining-- <= 0 || --targetRemaining <= 0) { 151 | break; 152 | } 153 | byteInt = nextByte(source, state, last, false); 154 | if ((byteInt & 0xF000) != 0) { 155 | source.position(source.position() - 1); 156 | continue; 157 | } 158 | } 159 | if (state == 2) { 160 | target.put((byte) (last | (byteInt >>> 2))); 161 | last = (byteInt & 0x3) << 6; 162 | state++; 163 | if (remaining-- <= 0 || --targetRemaining <= 0) { 164 | break; 165 | } 166 | byteInt = nextByte(source, state, last, false); 167 | if ((byteInt & 0xF000) != 0) { 168 | source.position(source.position() - 1); 169 | continue; 170 | } 171 | } 172 | if (state == 3) { 173 | target.put((byte)(last | byteInt)); 174 | last = state = 0; 175 | targetRemaining--; 176 | } 177 | } 178 | 179 | if (remaining > 0) { 180 | drain(source, byteInt, state, last); 181 | } 182 | 183 | this.last = last; 184 | this.state = state; 185 | } 186 | 187 | private static void drain(final ByteBuffer source, int byteInt, final int state, int last) { 188 | while (byteInt != DONE && source.remaining() > 0) { 189 | try { 190 | byteInt = nextByte(source, state, last, true); 191 | } catch (final IOException e) { 192 | byteInt = 0; 193 | } 194 | 195 | if (byteInt == MARK) { 196 | last = MARK; 197 | continue; 198 | } 199 | 200 | // Not WS/pad 201 | if ((byteInt & 0xF000) == 0) { 202 | source.position(source.position() - 1); 203 | break; 204 | } 205 | } 206 | 207 | if (byteInt == DONE) { 208 | // SKIP one line of trailing whitespace 209 | while (source.remaining() > 0) { 210 | byteInt = source.get(); 211 | if (byteInt == '\n') { 212 | break; 213 | } else if (byteInt != ' ' && byteInt != '\t' && byteInt != '\r') { 214 | source.position(source.position() - 1); 215 | break; 216 | } 217 | 218 | } 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/internal/BlockingUploadParser.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload.internal; 18 | 19 | import com.github.elopteryx.upload.UploadContext; 20 | import com.github.elopteryx.upload.errors.MultipartException; 21 | 22 | import java.io.IOException; 23 | import java.io.InputStream; 24 | import jakarta.servlet.ServletException; 25 | import jakarta.servlet.http.HttpServletRequest; 26 | 27 | /** 28 | * The blocking implementation of the parser. This parser can be used to perform a 29 | * blocking parse, whether the servlet supports async mode or not. 30 | */ 31 | public final class BlockingUploadParser extends AbstractUploadParser { 32 | 33 | /** 34 | * The request object. 35 | */ 36 | private final HttpServletRequest request; 37 | 38 | /** 39 | * The stream to read. 40 | */ 41 | private InputStream inputStream; 42 | 43 | public BlockingUploadParser(final HttpServletRequest request) { 44 | this.request = request; 45 | } 46 | 47 | /** 48 | * Sets up the necessary objects to start the parsing. Depending upon 49 | * the environment the concrete implementations can be very different. 50 | * @throws IOException If an error occurs with the IO 51 | */ 52 | private void init() throws IOException { 53 | init(request); 54 | inputStream = request.getInputStream(); 55 | } 56 | 57 | /** 58 | * Performs a full parsing and returns the used context object. 59 | * @return The upload context 60 | * @throws IOException If an error occurred with the I/O 61 | * @throws ServletException If an error occurred with the servlet 62 | */ 63 | public UploadContext doBlockingParse() throws IOException, ServletException { 64 | init(); 65 | try { 66 | blockingRead(); 67 | if (requestCallback != null) { 68 | requestCallback.onRequestComplete(context); 69 | } 70 | } catch (final Exception e) { 71 | if (errorCallback != null) { 72 | errorCallback.onError(context, e); 73 | } 74 | } 75 | return context; 76 | } 77 | 78 | /** 79 | * Reads everything from the input stream in a blocking mode. It will 80 | * throw an exception if the data is malformed, for example 81 | * it is not closed with the proper characters. 82 | * @throws IOException If an error occurred with the I/O 83 | */ 84 | private void blockingRead() throws IOException { 85 | while (true) { 86 | final var count = inputStream.read(dataBuffer.array()); 87 | if (count == -1) { 88 | if (parseState.isComplete()) { 89 | break; 90 | } else { 91 | throw new MultipartException("Stream ended unexpectedly!"); 92 | } 93 | } else if (count > 0) { 94 | checkRequestSize(count); 95 | dataBuffer.position(0); 96 | dataBuffer.limit(count); 97 | parseState.parse(dataBuffer); 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/internal/Headers.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload.internal; 18 | 19 | import java.util.ArrayList; 20 | import java.util.Collection; 21 | import java.util.Collections; 22 | import java.util.LinkedHashMap; 23 | import java.util.List; 24 | import java.util.Locale; 25 | import java.util.Map; 26 | 27 | /** 28 | * This class is used to extract, store and retrieve header keys 29 | * and values. Supports the HTTP request headers and also the headers 30 | * for the part items received in the multipart request. 31 | */ 32 | public class Headers { 33 | 34 | private static final String BOUNDARY = "boundary"; 35 | 36 | public static final String CONTENT_DISPOSITION = "Content-Disposition"; 37 | 38 | public static final String CONTENT_ENCODING = "Content-Encoding"; 39 | 40 | public static final String CONTENT_LENGTH = "Content-Length"; 41 | 42 | public static final String CONTENT_TYPE = "Content-Type"; 43 | 44 | private final Map> headerNameToValueListMap = new LinkedHashMap<>(); 45 | 46 | String getHeader(final String name) { 47 | final var nameLower = name.toLowerCase(Locale.ENGLISH); 48 | final var headerValueList = headerNameToValueListMap.get(nameLower); 49 | return headerValueList == null ? null : headerValueList.get(0); 50 | } 51 | 52 | Collection getHeaders(final String name) { 53 | final var nameLower = name.toLowerCase(Locale.ENGLISH); 54 | return headerNameToValueListMap.getOrDefault(nameLower, Collections.emptyList()); 55 | } 56 | 57 | Collection getHeaderNames() { 58 | return headerNameToValueListMap.keySet(); 59 | } 60 | 61 | /** 62 | * Method to add header values to this instance. 63 | * 64 | * @param name name of this header 65 | * @param value value of this header 66 | */ 67 | void addHeader(final String name, final String value) { 68 | final var nameLower = name.toLowerCase(Locale.ENGLISH); 69 | headerNameToValueListMap.computeIfAbsent(nameLower, key -> new ArrayList<>()).add(value); 70 | } 71 | 72 | /** 73 | * Extracts a token from a header that has a given key. For instance if the header is 74 | * content-type=multipart/form-data boundary=myboundary 75 | * and the key is boundary the myboundary will be returned. 76 | * 77 | * @param header The header 78 | * @return The token, or null if it was not found 79 | */ 80 | public static String extractBoundaryFromHeader(final String header) { 81 | 82 | final var pos = header.indexOf(BOUNDARY + '='); 83 | if (pos == -1) { 84 | return null; 85 | } 86 | int end; 87 | final var start = pos + BOUNDARY.length() + 1; 88 | for (end = start; end < header.length(); ++end) { 89 | final var character = header.charAt(end); 90 | if (character == ' ' || character == '\t' || character == ';') { 91 | break; 92 | } 93 | } 94 | return header.substring(start, end); 95 | } 96 | 97 | /** 98 | * Extracts a quoted value from a header that has a given key. For instance if the header is 99 | * content-disposition=form-data; name="my field" 100 | * and the key is name then "my field" will be returned without the quotes. 101 | * 102 | * @param header The header 103 | * @param key The key that identifies the token to extract 104 | * @return The token, or null if it was not found 105 | */ 106 | public static String extractQuotedValueFromHeader(final String header, final String key) { 107 | 108 | var keyPosition = 0; 109 | var pos = -1; 110 | var inQuotes = false; 111 | for (var i = 0; i < header.length() - 1; ++i) { //-1 because we need room for the = at the end 112 | final var character = header.charAt(i); 113 | if (inQuotes) { 114 | if (character == '"') { 115 | inQuotes = false; 116 | } 117 | } else { 118 | if (key.charAt(keyPosition) == character) { 119 | keyPosition++; 120 | } else if (character == '"') { 121 | keyPosition = 0; 122 | inQuotes = true; 123 | } else { 124 | keyPosition = 0; 125 | } 126 | if (keyPosition == key.length()) { 127 | if (header.charAt(i + 1) == '=') { 128 | pos = i + 2; 129 | break; 130 | } else { 131 | keyPosition = 0; 132 | } 133 | } 134 | } 135 | 136 | } 137 | if (pos == -1) { 138 | return null; 139 | } 140 | 141 | int end; 142 | var start = pos; 143 | if (header.charAt(start) == '"') { 144 | start++; 145 | for (end = start; end < header.length(); ++end) { 146 | final var character = header.charAt(end); 147 | if (character == '"') { 148 | break; 149 | } 150 | } 151 | 152 | } else { 153 | //no quotes 154 | for (end = start; end < header.length(); ++end) { 155 | final var character = header.charAt(end); 156 | if (character == ' ' || character == '\t') { 157 | break; 158 | } 159 | } 160 | } 161 | return header.substring(start, end); 162 | } 163 | 164 | } 165 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/internal/PartStreamImpl.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload.internal; 18 | 19 | import com.github.elopteryx.upload.PartOutput; 20 | import com.github.elopteryx.upload.PartStream; 21 | 22 | import java.util.Collection; 23 | 24 | /** 25 | * Default implementation of {@link PartStream}. 26 | */ 27 | public class PartStreamImpl implements PartStream { 28 | 29 | /** 30 | * The content type of the part. 31 | */ 32 | private final String contentType; 33 | /** 34 | * The file name of the part. 35 | */ 36 | private final String fileName; 37 | /** 38 | * The field name of the part. 39 | */ 40 | private final String fieldName; 41 | /** 42 | * Whether the part is a file field. 43 | */ 44 | private final boolean fileField; 45 | /** 46 | * The headers, if any. 47 | */ 48 | private final Headers headers; 49 | /** 50 | * The size of the part, updated on each read. 51 | */ 52 | private long size; 53 | /** 54 | * Boolean flag storing whether the part is 55 | * completely uploaded. 56 | */ 57 | private boolean finished; 58 | /** 59 | * The output object supplied by the caller. 60 | */ 61 | private PartOutput output; 62 | 63 | /** 64 | * Creates a new instance. 65 | * @param fileName The file name. 66 | * @param fieldName The form field name. 67 | * @param headers The object containing the headers 68 | */ 69 | public PartStreamImpl(final String fileName, final String fieldName, final Headers headers) { 70 | this.fileName = fileName; 71 | this.fieldName = fieldName; 72 | this.contentType = headers.getHeader(Headers.CONTENT_TYPE); 73 | this.fileField = fileName != null; 74 | this.headers = headers; 75 | } 76 | 77 | @Override 78 | public String getContentType() { 79 | return contentType; 80 | } 81 | 82 | @Override 83 | public String getName() { 84 | return fieldName; 85 | } 86 | 87 | @Override 88 | public long getKnownSize() { 89 | return size; 90 | } 91 | 92 | @Override 93 | public String getSubmittedFileName() { 94 | return checkFileName(fileName); 95 | } 96 | 97 | @Override 98 | public boolean isFile() { 99 | return fileField; 100 | } 101 | 102 | @Override 103 | public boolean isFinished() { 104 | return finished; 105 | } 106 | 107 | @Override 108 | public String getHeader(final String name) { 109 | return headers.getHeader(name); 110 | } 111 | 112 | @Override 113 | public Collection getHeaderNames() { 114 | return headers.getHeaderNames(); 115 | } 116 | 117 | @Override 118 | public Collection getHeaders(final String name) { 119 | return headers.getHeaders(name); 120 | } 121 | 122 | void setSize(final long size) { 123 | this.size = size; 124 | } 125 | 126 | void markAsFinished() { 127 | this.finished = true; 128 | } 129 | 130 | public PartOutput getOutput() { 131 | return output; 132 | } 133 | 134 | void setOutput(final PartOutput output) { 135 | this.output = output; 136 | } 137 | 138 | private String checkFileName(final String fileName) { 139 | if (fileName != null && fileName.indexOf('\u0000') != -1) { 140 | final var sb = new StringBuilder(); 141 | for (var i = 0; i < fileName.length(); i++) { 142 | final var character = fileName.charAt(i); 143 | final var append = character == 0 ? "\\0" : character; 144 | sb.append(append); 145 | } 146 | throw new IllegalArgumentException(fileName + " Invalid file name: " + sb); 147 | } 148 | return fileName; 149 | } 150 | 151 | } 152 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/internal/UploadContextImpl.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload.internal; 18 | 19 | import com.github.elopteryx.upload.PartOutput; 20 | import com.github.elopteryx.upload.PartStream; 21 | import com.github.elopteryx.upload.UploadContext; 22 | 23 | import java.util.ArrayList; 24 | import java.util.Collections; 25 | import java.util.List; 26 | import jakarta.servlet.http.HttpServletRequest; 27 | 28 | /** 29 | * Default implementation of {@link UploadContext}. 30 | */ 31 | public class UploadContextImpl implements UploadContext { 32 | 33 | /** 34 | * The request containing the bytes of the file. It's always a multipart, POST request. 35 | */ 36 | private final HttpServletRequest request; 37 | /** 38 | * The user object. Only used by the callers, not necessary for these classes. 39 | */ 40 | private final Object userObject; 41 | /** 42 | * The currently processed item. 43 | */ 44 | private PartStreamImpl currentPart; 45 | /** 46 | * The active output. 47 | */ 48 | private PartOutput output; 49 | /** 50 | * The list of the already processed items. 51 | */ 52 | private final List partStreams = new ArrayList<>(); 53 | /** 54 | * Determines whether the current item is buffering, that is, should new bytes be 55 | * stored in memory or written out the channel. It is set to false after the 56 | * part begin function is called. 57 | */ 58 | private boolean buffering = true; 59 | /** 60 | * The total number for the bytes read for the current part. 61 | */ 62 | private int partBytesRead; 63 | 64 | public UploadContextImpl(final HttpServletRequest request, final Object userObject) { 65 | this.request = request; 66 | this.userObject = userObject; 67 | } 68 | 69 | @Override 70 | public HttpServletRequest getRequest() { 71 | return request; 72 | } 73 | 74 | @Override 75 | public T getUserObject(final Class clazz) { 76 | return userObject == null ? null : clazz.cast(userObject); 77 | } 78 | 79 | @Override 80 | public PartStreamImpl getCurrentPart() { 81 | return currentPart; 82 | } 83 | 84 | @Override 85 | public PartOutput getCurrentOutput() { 86 | return output; 87 | } 88 | 89 | @Override 90 | public List getPartStreams() { 91 | return Collections.unmodifiableList(partStreams); 92 | } 93 | 94 | void reset(final PartStreamImpl newPart) { 95 | buffering = true; 96 | partBytesRead = 0; 97 | currentPart = newPart; 98 | partStreams.add(newPart); 99 | output = null; 100 | } 101 | 102 | void setOutput(final PartOutput output) { 103 | this.output = output; 104 | this.currentPart.setOutput(output); 105 | } 106 | 107 | boolean isBuffering() { 108 | return buffering; 109 | } 110 | 111 | void finishBuffering() { 112 | buffering = false; 113 | } 114 | 115 | void updatePartBytesRead() { 116 | currentPart.setSize(partBytesRead); 117 | } 118 | 119 | int getPartBytesRead() { 120 | return partBytesRead; 121 | } 122 | 123 | int incrementAndGetPartBytesRead(final int additional) { 124 | partBytesRead += additional; 125 | return partBytesRead; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/internal/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * The package for the actual implementation. Classes of this 3 | * package are considered internal, can change a lot during 4 | * releases, but are otherwise usable. If the public API 5 | * does not meet the needs then these classes can be used 6 | * instead. 7 | */ 8 | package com.github.elopteryx.upload.internal; -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * The top level of the package hierarchy which contains the 3 | * public API of this library. Interfaces and classes here will 4 | * not be changed frequently. 5 | * 6 | *

The library has been designed to hide the implementation details 7 | * from the users. When using it the users should not 8 | * need to import from the internal package, using the public 9 | * classes and the exception classes should be more than enough.

10 | * 11 | *

The parser object provides full control over the uploading 12 | * process. The functional interfaces can be used to perform 13 | * operations on the part objects. Information about the process 14 | * is provided in the upload context object, which is available 15 | * in every stage of the process.

16 | * 17 | *

The classes here have been designed to work with servlets. There 18 | * are two kinds of parsing, you can do it asynchronously or in 19 | * a blocking way. For the former the servlet must support support 20 | * async mode. Both of them are incompatible with the multipart 21 | * API of the servlet specification. Using that makes the 22 | * servlet input stream unavailable for this library or any code 23 | * that is written by the users.

24 | */ 25 | package com.github.elopteryx.upload; -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/util/ByteBufferBackedInputStream.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload.util; 18 | 19 | import java.io.IOException; 20 | import java.io.InputStream; 21 | import java.nio.ByteBuffer; 22 | import java.util.Objects; 23 | 24 | /** 25 | * An input stream implementation which reads from the given byte buffer. 26 | * The stream will not copy bytes to a temporary buffer, therefore read-only 27 | * and direct buffers are not supported. 28 | */ 29 | public class ByteBufferBackedInputStream extends InputStream { 30 | 31 | /** 32 | * The byte buffer. Cannot be read-only or direct. 33 | */ 34 | private final ByteBuffer buffer; 35 | 36 | /** 37 | * Flag to determine whether the channel is closed or not. 38 | */ 39 | private boolean open = true; 40 | 41 | /** 42 | * Public constructor. 43 | * @param buffer The byte buffer 44 | */ 45 | public ByteBufferBackedInputStream(final ByteBuffer buffer) { 46 | this.buffer = Objects.requireNonNull(buffer); 47 | if (buffer.isDirect() || buffer.isReadOnly()) { 48 | throw new IllegalArgumentException("The buffer cannot be direct or read-only!"); 49 | } 50 | } 51 | 52 | @Override 53 | public int available() throws IOException { 54 | return buffer.remaining(); 55 | } 56 | 57 | @Override 58 | public int read() throws IOException { 59 | if (!open) { 60 | throw new IOException("The stream was closed!"); 61 | } 62 | if (!buffer.hasRemaining()) { 63 | return -1; 64 | } 65 | return buffer.get() & 0xFF; 66 | } 67 | 68 | @Override 69 | public int read(final byte[] bytes, final int off, int len) throws IOException { 70 | if (!open) { 71 | throw new IOException("The stream was closed!"); 72 | } 73 | if (!buffer.hasRemaining()) { 74 | return -1; 75 | } 76 | len = Math.min(len, buffer.remaining()); 77 | buffer.get(bytes, off, len); 78 | return len; 79 | } 80 | 81 | @Override 82 | public void close() throws IOException { 83 | open = false; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/util/ByteBufferBackedOutputStream.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.util; 2 | 3 | import java.io.IOException; 4 | import java.io.OutputStream; 5 | import java.nio.ByteBuffer; 6 | import java.util.Objects; 7 | 8 | /** 9 | * An output stream implementation which writes to the given byte buffer. 10 | * The stream will not copy bytes to a temporary buffer, therefore read-only 11 | * and direct buffers are not supported. 12 | */ 13 | public class ByteBufferBackedOutputStream extends OutputStream { 14 | 15 | /** 16 | * The byte buffer. Cannot be read-only or direct. 17 | */ 18 | private final ByteBuffer buffer; 19 | 20 | /** 21 | * Flag to determine whether the channel is closed or not. 22 | */ 23 | private boolean open = true; 24 | 25 | /** 26 | * Public constructor. 27 | * @param buffer The byte buffer 28 | */ 29 | public ByteBufferBackedOutputStream(final ByteBuffer buffer) { 30 | this.buffer = Objects.requireNonNull(buffer); 31 | if (buffer.isDirect() || buffer.isReadOnly()) { 32 | throw new IllegalArgumentException("The buffer cannot be direct or read-only!"); 33 | } 34 | } 35 | 36 | @Override 37 | public void write(final int byteToWrite) throws IOException { 38 | if (!open) { 39 | throw new IOException("The stream was closed!"); 40 | } 41 | buffer.put((byte) byteToWrite); 42 | } 43 | 44 | @Override 45 | public void write(final byte[] bytes, final int off, final int len) throws IOException { 46 | if (!open) { 47 | throw new IOException("The stream was closed!"); 48 | } 49 | buffer.put(bytes, off, len); 50 | } 51 | 52 | @Override 53 | public void close() throws IOException { 54 | open = false; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/util/InputStreamBackedChannel.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.util; 2 | 3 | import java.io.IOException; 4 | import java.io.InputStream; 5 | import java.nio.ByteBuffer; 6 | import java.nio.channels.ClosedChannelException; 7 | import java.nio.channels.ReadableByteChannel; 8 | import java.util.Objects; 9 | 10 | /** 11 | * A channel implementation which reads the ByteBuffer 12 | * data from the given InputStream instance. 13 | * 14 | *

This implementation differs from the one returned 15 | * in {@link java.nio.channels.Channels#newChannel(InputStream)} 16 | * by one notable thing, it does not use a temporary buffer, because 17 | * it will not handle read-only or direct ByteBuffers.

18 | * 19 | *

The channel honors the close contract, it cannot be used after closing.

20 | */ 21 | public class InputStreamBackedChannel implements ReadableByteChannel { 22 | 23 | /** 24 | * Flag to determine whether the channel is closed or not. 25 | */ 26 | private boolean open = true; 27 | 28 | /** 29 | * The stream the channel will read from. 30 | */ 31 | private final InputStream inputStream; 32 | 33 | /** 34 | * Public constructor. 35 | * @param inputStream The input stream 36 | */ 37 | public InputStreamBackedChannel(final InputStream inputStream) { 38 | this.inputStream = Objects.requireNonNull(inputStream); 39 | } 40 | 41 | @Override 42 | public int read(final ByteBuffer dst) throws IOException { 43 | if (!open) { 44 | throw new ClosedChannelException(); 45 | } 46 | if (dst.isDirect() || dst.isReadOnly()) { 47 | throw new IllegalArgumentException("The buffer cannot be direct or read-only!"); 48 | } 49 | final var buf = dst.array(); 50 | final var offset = dst.position(); 51 | final var len = dst.remaining(); 52 | final var read = inputStream.read(buf, offset, len); 53 | dst.position(offset + read); 54 | return read; 55 | } 56 | 57 | @Override 58 | public boolean isOpen() { 59 | return open; 60 | } 61 | 62 | @Override 63 | public void close() throws IOException { 64 | inputStream.close(); 65 | open = false; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/util/NullChannel.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload.util; 18 | 19 | import com.github.elopteryx.upload.OnPartBegin; 20 | 21 | import java.io.IOException; 22 | import java.nio.ByteBuffer; 23 | import java.nio.channels.ClosedChannelException; 24 | import java.nio.channels.ReadableByteChannel; 25 | import java.nio.channels.WritableByteChannel; 26 | 27 | /** 28 | * A channel implementation which provides no data and discards the data supplied. 29 | * Used by the parser if it doesn't have a channel to write to. 30 | * The purpose of this is to make the {@link OnPartBegin} callback 31 | * optional, which is useful for testing. 32 | * 33 | *

The channel honors the close contract, it cannot be used after closing.

34 | */ 35 | public class NullChannel implements ReadableByteChannel, WritableByteChannel { 36 | 37 | /** 38 | * Flag to determine whether the channel is closed or not. 39 | */ 40 | private boolean open = true; 41 | 42 | @Override 43 | public int read(final ByteBuffer dst) throws IOException { 44 | if (!open) { 45 | throw new ClosedChannelException(); 46 | } 47 | return -1; 48 | } 49 | 50 | @Override 51 | public int write(final ByteBuffer src) throws IOException { 52 | if (!open) { 53 | throw new ClosedChannelException(); 54 | } 55 | final var remaining = src.remaining(); 56 | src.position(src.limit()); 57 | return remaining; 58 | } 59 | 60 | @Override 61 | public boolean isOpen() { 62 | return open; 63 | } 64 | 65 | @Override 66 | public void close() { 67 | open = false; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/util/OutputStreamBackedChannel.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2016 Adam Forgacs 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.github.elopteryx.upload.util; 18 | 19 | import java.io.IOException; 20 | import java.io.OutputStream; 21 | import java.nio.ByteBuffer; 22 | import java.nio.channels.ClosedChannelException; 23 | import java.nio.channels.WritableByteChannel; 24 | import java.util.Objects; 25 | 26 | /** 27 | * A channel implementation which writes the ByteBuffer data 28 | * to the given OutputStream instance. 29 | * 30 | *

This implementation differs from the one returned 31 | * in {@link java.nio.channels.Channels#newChannel(OutputStream)} 32 | * by one notable thing, it does not use a temporary buffer, because 33 | * it will not handle read-only or direct ByteBuffers.

34 | * 35 | *

The channel honors the close contract, it cannot be used after closing.

36 | */ 37 | public class OutputStreamBackedChannel implements WritableByteChannel { 38 | 39 | /** 40 | * Flag to determine whether the channel is closed or not. 41 | */ 42 | private boolean open = true; 43 | 44 | /** 45 | * The stream the channel will write to. 46 | */ 47 | private final OutputStream outputStream; 48 | 49 | /** 50 | * Public constructor. 51 | * @param outputStream The output stream 52 | */ 53 | public OutputStreamBackedChannel(final OutputStream outputStream) { 54 | this.outputStream = Objects.requireNonNull(outputStream); 55 | } 56 | 57 | @Override 58 | public int write(final ByteBuffer src) throws IOException { 59 | if (!open) { 60 | throw new ClosedChannelException(); 61 | } 62 | if (src.isDirect() || src.isReadOnly()) { 63 | throw new IllegalArgumentException("The buffer cannot be direct or read-only!"); 64 | } 65 | final var buf = src.array(); 66 | final var offset = src.position(); 67 | final var len = src.remaining(); 68 | outputStream.write(buf, offset, len); 69 | src.position(offset + len); 70 | return len; 71 | } 72 | 73 | @Override 74 | public boolean isOpen() { 75 | return open; 76 | } 77 | 78 | @Override 79 | public void close() throws IOException { 80 | outputStream.close(); 81 | open = false; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/com/github/elopteryx/upload/util/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * This package contains utility classes for channels and 3 | * streams used by this library. As they can be useful 4 | * not just for the internal implementation these classes 5 | * have their own package and can be used safely. 6 | */ 7 | package com.github.elopteryx.upload.util; -------------------------------------------------------------------------------- /upload-parser-core/src/main/java/module-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Async file upload module for servlets. 3 | */ 4 | module com.github.elopteryx.upload { 5 | requires jakarta.servlet; 6 | exports com.github.elopteryx.upload; 7 | exports com.github.elopteryx.upload.errors; 8 | exports com.github.elopteryx.upload.util; 9 | } -------------------------------------------------------------------------------- /upload-parser-tests/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'jacoco-report-aggregation' 3 | } 4 | 5 | dependencies { 6 | 7 | /* Upload parser. */ 8 | implementation(project(':upload-parser-core')) 9 | 10 | /* Test runner. */ 11 | testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion") 12 | testImplementation("org.junit.jupiter:junit-jupiter-params:$junitVersion") 13 | testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion") 14 | 15 | /* Undertow Http server. */ 16 | testImplementation("io.undertow:undertow-core:$undertowVersion") 17 | testImplementation("io.undertow:undertow-servlet:$undertowVersion") 18 | 19 | /* Jetty Http server. */ 20 | testImplementation("org.eclipse.jetty:jetty-server:$jettyVersion") 21 | testImplementation("org.eclipse.jetty:jetty-servlet:$jettyVersion") 22 | 23 | /* Tomcat Http server. */ 24 | testImplementation("org.apache.tomcat.embed:tomcat-embed-core:$tomcatVersion") 25 | 26 | /* Document and file parser. */ 27 | testImplementation("org.apache.tika:tika-core:$tikaVersion") 28 | testImplementation("org.apache.tika:tika-parsers:$tikaVersion") 29 | 30 | /* In-memory filesystem. */ 31 | testImplementation("com.google.jimfs:jimfs:$jimfsVersion") 32 | 33 | /* Object mocking. */ 34 | testImplementation("org.mockito:mockito-core:$mockitoVersion") 35 | 36 | /* Servlet API. */ 37 | testImplementation("jakarta.servlet:jakarta.servlet-api:$servletApiVersion") 38 | 39 | } 40 | 41 | tasks.named('check') { 42 | dependsOn tasks.named('testCodeCoverageReport', JacocoReport) 43 | } 44 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/PartOutputTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertFalse; 4 | import static org.junit.jupiter.api.Assertions.assertNotNull; 5 | import static org.junit.jupiter.api.Assertions.assertTrue; 6 | 7 | import org.junit.jupiter.api.Test; 8 | 9 | import java.io.ByteArrayOutputStream; 10 | import java.io.OutputStream; 11 | import java.nio.channels.Channel; 12 | import java.nio.channels.Channels; 13 | import java.nio.channels.WritableByteChannel; 14 | import java.nio.file.Path; 15 | import java.nio.file.Paths; 16 | 17 | class PartOutputTest { 18 | 19 | @Test 20 | void create_channel_output() { 21 | final var byteArrayOutputStream = new ByteArrayOutputStream(); 22 | final var channel = Channels.newChannel(byteArrayOutputStream); 23 | final var output = PartOutput.from(channel); 24 | 25 | assertTrue(output.safeToCast(Channel.class)); 26 | assertTrue(output.safeToCast(WritableByteChannel.class)); 27 | assertFalse(output.safeToCast(OutputStream.class)); 28 | assertFalse(output.safeToCast(ByteArrayOutputStream.class)); 29 | 30 | assertNotNull(output.unwrap(WritableByteChannel.class)); 31 | } 32 | 33 | @Test 34 | void create_stream_output() { 35 | final var byteArrayOutputStream = new ByteArrayOutputStream(); 36 | final var output = PartOutput.from(byteArrayOutputStream); 37 | 38 | assertTrue(output.safeToCast(OutputStream.class)); 39 | assertTrue(output.safeToCast(ByteArrayOutputStream.class)); 40 | assertFalse(output.safeToCast(Channel.class)); 41 | assertFalse(output.safeToCast(WritableByteChannel.class)); 42 | 43 | assertNotNull(output.unwrap(OutputStream.class)); 44 | } 45 | 46 | @Test 47 | void create_path_output() { 48 | final var path = Paths.get(""); 49 | final var output = PartOutput.from(path); 50 | 51 | assertTrue(output.safeToCast(Path.class)); 52 | 53 | assertNotNull(output.unwrap(Path.class)); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/UploadParserTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload; 2 | 3 | import static com.github.elopteryx.upload.util.Servlets.newRequest; 4 | import static com.github.elopteryx.upload.util.Servlets.newResponse; 5 | import static org.junit.jupiter.api.Assertions.assertAll; 6 | import static org.junit.jupiter.api.Assertions.assertFalse; 7 | import static org.junit.jupiter.api.Assertions.assertThrows; 8 | import static org.junit.jupiter.api.Assertions.assertTrue; 9 | import static org.mockito.Mockito.mock; 10 | import static org.mockito.Mockito.when; 11 | 12 | import com.github.elopteryx.upload.util.NullChannel; 13 | import com.google.common.jimfs.Jimfs; 14 | import org.junit.jupiter.api.BeforeAll; 15 | import org.junit.jupiter.api.Test; 16 | 17 | import java.nio.ByteBuffer; 18 | import java.nio.file.FileSystem; 19 | import java.nio.file.Files; 20 | import jakarta.servlet.AsyncContext; 21 | import jakarta.servlet.ServletInputStream; 22 | import jakarta.servlet.http.HttpServletResponse; 23 | 24 | class UploadParserTest implements OnPartBegin, OnPartEnd, OnRequestComplete, OnError { 25 | 26 | private static FileSystem fileSystem; 27 | 28 | @BeforeAll 29 | static void setUp() { 30 | fileSystem = Jimfs.newFileSystem(); 31 | } 32 | 33 | @Test 34 | void valid_content_type() throws Exception { 35 | final var request = newRequest(); 36 | 37 | when(request.getContentType()).thenReturn("multipart/"); 38 | assertTrue(UploadParser.isMultipart(request)); 39 | } 40 | 41 | @Test 42 | void invalid_numeric_arguments() { 43 | assertAll( 44 | () -> assertThrows(IllegalArgumentException.class, () -> UploadParser.newParser().sizeThreshold(-1)), 45 | () -> assertThrows(IllegalArgumentException.class, () -> UploadParser.newParser().maxPartSize(-1)), 46 | () -> assertThrows(IllegalArgumentException.class, () -> UploadParser.newParser().maxRequestSize(-1)), 47 | () -> assertThrows(IllegalArgumentException.class, () -> UploadParser.newParser().maxBytesUsed(-1)) 48 | ); 49 | } 50 | 51 | @Test 52 | void invalid_content_type_async() throws Exception { 53 | final var request = newRequest(); 54 | 55 | when(request.getContentType()).thenReturn("text/plain;charset=UTF-8"); 56 | assertFalse(UploadParser.isMultipart(request)); 57 | assertThrows(IllegalArgumentException.class, () -> UploadParser.newParser().userObject(newResponse()).setupAsyncParse(request)); 58 | } 59 | 60 | @Test 61 | void invalid_content_type_blocking() throws Exception { 62 | final var request = newRequest(); 63 | 64 | when(request.getContentType()).thenReturn("text/plain;charset=UTF-8"); 65 | assertFalse(UploadParser.isMultipart(request)); 66 | assertThrows(IllegalArgumentException.class, () -> UploadParser.newParser().userObject(newResponse()).doBlockingParse(request)); 67 | } 68 | 69 | @Test 70 | void use_the_full_api() throws Exception { 71 | final var request = newRequest(); 72 | final var response = newResponse(); 73 | 74 | when(request.startAsync()).thenReturn(mock(AsyncContext.class)); 75 | when(request.getInputStream()).thenReturn(mock(ServletInputStream.class)); 76 | 77 | UploadParser.newParser() 78 | .onPartBegin(this) 79 | .onPartEnd(this) 80 | .onRequestComplete(this) 81 | .onError(this) 82 | .userObject(response) 83 | .maxBytesUsed(4096) 84 | .sizeThreshold(1024 * 1024 * 10) 85 | .maxPartSize(1024 * 1024 * 50) 86 | .maxRequestSize(1024 * 1024 * 50) 87 | .setupAsyncParse(request); 88 | } 89 | 90 | @Test 91 | void output_channel() { 92 | UploadParser.newParser() 93 | .onPartBegin((context, buffer) -> { 94 | final var test = fileSystem.getPath("test1"); 95 | Files.createFile(test); 96 | return PartOutput.from(Files.newByteChannel(test)); 97 | }); 98 | } 99 | 100 | @Test 101 | void output_stream() { 102 | UploadParser.newParser() 103 | .onPartBegin((context, buffer) -> { 104 | final var test = fileSystem.getPath("test2"); 105 | Files.createFile(test); 106 | return PartOutput.from(Files.newOutputStream(test)); 107 | }); 108 | } 109 | 110 | @Test 111 | void output_path() { 112 | UploadParser.newParser() 113 | .onPartBegin((context, buffer) -> { 114 | final var test = fileSystem.getPath("test2"); 115 | Files.createFile(test); 116 | return PartOutput.from(test); 117 | }); 118 | } 119 | 120 | @Test 121 | void use_with_custom_object() { 122 | UploadParser.newParser() 123 | .userObject(newResponse()) 124 | .onPartBegin((context, buffer) -> { 125 | final var test = fileSystem.getPath("test2"); 126 | Files.createFile(test); 127 | return PartOutput.from(test); 128 | }) 129 | .onRequestComplete(context -> context.getUserObject(HttpServletResponse.class).setStatus(HttpServletResponse.SC_OK)); 130 | } 131 | 132 | @Override 133 | public PartOutput onPartBegin(final UploadContext context, final ByteBuffer buffer) { 134 | return PartOutput.from(new NullChannel()); 135 | } 136 | 137 | @Override 138 | public void onPartEnd(final UploadContext context) { 139 | // No-op 140 | } 141 | 142 | @Override 143 | public void onRequestComplete(final UploadContext context) { 144 | // No-op 145 | } 146 | 147 | @Override 148 | public void onError(final UploadContext context, final Throwable throwable) { 149 | // No-op 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/AbstractUploadParserTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | import static org.junit.jupiter.api.Assertions.assertTrue; 6 | import static org.mockito.Mockito.when; 7 | 8 | import com.github.elopteryx.upload.errors.PartSizeException; 9 | import com.github.elopteryx.upload.errors.RequestSizeException; 10 | import com.github.elopteryx.upload.util.Servlets; 11 | import org.junit.jupiter.api.Test; 12 | 13 | class AbstractUploadParserTest { 14 | 15 | private static final long SIZE = 1024 * 1024 * 100L; 16 | private static final long SMALL_SIZE = 1024; 17 | 18 | private AbstractUploadParser runSetupForSize(final long requestSize, final long allowedRequestSize, final long allowedPartSize) throws Exception { 19 | final var request = Servlets.newRequest(); 20 | 21 | when(request.getContentLengthLong()).thenReturn(requestSize); 22 | 23 | final var parser = new AsyncUploadParser(request); 24 | parser.setMaxPartSize(allowedPartSize); 25 | parser.setMaxRequestSize(allowedRequestSize); 26 | parser.setupAsyncParse(); 27 | return parser; 28 | } 29 | 30 | @Test 31 | void setup_should_work_if_lesser() throws Exception { 32 | runSetupForSize(SIZE - 1, SIZE, -1); 33 | } 34 | 35 | @Test 36 | void setup_should_work_if_equals() throws Exception { 37 | runSetupForSize(SIZE, SIZE, -1); 38 | } 39 | 40 | @Test 41 | void setup_should_throw_size_exception_if_greater() { 42 | assertThrows(RequestSizeException.class, () -> runSetupForSize(SIZE + 1, SIZE, -1)); 43 | } 44 | 45 | @Test 46 | void parser_should_throw_exception_for_request_size() { 47 | final var exception = assertThrows(RequestSizeException.class, () -> { 48 | final var parser = runSetupForSize(0, SMALL_SIZE, -1); 49 | for (var i = 0; i < 11; i++) { 50 | parser.checkRequestSize(100); 51 | } 52 | }); 53 | assertEquals(exception.getPermittedSize(), SMALL_SIZE); 54 | assertTrue(exception.getActualSize() > SMALL_SIZE); 55 | } 56 | 57 | @Test 58 | void parser_should_throw_exception_for_part_size() { 59 | final var exception = assertThrows(PartSizeException.class, () -> { 60 | final var parser = runSetupForSize(0, -1, SMALL_SIZE); 61 | for (var i = 0; i < 11; i++) { 62 | parser.checkPartSize(100); 63 | } 64 | }); 65 | assertEquals(exception.getPermittedSize(), SMALL_SIZE); 66 | assertTrue(exception.getActualSize() > SMALL_SIZE); 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/AsyncUploadParserTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertThrows; 4 | import static org.mockito.Mockito.when; 5 | 6 | import com.github.elopteryx.upload.UploadParser; 7 | import com.github.elopteryx.upload.errors.MultipartException; 8 | import com.github.elopteryx.upload.util.MockServletInputStream; 9 | import com.github.elopteryx.upload.util.Servlets; 10 | import org.junit.jupiter.api.Test; 11 | 12 | class AsyncUploadParserTest { 13 | 14 | @Test 15 | void this_should_end_with_multipart_exception() throws Exception { 16 | final var request = Servlets.newRequest(); 17 | 18 | when(request.isAsyncSupported()).thenReturn(true); 19 | when(request.getHeader(Headers.CONTENT_TYPE)).thenReturn("multipart/form-data; boundary=----1234"); 20 | 21 | UploadParser.newParser().setupAsyncParse(request); 22 | final var servletInputStream = (MockServletInputStream)request.getInputStream(); 23 | assertThrows(MultipartException.class, servletInputStream::onDataAvailable); 24 | } 25 | 26 | @Test 27 | void this_should_end_with_illegal_state_exception() throws Exception { 28 | final var request = Servlets.newRequest(); 29 | 30 | when(request.isAsyncSupported()).thenReturn(false); 31 | 32 | assertThrows(IllegalStateException.class, () -> UploadParser.newParser().setupAsyncParse(request)); 33 | } 34 | 35 | @Test 36 | void this_should_end_with_illegal_argument_exception() throws Exception { 37 | final var request = Servlets.newRequest(); 38 | 39 | when(request.isAsyncSupported()).thenReturn(true); 40 | when(request.getHeader(Headers.CONTENT_TYPE)).thenReturn("multipart/form-data; boundary;"); 41 | 42 | assertThrows(IllegalArgumentException.class, () -> UploadParser.newParser().setupAsyncParse(request)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/Base64EncodingTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal; 2 | 3 | import static java.nio.charset.StandardCharsets.US_ASCII; 4 | import static org.junit.jupiter.api.Assertions.assertEquals; 5 | import static org.junit.jupiter.api.Assertions.assertThrows; 6 | 7 | import org.junit.jupiter.api.Test; 8 | 9 | import java.io.IOException; 10 | import java.nio.ByteBuffer; 11 | 12 | class Base64EncodingTest { 13 | 14 | @Test 15 | void these_values_should_work() throws IOException { 16 | checkEncoding("", ""); 17 | checkEncoding("f", "Zg=="); 18 | checkEncoding("fo", "Zm8="); 19 | checkEncoding("foo", "Zm9v"); 20 | checkEncoding("foob", "Zm9vYg=="); 21 | checkEncoding("fooba", "Zm9vYmE="); 22 | checkEncoding("foobar", "Zm9vYmFy"); 23 | } 24 | 25 | @Test 26 | void must_throw_exception_on_invalid_data() { 27 | assertThrows(IOException.class, () -> checkEncoding("f", "Zg=�=")); 28 | } 29 | 30 | private static void checkEncoding(final String original, final String encoded) throws IOException { 31 | 32 | final var encoding = new MultipartParser.Base64Encoding(1024); 33 | encoding.handle(new MultipartParser.PartHandler() { 34 | 35 | @Override 36 | public void beginPart(final Headers headers) { 37 | // No-op 38 | } 39 | 40 | @Override 41 | public void data(final ByteBuffer buffer) { 42 | final var parserResult = new String(buffer.array(), US_ASCII).trim(); 43 | assertEquals(parserResult, original); 44 | } 45 | 46 | @Override 47 | public void endPart() { 48 | // No-op 49 | } 50 | 51 | }, ByteBuffer.wrap(encoded.getBytes(US_ASCII))); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/BlockingUploadParserTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertFalse; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | import static org.junit.jupiter.api.Assertions.assertTrue; 6 | import static org.mockito.Mockito.when; 7 | 8 | import com.github.elopteryx.upload.OnError; 9 | import com.github.elopteryx.upload.OnPartBegin; 10 | import com.github.elopteryx.upload.OnPartEnd; 11 | import com.github.elopteryx.upload.PartOutput; 12 | import com.github.elopteryx.upload.UploadContext; 13 | import com.github.elopteryx.upload.UploadParser; 14 | import com.github.elopteryx.upload.errors.MultipartException; 15 | import com.github.elopteryx.upload.util.Servlets; 16 | import org.junit.jupiter.api.Test; 17 | 18 | import java.io.ByteArrayOutputStream; 19 | import java.nio.ByteBuffer; 20 | import java.util.ArrayList; 21 | import java.util.List; 22 | 23 | class BlockingUploadParserTest implements OnPartBegin, OnPartEnd, OnError { 24 | 25 | private final List strings = new ArrayList<>(); 26 | 27 | @Test 28 | void this_should_end_with_multipart_exception() throws Exception { 29 | final var request = Servlets.newRequest(); 30 | final var response = Servlets.newResponse(); 31 | 32 | when(request.isAsyncSupported()).thenReturn(false); 33 | when(request.getHeader(Headers.CONTENT_TYPE)).thenReturn("multipart/form-data; boundary=----1234"); 34 | 35 | UploadParser.newParser() 36 | .onPartBegin(this) 37 | .onPartEnd(this) 38 | .onError(this) 39 | .userObject(response) 40 | .doBlockingParse(request); 41 | } 42 | 43 | @Test 44 | void this_should_end_with_illegal_argument_exception() throws Exception { 45 | final var request = Servlets.newRequest(); 46 | 47 | when(request.isAsyncSupported()).thenReturn(false); 48 | when(request.getHeader(Headers.CONTENT_TYPE)).thenReturn("multipart/form-data;"); 49 | 50 | assertThrows(IllegalArgumentException.class, () -> UploadParser.newParser().doBlockingParse(request)); 51 | } 52 | 53 | @Override 54 | public PartOutput onPartBegin(final UploadContext context, final ByteBuffer buffer) { 55 | final var baos = new ByteArrayOutputStream(); 56 | strings.add(baos); 57 | return PartOutput.from(baos); 58 | } 59 | 60 | @Override 61 | public void onPartEnd(final UploadContext context) { 62 | assertFalse(strings.isEmpty()); 63 | } 64 | 65 | @Override 66 | public void onError(final UploadContext context, final Throwable throwable) { 67 | assertTrue(throwable instanceof MultipartException); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/HeadersTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertFalse; 5 | import static org.junit.jupiter.api.Assertions.assertNull; 6 | import static org.junit.jupiter.api.Assertions.assertTrue; 7 | 8 | import org.junit.jupiter.api.Test; 9 | 10 | import java.util.Iterator; 11 | import java.util.Locale; 12 | 13 | class HeadersTest { 14 | 15 | @Test 16 | void add_and_retrieve_headers() { 17 | final var headers = new Headers(); 18 | headers.addHeader(Headers.CONTENT_DISPOSITION, "form-data; name=\"FileItem\"; filename=\"file1.txt\""); 19 | headers.addHeader(Headers.CONTENT_TYPE, "text/plain"); 20 | headers.addHeader("TestHeader", "headerValue1"); 21 | headers.addHeader("TestHeader", "headerValue2"); 22 | headers.addHeader("TestHeader", "headerValue3"); 23 | headers.addHeader("testheader", "headerValue4"); 24 | 25 | final var headerNames = headers.getHeaderNames().iterator(); 26 | assertEquals(Headers.CONTENT_DISPOSITION.toLowerCase(Locale.ENGLISH), headerNames.next()); 27 | assertEquals(Headers.CONTENT_TYPE.toLowerCase(Locale.ENGLISH), headerNames.next()); 28 | assertEquals("testheader", headerNames.next()); 29 | assertFalse(headerNames.hasNext()); 30 | 31 | assertEquals(headers.getHeader(Headers.CONTENT_DISPOSITION), "form-data; name=\"FileItem\"; filename=\"file1.txt\""); 32 | assertEquals(headers.getHeader(Headers.CONTENT_TYPE), "text/plain"); 33 | assertEquals(headers.getHeader(Headers.CONTENT_TYPE), "text/plain"); 34 | assertEquals(headers.getHeader("TestHeader"), "headerValue1"); 35 | assertNull(headers.getHeader("DummyHeader")); 36 | 37 | Iterator headerValues; 38 | 39 | headerValues = headers.getHeaders("Content-Type").iterator(); 40 | assertTrue(headerValues.hasNext()); 41 | assertEquals(headerValues.next(), "text/plain"); 42 | assertFalse(headerValues.hasNext()); 43 | 44 | headerValues = headers.getHeaders("content-type").iterator(); 45 | assertTrue(headerValues.hasNext()); 46 | assertEquals(headerValues.next(), "text/plain"); 47 | assertFalse(headerValues.hasNext()); 48 | 49 | headerValues = headers.getHeaders("TestHeader").iterator(); 50 | assertTrue(headerValues.hasNext()); 51 | assertEquals(headerValues.next(), "headerValue1"); 52 | assertTrue(headerValues.hasNext()); 53 | assertEquals(headerValues.next(), "headerValue2"); 54 | assertTrue(headerValues.hasNext()); 55 | assertEquals(headerValues.next(), "headerValue3"); 56 | assertTrue(headerValues.hasNext()); 57 | assertEquals(headerValues.next(), "headerValue4"); 58 | assertFalse(headerValues.hasNext()); 59 | 60 | headerValues = headers.getHeaders("DummyHeader").iterator(); 61 | assertFalse(headerValues.hasNext()); 62 | } 63 | 64 | @Test 65 | void charset_parsing() { 66 | assertNull(Headers.extractQuotedValueFromHeader("text/html; other-data=\"charset=UTF-8\"", "charset")); 67 | assertNull(Headers.extractQuotedValueFromHeader("text/html;", "charset")); 68 | assertEquals("UTF-8", Headers.extractQuotedValueFromHeader("text/html; charset=\"UTF-8\"", "charset")); 69 | assertEquals("UTF-8", Headers.extractQuotedValueFromHeader("text/html; charset=UTF-8", "charset")); 70 | assertEquals("UTF-8", Headers.extractQuotedValueFromHeader("text/html; charset=\"UTF-8\"; foo=bar", "charset")); 71 | assertEquals("UTF-8", Headers.extractQuotedValueFromHeader("text/html; charset=UTF-8 foo=bar", "charset")); 72 | } 73 | 74 | @Test 75 | void extract_existing_boundary() { 76 | assertEquals("--xyz", Headers.extractBoundaryFromHeader("multipart/form-data; boundary=--xyz; param=abc")); 77 | } 78 | 79 | @Test 80 | void extract_missing_boundary() { 81 | assertNull(Headers.extractBoundaryFromHeader("multipart/form-data; boundary;")); 82 | } 83 | 84 | @Test 85 | void extract_param_with_tailing_whitespace() { 86 | assertEquals("abc", Headers.extractQuotedValueFromHeader("multipart/form-data; boundary=--xyz; param=abc", "param")); 87 | assertEquals("abc", Headers.extractQuotedValueFromHeader("multipart/form-data; boundary=--xyz; param=abc ", "param")); 88 | assertEquals("", Headers.extractQuotedValueFromHeader("multipart/form-data; boundary=--xyz; param= abc", "param")); 89 | assertEquals("", Headers.extractQuotedValueFromHeader("multipart/form-data; boundary=--xyz; param= abc ", "param")); 90 | 91 | assertEquals("abc", Headers.extractQuotedValueFromHeader("multipart/form-data; boundary=--xyz; param=\"abc\"", "param")); 92 | assertEquals("abc ", Headers.extractQuotedValueFromHeader("multipart/form-data; boundary=--xyz; param=\"abc \"", "param")); 93 | assertEquals(" abc", Headers.extractQuotedValueFromHeader("multipart/form-data; boundary=--xyz; param=\" abc\"", "param")); 94 | assertEquals(" abc ", Headers.extractQuotedValueFromHeader("multipart/form-data; boundary=--xyz; param=\" abc \"", "param")); 95 | 96 | assertNull(Headers.extractQuotedValueFromHeader("multipart/form-data; boundary=--xyz; param\t=abc", "param")); 97 | assertEquals("ab", Headers.extractQuotedValueFromHeader("multipart/form-data; boundary=--xyz; param=ab\tc", "param")); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/IdentityEncodingTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal; 2 | 3 | import static java.nio.charset.StandardCharsets.UTF_8; 4 | import static org.junit.jupiter.api.Assertions.assertEquals; 5 | 6 | import org.junit.jupiter.api.Test; 7 | 8 | import java.io.IOException; 9 | import java.nio.ByteBuffer; 10 | 11 | class IdentityEncodingTest { 12 | 13 | @Test 14 | void these_values_should_work() throws IOException { 15 | checkEncoding(""); 16 | checkEncoding("abc"); 17 | checkEncoding("öüóúőűáéí"); 18 | } 19 | 20 | private static void checkEncoding(final String original) throws IOException { 21 | 22 | final var encoding = new MultipartParser.IdentityEncoding(); 23 | encoding.handle(new MultipartParser.PartHandler() { 24 | 25 | @Override 26 | public void beginPart(final Headers headers) { 27 | // No-op 28 | } 29 | 30 | @Override 31 | public void data(final ByteBuffer buffer) { 32 | final var parserResult = new String(buffer.array(), UTF_8); 33 | assertEquals(parserResult, original); 34 | } 35 | 36 | @Override 37 | public void endPart() { 38 | // No-op 39 | } 40 | 41 | }, ByteBuffer.wrap(original.getBytes(UTF_8))); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/MultipartParserTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal; 2 | 3 | import static java.nio.charset.StandardCharsets.ISO_8859_1; 4 | import static java.nio.charset.StandardCharsets.UTF_8; 5 | import static org.junit.jupiter.api.Assertions.assertEquals; 6 | import static org.junit.jupiter.api.Assertions.assertFalse; 7 | import static org.junit.jupiter.api.Assertions.assertThrows; 8 | import static org.junit.jupiter.api.Assertions.assertTrue; 9 | 10 | import io.undertow.util.FileUtils; 11 | import org.junit.jupiter.params.ParameterizedTest; 12 | import org.junit.jupiter.params.provider.MethodSource; 13 | 14 | import java.io.IOException; 15 | import java.nio.ByteBuffer; 16 | import java.util.ArrayList; 17 | import java.util.List; 18 | 19 | class MultipartParserTest { 20 | 21 | private static int[] bufferSizeProvider() { 22 | return new int[]{2, 10, 1024, 4096}; 23 | } 24 | 25 | @ParameterizedTest 26 | @MethodSource("bufferSizeProvider") 27 | void mime_decoding_with_preamble(final int bufferSize) throws IOException { 28 | final var data = fixLineEndings(FileUtils.readFile(MultipartParserTest.class, "mime1.txt")); 29 | final var handler = new MockPartHandler(); 30 | final var parser = MultipartParser.beginParse(handler, "unique-boundary-1".getBytes(), bufferSize, ISO_8859_1); 31 | 32 | final var buf = ByteBuffer.wrap(data.getBytes()); 33 | parser.parse(buf); 34 | assertTrue(parser.isComplete()); 35 | assertEquals(2, handler.parts.size()); 36 | assertEquals("Here is some text.", handler.parts.get(0).data.toString()); 37 | assertEquals("Here is some more text.", handler.parts.get(1).data.toString()); 38 | 39 | assertEquals("text/plain", handler.parts.get(0).map.getHeader(Headers.CONTENT_TYPE)); 40 | } 41 | 42 | 43 | @ParameterizedTest 44 | @MethodSource("bufferSizeProvider") 45 | void mime_decoding_with_utf8_headers(final int bufferSize) throws IOException { 46 | final var data = fixLineEndings(FileUtils.readFile(MultipartParserTest.class, "mime-utf8.txt")); 47 | final var handler = new MockPartHandler(); 48 | final var parser = MultipartParser.beginParse(handler, "unique-boundary-1".getBytes(), bufferSize, UTF_8); 49 | 50 | final var buf = ByteBuffer.wrap(data.getBytes()); 51 | parser.parse(buf); 52 | assertTrue(parser.isComplete()); 53 | assertEquals(1, handler.parts.size()); 54 | assertEquals("Just some chinese characters I copied from the internet, no idea what it says.", handler.parts.get(0).data.toString()); 55 | 56 | assertEquals("text/plain", handler.parts.get(0).map.getHeader(Headers.CONTENT_TYPE)); 57 | } 58 | 59 | @ParameterizedTest 60 | @MethodSource("bufferSizeProvider") 61 | void mime_decoding_without_preamble(final int bufferSize) throws IOException { 62 | final var data = fixLineEndings(FileUtils.readFile(MultipartParserTest.class, "mime2.txt")); 63 | final var handler = new MockPartHandler(); 64 | final var parser = MultipartParser.beginParse(handler, "unique-boundary-1".getBytes(), bufferSize, ISO_8859_1); 65 | 66 | final var buf = ByteBuffer.wrap(data.getBytes()); 67 | parser.parse(buf); 68 | assertTrue(parser.isComplete()); 69 | assertEquals(2, handler.parts.size()); 70 | assertEquals("Here is some text.", handler.parts.get(0).data.toString()); 71 | assertEquals("Here is some more text.", handler.parts.get(1).data.toString()); 72 | 73 | assertEquals("text/plain", handler.parts.get(0).map.getHeader(Headers.CONTENT_TYPE)); 74 | } 75 | 76 | @ParameterizedTest 77 | @MethodSource("bufferSizeProvider") 78 | void base64_mime_decoding(final int bufferSize) throws IOException { 79 | final var data = fixLineEndings(FileUtils.readFile(MultipartParserTest.class, "mime3.txt")); 80 | final var handler = new MockPartHandler(); 81 | final var parser = MultipartParser.beginParse(handler, "unique-boundary-1".getBytes(), bufferSize, ISO_8859_1); 82 | 83 | final var buf = ByteBuffer.wrap(data.getBytes()); 84 | parser.parse(buf); 85 | assertTrue(parser.isComplete()); 86 | assertEquals(2, handler.parts.size()); 87 | assertEquals("This is some base64 text.", handler.parts.get(0).data.toString()); 88 | assertEquals("This is some more base64 text.", handler.parts.get(1).data.toString()); 89 | 90 | assertEquals("text/plain", handler.parts.get(0).map.getHeader(Headers.CONTENT_TYPE)); 91 | } 92 | 93 | @ParameterizedTest 94 | @MethodSource("bufferSizeProvider") 95 | void quoted_printable(final int bufferSize) throws IOException { 96 | final var data = fixLineEndings(FileUtils.readFile(MultipartParserTest.class, "mime4.txt")); 97 | final var handler = new MockPartHandler(); 98 | final var parser = MultipartParser.beginParse(handler, "someboundarytext".getBytes(), bufferSize, ISO_8859_1); 99 | 100 | final var buf = ByteBuffer.wrap(data.getBytes()); 101 | parser.parse(buf); 102 | assertTrue(parser.isComplete()); 103 | assertEquals(1, handler.parts.size()); 104 | assertEquals("time=money.", handler.parts.get(0).data.toString()); 105 | 106 | assertEquals("text/plain", handler.parts.get(0).map.getHeader(Headers.CONTENT_TYPE)); 107 | } 108 | 109 | @ParameterizedTest 110 | @MethodSource("bufferSizeProvider") 111 | void mime_decoding_malformed(final int bufferSize) throws IOException { 112 | final var data = fixLineEndings(FileUtils.readFile(MultipartParserTest.class, "mime5_malformed.txt")); 113 | final var handler = new MockPartHandler(); 114 | final var parser = MultipartParser.beginParse(handler, "someboundarytext".getBytes(), bufferSize, ISO_8859_1); 115 | 116 | final var buf = ByteBuffer.wrap(data.getBytes()); 117 | parser.parse(buf); 118 | assertFalse(parser.isComplete()); 119 | } 120 | 121 | @ParameterizedTest 122 | @MethodSource("bufferSizeProvider") 123 | void base64_mime_decoding_malformed(final int bufferSize) { 124 | final var data = fixLineEndings(FileUtils.readFile(MultipartParserTest.class, "mime6_malformed.txt")); 125 | final var handler = new MockPartHandler(); 126 | final var parser = MultipartParser.beginParse(handler, "unique-boundary-1".getBytes(), bufferSize, ISO_8859_1); 127 | 128 | final var buf = ByteBuffer.wrap(data.getBytes()); 129 | assertThrows(IOException.class, () -> parser.parse(buf)); 130 | } 131 | 132 | @ParameterizedTest 133 | @MethodSource("bufferSizeProvider") 134 | void quoted_printable_malformed(final int bufferSize) throws IOException { 135 | final var data = fixLineEndings(FileUtils.readFile(MultipartParserTest.class, "mime7_malformed.txt")); 136 | final var handler = new MockPartHandler(); 137 | final var parser = MultipartParser.beginParse(handler, "someboundarytext".getBytes(), bufferSize, ISO_8859_1); 138 | 139 | final var buf = ByteBuffer.wrap(data.getBytes()); 140 | parser.parse(buf); 141 | assertTrue(parser.isComplete()); 142 | assertEquals(1, handler.parts.size()); 143 | assertEquals("time\nmoney.", handler.parts.get(0).data.toString()); 144 | 145 | assertEquals("text/plain", handler.parts.get(0).map.getHeader(Headers.CONTENT_TYPE)); 146 | } 147 | 148 | private static class MockPartHandler implements MultipartParser.PartHandler { 149 | 150 | private final List parts = new ArrayList<>(); 151 | private Part current; 152 | 153 | @Override 154 | public void beginPart(final Headers headers) { 155 | current = new Part(headers); 156 | parts.add(current); 157 | } 158 | 159 | @Override 160 | public void data(final ByteBuffer buffer) { 161 | while (buffer.hasRemaining()) { 162 | current.data.append((char) buffer.get()); 163 | } 164 | } 165 | 166 | @Override 167 | public void endPart() { 168 | 169 | } 170 | } 171 | 172 | private static class Part { 173 | private final Headers map; 174 | private final StringBuilder data = new StringBuilder(); 175 | 176 | private Part(final Headers map) { 177 | this.map = map; 178 | } 179 | } 180 | 181 | private static String fixLineEndings(final String string) { 182 | final var builder = new StringBuilder(); 183 | for (var i = 0; i < string.length(); ++i) { 184 | final var character = string.charAt(i); 185 | if (character == '\n') { 186 | if (i == 0 || string.charAt(i - 1) != '\r') { 187 | builder.append("\r\n"); 188 | } else { 189 | builder.append('\n'); 190 | } 191 | } else if (character == '\r') { 192 | if (i + 1 == string.length() || string.charAt(i + 1) != '\n') { 193 | builder.append("\r\n"); 194 | } else { 195 | builder.append('\r'); 196 | } 197 | } else { 198 | builder.append(character); 199 | } 200 | } 201 | return builder.toString(); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/PartStreamImplTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertFalse; 5 | import static org.junit.jupiter.api.Assertions.assertThrows; 6 | 7 | import com.github.elopteryx.upload.PartStream; 8 | import org.junit.jupiter.api.Test; 9 | 10 | class PartStreamImplTest { 11 | 12 | @Test 13 | void it_should_return_the_correct_data() { 14 | final var fileName = "r-" + System.currentTimeMillis(); 15 | final var fieldName = "r-" + System.currentTimeMillis(); 16 | final var contentType = "r-" + System.currentTimeMillis(); 17 | final var headers = new Headers(); 18 | headers.addHeader(Headers.CONTENT_TYPE, contentType); 19 | final PartStream partStream = new PartStreamImpl(fileName, fieldName, headers); 20 | assertEquals(fileName, partStream.getSubmittedFileName()); 21 | assertEquals(fieldName, partStream.getName()); 22 | assertEquals(contentType, partStream.getContentType()); 23 | assertEquals(partStream.isFile(), partStream.getSubmittedFileName() != null); 24 | assertFalse(partStream.isFinished()); 25 | } 26 | 27 | @Test 28 | void invalid_file_names_are_not_allowed() { 29 | final var fileName = "r-" + System.currentTimeMillis() + '\u0000'; 30 | final PartStream partStream = new PartStreamImpl(fileName, null, new Headers()); 31 | assertThrows(IllegalArgumentException.class, partStream::getSubmittedFileName); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/QuotedPrintableEncodingTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal; 2 | 3 | import static java.nio.charset.StandardCharsets.US_ASCII; 4 | import static org.junit.jupiter.api.Assertions.assertEquals; 5 | 6 | import org.junit.jupiter.api.Test; 7 | 8 | import java.io.IOException; 9 | import java.nio.ByteBuffer; 10 | 11 | class QuotedPrintableEncodingTest { 12 | 13 | @Test 14 | void empty_decode() throws IOException { 15 | checkEncoding("", ""); 16 | } 17 | 18 | @Test 19 | void plain_decode() throws IOException { 20 | checkEncoding("A longer sentence without special characters.", "A longer sentence without special characters."); 21 | } 22 | 23 | @Test 24 | void basic_encode_decode() throws IOException { 25 | checkEncoding("= Hello world =\r\n", "=3D Hello world =3D=0D=0A"); 26 | } 27 | 28 | @Test 29 | void unsafe_decode() throws IOException { 30 | checkEncoding("=\r\n", "=3D=0D=0A"); 31 | } 32 | 33 | @Test 34 | void unsafe_decode_lowercase() throws IOException { 35 | checkEncoding("=\r\n", "=3d=0d=0a"); 36 | } 37 | 38 | private static void checkEncoding(final String original, final String encoded) throws IOException { 39 | final var encoding = new MultipartParser.QuotedPrintableEncoding(1024); 40 | encoding.handle(new MultipartParser.PartHandler() { 41 | 42 | @Override 43 | public void beginPart(final Headers headers) { 44 | // No-op 45 | } 46 | 47 | @Override 48 | public void data(final ByteBuffer buffer) { 49 | final var parserResult = new String(buffer.array(), US_ASCII).trim(); 50 | assertEquals(parserResult, original.trim()); 51 | } 52 | 53 | @Override 54 | public void endPart() { 55 | // No-op 56 | } 57 | 58 | }, ByteBuffer.wrap(encoded.getBytes(US_ASCII))); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/integration/AsyncUploadServlet.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal.integration; 2 | 3 | import static java.nio.charset.StandardCharsets.ISO_8859_1; 4 | import static java.nio.file.StandardOpenOption.CREATE; 5 | import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING; 6 | import static java.nio.file.StandardOpenOption.WRITE; 7 | import static org.junit.jupiter.api.Assertions.assertArrayEquals; 8 | import static org.junit.jupiter.api.Assertions.assertEquals; 9 | import static org.junit.jupiter.api.Assertions.assertFalse; 10 | import static org.junit.jupiter.api.Assertions.assertTrue; 11 | import static org.junit.jupiter.api.Assertions.fail; 12 | 13 | import com.github.elopteryx.upload.OnRequestComplete; 14 | import com.github.elopteryx.upload.PartOutput; 15 | import com.github.elopteryx.upload.UploadParser; 16 | import com.github.elopteryx.upload.util.ByteBufferBackedInputStream; 17 | import com.github.elopteryx.upload.util.NullChannel; 18 | 19 | import java.io.ByteArrayOutputStream; 20 | import java.io.IOException; 21 | import java.nio.channels.Channel; 22 | import java.nio.file.Files; 23 | import java.util.*; 24 | import java.util.concurrent.atomic.AtomicInteger; 25 | import jakarta.servlet.ServletException; 26 | import jakarta.servlet.annotation.WebServlet; 27 | import jakarta.servlet.http.HttpServlet; 28 | import jakarta.servlet.http.HttpServletRequest; 29 | import jakarta.servlet.http.HttpServletResponse; 30 | 31 | @WebServlet(value = "/async", asyncSupported = true) 32 | public class AsyncUploadServlet extends HttpServlet { 33 | 34 | @Override 35 | @SuppressWarnings("PMD.SwitchStmtsShouldHaveDefault") 36 | protected void doPost(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException { 37 | final var query = request.getQueryString(); 38 | switch (query) { 39 | case ClientRequest.SIMPLE -> simple(request, response); 40 | case ClientRequest.THRESHOLD_LESSER -> thresholdLesser(request, response); 41 | case ClientRequest.THRESHOLD_GREATER -> thresholdGreater(request, response); 42 | case ClientRequest.ERROR -> error(request, response); 43 | case ClientRequest.IO_ERROR_UPON_ERROR -> ioErrorUponError(request); 44 | case ClientRequest.SERVLET_ERROR_UPON_ERROR -> servletErrorUponError(request); 45 | case ClientRequest.COMPLEX -> complex(request, response); 46 | } 47 | } 48 | 49 | private void simple(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException { 50 | UploadParser.newParser() 51 | .onPartBegin((context, buffer) -> { 52 | if (context.getPartStreams().size() == 1) { 53 | final var dir = ClientRequest.FILE_SYSTEM.getPath(""); 54 | final var temp = dir.resolve(context.getCurrentPart().getSubmittedFileName()); 55 | return PartOutput.from(Files.newByteChannel(temp, EnumSet.of(CREATE, TRUNCATE_EXISTING, WRITE))); 56 | } else if (context.getPartStreams().size() == 2) { 57 | final var dir = ClientRequest.FILE_SYSTEM.getPath(""); 58 | final var temp = dir.resolve(context.getCurrentPart().getSubmittedFileName()); 59 | return PartOutput.from(Files.newOutputStream(temp)); 60 | } else if (context.getPartStreams().size() == 3) { 61 | final var dir = ClientRequest.FILE_SYSTEM.getPath(""); 62 | final var temp = dir.resolve(context.getCurrentPart().getSubmittedFileName()); 63 | return PartOutput.from(temp); 64 | } else { 65 | return PartOutput.from(new NullChannel()); 66 | } 67 | }) 68 | .onPartEnd(context -> { 69 | if (context.getCurrentOutput() != null && context.getCurrentOutput().safeToCast(Channel.class)) { 70 | final var channel = context.getCurrentOutput().unwrap(Channel.class); 71 | if (channel.isOpen()) { 72 | fail("The parser should close it!"); 73 | } 74 | } 75 | }) 76 | .onRequestComplete(context -> { 77 | request.getAsyncContext().complete(); 78 | response.setStatus(200); 79 | }) 80 | .setupAsyncParse(request); 81 | } 82 | 83 | private static class EvilOutput extends PartOutput { 84 | EvilOutput(final Object value) { 85 | super(value); 86 | } 87 | } 88 | 89 | private void thresholdLesser(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException { 90 | 91 | UploadParser.newParser() 92 | .onPartBegin((context, buffer) -> { 93 | final var currentPart = context.getCurrentPart(); 94 | assertTrue(currentPart.isFinished()); 95 | return PartOutput.from(new NullChannel()); 96 | }) 97 | .onRequestComplete(onSuccessfulFinish(request, response, 512)) 98 | .sizeThreshold(1024) 99 | .setupAsyncParse(request); 100 | } 101 | 102 | private void thresholdGreater(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException { 103 | 104 | UploadParser.newParser() 105 | .onPartBegin((context, buffer) -> { 106 | final var currentPart = context.getCurrentPart(); 107 | assertFalse(currentPart.isFinished()); 108 | return PartOutput.from(new NullChannel()); 109 | }) 110 | .onRequestComplete(onSuccessfulFinish(request, response, 2048)) 111 | .sizeThreshold(1024) 112 | .setupAsyncParse(request); 113 | } 114 | 115 | private void error(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException { 116 | 117 | UploadParser.newParser() 118 | .onPartBegin((context, buffer) -> new EvilOutput("This will cause an error!")) 119 | .onError((context, throwable) -> response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)) 120 | .setupAsyncParse(request); 121 | } 122 | 123 | private void ioErrorUponError(final HttpServletRequest request) throws ServletException, IOException { 124 | request.startAsync().setTimeout(500); 125 | UploadParser.newParser() 126 | .onRequestComplete(context -> { 127 | throw new IOException(); 128 | }) 129 | .onError((context, throwable) -> { 130 | throw new ServletException(); 131 | }) 132 | .setupAsyncParse(request); 133 | } 134 | 135 | private void servletErrorUponError(final HttpServletRequest request) throws ServletException, IOException { 136 | request.startAsync().setTimeout(500); 137 | // onError will not be called for ServletException! 138 | UploadParser.newParser() 139 | .onRequestComplete(context -> { 140 | throw new ServletException(); 141 | }) 142 | .setupAsyncParse(request); 143 | } 144 | 145 | private void complex(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException { 146 | 147 | if (!UploadParser.isMultipart(request)) { 148 | throw new ServletException("Not multipart!"); 149 | } 150 | 151 | final var partCounter = new AtomicInteger(0); 152 | final List formFields = new ArrayList<>(); 153 | 154 | final var expectedContentTypes = Arrays.asList( 155 | "text/plain", 156 | "text/plain", 157 | "text/plain", 158 | "text/plain", 159 | "text/plain", 160 | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 161 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 162 | "image/jpeg"); 163 | 164 | UploadParser.newParser() 165 | .onPartBegin((context, buffer) -> { 166 | final var part = context.getCurrentPart(); 167 | 168 | final var detectedType = ClientRequest.TIKA.detect(new ByteBufferBackedInputStream(buffer), part.getSubmittedFileName()); 169 | final var expectedType = expectedContentTypes.get(partCounter.getAndIncrement()); 170 | if ("text/plain".equals(expectedType)) { 171 | assertTrue("text/plain".equals(detectedType) || "application/octet-stream".equals(detectedType)); 172 | } else { 173 | assertEquals(detectedType, expectedType); 174 | } 175 | assertEquals(part.getHeaderNames(), Set.of("content-disposition", "content-type")); 176 | if (part.isFile()) { 177 | if ("".equals(part.getSubmittedFileName())) { 178 | throw new IOException("No file was chosen for the form field!"); 179 | } 180 | assertFalse(part.getHeaders("content-type").isEmpty()); 181 | assertEquals(part.getContentType(), part.getHeader("content-type")); 182 | final var baos = new ByteArrayOutputStream(); 183 | formFields.add(baos); 184 | return PartOutput.from(baos); 185 | } else { 186 | final var baos = new ByteArrayOutputStream(); 187 | formFields.add(baos); 188 | return PartOutput.from(baos); 189 | } 190 | }) 191 | .onPartEnd(context -> {}) 192 | .onRequestComplete(context -> { 193 | assertArrayEquals(formFields.get(0).toByteArray(), RequestSupplier.LARGE_FILE); 194 | assertArrayEquals(formFields.get(1).toByteArray(), RequestSupplier.EMPTY_FILE); 195 | assertArrayEquals(formFields.get(2).toByteArray(), RequestSupplier.SMALL_FILE); 196 | assertArrayEquals(formFields.get(3).toByteArray(), RequestSupplier.TEXT_VALUE_1.getBytes(ISO_8859_1)); 197 | assertArrayEquals(formFields.get(4).toByteArray(), RequestSupplier.TEXT_VALUE_2.getBytes(ISO_8859_1)); 198 | 199 | context.getUserObject(HttpServletResponse.class).setStatus(HttpServletResponse.SC_OK); 200 | context.getRequest().getAsyncContext().complete(); 201 | }) 202 | .onError((context, throwable) -> response.sendError(500)) 203 | .userObject(response) 204 | .sizeThreshold(4096) 205 | .maxPartSize(Long.MAX_VALUE) 206 | .maxRequestSize(Long.MAX_VALUE) 207 | .setupAsyncParse(request); 208 | } 209 | 210 | private static OnRequestComplete onSuccessfulFinish(final HttpServletRequest request, final HttpServletResponse response, final int size) { 211 | return context -> { 212 | final var currentPart = context.getCurrentPart(); 213 | assertTrue(currentPart.isFinished()); 214 | assertEquals(size, currentPart.getKnownSize()); 215 | assertTrue(request.isAsyncStarted()); 216 | request.getAsyncContext().complete(); 217 | response.setStatus(200); 218 | }; 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/integration/BlockingUploadServlet.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal.integration; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertFalse; 5 | import static org.junit.jupiter.api.Assertions.assertTrue; 6 | import static org.junit.jupiter.api.Assertions.fail; 7 | 8 | import com.github.elopteryx.upload.OnRequestComplete; 9 | import com.github.elopteryx.upload.PartOutput; 10 | import com.github.elopteryx.upload.UploadParser; 11 | import com.github.elopteryx.upload.util.NullChannel; 12 | 13 | import java.io.IOException; 14 | import java.nio.channels.Channel; 15 | import jakarta.servlet.ServletException; 16 | import jakarta.servlet.annotation.WebServlet; 17 | import jakarta.servlet.http.HttpServlet; 18 | import jakarta.servlet.http.HttpServletRequest; 19 | import jakarta.servlet.http.HttpServletResponse; 20 | 21 | @WebServlet("/blocking") 22 | public class BlockingUploadServlet extends HttpServlet { 23 | 24 | @Override 25 | @SuppressWarnings("PMD.SwitchStmtsShouldHaveDefault") 26 | protected void doPost(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException { 27 | final var query = request.getQueryString(); 28 | switch (query) { 29 | case ClientRequest.SIMPLE -> simple(request, response); 30 | case ClientRequest.THRESHOLD_LESSER -> thresholdLesser(request, response); 31 | case ClientRequest.THRESHOLD_GREATER -> thresholdGreater(request, response); 32 | case ClientRequest.ERROR -> error(request, response); 33 | } 34 | } 35 | 36 | private void simple(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException { 37 | final var context = UploadParser.newParser() 38 | .onPartEnd(context1 -> { 39 | if (context1.getCurrentOutput() != null && context1.getCurrentOutput().safeToCast(Channel.class)) { 40 | final var channel = context1.getCurrentOutput().unwrap(Channel.class); 41 | if (channel.isOpen()) { 42 | fail("The parser should close it!"); 43 | } 44 | } 45 | }) 46 | .onRequestComplete(context1 -> response.setStatus(200)) 47 | .doBlockingParse(request); 48 | assertEquals(8, context.getPartStreams().size()); 49 | } 50 | 51 | private void thresholdLesser(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException { 52 | 53 | UploadParser.newParser() 54 | .onPartBegin((context, buffer) -> { 55 | final var currentPart = context.getCurrentPart(); 56 | assertTrue(currentPart.isFinished()); 57 | return PartOutput.from(new NullChannel()); 58 | }) 59 | .onRequestComplete(onSuccessfulFinish(request, response, 512)) 60 | .sizeThreshold(1024) 61 | .doBlockingParse(request); 62 | } 63 | 64 | private void thresholdGreater(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException { 65 | 66 | UploadParser.newParser() 67 | .onPartBegin((context, buffer) -> { 68 | final var currentPart = context.getCurrentPart(); 69 | assertFalse(currentPart.isFinished()); 70 | return PartOutput.from(new NullChannel()); 71 | }) 72 | .onRequestComplete(onSuccessfulFinish(request, response, 2048)) 73 | .sizeThreshold(1024) 74 | .doBlockingParse(request); 75 | } 76 | 77 | private void error(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException { 78 | 79 | UploadParser.newParser() 80 | .onError((context, throwable) -> response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)) 81 | .maxRequestSize(4096) 82 | .doBlockingParse(request); 83 | } 84 | 85 | private static OnRequestComplete onSuccessfulFinish(final HttpServletRequest request, final HttpServletResponse response, final int size) { 86 | return context -> { 87 | final var currentPart = context.getCurrentPart(); 88 | assertTrue(currentPart.isFinished()); 89 | assertEquals(size, currentPart.getKnownSize()); 90 | assertFalse(request.isAsyncStarted()); 91 | response.setStatus(200); 92 | }; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/integration/ClientRequest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal.integration; 2 | 3 | import static com.github.elopteryx.upload.internal.integration.RequestSupplier.withSeveralFields; 4 | import static java.net.http.HttpClient.Version.HTTP_1_1; 5 | import static org.junit.jupiter.api.Assertions.assertEquals; 6 | 7 | import com.google.common.jimfs.Jimfs; 8 | import org.apache.tika.Tika; 9 | 10 | import java.io.IOException; 11 | import java.net.URI; 12 | import java.net.http.HttpClient; 13 | import java.net.http.HttpRequest; 14 | import java.net.http.HttpResponse; 15 | import java.nio.ByteBuffer; 16 | import java.nio.charset.StandardCharsets; 17 | import java.nio.file.FileSystem; 18 | import java.time.Duration; 19 | 20 | /** 21 | * Utility class for making multipart requests. 22 | */ 23 | public final class ClientRequest { 24 | 25 | static final String BOUNDARY = "--TNoK9riv6EjfMhxBzj22SKGnOaIhZlxhar"; 26 | 27 | static final String SIMPLE = "simple"; 28 | static final String THRESHOLD_LESSER = "threshold_lesser"; 29 | static final String THRESHOLD_GREATER = "threshold_greater"; 30 | static final String ERROR = "error"; 31 | static final String IO_ERROR_UPON_ERROR = "io_error_upon_error"; 32 | static final String SERVLET_ERROR_UPON_ERROR = "servlet_error_upon_error"; 33 | static final String COMPLEX = "complex"; 34 | 35 | static final FileSystem FILE_SYSTEM = Jimfs.newFileSystem(); 36 | 37 | static final Tika TIKA = new Tika(); 38 | 39 | /** 40 | * Creates and sends a randomized multipart request for the 41 | * given address. 42 | * @param url The target address 43 | * @param expectedStatus The expected HTTP response, can be null 44 | * @throws IOException If an IO error occurred 45 | */ 46 | public static void performRequest(final String url, final Integer expectedStatus) throws IOException { 47 | performRequest(url, expectedStatus, withSeveralFields()); 48 | } 49 | 50 | /** 51 | * Creates and sends a randomized multipart request for the 52 | * given address. 53 | * @param url The target address 54 | * @param expectedStatus The expected HTTP response, can be null 55 | * @param requestData The multipart body, can't be null 56 | * @throws IOException If an IO error occurred 57 | */ 58 | public static void performRequest(final String url, final Integer expectedStatus, final ByteBuffer requestData) throws IOException { 59 | final var client = HttpClient.newBuilder().version(HTTP_1_1).build(); 60 | final var request = HttpRequest.newBuilder() 61 | .uri(URI.create(url)) 62 | .timeout(Duration.ofSeconds(5)) 63 | .header("Content-Type", "multipart/form-data; boundary=" + BOUNDARY) 64 | .POST(HttpRequest.BodyPublishers.ofByteArray(requestData.array(), 0, requestData.limit())) 65 | .build(); 66 | try { 67 | client.send(request, responseInfo -> { 68 | final var statusCode = responseInfo.statusCode(); 69 | if (expectedStatus != null) { 70 | assertEquals((int) expectedStatus, statusCode); 71 | } 72 | return HttpResponse.BodySubscribers.ofString(StandardCharsets.UTF_8); 73 | }); 74 | } catch (final InterruptedException e) { 75 | throw new RuntimeException(e); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/integration/JettyIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal.integration; 2 | 3 | import static com.github.elopteryx.upload.internal.integration.RequestSupplier.withOneLargerPicture; 4 | import static com.github.elopteryx.upload.internal.integration.RequestSupplier.withOneSmallerPicture; 5 | import static org.junit.jupiter.api.Assertions.fail; 6 | 7 | import org.eclipse.jetty.server.Server; 8 | import org.eclipse.jetty.servlet.ServletHandler; 9 | import org.junit.jupiter.api.AfterAll; 10 | import org.junit.jupiter.api.BeforeAll; 11 | import org.junit.jupiter.api.Test; 12 | 13 | import java.io.IOException; 14 | import java.nio.ByteBuffer; 15 | import jakarta.servlet.http.HttpServletResponse; 16 | 17 | class JettyIntegrationTest { 18 | 19 | private static Server server; 20 | 21 | /** 22 | * Sets up the test environment, generates data to upload, starts a 23 | * Jetty instance which will receive the client requests. 24 | * @throws Exception If an error occurred with the servlets 25 | */ 26 | @BeforeAll 27 | static void setUpClass() throws Exception { 28 | server = new Server(8090); 29 | 30 | final var handler = new ServletHandler(); 31 | server.setHandler(handler); 32 | 33 | handler.addServletWithMapping(AsyncUploadServlet.class, "/async"); 34 | handler.addServletWithMapping(BlockingUploadServlet.class, "/blocking"); 35 | 36 | server.start(); 37 | } 38 | 39 | @Test 40 | void test_with_a_real_request_simple_async() { 41 | performRequest("http://localhost:8090/async?" + ClientRequest.SIMPLE, HttpServletResponse.SC_OK); 42 | } 43 | 44 | @Test 45 | void test_with_a_real_request_simple_blocking() { 46 | performRequest("http://localhost:8090/blocking?" + ClientRequest.SIMPLE, HttpServletResponse.SC_OK); 47 | } 48 | 49 | @Test 50 | void test_with_a_real_request_threshold_lesser_async() { 51 | performRequest("http://localhost:8090/async?" + ClientRequest.THRESHOLD_LESSER, HttpServletResponse.SC_OK, withOneSmallerPicture()); 52 | } 53 | 54 | @Test 55 | void test_with_a_real_request_threshold_lesser_blocking() { 56 | performRequest("http://localhost:8090/blocking?" + ClientRequest.THRESHOLD_LESSER, HttpServletResponse.SC_OK, withOneSmallerPicture()); 57 | } 58 | 59 | @Test 60 | void test_with_a_real_request_threshold_greater_async() { 61 | performRequest("http://localhost:8090/async?" + ClientRequest.THRESHOLD_GREATER, HttpServletResponse.SC_OK, withOneLargerPicture()); 62 | } 63 | 64 | @Test 65 | void test_with_a_real_request_threshold_greater_blocking() { 66 | performRequest("http://localhost:8090/blocking?" + ClientRequest.THRESHOLD_GREATER, HttpServletResponse.SC_OK, withOneLargerPicture()); 67 | } 68 | 69 | @Test 70 | void test_with_a_real_request_error_async() { 71 | performRequest("http://localhost:8090/async?" + ClientRequest.ERROR, HttpServletResponse.SC_INTERNAL_SERVER_ERROR); 72 | } 73 | 74 | @Test 75 | void test_with_a_real_request_io_error_upon_error_async() { 76 | performRequest("http://localhost:8090/async?" + ClientRequest.IO_ERROR_UPON_ERROR, HttpServletResponse.SC_INTERNAL_SERVER_ERROR); 77 | } 78 | 79 | @Test 80 | void test_with_a_real_request_servlet_error_upon_error_async() { 81 | performRequest("http://localhost:8090/async?" + ClientRequest.SERVLET_ERROR_UPON_ERROR, null); 82 | } 83 | 84 | @Test 85 | void test_with_a_real_request_error_blocking() { 86 | performRequest("http://localhost:8090/blocking?" + ClientRequest.ERROR, HttpServletResponse.SC_INTERNAL_SERVER_ERROR); 87 | } 88 | 89 | @Test 90 | void test_with_a_real_request_complex() { 91 | performRequest("http://localhost:8090/async?" + ClientRequest.COMPLEX, HttpServletResponse.SC_OK); 92 | } 93 | 94 | private void performRequest(final String url, final Integer expectedStatus) { 95 | try { 96 | ClientRequest.performRequest(url, expectedStatus); 97 | } catch (final IOException e) { 98 | if (expectedStatus != null) { 99 | fail("Status returned: " + expectedStatus); 100 | } 101 | } 102 | } 103 | 104 | private void performRequest(final String url, final Integer expectedStatus, final ByteBuffer requestData) { 105 | try { 106 | ClientRequest.performRequest(url, expectedStatus, requestData); 107 | } catch (final IOException e) { 108 | if (expectedStatus != null) { 109 | fail("Status returned: " + expectedStatus); 110 | } 111 | } 112 | } 113 | 114 | @AfterAll 115 | static void tearDown() throws Exception { 116 | server.stop(); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/integration/RequestBuilder.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal.integration; 2 | 3 | import static java.nio.charset.StandardCharsets.ISO_8859_1; 4 | 5 | import java.nio.ByteBuffer; 6 | 7 | final class RequestBuilder { 8 | 9 | private static final byte[] LINE_FEED = "\r\n".getBytes(ISO_8859_1); 10 | 11 | private final ByteBuffer buffer; 12 | 13 | private final String boundary; 14 | 15 | static RequestBuilder newBuilder(final String boundary) { 16 | return new RequestBuilder(boundary); 17 | } 18 | 19 | private RequestBuilder(final String boundary) { 20 | this.buffer = ByteBuffer.allocate(300_000); 21 | this.boundary = boundary; 22 | } 23 | 24 | RequestBuilder addFormField(final String name, final String value) { 25 | buffer.put(LINE_FEED); 26 | buffer.put(("--" + boundary).getBytes(ISO_8859_1)); 27 | buffer.put(LINE_FEED); 28 | buffer.put(("Content-Disposition: form-data; name=\"" + name + "\"").getBytes(ISO_8859_1)); 29 | buffer.put(LINE_FEED); 30 | buffer.put(("Content-Type: text/plain; charset=" + ISO_8859_1.name()).getBytes(ISO_8859_1)); 31 | buffer.put(LINE_FEED); 32 | buffer.put(LINE_FEED); 33 | buffer.put(value.getBytes(ISO_8859_1)); 34 | 35 | return this; 36 | } 37 | 38 | RequestBuilder addFilePart(final String fieldName, final byte[] content, final String contentType, final String fileName) { 39 | buffer.put(LINE_FEED); 40 | buffer.put(("--" + boundary).getBytes(ISO_8859_1)); 41 | buffer.put(LINE_FEED); 42 | buffer.put(("Content-Disposition: form-data; name=\"" + fieldName + "\"; filename=\"" + fileName + "\"").getBytes(ISO_8859_1)); 43 | buffer.put(LINE_FEED); 44 | buffer.put(("Content-Type: " + contentType).getBytes(ISO_8859_1)); 45 | buffer.put(LINE_FEED); 46 | buffer.put(LINE_FEED); 47 | buffer.put(content); 48 | 49 | return this; 50 | } 51 | 52 | ByteBuffer finish() { 53 | buffer.put(LINE_FEED); 54 | buffer.put(("--" + boundary + "--").getBytes(ISO_8859_1)); 55 | buffer.put(LINE_FEED); 56 | 57 | buffer.flip(); 58 | return buffer; 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/integration/RequestSupplier.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal.integration; 2 | 3 | import static com.github.elopteryx.upload.internal.integration.ClientRequest.BOUNDARY; 4 | import static java.nio.charset.StandardCharsets.UTF_8; 5 | 6 | import java.io.IOException; 7 | import java.net.URISyntaxException; 8 | import java.nio.ByteBuffer; 9 | import java.nio.file.Files; 10 | import java.nio.file.Paths; 11 | import java.util.Random; 12 | 13 | /** 14 | * Utility class which is responsible for the request body creation. 15 | */ 16 | final class RequestSupplier { 17 | 18 | static final byte[] EMPTY_FILE; 19 | static final byte[] SMALL_FILE; 20 | static final byte[] LARGE_FILE; 21 | 22 | static final String TEXT_VALUE_1 = "íéáűúőóüö"; 23 | static final String TEXT_VALUE_2 = "abcdef"; 24 | 25 | static { 26 | EMPTY_FILE = new byte[0]; 27 | SMALL_FILE = "0123456789".getBytes(UTF_8); 28 | final var random = new Random(); 29 | final var builder = new StringBuilder(); 30 | for (var i = 0; i < 100_000; i++) { 31 | builder.append(random.nextInt(100)); 32 | } 33 | LARGE_FILE = builder.toString().getBytes(UTF_8); 34 | } 35 | 36 | static ByteBuffer withSeveralFields() { 37 | return RequestBuilder.newBuilder(BOUNDARY) 38 | .addFilePart("filefield1", LARGE_FILE, "application/octet-stream", "file1.txt") 39 | .addFilePart("filefield2", EMPTY_FILE, "text/plain", "file2.txt") 40 | .addFilePart("filefield3", SMALL_FILE, "application/octet-stream", "file3.txt") 41 | .addFormField("textfield1", TEXT_VALUE_1) 42 | .addFormField("textfield2", TEXT_VALUE_2) 43 | .addFilePart("filefield4", getContents("test.xlsx"), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "test.xlsx") 44 | .addFilePart("filefield5", getContents("test.docx"), "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "test.docx") 45 | .addFilePart("filefield6", getContents("test.jpg"), "image/jpeg", "test.jpg") 46 | .finish(); 47 | } 48 | 49 | static ByteBuffer withOneSmallerPicture() { 50 | return RequestBuilder.newBuilder(BOUNDARY) 51 | .addFilePart("filefield", new byte[512], "image/jpeg", "test.jpg") 52 | .finish(); 53 | } 54 | 55 | static ByteBuffer withOneLargerPicture() { 56 | return RequestBuilder.newBuilder(BOUNDARY) 57 | .addFilePart("filefield", new byte[2048], "image/jpeg", "test.jpg") 58 | .finish(); 59 | } 60 | 61 | private static byte[] getContents(final String resource) { 62 | try { 63 | final var path = Paths.get(ClientRequest.class.getResource(resource).toURI()); 64 | return Files.readAllBytes(path); 65 | } catch (final URISyntaxException | IOException e) { 66 | throw new RuntimeException(e); 67 | } 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/integration/TomcatIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal.integration; 2 | 3 | import static com.github.elopteryx.upload.internal.integration.RequestSupplier.withOneLargerPicture; 4 | import static com.github.elopteryx.upload.internal.integration.RequestSupplier.withOneSmallerPicture; 5 | import static org.junit.jupiter.api.Assertions.fail; 6 | 7 | import org.apache.catalina.WebResourceRoot; 8 | import org.apache.catalina.core.StandardContext; 9 | import org.apache.catalina.startup.Tomcat; 10 | import org.apache.catalina.webresources.DirResourceSet; 11 | import org.apache.catalina.webresources.StandardRoot; 12 | import org.junit.jupiter.api.AfterAll; 13 | import org.junit.jupiter.api.BeforeAll; 14 | import org.junit.jupiter.api.Test; 15 | 16 | import java.io.IOException; 17 | import java.nio.ByteBuffer; 18 | import java.nio.file.Files; 19 | import java.nio.file.Paths; 20 | import jakarta.servlet.http.HttpServletResponse; 21 | 22 | class TomcatIntegrationTest { 23 | 24 | private static Tomcat server; 25 | 26 | /** 27 | * Sets up the test environment, generates data to upload, starts a 28 | * Tomcat instance which will receive the client requests. 29 | * @throws Exception If an error occurred with the servlets 30 | */ 31 | @BeforeAll 32 | static void setUpClass() throws Exception { 33 | server = new Tomcat(); 34 | 35 | final var base = Paths.get("build/tomcat"); 36 | Files.createDirectories(base); 37 | 38 | server.setPort(8100); 39 | server.setBaseDir("build/tomcat"); 40 | server.getHost().setAppBase("build/tomcat"); 41 | server.getHost().setAutoDeploy(true); 42 | server.getHost().setDeployOnStartup(true); 43 | 44 | final var context = (StandardContext) server.addWebapp("", base.toAbsolutePath().toString()); 45 | 46 | final var additionWebInfClasses = Paths.get("build/classes"); 47 | final WebResourceRoot resources = new StandardRoot(context); 48 | resources.addPreResources(new DirResourceSet(resources, "/WEB-INF/classes", 49 | additionWebInfClasses.toAbsolutePath().toString(), "/")); 50 | context.setResources(resources); 51 | context.getJarScanner().setJarScanFilter((jarScanType, jarName) -> false); 52 | 53 | server.getConnector(); 54 | server.start(); 55 | } 56 | 57 | @Test 58 | void test_with_a_real_request_simple_async() { 59 | performRequest("http://localhost:8100/async?" + ClientRequest.SIMPLE, HttpServletResponse.SC_OK); 60 | } 61 | 62 | @Test 63 | void test_with_a_real_request_simple_blocking() { 64 | performRequest("http://localhost:8100/blocking?" + ClientRequest.SIMPLE, HttpServletResponse.SC_OK); 65 | } 66 | 67 | @Test 68 | void test_with_a_real_request_threshold_lesser_async() { 69 | performRequest("http://localhost:8100/async?" + ClientRequest.THRESHOLD_LESSER, HttpServletResponse.SC_OK, withOneSmallerPicture()); 70 | } 71 | 72 | @Test 73 | void test_with_a_real_request_threshold_lesser_blocking() { 74 | performRequest("http://localhost:8100/blocking?" + ClientRequest.THRESHOLD_LESSER, HttpServletResponse.SC_OK, withOneSmallerPicture()); 75 | } 76 | 77 | @Test 78 | void test_with_a_real_request_threshold_greater_async() { 79 | performRequest("http://localhost:8100/async?" + ClientRequest.THRESHOLD_GREATER, HttpServletResponse.SC_OK, withOneLargerPicture()); 80 | } 81 | 82 | @Test 83 | void test_with_a_real_request_threshold_greater_blocking() { 84 | performRequest("http://localhost:8100/blocking?" + ClientRequest.THRESHOLD_GREATER, HttpServletResponse.SC_OK, withOneLargerPicture()); 85 | } 86 | 87 | @Test 88 | void test_with_a_real_request_error_async() { 89 | performRequest("http://localhost:8100/async?" + ClientRequest.ERROR, null); 90 | } 91 | 92 | @Test 93 | void test_with_a_real_request_io_error_upon_error_async() { 94 | performRequest("http://localhost:8100/async?" + ClientRequest.IO_ERROR_UPON_ERROR, null); 95 | } 96 | 97 | @Test 98 | void test_with_a_real_request_servlet_error_upon_error_async() { 99 | performRequest("http://localhost:8100/async?" + ClientRequest.SERVLET_ERROR_UPON_ERROR, null); 100 | } 101 | 102 | @Test 103 | void test_with_a_real_request_error_blocking() { 104 | performRequest("http://localhost:8100/blocking?" + ClientRequest.ERROR, HttpServletResponse.SC_INTERNAL_SERVER_ERROR); 105 | } 106 | 107 | @Test 108 | void test_with_a_real_request_complex() { 109 | performRequest("http://localhost:8100/async?" + ClientRequest.COMPLEX, HttpServletResponse.SC_OK); 110 | } 111 | 112 | private void performRequest(final String url, final Integer expectedStatus) { 113 | try { 114 | ClientRequest.performRequest(url, expectedStatus); 115 | } catch (final IOException e) { 116 | if (expectedStatus != null) { 117 | fail("Status returned: " + expectedStatus); 118 | } 119 | } 120 | } 121 | 122 | private void performRequest(final String url, final Integer expectedStatus, final ByteBuffer requestData) { 123 | try { 124 | ClientRequest.performRequest(url, expectedStatus, requestData); 125 | } catch (final IOException e) { 126 | if (expectedStatus != null) { 127 | fail("Status returned: " + expectedStatus); 128 | } 129 | } 130 | } 131 | 132 | @AfterAll 133 | static void tearDown() throws Exception { 134 | server.stop(); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/internal/integration/UndertowIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.internal.integration; 2 | 3 | import static com.github.elopteryx.upload.internal.integration.RequestSupplier.withOneLargerPicture; 4 | import static com.github.elopteryx.upload.internal.integration.RequestSupplier.withOneSmallerPicture; 5 | 6 | import io.undertow.Handlers; 7 | import io.undertow.Undertow; 8 | import io.undertow.servlet.Servlets; 9 | import org.junit.jupiter.api.AfterAll; 10 | import org.junit.jupiter.api.BeforeAll; 11 | import org.junit.jupiter.api.Test; 12 | 13 | import java.io.IOException; 14 | import java.nio.ByteBuffer; 15 | import jakarta.servlet.http.HttpServletResponse; 16 | 17 | class UndertowIntegrationTest { 18 | 19 | private static Undertow server; 20 | 21 | /** 22 | * Sets up the test environment, generates data to upload, starts an 23 | * Undertow instance which will receive the client requests. 24 | * @throws Exception If an error occurred with the servlets 25 | */ 26 | @BeforeAll 27 | static void setUpClass() throws Exception { 28 | final var servletBuilder = Servlets.deployment() 29 | .setClassLoader(Thread.currentThread().getContextClassLoader()) 30 | .setContextPath("/") 31 | .setDeploymentName("ROOT.war") 32 | .addServlets( 33 | Servlets.servlet("AsyncUploadServlet", AsyncUploadServlet.class) 34 | .addMapping("/async") 35 | .setAsyncSupported(true), 36 | Servlets.servlet("BlockingUploadServlet", BlockingUploadServlet.class) 37 | .addMapping("/blocking") 38 | .setAsyncSupported(false) 39 | ); 40 | 41 | final var manager = Servlets.defaultContainer().addDeployment(servletBuilder); 42 | manager.deploy(); 43 | final var path = Handlers.path(Handlers.redirect("/")).addPrefixPath("/", manager.start()); 44 | 45 | server = Undertow.builder() 46 | .addHttpListener(8080, "localhost") 47 | .setHandler(path) 48 | .build(); 49 | server.start(); 50 | } 51 | 52 | @Test 53 | void test_with_a_real_request_simple_async() throws IOException { 54 | performRequest("http://localhost:8080/async?" + ClientRequest.SIMPLE, HttpServletResponse.SC_OK); 55 | } 56 | 57 | @Test 58 | void test_with_a_real_request_simple_blocking() throws IOException { 59 | performRequest("http://localhost:8080/blocking?" + ClientRequest.SIMPLE, HttpServletResponse.SC_OK); 60 | } 61 | 62 | @Test 63 | void test_with_a_real_request_threshold_lesser_async() throws IOException { 64 | performRequest("http://localhost:8080/async?" + ClientRequest.THRESHOLD_LESSER, HttpServletResponse.SC_OK, withOneSmallerPicture()); 65 | } 66 | 67 | @Test 68 | void test_with_a_real_request_threshold_lesser_blocking() throws IOException { 69 | performRequest("http://localhost:8080/blocking?" + ClientRequest.THRESHOLD_LESSER, HttpServletResponse.SC_OK, withOneSmallerPicture()); 70 | } 71 | 72 | @Test 73 | void test_with_a_real_request_threshold_greater_async() throws IOException { 74 | performRequest("http://localhost:8080/async?" + ClientRequest.THRESHOLD_GREATER, HttpServletResponse.SC_OK, withOneLargerPicture()); 75 | } 76 | 77 | @Test 78 | void test_with_a_real_request_threshold_greater_blocking() throws IOException { 79 | performRequest("http://localhost:8080/blocking?" + ClientRequest.THRESHOLD_GREATER, HttpServletResponse.SC_OK, withOneLargerPicture()); 80 | } 81 | 82 | @Test 83 | void test_with_a_real_request_error_async() throws IOException { 84 | performRequest("http://localhost:8080/async?" + ClientRequest.ERROR, HttpServletResponse.SC_INTERNAL_SERVER_ERROR); 85 | } 86 | 87 | @Test 88 | void test_with_a_real_request_io_error_upon_error_async() throws IOException { 89 | performRequest("http://localhost:8080/async?" + ClientRequest.IO_ERROR_UPON_ERROR, HttpServletResponse.SC_INTERNAL_SERVER_ERROR); 90 | } 91 | 92 | @Test 93 | void test_with_a_real_request_servlet_error_upon_error_async() throws IOException { 94 | performRequest("http://localhost:8080/async?" + ClientRequest.SERVLET_ERROR_UPON_ERROR, HttpServletResponse.SC_INTERNAL_SERVER_ERROR); 95 | } 96 | 97 | @Test 98 | void test_with_a_real_request_error_blocking() throws IOException { 99 | performRequest("http://localhost:8080/blocking?" + ClientRequest.ERROR, HttpServletResponse.SC_INTERNAL_SERVER_ERROR); 100 | } 101 | 102 | @Test 103 | void test_with_a_real_request_complex() throws IOException { 104 | performRequest("http://localhost:8080/async?" + ClientRequest.COMPLEX, HttpServletResponse.SC_OK); 105 | } 106 | 107 | private void performRequest(final String url, final int expectedStatus) throws IOException { 108 | ClientRequest.performRequest(url, expectedStatus); 109 | } 110 | 111 | private void performRequest(final String url, final int expectedStatus, final ByteBuffer requestData) throws IOException { 112 | ClientRequest.performRequest(url, expectedStatus, requestData); 113 | } 114 | 115 | @AfterAll 116 | static void tearDown() { 117 | server.stop(); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/util/ByteBufferBackedInputStreamTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.util; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | 6 | import org.junit.jupiter.api.Test; 7 | 8 | import java.io.IOException; 9 | import java.nio.ByteBuffer; 10 | 11 | class ByteBufferBackedInputStreamTest { 12 | 13 | private static final String TEST_TEXT = "Test text."; 14 | 15 | @Test 16 | void create_with_direct() { 17 | assertThrows(IllegalArgumentException.class, () -> new ByteBufferBackedInputStream(ByteBuffer.allocate(0).asReadOnlyBuffer())); 18 | } 19 | 20 | @Test 21 | void create_with_read_only() { 22 | assertThrows(IllegalArgumentException.class, () -> new ByteBufferBackedInputStream(ByteBuffer.allocateDirect(0))); 23 | } 24 | 25 | @Test 26 | void check_available_bytes() throws Exception { 27 | final var stream = new ByteBufferBackedInputStream(ByteBuffer.wrap(TEST_TEXT.getBytes())); 28 | final var length = TEST_TEXT.getBytes().length; 29 | assertEquals(length, stream.available()); 30 | final var read = stream.read(new byte[1024]); 31 | assertEquals(length, read); 32 | assertEquals(0, stream.available()); 33 | } 34 | 35 | @Test 36 | void read_a_byte() throws Exception { 37 | final var stream = new ByteBufferBackedInputStream(ByteBuffer.wrap(TEST_TEXT.getBytes())); 38 | final var read = stream.read(); 39 | assertEquals(read, "T".getBytes()[0]); 40 | } 41 | 42 | @Test 43 | void try_to_read_an_exhausted_stream() throws Exception { 44 | final var stream = new ByteBufferBackedInputStream(ByteBuffer.allocate(0)); 45 | final var read = stream.read(); 46 | assertEquals(read, -1); 47 | } 48 | 49 | @Test 50 | void try_to_read_a_closed_stream() throws Exception { 51 | final var stream = new ByteBufferBackedInputStream(ByteBuffer.wrap(TEST_TEXT.getBytes())); 52 | stream.close(); 53 | assertThrows(IOException.class, stream::read); 54 | } 55 | 56 | @Test 57 | void read_into_byte_array() throws Exception { 58 | final var stream = new ByteBufferBackedInputStream(ByteBuffer.wrap(TEST_TEXT.getBytes())); 59 | final var buf = new byte[1024]; 60 | final var read = stream.read(buf); 61 | assertEquals(TEST_TEXT, new String(buf, 0, read)); 62 | } 63 | 64 | @Test 65 | void try_to_read_an_exhausted_stream_to_array() throws Exception { 66 | final var stream = new ByteBufferBackedInputStream(ByteBuffer.allocate(0)); 67 | final var buf = new byte[1024]; 68 | final var read = stream.read(buf); 69 | assertEquals(read, -1); 70 | } 71 | 72 | @Test 73 | void try_to_read_a_closed_stream_to_array() throws Exception { 74 | final var stream = new ByteBufferBackedInputStream(ByteBuffer.wrap(TEST_TEXT.getBytes())); 75 | stream.close(); 76 | final var buf = new byte[1024]; 77 | assertThrows(IOException.class, () -> stream.read(buf)); 78 | } 79 | 80 | } -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/util/ByteBufferBackedOutputStreamTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.util; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | 6 | import org.junit.jupiter.api.Test; 7 | 8 | import java.io.IOException; 9 | import java.nio.BufferOverflowException; 10 | import java.nio.ByteBuffer; 11 | 12 | class ByteBufferBackedOutputStreamTest { 13 | 14 | private static final String TEST_TEXT = "Test text."; 15 | 16 | @Test 17 | void create_with_direct() { 18 | assertThrows(IllegalArgumentException.class, () -> new ByteBufferBackedOutputStream(ByteBuffer.allocate(0).asReadOnlyBuffer())); 19 | } 20 | 21 | @Test 22 | void create_with_read_only() { 23 | assertThrows(IllegalArgumentException.class, () -> new ByteBufferBackedOutputStream(ByteBuffer.allocateDirect(0))); 24 | } 25 | 26 | @Test 27 | void write_a_byte() throws Exception { 28 | final var buffer = ByteBuffer.allocate(1024); 29 | final var stream = new ByteBufferBackedOutputStream(buffer); 30 | final var oneByte = "T".getBytes()[0]; 31 | stream.write(oneByte); 32 | assertEquals(buffer.get(0), oneByte); 33 | } 34 | 35 | @Test 36 | void try_to_write_an_exhausted_stream() { 37 | final var stream = new ByteBufferBackedOutputStream(ByteBuffer.allocate(0)); 38 | assertThrows(BufferOverflowException.class, () -> stream.write(1)); 39 | } 40 | 41 | @Test 42 | void try_to_write_a_closed_stream() throws Exception { 43 | final var stream = new ByteBufferBackedOutputStream(ByteBuffer.wrap(TEST_TEXT.getBytes())); 44 | stream.close(); 45 | assertThrows(IOException.class, () -> stream.write(1)); 46 | } 47 | 48 | @Test 49 | void write_into_byte_array() throws IOException { 50 | final var bytes = TEST_TEXT.getBytes(); 51 | final var buffer = ByteBuffer.allocate(1024); 52 | final var stream = new ByteBufferBackedOutputStream(buffer); 53 | stream.write(bytes); 54 | assertEquals(TEST_TEXT, new String(buffer.array(), 0, bytes.length)); 55 | } 56 | 57 | @Test 58 | void try_to_read_an_exhausted_stream_to_array() { 59 | final var stream = new ByteBufferBackedOutputStream(ByteBuffer.allocate(0)); 60 | assertThrows(BufferOverflowException.class, () -> stream.write(TEST_TEXT.getBytes())); 61 | } 62 | 63 | @Test 64 | void try_to_read_a_closed_stream_to_array() throws Exception { 65 | final var stream = new ByteBufferBackedOutputStream(ByteBuffer.allocate(1024)); 66 | stream.close(); 67 | assertThrows(IOException.class, () -> stream.write(TEST_TEXT.getBytes())); 68 | } 69 | 70 | } -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/util/InputStreamBackedChannelTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.util; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | import static org.junit.jupiter.api.Assertions.assertTrue; 6 | 7 | import org.junit.jupiter.api.Test; 8 | 9 | import java.io.ByteArrayInputStream; 10 | import java.io.IOException; 11 | import java.nio.ByteBuffer; 12 | import java.nio.channels.ClosedChannelException; 13 | 14 | class InputStreamBackedChannelTest { 15 | 16 | private static final String TEST_TEXT = "Test text."; 17 | 18 | @Test 19 | void read_the_string() throws Exception { 20 | final var stream = new ByteArrayInputStream(TEST_TEXT.getBytes()); 21 | final var channel = new InputStreamBackedChannel(stream); 22 | final var buffer = ByteBuffer.allocate(1024); 23 | channel.read(buffer); 24 | assertEquals(TEST_TEXT, new String(buffer.array(), 0, buffer.position())); 25 | } 26 | 27 | @Test 28 | void try_to_read_to_direct_buffer() { 29 | final var stream = new ByteArrayInputStream(TEST_TEXT.getBytes()); 30 | final var channel = new InputStreamBackedChannel(stream); 31 | final var buffer = ByteBuffer.allocateDirect(1024); 32 | assertThrows(IllegalArgumentException.class, () -> channel.read(buffer)); 33 | } 34 | 35 | @Test 36 | void try_to_read_to_read_only_buffer() { 37 | final var stream = new ByteArrayInputStream(TEST_TEXT.getBytes()); 38 | final var channel = new InputStreamBackedChannel(stream); 39 | final var buffer = ByteBuffer.allocateDirect(1024).asReadOnlyBuffer(); 40 | assertThrows(IllegalArgumentException.class, () -> channel.read(buffer)); 41 | } 42 | 43 | @Test 44 | void open_and_close_and_try_to_read() throws IOException { 45 | final var stream = new ByteArrayInputStream(TEST_TEXT.getBytes()); 46 | final var channel = new InputStreamBackedChannel(stream); 47 | assertTrue(channel.isOpen()); 48 | channel.close(); 49 | assertThrows(ClosedChannelException.class, () -> channel.read(ByteBuffer.allocate(0))); 50 | } 51 | 52 | } -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/util/MockServletInputStream.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.util; 2 | 3 | import java.io.ByteArrayInputStream; 4 | import java.io.IOException; 5 | import java.nio.charset.StandardCharsets; 6 | import jakarta.servlet.ReadListener; 7 | import jakarta.servlet.ServletInputStream; 8 | 9 | public class MockServletInputStream extends ServletInputStream { 10 | 11 | private static final String FILE_NAME = "foo.txt"; 12 | private static final String REQUEST_DATA = 13 | "-----1234\r\n" 14 | + "Content-Disposition: form-data; name=\"file\"; filename=\"" + FILE_NAME + "\"\r\n" 15 | + "Content-Type: text/whatever\r\n" 16 | + "\r\n" 17 | + "This is the content of the file\n" 18 | + "\r\n" 19 | + "-----1234\r\n" 20 | + "Content-Disposition: form-data; name=\"field\"\r\n" 21 | + "\r\n" 22 | + "fieldValue\r\n" 23 | + "-----1234\r\n" 24 | + "Content-Disposition: form-data; name=\"multi\"\r\n" 25 | + "\r\n" 26 | + "value1\r\n" 27 | + "-----1234\r\n" 28 | + "Content-Disposition: form-data; name=\"multi\"\r\n" 29 | + "\r\n" 30 | + "value2\r\n" 31 | + "-----1234--\r\n"; 32 | 33 | private final ByteArrayInputStream sourceStream; 34 | 35 | private ReadListener readListener; 36 | 37 | MockServletInputStream() { 38 | this.sourceStream = new ByteArrayInputStream(REQUEST_DATA.getBytes(StandardCharsets.US_ASCII)); 39 | } 40 | 41 | public void onDataAvailable() throws IOException { 42 | readListener.onDataAvailable(); 43 | } 44 | 45 | @Override 46 | public int read() { 47 | return this.sourceStream.read(); 48 | } 49 | 50 | @Override 51 | public void close() throws IOException { 52 | super.close(); 53 | this.sourceStream.close(); 54 | } 55 | 56 | @Override 57 | public boolean isFinished() { 58 | return false; 59 | } 60 | 61 | @Override 62 | public boolean isReady() { 63 | return true; 64 | } 65 | 66 | @Override 67 | public void setReadListener(final ReadListener readListener) { 68 | this.readListener = readListener; 69 | } 70 | } -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/util/NullChannelTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.util; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertFalse; 5 | import static org.junit.jupiter.api.Assertions.assertThrows; 6 | import static org.junit.jupiter.api.Assertions.assertTrue; 7 | 8 | import org.junit.jupiter.api.Test; 9 | 10 | import java.io.IOException; 11 | import java.nio.ByteBuffer; 12 | import java.nio.channels.ClosedChannelException; 13 | 14 | class NullChannelTest { 15 | 16 | @Test 17 | void read_from_open_channel() throws Exception { 18 | final var channel = new NullChannel(); 19 | assertEquals(-1, channel.read(ByteBuffer.allocate(0))); 20 | } 21 | 22 | @Test 23 | void read_from_closed_channel() { 24 | final var channel = new NullChannel(); 25 | assertTrue(channel.isOpen()); 26 | channel.close(); 27 | assertFalse(channel.isOpen()); 28 | assertThrows(ClosedChannelException.class, () -> channel.read(ByteBuffer.allocate(0))); 29 | } 30 | 31 | @Test 32 | void write_to_open_channel() throws IOException { 33 | final var channel = new NullChannel(); 34 | channel.write(ByteBuffer.allocate(0)); 35 | } 36 | 37 | @Test 38 | void write_to_closed_channel() { 39 | final var channel = new NullChannel(); 40 | assertTrue(channel.isOpen()); 41 | channel.close(); 42 | assertFalse(channel.isOpen()); 43 | assertThrows(ClosedChannelException.class, () -> channel.write(ByteBuffer.allocate(0))); 44 | } 45 | 46 | } -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/util/OutputStreamBackedChannelTest.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.util; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | import static org.junit.jupiter.api.Assertions.assertTrue; 6 | 7 | import org.junit.jupiter.api.Test; 8 | 9 | import java.io.ByteArrayOutputStream; 10 | import java.io.IOException; 11 | import java.nio.ByteBuffer; 12 | import java.nio.channels.ClosedChannelException; 13 | 14 | class OutputStreamBackedChannelTest { 15 | 16 | private static final String TEST_TEXT = "Test text."; 17 | 18 | @Test 19 | void write_the_string() throws IOException { 20 | final var stream = new ByteArrayOutputStream(1024); 21 | final var channel = new OutputStreamBackedChannel(stream); 22 | final var buffer = ByteBuffer.wrap(TEST_TEXT.getBytes()); 23 | channel.write(buffer); 24 | assertEquals(TEST_TEXT, new String(buffer.array(), 0, buffer.position())); 25 | } 26 | 27 | @Test 28 | void try_to_write_from_direct_buffer() { 29 | final var stream = new ByteArrayOutputStream(1024); 30 | final var channel = new OutputStreamBackedChannel(stream); 31 | final var buffer = ByteBuffer.allocateDirect(0); 32 | assertThrows(IllegalArgumentException.class, () -> channel.write(buffer)); 33 | } 34 | 35 | @Test 36 | void try_to_write_from_read_only_buffer() { 37 | final var stream = new ByteArrayOutputStream(1024); 38 | final var channel = new OutputStreamBackedChannel(stream); 39 | final var buffer = ByteBuffer.allocate(0).asReadOnlyBuffer(); 40 | assertThrows(IllegalArgumentException.class, () -> channel.write(buffer)); 41 | } 42 | 43 | @Test 44 | void open_and_close_and_try_to_write() throws Exception { 45 | final var stream = new ByteArrayOutputStream(1024); 46 | final var channel = new OutputStreamBackedChannel(stream); 47 | assertTrue(channel.isOpen()); 48 | channel.close(); 49 | assertThrows(ClosedChannelException.class, () -> channel.write(ByteBuffer.wrap(TEST_TEXT.getBytes()))); 50 | } 51 | 52 | } -------------------------------------------------------------------------------- /upload-parser-tests/src/test/java/com/github/elopteryx/upload/util/Servlets.java: -------------------------------------------------------------------------------- 1 | package com.github.elopteryx.upload.util; 2 | 3 | import static org.mockito.Mockito.mock; 4 | import static org.mockito.Mockito.when; 5 | 6 | import jakarta.servlet.http.HttpServletRequest; 7 | import jakarta.servlet.http.HttpServletResponse; 8 | 9 | public final class Servlets { 10 | 11 | private Servlets() { 12 | // No need to instantiate 13 | } 14 | 15 | /** 16 | * Creates a new mock servlet request. 17 | * @return The mocked request. 18 | * @throws Exception If an error occurred 19 | */ 20 | public static HttpServletRequest newRequest() throws Exception { 21 | final var request = mock(HttpServletRequest.class); 22 | 23 | when(request.getMethod()).thenReturn("POST"); 24 | when(request.getContentType()).thenReturn("multipart/"); 25 | when(request.getContentLengthLong()).thenReturn(1024 * 1024L); 26 | when(request.getInputStream()).thenReturn(new MockServletInputStream()); 27 | when(request.isAsyncSupported()).thenReturn(true); 28 | 29 | return request; 30 | } 31 | 32 | /** 33 | * Creates a new mock servlet response. 34 | * @return The mocked response. 35 | */ 36 | public static HttpServletResponse newResponse() { 37 | final var response = mock(HttpServletResponse.class); 38 | 39 | when(response.getStatus()).thenReturn(200); 40 | 41 | return response; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/resources/com/github/elopteryx/upload/internal/integration/test.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elopteryx/upload-parser/ae2ea72fedea25bd051823677241c05489d2df06/upload-parser-tests/src/test/resources/com/github/elopteryx/upload/internal/integration/test.docx -------------------------------------------------------------------------------- /upload-parser-tests/src/test/resources/com/github/elopteryx/upload/internal/integration/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elopteryx/upload-parser/ae2ea72fedea25bd051823677241c05489d2df06/upload-parser-tests/src/test/resources/com/github/elopteryx/upload/internal/integration/test.jpg -------------------------------------------------------------------------------- /upload-parser-tests/src/test/resources/com/github/elopteryx/upload/internal/integration/test.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elopteryx/upload-parser/ae2ea72fedea25bd051823677241c05489d2df06/upload-parser-tests/src/test/resources/com/github/elopteryx/upload/internal/integration/test.xlsx -------------------------------------------------------------------------------- /upload-parser-tests/src/test/resources/com/github/elopteryx/upload/internal/mime-utf8.txt: -------------------------------------------------------------------------------- 1 | This is a preamble 2 | --unique-boundary-1 3 | Content-type: text/plain 4 | Content-Disposition: attachment; filename=个专为语文教学而设计的电脑软件.txt 5 | 6 | Just some chinese characters I copied from the internet, no idea what it says. 7 | --unique-boundary-1-- 8 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/resources/com/github/elopteryx/upload/internal/mime1.txt: -------------------------------------------------------------------------------- 1 | this is a preamble 2 | --unique-boundary-1 3 | Content-type: text/plain 4 | 5 | Here is some text. 6 | --unique-boundary-1 7 | 8 | Here is some more text. 9 | --unique-boundary-1-- 10 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/resources/com/github/elopteryx/upload/internal/mime2.txt: -------------------------------------------------------------------------------- 1 | --unique-boundary-1 2 | Content-type: text/plain 3 | 4 | Here is some text. 5 | --unique-boundary-1 6 | 7 | Here is some more text. 8 | --unique-boundary-1-- 9 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/resources/com/github/elopteryx/upload/internal/mime3.txt: -------------------------------------------------------------------------------- 1 | --unique-boundary-1 2 | Content-type: text/plain 3 | Content-Transfer-encoding: base64 4 | 5 | VGhpcyBpcyBzb21lIGJhc2U2NCB0ZXh0Lg== 6 | --unique-boundary-1 7 | Content-Transfer-encoding: base64 8 | 9 | VGhpcyBpcyBzb21lIG1vcmUgYmFzZTY0IHRleHQu 10 | --unique-boundary-1-- 11 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/resources/com/github/elopteryx/upload/internal/mime4.txt: -------------------------------------------------------------------------------- 1 | --someboundarytext 2 | Content-type: text/plain 3 | Content-Transfer-encoding: quoted-printable 4 | 5 | time=3Dmoney. 6 | --someboundarytext-- 7 | -------------------------------------------------------------------------------- /upload-parser-tests/src/test/resources/com/github/elopteryx/upload/internal/mime5_malformed.txt: -------------------------------------------------------------------------------- 1 | this is a preamble 2 | --unique-boundary-1 3 | Content-type: text/plain 4 | 5 | Here is some text. 6 | --unique-boundary-1 7 | 8 | Here is some more text. 9 | --unique-boundary-1--- -------------------------------------------------------------------------------- /upload-parser-tests/src/test/resources/com/github/elopteryx/upload/internal/mime6_malformed.txt: -------------------------------------------------------------------------------- 1 | --unique-boundary-1 2 | Content-type: text/plain 3 | Content-Transfer-encoding: base64 4 | 5 | vGhpcyBpcyBzb21lIGJhc2U2NCB0ZXh0Lg=== 6 | --unique-boundary-1 7 | Content-Transfer-encoding: base64 8 | 9 | vGhpcyBpcyBzb21lIG1vcmUgYmFzZTY0IHRleHQu= 10 | --unique-boundary-1-- -------------------------------------------------------------------------------- /upload-parser-tests/src/test/resources/com/github/elopteryx/upload/internal/mime7_malformed.txt: -------------------------------------------------------------------------------- 1 | --someboundarytext 2 | Content-type: text/plain 3 | Content-Transfer-encoding: quoted-printable 4 | 5 | time= 6 | money. 7 | --someboundarytext-- --------------------------------------------------------------------------------