diff --git a/exist-core/src/main/java/org/exist/http/servlets/HttpRequestWrapper.java b/exist-core/src/main/java/org/exist/http/servlets/HttpRequestWrapper.java index 587fa300eb1..2ec2623de07 100644 --- a/exist-core/src/main/java/org/exist/http/servlets/HttpRequestWrapper.java +++ b/exist-core/src/main/java/org/exist/http/servlets/HttpRequestWrapper.java @@ -38,6 +38,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; +import java.lang.ref.Cleaner; import java.net.URLDecoder; import java.nio.file.Files; import java.nio.file.Path; @@ -68,6 +69,8 @@ public class HttpRequestWrapper implements RequestWrapper { // to handlers not written to expect a body. private static final Set METHODS_WITH_REQUEST_BODY = Set.of("POST", "PUT", "PATCH"); + private static final Cleaner CLEANER = Cleaner.create(); + private static final Path TMP_DIR; static { try { @@ -93,7 +96,7 @@ public class HttpRequestWrapper implements RequestWrapper { // flag to indicate whether multipart form data was processed private final boolean isFormDataParsed; - @Nullable private Map temporaryUploadedFilesPathCache = null; + private final Map temporaryUploadedFilesPathCache = new HashMap<>(); private boolean parsedQueryString = false; @Nullable private Map queryStringParameters = null; @@ -170,6 +173,10 @@ public HttpRequestWrapper(final HttpServletRequest servletRequest, } LOG.debug("Retrieved {} parameters.", params.size()); + + // Delete any temporary files created for uploaded file parts once this wrapper is + // unreachable. The cleaning action holds only the map, never `this`. + CLEANER.register(this, new TemporaryUploadedFilesCleaner(temporaryUploadedFilesPathCache)); } @Override @@ -554,10 +561,7 @@ public List getFileUploadParam(final String name) { final List parts = getFileItem(o); final List files = new ArrayList<>(parts.size()); for (final Part part : parts) { - Path temporaryUploadedFilePath = null; - if (temporaryUploadedFilesPathCache != null) { - temporaryUploadedFilePath = temporaryUploadedFilesPathCache.get(part); - } + Path temporaryUploadedFilePath = temporaryUploadedFilesPathCache.get(part); if (temporaryUploadedFilePath == null) { try { @@ -570,10 +574,6 @@ public List getFileUploadParam(final String name) { continue; } - if (temporaryUploadedFilesPathCache == null) { - temporaryUploadedFilesPathCache = new HashMap<>(); - } - temporaryUploadedFilesPathCache.put(part, temporaryUploadedFilePath); } @@ -604,6 +604,29 @@ public List getUploadedFileName(final String name) { return files; } + @Override + public List> getUploadedFileHeaders(final String name) { + if (!isFormDataParsed) { + return null; + } + + final Object o = params.get(name); + if (o == null) { + return null; + } + + final List parts = getFileItem(o); + final List> fileHeaders = new ArrayList<>(parts.size()); + for (final Part part : parts) { + final Map headers = new LinkedHashMap<>(); + for (final String headerName : part.getHeaderNames()) { + headers.put(headerName, part.getHeader(headerName)); + } + fileHeaders.add(headers); + } + return fileHeaders; + } + /** * @see jakarta.servlet.http.HttpServletRequest#getParameterNames() */ @@ -938,22 +961,25 @@ public RequestDispatcher getRequestDispatcher(final String path) { return servletRequest.getRequestDispatcher(path); } - @Override - protected void finalize() { - if (temporaryUploadedFilesPathCache == null) { - return; - } + /** + * Deletes the temporary files created for uploaded file parts once the owning request + * wrapper is no longer reachable. Registered with a {@link Cleaner}; holds only the map of + * temporary files (never the wrapper), so it does not keep the wrapper alive. + */ + private record TemporaryUploadedFilesCleaner(Map temporaryUploadedFiles) implements Runnable { - for (final Map.Entry temporaryUploadedFilePathCache : temporaryUploadedFilesPathCache.entrySet()) { - final Part part = temporaryUploadedFilePathCache.getKey(); - try { - part.delete(); - } catch (final IOException e) { - LOG.error("Unable to delete: {}", part.getSubmittedFileName(), e); - } + @Override + public void run() { + for (final Map.Entry entry : temporaryUploadedFiles.entrySet()) { + final Part part = entry.getKey(); + try { + part.delete(); + } catch (final IOException e) { + LOG.error("Unable to delete: {}", part.getSubmittedFileName(), e); + } - final Path temporaryFile = temporaryUploadedFilePathCache.getValue(); - FileUtils.deleteQuietly(temporaryFile); + FileUtils.deleteQuietly(entry.getValue()); + } } } } diff --git a/exist-core/src/main/java/org/exist/http/servlets/RequestWrapper.java b/exist-core/src/main/java/org/exist/http/servlets/RequestWrapper.java index d11eaf45334..9249dba38b1 100644 --- a/exist-core/src/main/java/org/exist/http/servlets/RequestWrapper.java +++ b/exist-core/src/main/java/org/exist/http/servlets/RequestWrapper.java @@ -30,6 +30,7 @@ import java.security.Principal; import java.util.Enumeration; import java.util.List; +import java.util.Map; public interface RequestWrapper { @@ -578,4 +579,18 @@ public interface RequestWrapper { * @return the list of file names. */ List getUploadedFileName(String name); + + /** + * Get the part headers of each uploaded file submitted under a parameter name. + * + * There is one map per uploaded file (in submission order), aligned with the entries + * returned by {@link #getUploadedFileName(String)} and {@link #getFileUploadParam(String)}. + * Each map holds the file part's own headers (for example {@code Content-Type} and + * {@code Content-Disposition}), keyed by header name as submitted. + * + * @param name the parameter name + * + * @return the list of header maps, one per uploaded file. + */ + List> getUploadedFileHeaders(String name); } diff --git a/exist-core/src/main/java/org/exist/xquery/functions/request/GetUploadedFileHeaders.java b/exist-core/src/main/java/org/exist/xquery/functions/request/GetUploadedFileHeaders.java new file mode 100644 index 00000000000..5c7a277e05f --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/request/GetUploadedFileHeaders.java @@ -0,0 +1,85 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.functions.request; + +import java.util.List; +import java.util.Map; + +import org.exist.dom.QName; +import org.exist.http.servlets.RequestWrapper; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.functions.map.MapType; +import org.exist.xquery.value.FunctionParameterSequenceType; +import org.exist.xquery.value.FunctionReturnSequenceType; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.SequenceType; +import org.exist.xquery.value.StringValue; +import org.exist.xquery.value.Type; +import org.exist.xquery.value.ValueSequence; + +import javax.annotation.Nonnull; + +/** + * Retrieve the part headers of each uploaded file in a multi-part request. + */ +public class GetUploadedFileHeaders extends StrictRequestFunction { + + public final static FunctionSignature signature = + new FunctionSignature( + new QName("get-uploaded-file-headers", RequestModule.NAMESPACE_URI, RequestModule.PREFIX), + "Retrieve the part headers of each uploaded file submitted under a parameter name in a " + + "multi-part request. Returns one map (header name to header value) per uploaded file, in " + + "submission order and aligned with request:get-uploaded-file-name. Header names are keyed " + + "as submitted. Returns the empty sequence if the request is not a multi-part request or the " + + "parameter name does not point to a file part.", + new SequenceType[] { + new FunctionParameterSequenceType("upload-param-name", Type.STRING, Cardinality.EXACTLY_ONE, "The parameter name") + }, + new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.ZERO_OR_MORE, "one map of header name to header value per uploaded file")); + + public GetUploadedFileHeaders(final XQueryContext context) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, @Nonnull final RequestWrapper request) + throws XPathException { + final String uploadParamName = args[0].getStringValue(); + final List> fileHeaders = request.getUploadedFileHeaders(uploadParamName); + if (fileHeaders == null || fileHeaders.isEmpty()) { + return Sequence.EMPTY_SEQUENCE; + } + + final ValueSequence result = new ValueSequence(); + for (final Map headers : fileHeaders) { + final MapType map = new MapType(this, context); + for (final Map.Entry header : headers.entrySet()) { + map.add(new StringValue(this, header.getKey()), new StringValue(this, header.getValue())); + } + result.add(map); + } + return result; + } +} diff --git a/exist-core/src/main/java/org/exist/xquery/functions/request/RequestModule.java b/exist-core/src/main/java/org/exist/xquery/functions/request/RequestModule.java index dd48c8027d5..b2099c6cb7d 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/request/RequestModule.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/request/RequestModule.java @@ -62,6 +62,7 @@ public class RequestModule extends AbstractInternalModule { new FunctionDef(GetQueryString.signature, GetQueryString.class), new FunctionDef(GetUploadedFile.signatures[0], GetUploadedFile.class), new FunctionDef(GetUploadedFileName.signature, GetUploadedFileName.class), + new FunctionDef(GetUploadedFileHeaders.signature, GetUploadedFileHeaders.class), new FunctionDef(GetUploadedFileSize.signature, GetUploadedFileSize.class), new FunctionDef(GetURI.signatures[0], GetURI.class), new FunctionDef(GetURI.signatures[1], GetURI.class), diff --git a/exist-core/src/test/java/org/exist/xquery/functions/request/GetUploadedFileHeadersTest.java b/exist-core/src/test/java/org/exist/xquery/functions/request/GetUploadedFileHeadersTest.java new file mode 100644 index 00000000000..17c654a5eca --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/request/GetUploadedFileHeadersTest.java @@ -0,0 +1,170 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.functions.request; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; + +import com.github.mizosoft.methanol.MediaType; +import com.github.mizosoft.methanol.MoreBodyPublishers; +import com.github.mizosoft.methanol.MultipartBodyPublisher; +import org.exist.http.AbstractHttpTest.HttpResponseResult; +import org.exist.http.RESTTest; +import org.exist.xmldb.EXistResource; +import org.exist.xmldb.UserManagementService; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.xmldb.api.DatabaseManager; +import org.xmldb.api.base.Collection; +import org.xmldb.api.base.XMLDBException; +import org.xmldb.api.modules.BinaryResource; + +/** + * Tests request:get-uploaded-file-headers() — the file part headers of a multipart upload. + * + *

The stored query inspects the upload parameter named by the {@code inspect} URL query + * parameter (defaulting to {@code fileUpload}) and reports, for that name: the number of + * uploaded files, the file names (from request:get-uploaded-file-name, to check positional + * alignment), and each file's Content-Type / Content-Disposition (looked up case-insensitively, + * since header-name casing is the servlet container's to decide).

+ */ +public class GetUploadedFileHeadersTest extends RESTTest { + + private static final String XQUERY = + """ + xquery version "3.1"; + let $name := request:get-parameter("inspect", "fileUpload") + let $headers := request:get-uploaded-file-headers($name) + let $names := request:get-uploaded-file-name($name) + return string-join(( + "count=" || count($headers), + "names=[" || string-join($names, ",") || "]", + for $i in 1 to count($headers) + let $m := $headers[$i] + let $ct := $m(map:keys($m)[lower-case(.) = "content-type"]) + let $cd := $m(map:keys($m)[lower-case(.) = "content-disposition"]) + return "file" || $i || ":ct=" || ($ct, "")[1] || ":cd=" || ($cd, "")[1] + ), "|")"""; + private static final String XQUERY_FILENAME = "test-get-uploaded-file-headers.xql"; + + private static Collection root; + + @BeforeClass + public static void beforeClass() throws XMLDBException { + root = DatabaseManager.getCollection("xmldb:exist://localhost:" + existWebServer.getPort() + "/xmlrpc/db", "admin", ""); + final BinaryResource res = root.createResource(XQUERY_FILENAME, BinaryResource.class); + ((EXistResource) res).setMimeType("application/xquery"); + res.setContent(XQUERY); + root.storeResource(res); + final UserManagementService ums = root.getService(UserManagementService.class); + ums.chmod(res, 0777); + } + + @AfterClass + public static void afterClass() throws XMLDBException { + final BinaryResource res = (BinaryResource) root.getResource(XQUERY_FILENAME); + root.removeResource(res); + } + + @Test + public void singleFileExposesItsHeaders() throws IOException { + final MultipartBodyPublisher body = MultipartBodyPublisher.newBuilder() + .textPart("param1", "value1") + .formPart("fileUpload", "helloworld.txt", filePart("hello world", MediaType.TEXT_PLAIN)) + .build(); + + final String result = post(body, "fileUpload"); + assertTrue("one header map for the single uploaded file: " + result, result.contains("count=1")); + assertTrue("file names aligned: " + result, result.contains("names=[helloworld.txt]")); + assertTrue("Content-Type exposed: " + result, result.contains("file1:ct=text/plain:")); + assertTrue("Content-Disposition exposed with filename: " + result, result.contains("filename=\"helloworld.txt\"")); + } + + @Test + public void plainFormFieldHasNoFileHeaders() throws IOException { + final MultipartBodyPublisher body = MultipartBodyPublisher.newBuilder() + .textPart("param1", "value1") + .formPart("fileUpload", "helloworld.txt", filePart("hello world", MediaType.TEXT_PLAIN)) + .build(); + + final String result = post(body, "param1"); + assertTrue("a plain form field is not a file part, so no header maps: " + result, result.contains("count=0")); + } + + @Test + public void multipleFilesEachHaveHeadersPositionallyAligned() throws IOException { + final MultipartBodyPublisher body = MultipartBodyPublisher.newBuilder() + .formPart("fileUpload", "first.xml", filePart("", MediaType.APPLICATION_XML)) + .formPart("fileUpload", "second.json", filePart("{}", MediaType.APPLICATION_JSON)) + .build(); + + final String result = post(body, "fileUpload"); + assertTrue("one header map per uploaded file: " + result, result.contains("count=2")); + assertTrue("both file names present and ordered: " + result, result.contains("names=[first.xml,second.json]")); + // header map i aligns with file name i + assertTrue("first file's headers align with first.xml: " + result, + result.contains("file1:ct=application/xml:") && result.contains("filename=\"first.xml\"")); + assertTrue("second file's headers align with second.json: " + result, + result.contains("file2:ct=application/json:") && result.contains("filename=\"second.json\"")); + } + + @Test + public void nonExistentParameterReturnsEmpty() throws IOException { + final MultipartBodyPublisher body = MultipartBodyPublisher.newBuilder() + .formPart("fileUpload", "helloworld.txt", filePart("hello world", MediaType.TEXT_PLAIN)) + .build(); + + final String result = post(body, "doesNotExist"); + assertTrue("an unknown parameter yields the empty sequence: " + result, result.contains("count=0")); + } + + @Test + public void nonMultipartRequestReturnsEmpty() throws IOException { + // A plain GET is not a multipart request, so request:get-uploaded-file-headers() must be empty. + final HttpRequest get = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME + "?inspect=fileUpload")) + .GET() + .build(); + final HttpResponseResult result = withHttpClient(client -> executeForStatusAndBody(client, get)); + assertEquals(200, result.statusCode()); + assertTrue("a non-multipart request yields the empty sequence: " + result.body(), result.body().contains("count=0")); + } + + private static HttpRequest.BodyPublisher filePart(final String content, final MediaType mediaType) { + return MoreBodyPublishers.ofMediaType(HttpRequest.BodyPublishers.ofByteArray(content.getBytes(UTF_8)), mediaType); + } + + private String post(final MultipartBodyPublisher body, final String inspect) throws IOException { + final HttpRequest request = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME + "?inspect=" + inspect)) + .header("Content-Type", body.mediaType().toString()) + .POST(body) + .build(); + final HttpResponseResult result = withHttpClient(client -> executeForStatusAndBody(client, request)); + assertEquals(200, result.statusCode()); + return result.body(); + } +}