From 18b19a9124805a0859c2c4beec0a58a133f5f605 Mon Sep 17 00:00:00 2001 From: Maxim Nesen Date: Fri, 28 Mar 2025 13:40:24 +0100 Subject: [PATCH] File upload handling after redirect Signed-off-by: Maxim Nesen --- .../netty/connector/JerseyClientHandler.java | 7 +- .../client/RedirectFileUploadServerTest.java | 217 ++++++++++++++++++ .../e2e/client/RedirectLargeFileTest.java | 115 ++++++++++ 3 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/RedirectFileUploadServerTest.java create mode 100644 tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/RedirectLargeFileTest.java diff --git a/connectors/netty-connector/src/main/java/org/glassfish/jersey/netty/connector/JerseyClientHandler.java b/connectors/netty-connector/src/main/java/org/glassfish/jersey/netty/connector/JerseyClientHandler.java index c62e18fb17..45a36972d3 100644 --- a/connectors/netty-connector/src/main/java/org/glassfish/jersey/netty/connector/JerseyClientHandler.java +++ b/connectors/netty-connector/src/main/java/org/glassfish/jersey/netty/connector/JerseyClientHandler.java @@ -88,7 +88,7 @@ class JerseyClientHandler extends SimpleChannelInboundHandler { @Override public void channelReadComplete(ChannelHandlerContext ctx) { - notifyResponse(); + notifyResponse(ctx); } @Override @@ -104,7 +104,7 @@ public void channelInactive(ChannelHandlerContext ctx) { } } - protected void notifyResponse() { + protected void notifyResponse(ChannelHandlerContext ctx) { if (jerseyResponse != null) { ClientResponse cr = jerseyResponse; jerseyResponse = null; @@ -143,6 +143,7 @@ protected void notifyResponse() { } else { ClientRequest newReq = new ClientRequest(jerseyRequest); newReq.setUri(newUri); + ctx.close(); if (redirectController.prepareRedirect(newReq, cr)) { final NettyConnector newConnector = new NettyConnector(newReq.getClient()); newConnector.execute(newReq, redirectUriHistory, new CompletableFuture() { @@ -224,7 +225,7 @@ public String getReasonPhrase() { if (msg instanceof LastHttpContent) { responseDone.complete(null); - notifyResponse(); + notifyResponse(ctx); } } } diff --git a/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/RedirectFileUploadServerTest.java b/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/RedirectFileUploadServerTest.java new file mode 100644 index 0000000000..eddcba59e6 --- /dev/null +++ b/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/RedirectFileUploadServerTest.java @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. All rights reserved. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + */ + +package org.glassfish.jersey.tests.e2e.client; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.UUID; +import java.util.concurrent.Executors; + + +/** + * Server for the file upload test that redirects from /submit to /upload. + */ +class RedirectFileUploadServerTest { + private static final String UPLOAD_DIRECTORY = "target/uploads"; + private static final String BOUNDARY_PREFIX = "boundary="; + private static final Path uploadDir = Paths.get(UPLOAD_DIRECTORY); + + private static HttpServer server; + + + static void start(int port) throws IOException { + // Create upload directory if it doesn't exist + if (!Files.exists(uploadDir)) { + Files.createDirectory(uploadDir); + } + + // Create HTTP server + server = HttpServer.create(new InetSocketAddress(port), 0); + + // Create contexts for different endpoints + server.createContext("/submit", new SubmitHandler()); + server.createContext("/upload", new UploadHandler()); + + // Set executor and start server + server.setExecutor(Executors.newFixedThreadPool(10)); + server.start(); + System.out.println("Server running on port " + port); + } + + public static void stop() { + server.stop(0); + } + + + // Handler for /submit endpoint that redirects to /upload + static class SubmitHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + if (!"POST".equals(exchange.getRequestMethod())) { + sendResponse(exchange, 405, "Method Not Allowed. Only POST is supported."); + return; + } + + final BufferedReader reader + = new BufferedReader(new InputStreamReader(exchange.getRequestBody(), StandardCharsets.UTF_8)); + while (reader.readLine() != null) { + //discard payload - required for JDK 1.8 + } + reader.close(); + + // Send a 307 Temporary Redirect to /upload + // This preserves the POST method and body in the redirect + exchange.getResponseHeaders().add("Location", "/upload"); + exchange.sendResponseHeaders(307, -1); + } finally { + exchange.close(); + } + } + } + + // Handler for /upload endpoint that processes file uploads + static class UploadHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + if (!"POST".equals(exchange.getRequestMethod())) { + sendResponse(exchange, 405, "Method Not Allowed. Only POST is supported."); + return; + } + + // Check if the request contains multipart form data + String contentType = exchange.getRequestHeaders().getFirst("Content-Type"); + if (contentType == null || !contentType.startsWith("multipart/form-data")) { + sendResponse(exchange, 400, "Bad Request. Content type must be multipart/form-data."); + return; + } + + // Extract boundary from content type + String boundary = extractBoundary(contentType); + if (boundary == null) { + sendResponse(exchange, 400, "Bad Request. Could not determine boundary."); + return; + } + + // Process the multipart request and save the file + String fileName = processMultipartRequest(exchange, boundary); + + if (fileName != null) { + sendResponse(exchange, 200, "File uploaded successfully: " + fileName); + } else { + sendResponse(exchange, 400, "Bad Request. No file found in request."); + } + } catch (Exception e) { + e.printStackTrace(); + sendResponse(exchange, 500, "Internal Server Error: " + e.getMessage()); + } finally { + exchange.close(); + Files.deleteIfExists(uploadDir); + } + } + + private String extractBoundary(String contentType) { + int boundaryIndex = contentType.indexOf(BOUNDARY_PREFIX); + if (boundaryIndex != -1) { + return "--" + contentType.substring(boundaryIndex + BOUNDARY_PREFIX.length()); + } + return null; + } + + private String processMultipartRequest(HttpExchange exchange, String boundary) throws IOException { + InputStream requestBody = exchange.getRequestBody(); + BufferedReader reader = new BufferedReader(new InputStreamReader(requestBody, StandardCharsets.UTF_8)); + + String line; + String fileName = null; + Path tempFile = null; + boolean isFileContent = false; + + // Generate a random filename for the temporary file + String tempFileName = UUID.randomUUID().toString(); + tempFile = Files.createTempFile(tempFileName, ".tmp"); + + try (OutputStream fileOut = Files.newOutputStream(tempFile)) { + while ((line = reader.readLine()) != null) { + // Check for the boundary + if (line.startsWith(boundary)) { + if (isFileContent) { + // We've reached the end of the file content + break; + } + + // Read the next line (Content-Disposition) + line = reader.readLine(); + if (line != null && line.startsWith("Content-Type")) { + line = reader.readLine(); + } + if (line != null && line.contains("filename=")) { + // Extract filename + int filenameStart = line.indexOf("filename=\"") + 10; + int filenameEnd = line.indexOf("\"", filenameStart); + fileName = line.substring(filenameStart, filenameEnd); + + // Skip Content-Type line and empty line + reader.readLine(); // Content-Type +// System.out.println(reader.readLine()); // Empty line + isFileContent = true; + } + } else if (isFileContent) { + // If we're reading file content and this line is not a boundary, + // write it to the file (append a newline unless it's the first line) + fileOut.write(line.getBytes(StandardCharsets.UTF_8)); + fileOut.write('\n'); + } + } + } + + // If we found a file, move it from the temp location to the uploads directory + if (fileName != null && !fileName.isEmpty()) { + Path targetPath = Paths.get(UPLOAD_DIRECTORY, fileName); + Files.move(tempFile, targetPath, StandardCopyOption.REPLACE_EXISTING); + return fileName; + } else { + // If no file was found, delete the temp file + Files.deleteIfExists(tempFile); + return null; + } + } + } + + // Helper method to send HTTP responses + private static void sendResponse(HttpExchange exchange, int statusCode, String response) throws IOException { + exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=UTF-8"); + exchange.sendResponseHeaders(statusCode, response.length()); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response.getBytes(StandardCharsets.UTF_8)); + } + } +} \ No newline at end of file diff --git a/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/RedirectLargeFileTest.java b/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/RedirectLargeFileTest.java new file mode 100644 index 0000000000..56e5d71fa1 --- /dev/null +++ b/tests/e2e-client/src/test/java/org/glassfish/jersey/tests/e2e/client/RedirectLargeFileTest.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. All rights reserved. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + */ + +package org.glassfish.jersey.tests.e2e.client; + +import javax.ws.rs.client.Client; +import javax.ws.rs.client.ClientBuilder; +import javax.ws.rs.client.Entity; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import org.glassfish.jersey.client.ClientConfig; +import org.glassfish.jersey.media.multipart.FormDataBodyPart; +import org.glassfish.jersey.media.multipart.FormDataContentDisposition; +import org.glassfish.jersey.media.multipart.FormDataMultiPart; +import org.glassfish.jersey.media.multipart.MultiPartFeature; +import org.glassfish.jersey.netty.connector.NettyConnectorProvider; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.FileWriter; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public class RedirectLargeFileTest { + + private static final int SERVER_PORT = 9997; + private static final String SERVER_ADDR = String.format("http://localhost:%d/submit", SERVER_PORT); + + Client client() { + final ClientConfig config = new ClientConfig(); + config.connectorProvider(new NettyConnectorProvider()); + config.register(MultiPartFeature.class); + return ClientBuilder.newClient(config); + } + + @BeforeAll + static void startServer() throws Exception{ + RedirectFileUploadServerTest.start(SERVER_PORT); + } + + @AfterAll + static void stopServer() { + RedirectFileUploadServerTest.stop(); + } + + @Test + void sendFileTest() throws Exception { + + final String fileName = "bigFile.json"; + final String path = "target/" + fileName; + + final Path pathResource = Paths.get(path); + try { + final Path realFilePath = Files.createFile(pathResource.toAbsolutePath()); + + generateJson(realFilePath.toString(), 1000000); // 33Mb real file size + + final byte[] content = Files.readAllBytes(realFilePath); + + final FormDataMultiPart mp = new FormDataMultiPart(); + mp.bodyPart(new FormDataBodyPart(FormDataContentDisposition.name(fileName).fileName(fileName).build(), + content, + MediaType.TEXT_PLAIN_TYPE)); + + try (final Response response = client().target(SERVER_ADDR).request() + .post(Entity.entity(mp, MediaType.MULTIPART_FORM_DATA_TYPE))) { + Assertions.assertEquals(200, response.getStatus()); + } + } finally { + Files.deleteIfExists(pathResource); + } + } + + private static void generateJson(final String filePath, int recordCount) throws Exception { + + try (final JsonGenerator generator = new JsonFactory().createGenerator(new FileWriter(filePath))) { + generator.writeStartArray(); + + for (int i = 0; i < recordCount; i++) { + generator.writeStartObject(); + generator.writeNumberField("id", i); + generator.writeStringField("name", "User" + i); + // Add more fields as needed + generator.writeEndObject(); + + if (i % 10000 == 0) { + generator.flush(); + } + } + + generator.writeEndArray(); + } + } +}