This is an automated email from the ASF dual-hosted git repository.
pvillard31 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 28c02870529 NIFI-15888 Add size limit to Standard Content Viewer
(#11303)
28c02870529 is described below
commit 28c02870529d4e958fd65149610479fd35afd68c
Author: David Handermann <[email protected]>
AuthorDate: Tue Jun 2 15:37:09 2026 -0500
NIFI-15888 Add size limit to Standard Content Viewer (#11303)
---
.../StandardContentViewerController.java | 156 ++++++++++++++-------
.../StandardContentViewerControllerTest.java | 75 +++++++++-
.../org/apache/nifi/web/DownloadableContent.java | 18 ++-
.../apache/nifi/web/StandardNiFiContentAccess.java | 4 +-
.../nifi/web/controller/ControllerFacade.java | 3 +-
.../nifi/web/dao/impl/StandardConnectionDAO.java | 4 +-
6 files changed, 206 insertions(+), 54 deletions(-)
diff --git
a/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/main/java/org/apache/nifi/web/controller/StandardContentViewerController.java
b/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/main/java/org/apache/nifi/web/controller/StandardContentViewerController.java
index 5db1cbc65b0..3e4057979e3 100644
---
a/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/main/java/org/apache/nifi/web/controller/StandardContentViewerController.java
+++
b/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/main/java/org/apache/nifi/web/controller/StandardContentViewerController.java
@@ -28,6 +28,7 @@ import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericDatumReader;
import org.apache.avro.io.DatumReader;
import org.apache.nifi.authorization.AccessDeniedException;
+import org.apache.nifi.stream.io.LimitingInputStream;
import org.apache.nifi.web.ContentAccess;
import org.apache.nifi.web.ContentRequestContext;
import org.apache.nifi.web.DownloadableContent;
@@ -45,6 +46,10 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
import javax.xml.transform.stream.StreamResult;
import javax.xml.transform.stream.StreamSource;
@@ -52,17 +57,24 @@ public class StandardContentViewerController extends
HttpServlet {
static final String CONTENT_ACCESS_ATTRIBUTE = "nifi-content-access";
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private static final int CONTENT_LENGTH_LIMIT = 10_485_760;
+
+ private static final Map<String, ContentType> CONTENT_TYPES =
Arrays.stream(ContentType.values())
+ .collect(
+ Collectors.toMap(ContentType::name, Function.identity())
+ );
+
private static final Logger logger =
LoggerFactory.getLogger(StandardContentViewerController.class);
@Override
public void doGet(final HttpServletRequest request, final
HttpServletResponse response) throws IOException {
final ContentRequestContext requestContext = new
HttpServletContentRequestContext(request);
- // get the content
final ServletContext servletContext = request.getServletContext();
final ContentAccess contentAccess = (ContentAccess)
servletContext.getAttribute(CONTENT_ACCESS_ATTRIBUTE);
- // get the content
final DownloadableContent downloadableContent;
try {
downloadableContent = contentAccess.getContent(requestContext);
@@ -80,38 +92,61 @@ public class StandardContentViewerController extends
HttpServlet {
return;
}
- response.setStatus(HttpServletResponse.SC_OK);
+ // Set Response Status before committing based on formatted or
unformatted stream handling
+ final int responseStatus = getResponseStatus(downloadableContent);
+ response.setStatus(responseStatus);
+
+ final LimitingInputStream contentStream =
getContentStream(downloadableContent);
final boolean formatted =
Boolean.parseBoolean(request.getParameter("formatted"));
- if (!formatted) {
- final InputStream contentStream = downloadableContent.getContent();
+ if (formatted) {
+ final ContentType formattedContentType =
getFormattedContentType(request, downloadableContent.getType());
+ if (formattedContentType == null) {
+ response.sendError(HttpURLConnection.HTTP_NOT_ACCEPTABLE,
"Unknown Content Type");
+ } else {
+ final String dataUri = requestContext.getDataUri();
+ final long contentLength =
downloadableContent.getContentLength();
+ writeContentFormatted(dataUri, formattedContentType,
contentStream, contentLength, response);
+ }
+ } else {
contentStream.transferTo(response.getOutputStream());
- return;
}
+ }
- // allow the user to drive the data type but fall back to the content
type if necessary
- String displayName = request.getParameter("mimeTypeDisplayName");
- if (displayName == null) {
- final String contentType = downloadableContent.getType();
- displayName = getDisplayName(contentType);
- }
+ private LimitingInputStream getContentStream(final DownloadableContent
downloadableContent) {
+ final InputStream contentStream = downloadableContent.getContent();
+ return new LimitingInputStream(contentStream, CONTENT_LENGTH_LIMIT);
+ }
- if (displayName == null) {
- response.sendError(HttpURLConnection.HTTP_BAD_REQUEST, "Unknown
content type");
- return;
+ private int getResponseStatus(final DownloadableContent
downloadableContent) {
+ final int responseStatus;
+
+ final long contentLength = downloadableContent.getContentLength();
+ if (contentLength > CONTENT_LENGTH_LIMIT) {
+ responseStatus = HttpURLConnection.HTTP_PARTIAL;
+ } else {
+ responseStatus = HttpURLConnection.HTTP_OK;
}
+ return responseStatus;
+ }
+
+ private void writeContentFormatted(
+ final String dataUri,
+ final ContentType contentType,
+ final LimitingInputStream contentStream,
+ final long contentLength,
+ final HttpServletResponse response
+ ) throws IOException {
try {
- switch (displayName) {
- case "json": {
- // format json
- final ObjectMapper mapper = new ObjectMapper();
- final Object objectJson =
mapper.readValue(downloadableContent.getContent(), Object.class);
-
mapper.writerWithDefaultPrettyPrinter().writeValue(response.getOutputStream(),
objectJson);
+ switch (contentType) {
+ case JSON: {
+ final Object objectJson =
OBJECT_MAPPER.readValue(contentStream, Object.class);
+
OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValue(response.getOutputStream(),
objectJson);
break;
}
- case "xml": {
- final StreamSource source = new
StreamSource(downloadableContent.getContent());
+ case XML: {
+ final StreamSource source = new
StreamSource(contentStream);
try (OutputStream outputStream = new
FormattingOutputStream(response.getOutputStream())) {
final StreamResult result = new
StreamResult(outputStream);
final StandardTransformProvider transformProvider =
new StandardTransformProvider();
@@ -121,7 +156,7 @@ public class StandardContentViewerController extends
HttpServlet {
}
break;
}
- case "avro": {
+ case AVRO: {
final StringBuilder sb = new StringBuilder();
sb.append("[");
// Use Avro conversions to display logical type values in
human readable way.
@@ -135,16 +170,12 @@ public class StandardContentViewerController extends
HttpServlet {
genericData.addLogicalTypeConversion(new
TimeConversions.LocalTimestampMicrosConversion());
genericData.addLogicalTypeConversion(new
TimeConversions.LocalTimestampMillisConversion());
final DatumReader<GenericData.Record> datumReader = new
GenericDatumReader<>(null, null, genericData);
- try (final DataFileStream<GenericData.Record>
dataFileReader = new DataFileStream<>(downloadableContent.getContent(),
datumReader)) {
+ try (final DataFileStream<GenericData.Record>
dataFileReader = new DataFileStream<>(contentStream, datumReader)) {
while (dataFileReader.hasNext()) {
final GenericData.Record record =
dataFileReader.next();
final String formattedRecord =
genericData.toString(record);
sb.append(formattedRecord);
sb.append(",");
- // Do not format more than 10 MB of content.
- if (sb.length() > 1024 * 1024 * 2) {
- break;
- }
}
}
@@ -154,16 +185,14 @@ public class StandardContentViewerController extends
HttpServlet {
sb.append("]");
final String json = sb.toString();
- final ObjectMapper mapper = new ObjectMapper();
- final Object objectJson = mapper.readValue(json,
Object.class);
-
-
mapper.writerWithDefaultPrettyPrinter().writeValue(response.getOutputStream(),
objectJson);
+ final Object objectJson = OBJECT_MAPPER.readValue(json,
Object.class);
+
OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValue(response.getOutputStream(),
objectJson);
break;
}
- case "yaml": {
+ case YAML: {
Yaml yaml = new Yaml();
// Parse the YAML file
- final Object yamlObject =
yaml.load(downloadableContent.getContent());
+ final Object yamlObject = yaml.load(contentStream);
DumperOptions options = new DumperOptions();
options.setIndent(2);
options.setPrettyFlow(true);
@@ -174,35 +203,66 @@ public class StandardContentViewerController extends
HttpServlet {
output.dump(yamlObject, response.getWriter());
break;
}
- case "csv":
- case "text": {
- final InputStream contentStream =
downloadableContent.getContent();
+ case CSV:
+ case TEXT: {
contentStream.transferTo(response.getOutputStream());
break;
}
default: {
- response.sendError(HttpURLConnection.HTTP_BAD_REQUEST,
"Unsupported content type: " + displayName);
+ response.sendError(HttpURLConnection.HTTP_NOT_ACCEPTABLE,
"Unsupported Content Type: %s".formatted(contentType));
}
}
} catch (final Throwable t) {
- logger.warn("Unable to format FlowFile content", t);
- response.sendError(HttpURLConnection.HTTP_INTERNAL_ERROR, "Unable
to format FlowFile content");
+ final String message;
+
+ if (contentLength > CONTENT_LENGTH_LIMIT) {
+ message = "FlowFile Content-Length exceeds maximum allowed";
+ logger.warn("Requested FlowFile [{}] Content-Length exceeds
maximum allowed [{} bytes]", dataUri, CONTENT_LENGTH_LIMIT, t);
+ } else {
+ message = "FlowFile formatting failed";
+ logger.warn("Requested FlowFile [{}] formatting failed for
Content Type [{}]", dataUri, contentType, t);
+ }
+
+ response.sendError(HttpURLConnection.HTTP_INTERNAL_ERROR, message);
+ }
+ }
+
+ private ContentType getFormattedContentType(final HttpServletRequest
request, final String downloadableContentType) {
+ final ContentType formattedContentType;
+
+ final String mimeTypeDisplayName =
request.getParameter("mimeTypeDisplayName");
+ if (mimeTypeDisplayName == null) {
+ formattedContentType =
getFormattedContentType(downloadableContentType);
+ } else {
+ final String upperCasedContentType =
mimeTypeDisplayName.toUpperCase();
+ formattedContentType = CONTENT_TYPES.get(upperCasedContentType);
}
+
+ return formattedContentType;
}
- private String getDisplayName(final String contentType) {
+ private ContentType getFormattedContentType(final String contentType) {
return switch (contentType) {
- case "application/json" -> "json";
- case "application/xml", "text/xml" -> "xml";
- case "application/avro-binary", "avro/binary",
"application/avro+binary" -> "avro";
+ case "application/json" -> ContentType.JSON;
+ case "application/xml", "text/xml" -> ContentType.XML;
+ case "application/avro-binary", "avro/binary",
"application/avro+binary" -> ContentType.AVRO;
case "text/x-yaml", "text/yaml", "text/yml", "application/x-yaml",
"application/x-yml", "application/yaml",
- "application/yml" -> "yaml";
- case "text/plain" -> "text";
- case "text/csv" -> "csv";
+ "application/yml" -> ContentType.YAML;
+ case "text/plain" -> ContentType.TEXT;
+ case "text/csv" -> ContentType.CSV;
case null, default -> null;
};
}
+ private enum ContentType {
+ AVRO,
+ CSV,
+ JSON,
+ TEXT,
+ XML,
+ YAML
+ }
+
private static class FormattingOutputStream extends FilterOutputStream {
private static final byte LINE_FEED = 10;
diff --git
a/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/test/java/org/apache/nifi/web/controller/StandardContentViewerControllerTest.java
b/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/test/java/org/apache/nifi/web/controller/StandardContentViewerControllerTest.java
index 038726a09b7..500b180f92d 100644
---
a/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/test/java/org/apache/nifi/web/controller/StandardContentViewerControllerTest.java
+++
b/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/test/java/org/apache/nifi/web/controller/StandardContentViewerControllerTest.java
@@ -25,6 +25,8 @@ import org.apache.nifi.web.ContentAccess;
import org.apache.nifi.web.DownloadableContent;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -36,8 +38,11 @@ import java.nio.charset.StandardCharsets;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -50,6 +55,8 @@ class StandardContentViewerControllerTest {
private static final String MIME_TYPE_DISPLAY_NAME = "mimeTypeDisplayName";
private static final String XML_DISPLAY_NAME = "xml";
+ private static final int CONTENT_LENGTH_LIMIT_EXCEEDED = 10_485_761;
+ private static final String APPLICATION_OCTET_STREAM =
"application/octet-stream";
private static final String FILENAME = "FlowFile";
private static final String TEXT_XML = "text/xml";
private static final String XML_DOCUMENT = "<?xml version=\"1.0\"
encoding=\"UTF-8\"?><document><element/></document>";
@@ -67,12 +74,42 @@ class StandardContentViewerControllerTest {
@Mock
private ContentAccess contentAccess;
+ @Captor
+ private ArgumentCaptor<String> errorMessageCaptor;
+
private final StandardContentViewerController controller = new
StandardContentViewerController();
+ @Test
+ void testDoGetFormattedContentTypeNotSupported() throws IOException {
+ final InputStream contentStream = new
ByteArrayInputStream(XML_DOCUMENT.getBytes(StandardCharsets.UTF_8));
+ final DownloadableContent downloadableContent = new
DownloadableContent(FILENAME, APPLICATION_OCTET_STREAM, contentStream,
XML_DOCUMENT.length());
+ setDownloadableContent(downloadableContent);
+
when(request.getParameter(eq(FORMATTED_PARAMETER))).thenReturn(Boolean.TRUE.toString());
+
when(request.getParameter(eq(CLIENT_ID_PARAMETER))).thenReturn(UUID.randomUUID().toString());
+
+ controller.doGet(request, response);
+
+ verify(response).sendError(eq(HttpServletResponse.SC_NOT_ACCEPTABLE),
anyString());
+ }
+
+ @Test
+ void testDoGetNotFormattedPartialContent() throws IOException {
+ final InputStream contentStream = new
ByteArrayInputStream(XML_DOCUMENT.getBytes(StandardCharsets.UTF_8));
+ final DownloadableContent downloadableContent = new
DownloadableContent(FILENAME, APPLICATION_OCTET_STREAM, contentStream,
CONTENT_LENGTH_LIMIT_EXCEEDED);
+ setDownloadableContent(downloadableContent);
+
+ final MockServletOutputStream mockServletOutputStream = new
MockServletOutputStream();
+ when(response.getOutputStream()).thenReturn(mockServletOutputStream);
+
+ controller.doGet(request, response);
+
+ verify(response).setStatus(eq(HttpServletResponse.SC_PARTIAL_CONTENT));
+ }
+
@Test
void testDoGetNotFormatted() throws IOException {
final InputStream contentStream = new
ByteArrayInputStream(XML_DOCUMENT.getBytes(StandardCharsets.UTF_8));
- final DownloadableContent downloadableContent = new
DownloadableContent(FILENAME, TEXT_XML, contentStream);
+ final DownloadableContent downloadableContent = new
DownloadableContent(FILENAME, TEXT_XML, contentStream, XML_DOCUMENT.length());
setDownloadableContent(downloadableContent);
final MockServletOutputStream mockServletOutputStream = new
MockServletOutputStream();
@@ -110,9 +147,43 @@ class StandardContentViewerControllerTest {
assertEquals(XML_DOCUMENT_FORMATTED, outputString);
}
+ @Test
+ void testDoGetFormattedXmlMalformed() throws IOException {
+ setDownloadableContentXml(FILENAME);
+
+ final MockServletOutputStream mockServletOutputStream = new
MockServletOutputStream();
+ when(response.getOutputStream()).thenReturn(mockServletOutputStream);
+
+ controller.doGet(request, response);
+
+
verify(response).sendError(eq(HttpServletResponse.SC_INTERNAL_SERVER_ERROR),
errorMessageCaptor.capture());
+ final String errorMessage = errorMessageCaptor.getValue();
+ assertTrue(errorMessage.contains("formatting"));
+ }
+
+ @Test
+ void testDoGetFormattedLimited() throws IOException {
+ final byte[] contentLimitExceededByteArray = new
byte[CONTENT_LENGTH_LIMIT_EXCEEDED];
+ final InputStream contentStream = new
ByteArrayInputStream(contentLimitExceededByteArray);
+ final DownloadableContent downloadableContent = new
DownloadableContent(FILENAME, TEXT_XML, contentStream,
CONTENT_LENGTH_LIMIT_EXCEEDED);
+ setDownloadableContent(downloadableContent);
+
+
when(request.getParameter(eq(FORMATTED_PARAMETER))).thenReturn(Boolean.TRUE.toString());
+
when(request.getParameter(eq(CLIENT_ID_PARAMETER))).thenReturn(UUID.randomUUID().toString());
+
+ final MockServletOutputStream mockServletOutputStream = new
MockServletOutputStream();
+ when(response.getOutputStream()).thenReturn(mockServletOutputStream);
+
+ controller.doGet(request, response);
+
+
verify(response).sendError(eq(HttpServletResponse.SC_INTERNAL_SERVER_ERROR),
errorMessageCaptor.capture());
+ final String errorMessage = errorMessageCaptor.getValue();
+ assertTrue(errorMessage.contains("Content-Length"));
+ }
+
private void setDownloadableContentXml(final String contentXml) {
final InputStream contentStream = new
ByteArrayInputStream(contentXml.getBytes(StandardCharsets.UTF_8));
- final DownloadableContent downloadableContent = new
DownloadableContent(FILENAME, TEXT_XML, contentStream);
+ final DownloadableContent downloadableContent = new
DownloadableContent(FILENAME, TEXT_XML, contentStream, XML_DOCUMENT.length());
setDownloadableContent(downloadableContent);
when(request.getParameter(eq(FORMATTED_PARAMETER))).thenReturn(Boolean.TRUE.toString());
diff --git
a/nifi-framework-api/src/main/java/org/apache/nifi/web/DownloadableContent.java
b/nifi-framework-api/src/main/java/org/apache/nifi/web/DownloadableContent.java
index ee3bc4f4655..c1551edd1c8 100644
---
a/nifi-framework-api/src/main/java/org/apache/nifi/web/DownloadableContent.java
+++
b/nifi-framework-api/src/main/java/org/apache/nifi/web/DownloadableContent.java
@@ -26,11 +26,18 @@ public final class DownloadableContent {
private final String filename;
private final String type;
private final InputStream content;
+ private final long contentLength;
- public DownloadableContent(String filename, String type, InputStream
content) {
+ public DownloadableContent(
+ final String filename,
+ final String type,
+ final InputStream content,
+ final long contentLength
+ ) {
this.filename = filename;
this.type = type;
this.content = content;
+ this.contentLength = contentLength;
}
/**
@@ -59,4 +66,13 @@ public final class DownloadableContent {
public InputStream getContent() {
return content;
}
+
+ /**
+ * Get length in bytes of the content stream
+ *
+ * @return Length in bytes of the content stream
+ */
+ public long getContentLength() {
+ return contentLength;
+ }
}
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiContentAccess.java
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiContentAccess.java
index 5f3743bff30..db5a285a9fa 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiContentAccess.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiContentAccess.java
@@ -123,8 +123,10 @@ public class StandardNiFiContentAccess implements
ContentAccess {
// get the content type
final String contentType = getHeader(responseHeaders,
"content-type");
+ final int contentLength = clientResponse.getLength();
+
// create the downloadable content
- return new DownloadableContent(filename, contentType,
nodeResponse.getInputStream());
+ return new DownloadableContent(filename, contentType,
nodeResponse.getInputStream(), contentLength);
} else {
// example URIs:
//
http://localhost:8080/nifi-api/provenance/events/{id}/content/{input|output}
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/controller/ControllerFacade.java
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/controller/ControllerFacade.java
index 4dfbbe89e06..9db50aa2173 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/controller/ControllerFacade.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/controller/ControllerFacade.java
@@ -1508,7 +1508,8 @@ public class ControllerFacade implements Authorizable {
// get the content
final InputStream content = flowController.getContent(event,
contentDirection, user.getIdentity(), uri);
- return new DownloadableContent(filename, type, content);
+ final long contentLength = event.getFileSize();
+ return new DownloadableContent(filename, type, content,
contentLength);
} catch (final ContentNotFoundException cnfe) {
throw new ResourceNotFoundException("Unable to find the specified
content.");
} catch (final IOException ioe) {
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/impl/StandardConnectionDAO.java
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/impl/StandardConnectionDAO.java
index 5b4ab5c1420..c16fb00aefc 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/impl/StandardConnectionDAO.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/impl/StandardConnectionDAO.java
@@ -703,7 +703,9 @@ public class StandardConnectionDAO extends ComponentDAO
implements ConnectionDAO
// get the content
final InputStream content = flowController.getContent(flowFile,
user.getIdentity(), requestUri);
- return new DownloadableContent(filename, type, content);
+ final long contentLength = flowFile.getSize();
+
+ return new DownloadableContent(filename, type, content,
contentLength);
} catch (final ContentNotFoundException cnfe) {
throw new ResourceNotFoundException("Unable to find the specified
content.");
} catch (final IOException ioe) {