diff --git a/api_app/visualizers_manager/classes.py b/api_app/visualizers_manager/classes.py index f95a4431e6..4f2665867b 100644 --- a/api_app/visualizers_manager/classes.py +++ b/api_app/visualizers_manager/classes.py @@ -146,7 +146,6 @@ def type(self) -> str: class VisualizableDownload(VisualizableObject): - def __init__( self, value: str, @@ -189,6 +188,63 @@ def attributes(self) -> List[str]: ] +class VisualizableImage(VisualizableObject): + """ + A visualizable component for displaying images in the visualizer. + + Supports two image sources: + - url: A URL to fetch the image from (e.g., URLscan screenshot) + - base64: Base64 encoded image data + + At least one of url or base64 must be provided. + If both are provided, url takes precedence. + """ + + def __init__( + self, + value: str = "", + url: str = "", + base64: str = "", + title: str = "", + description: str = "", + max_width: int = 500, + max_height: int = 400, + allow_expand: bool = True, + size: VisualizableSize = VisualizableSize.S_AUTO, + alignment: VisualizableAlignment = VisualizableAlignment.CENTER, + disable: bool = False, + ): + if not url and not base64: + raise ValueError("VisualizableImage requires either 'url' or 'base64'") + + super().__init__(size, alignment, disable) + self.value = value or title # value is used as alt text + self.url = url + self.base64 = base64 + self.title = title + self.description = description + self.max_width = max_width + self.max_height = max_height + self.allow_expand = allow_expand + + @property + def type(self) -> str: + return "image" + + @property + def attributes(self) -> List[str]: + return super().attributes + [ + "value", + "url", + "base64", + "title", + "description", + "max_width", + "max_height", + "allow_expand", + ] + + class VisualizableBool(VisualizableBase): def __init__( self, @@ -553,7 +609,6 @@ def get_pivots_reports(self) -> QuerySet: return PivotReport.objects.filter(job=self._job) def get_data_models(self) -> QuerySet: - data_model_class = self._job.analyzable.get_data_model_class() analyzer_reports_pk = [report.pk for report in self.get_analyzer_reports()] return data_model_class.objects.filter(analyzers_report__in=analyzer_reports_pk) diff --git a/frontend/src/components/common/visualizer/elements/const.js b/frontend/src/components/common/visualizer/elements/const.js index 71bb52d57b..827b90bd10 100644 --- a/frontend/src/components/common/visualizer/elements/const.js +++ b/frontend/src/components/common/visualizer/elements/const.js @@ -6,4 +6,5 @@ export const VisualizerComponentType = Object.freeze({ TITLE: "title", TABLE: "table", DOWNLOAD: "download", + IMAGE: "image", }); diff --git a/frontend/src/components/common/visualizer/elements/image.jsx b/frontend/src/components/common/visualizer/elements/image.jsx new file mode 100644 index 0000000000..b8f2f90b13 --- /dev/null +++ b/frontend/src/components/common/visualizer/elements/image.jsx @@ -0,0 +1,212 @@ +// This file is a part of IntelOwl https://github.com/intelowlproject/IntelOwl +// See the file 'LICENSE' for copying permission. + +import React from "react"; +import PropTypes from "prop-types"; +import { Modal, ModalBody, ModalHeader, UncontrolledTooltip } from "reactstrap"; +import { MdBrokenImage, MdZoomIn } from "react-icons/md"; + +/** + * ImageVisualizer component for displaying images in the visualizer. + * Supports both URL and base64 encoded images. + * Includes click-to-zoom functionality with a modal preview. + */ +export function ImageVisualizer({ + id, + url, + base64, + title, + description, + maxWidth, + maxHeight, + allowExpand, + disable, + size, +}) { + const [isModalOpen, setIsModalOpen] = React.useState(false); + const [hasError, setHasError] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(true); + + // Determine the image source - URL takes precedence over base64 + const imageSrc = React.useMemo(() => { + if (url) return url; + if (base64) { + // Handle base64 with or without data URI prefix + if (base64.startsWith("data:")) { + return base64; + } + // Default to PNG if no prefix provided + return `data:image/png;base64,${base64}`; + } + return null; + }, [url, base64]); + + const toggleModal = () => { + if (!disable && allowExpand && !hasError) { + setIsModalOpen(!isModalOpen); + } + }; + + const handleImageLoad = () => { + setIsLoading(false); + setHasError(false); + }; + + const handleImageError = () => { + setIsLoading(false); + setHasError(true); + }; + + // Reset states when image source changes + React.useEffect(() => { + setIsLoading(true); + setHasError(false); + }, [imageSrc]); + + const containerStyle = { + maxWidth: maxWidth || "500px", + display: "flex", + flexDirection: "column", + alignItems: "center", + }; + + const imageStyle = { + maxWidth: "100%", + maxHeight: maxHeight || "400px", + objectFit: "contain", + cursor: !disable && allowExpand && !hasError ? "pointer" : "default", + opacity: disable ? 0.5 : 1, + }; + + const renderImage = () => { + if (hasError) { + return ( +
+ + Failed to load image +
+ ); + } + + const imageElement = ( + {title + ); + + return ( + <> + {isLoading && ( +
+ + Loading... +
+ )} + {allowExpand && !disable ? ( + + ) : ( + imageElement + )} + + ); + }; + + if (!imageSrc) { + return ( +
+ No image source provided +
+ ); + } + + return ( +
+ {title &&
{title}
} +
+ {renderImage()} + {!hasError && !isLoading && allowExpand && !disable && ( + <> + + + Click to enlarge + + + )} +
+ {description && ( +
{description}
+ )} + + {/* Modal for expanded view */} + + + {title || "Image Preview"} + + + {title + {description &&

{description}

} +
+
+
+ ); +} + +ImageVisualizer.propTypes = { + id: PropTypes.string.isRequired, + url: PropTypes.string, + base64: PropTypes.string, + title: PropTypes.string, + description: PropTypes.string, + maxWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + maxHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + allowExpand: PropTypes.bool, + disable: PropTypes.bool, + size: PropTypes.string, +}; + +ImageVisualizer.defaultProps = { + url: "", + base64: "", + title: "", + description: "", + maxWidth: "500px", + maxHeight: "400px", + allowExpand: true, + disable: false, + size: "col-auto", +}; diff --git a/frontend/src/components/common/visualizer/validators.js b/frontend/src/components/common/visualizer/validators.js index cf7a3070a5..73db9353c1 100644 --- a/frontend/src/components/common/visualizer/validators.js +++ b/frontend/src/components/common/visualizer/validators.js @@ -144,6 +144,16 @@ function parseElementFields(rawElement) { // validation for the elements switch (validatedFields.type) { + case VisualizerComponentType.IMAGE: { + validatedFields.url = parseString(rawElement.url); + validatedFields.base64 = parseString(rawElement.base64); + validatedFields.title = parseString(rawElement.title); + validatedFields.description = parseString(rawElement.description); + validatedFields.maxWidth = parseString(rawElement.max_width || "500px"); + validatedFields.maxHeight = parseString(rawElement.max_height || "400px"); + validatedFields.allowExpand = parseBool(rawElement.allow_expand ?? true); + break; + } case VisualizerComponentType.DOWNLOAD: { validatedFields.value = parseString(rawElement.value); validatedFields.mimetype = parseMimetype(rawElement.mimetype); diff --git a/frontend/src/components/common/visualizer/visualizer.jsx b/frontend/src/components/common/visualizer/visualizer.jsx index 76b6d00482..afeceec011 100644 --- a/frontend/src/components/common/visualizer/visualizer.jsx +++ b/frontend/src/components/common/visualizer/visualizer.jsx @@ -8,6 +8,7 @@ import { BooleanVisualizer } from "./elements/bool"; import { BaseVisualizer } from "./elements/base"; import { VerticalListVisualizer } from "./elements/verticalList"; import { TitleVisualizer } from "./elements/title"; +import { ImageVisualizer } from "./elements/image"; import { VisualizerComponentType } from "./elements/const"; import { getIcon } from "../icon/icons"; @@ -27,6 +28,24 @@ import { DownloadVisualizer } from "./elements/download"; function convertToElement(element, idElement, isChild = false) { let visualizerElement; switch (element.type) { + case VisualizerComponentType.IMAGE: { + visualizerElement = ( + + ); + break; + } case VisualizerComponentType.DOWNLOAD: { visualizerElement = ( { + test("renders image from URL", () => { + render( + , + ); + + expect(screen.getByText("Test Image")).toBeInTheDocument(); + expect(screen.getByText("A test description")).toBeInTheDocument(); + expect(screen.getByAltText("Test Image")).toBeInTheDocument(); + }); + + test("renders image from base64", () => { + const base64Data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAA"; + render( + , + ); + + const img = screen.getByAltText("Base64 Image"); + expect(img).toBeInTheDocument(); + expect(img.src).toContain("data:image/png;base64,"); + }); + + test("renders image from base64 with data URI prefix", () => { + const base64DataUri = "data:image/jpeg;base64,/9j/4AAQSkZJRg=="; + render( + , + ); + + const img = screen.getByAltText("JPEG Image"); + expect(img.src).toBe(base64DataUri); + }); + + test("shows error message when no source provided", () => { + render(); + + expect(screen.getByText("No image source provided")).toBeInTheDocument(); + }); + + test("renders with disabled state", () => { + render( + , + ); + + const img = screen.getByAltText("Disabled Image"); + expect(img).toHaveStyle({ opacity: "0.5" }); + }); + + test("opens modal on image click when allowExpand is true", async () => { + render( + , + ); + + // Simulate image load + const img = screen.getByAltText("Expandable Image"); + fireEvent.load(img); + + // Click the button wrapper (image is inside a button when allowExpand is true) + const button = img.closest("button"); + fireEvent.click(button); + + // Modal should be open - header shows the title + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + }); + + test("does not open modal when allowExpand is false", () => { + render( + , + ); + + // Simulate image load + const img = screen.getByAltText("Non-expandable Image"); + fireEvent.load(img); + + // Click the image + fireEvent.click(img); + + // Modal should not be open + expect(screen.queryByText("Image Preview")).not.toBeInTheDocument(); + }); + + test("shows loading state initially", () => { + render( + , + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + test("shows error state when image fails to load", async () => { + render( + , + ); + + // Simulate image error + const img = screen.getByAltText("Broken Image"); + fireEvent.error(img); + + await waitFor(() => { + expect(screen.getByText("Failed to load image")).toBeInTheDocument(); + }); + }); +}); diff --git a/tests/api_app/visualizers_manager/test_classes.py b/tests/api_app/visualizers_manager/test_classes.py index ff41fb21d5..e9b3e70023 100644 --- a/tests/api_app/visualizers_manager/test_classes.py +++ b/tests/api_app/visualizers_manager/test_classes.py @@ -14,6 +14,7 @@ VisualizableBool, VisualizableDownload, VisualizableHorizontalList, + VisualizableImage, VisualizableLevel, VisualizableLevelSize, VisualizableObject, @@ -677,3 +678,60 @@ def test_to_dict(self): "disable_sort_by": True, } self.assertEqual(expected_result, result) + + +class VisualizableImageTestCase(CustomTestCase): + def test_to_dict_with_url(self): + img = VisualizableImage( + url="https://example.com/image.png", + title="Test Image", + description="A test image", + max_width=400, + max_height=300, + allow_expand=True, + ) + result = img.to_dict() + + self.assertEqual(result["type"], "image") + self.assertEqual(result["url"], "https://example.com/image.png") + self.assertEqual(result["title"], "Test Image") + self.assertEqual(result["description"], "A test image") + self.assertEqual(result["max_width"], 400) + self.assertEqual(result["max_height"], 300) + self.assertEqual(result["allow_expand"], True) + + def test_to_dict_with_base64(self): + img = VisualizableImage( + base64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAA", + title="Base64 Image", + ) + result = img.to_dict() + + self.assertEqual(result["type"], "image") + self.assertEqual(result["base64"], "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAA") + self.assertEqual(result["url"], "") + self.assertEqual(result["title"], "Base64 Image") + + def test_requires_url_or_base64(self): + with self.assertRaises(ValueError) as context: + VisualizableImage(title="No source") + self.assertIn("url", str(context.exception).lower()) + + def test_default_values(self): + img = VisualizableImage(url="https://example.com/img.png") + result = img.to_dict() + + self.assertEqual(result["max_width"], 500) + self.assertEqual(result["max_height"], 400) + self.assertEqual(result["allow_expand"], True) + self.assertEqual(result["disable"], False) + self.assertEqual(result["alignment"], "center") + + def test_disabled_image(self): + img = VisualizableImage( + url="https://example.com/img.png", + disable=True, + ) + result = img.to_dict() + + self.assertEqual(result["disable"], True)