mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-08-16 13:47:28 +02:00
Initial commit of Sanitize UI
This commit is contained in:
parent
90f0c5826a
commit
13ee46745b
@ -387,6 +387,10 @@
|
|||||||
"title": "Compress",
|
"title": "Compress",
|
||||||
"desc": "Compress PDFs to reduce their file size."
|
"desc": "Compress PDFs to reduce their file size."
|
||||||
},
|
},
|
||||||
|
"sanitize": {
|
||||||
|
"title": "Sanitize",
|
||||||
|
"desc": "Remove potentially harmful elements from PDF files."
|
||||||
|
},
|
||||||
"unlockPDFForms": {
|
"unlockPDFForms": {
|
||||||
"title": "Unlock PDF Forms",
|
"title": "Unlock PDF Forms",
|
||||||
"desc": "Remove read-only property of form fields in a PDF document."
|
"desc": "Remove read-only property of form fields in a PDF document."
|
||||||
@ -1612,6 +1616,38 @@
|
|||||||
"pdfaOptions": "PDF/A Options",
|
"pdfaOptions": "PDF/A Options",
|
||||||
"outputFormat": "Output Format",
|
"outputFormat": "Output Format",
|
||||||
"pdfaNote": "PDF/A-1b is more compatible, PDF/A-2b supports more features.",
|
"pdfaNote": "PDF/A-1b is more compatible, PDF/A-2b supports more features.",
|
||||||
"pdfaDigitalSignatureWarning": "The PDF contains a digital signature. This will be removed in the next step."
|
"pdfaDigitalSignatureWarning": "The PDF contains a digital signature. This will be removed in the next step.",
|
||||||
|
"sanitize": {
|
||||||
|
"submit": "Sanitize PDF",
|
||||||
|
"processing": "Sanitizing PDF...",
|
||||||
|
"completed": "Sanitization completed successfully",
|
||||||
|
"error": "Sanitization failed: {{error}}",
|
||||||
|
"error.generic": "Sanitization failed",
|
||||||
|
"steps": {
|
||||||
|
"files": "Files",
|
||||||
|
"settings": "Settings",
|
||||||
|
"results": "Results"
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"selected": "Selected: {{filename}}",
|
||||||
|
"placeholder": "Select a PDF file in the main view to get started"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"title": "Sanitization Options",
|
||||||
|
"note": "Select the elements you want to remove from the PDF. At least one option must be selected.",
|
||||||
|
"removeJavaScript": "Remove JavaScript",
|
||||||
|
"removeJavaScript.desc": "Remove JavaScript actions and scripts from the PDF",
|
||||||
|
"removeEmbeddedFiles": "Remove Embedded Files",
|
||||||
|
"removeEmbeddedFiles.desc": "Remove any files embedded within the PDF",
|
||||||
|
"removeXMPMetadata": "Remove XMP Metadata",
|
||||||
|
"removeXMPMetadata.desc": "Remove XMP metadata from the PDF",
|
||||||
|
"removeMetadata": "Remove Document Metadata",
|
||||||
|
"removeMetadata.desc": "Remove document information metadata (title, author, etc.)",
|
||||||
|
"removeLinks": "Remove Links",
|
||||||
|
"removeLinks.desc": "Remove external links and launch actions from the PDF",
|
||||||
|
"removeFonts": "Remove Fonts",
|
||||||
|
"removeFonts.desc": "Remove embedded fonts from the PDF"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
83
frontend/src/components/tools/sanitize/SanitizeSettings.tsx
Normal file
83
frontend/src/components/tools/sanitize/SanitizeSettings.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { Stack, Text, Checkbox } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { SanitizeParameters } from "../../../hooks/tools/sanitize/useSanitizeParameters";
|
||||||
|
|
||||||
|
interface SanitizeSettingsProps {
|
||||||
|
parameters: SanitizeParameters;
|
||||||
|
onParameterChange: (key: keyof SanitizeParameters, value: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SanitizeSettings = ({ parameters, onParameterChange, disabled = false }: SanitizeSettingsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
key: 'removeJavaScript' as const,
|
||||||
|
label: t('sanitize.options.removeJavaScript', 'Remove JavaScript'),
|
||||||
|
description: t('sanitize.options.removeJavaScript.desc', 'Remove JavaScript actions and scripts from the PDF'),
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'removeEmbeddedFiles' as const,
|
||||||
|
label: t('sanitize.options.removeEmbeddedFiles', 'Remove Embedded Files'),
|
||||||
|
description: t('sanitize.options.removeEmbeddedFiles.desc', 'Remove any files embedded within the PDF'),
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'removeXMPMetadata' as const,
|
||||||
|
label: t('sanitize.options.removeXMPMetadata', 'Remove XMP Metadata'),
|
||||||
|
description: t('sanitize.options.removeXMPMetadata.desc', 'Remove XMP metadata from the PDF'),
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'removeMetadata' as const,
|
||||||
|
label: t('sanitize.options.removeMetadata', 'Remove Document Metadata'),
|
||||||
|
description: t('sanitize.options.removeMetadata.desc', 'Remove document information metadata (title, author, etc.)'),
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'removeLinks' as const,
|
||||||
|
label: t('sanitize.options.removeLinks', 'Remove Links'),
|
||||||
|
description: t('sanitize.options.removeLinks.desc', 'Remove external links and launch actions from the PDF'),
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'removeFonts' as const,
|
||||||
|
label: t('sanitize.options.removeFonts', 'Remove Fonts'),
|
||||||
|
description: t('sanitize.options.removeFonts.desc', 'Remove embedded fonts from the PDF'),
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t('sanitize.options.title', 'Sanitization Options')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
{options.map((option) => (
|
||||||
|
<Checkbox
|
||||||
|
key={option.key}
|
||||||
|
checked={parameters[option.key]}
|
||||||
|
onChange={(event) => onParameterChange(option.key, event.currentTarget.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
label={
|
||||||
|
<div>
|
||||||
|
<Text size="sm">{option.label}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{option.description}</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('sanitize.options.note', 'Select the elements you want to remove from the PDF. At least one option must be selected.')}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SanitizeSettings;
|
82
frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts
Normal file
82
frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { SanitizeParameters } from './useSanitizeParameters';
|
||||||
|
|
||||||
|
export const useSanitizeOperation = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const executeOperation = useCallback(async (
|
||||||
|
parameters: SanitizeParameters,
|
||||||
|
selectedFiles: File[]
|
||||||
|
) => {
|
||||||
|
if (selectedFiles.length === 0) {
|
||||||
|
throw new Error(t('error.noFilesSelected', 'No files selected'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
setStatus(t('sanitize.processing', 'Sanitizing PDF...'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('fileInput', selectedFiles[0]);
|
||||||
|
|
||||||
|
// Add parameters
|
||||||
|
formData.append('removeJavaScript', parameters.removeJavaScript.toString());
|
||||||
|
formData.append('removeEmbeddedFiles', parameters.removeEmbeddedFiles.toString());
|
||||||
|
formData.append('removeXMPMetadata', parameters.removeXMPMetadata.toString());
|
||||||
|
formData.append('removeMetadata', parameters.removeMetadata.toString());
|
||||||
|
formData.append('removeLinks', parameters.removeLinks.toString());
|
||||||
|
formData.append('removeFonts', parameters.removeFonts.toString());
|
||||||
|
|
||||||
|
const response = await fetch('/api/v1/security/sanitize-pdf', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(t('sanitize.error', 'Sanitization failed: {{error}}', { error: errorText }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setDownloadUrl(url);
|
||||||
|
setStatus(t('sanitize.completed', 'Sanitization completed successfully'));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : t('sanitize.error.generic', 'Sanitization failed');
|
||||||
|
setErrorMessage(message);
|
||||||
|
setStatus(null);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const resetResults = useCallback(() => {
|
||||||
|
if (downloadUrl) {
|
||||||
|
URL.revokeObjectURL(downloadUrl);
|
||||||
|
}
|
||||||
|
setDownloadUrl(null);
|
||||||
|
setErrorMessage(null);
|
||||||
|
setStatus(null);
|
||||||
|
}, [downloadUrl]);
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setErrorMessage(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading,
|
||||||
|
errorMessage,
|
||||||
|
downloadUrl,
|
||||||
|
status,
|
||||||
|
executeOperation,
|
||||||
|
resetResults,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
|
};
|
52
frontend/src/hooks/tools/sanitize/useSanitizeParameters.ts
Normal file
52
frontend/src/hooks/tools/sanitize/useSanitizeParameters.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface SanitizeParameters {
|
||||||
|
removeJavaScript: boolean;
|
||||||
|
removeEmbeddedFiles: boolean;
|
||||||
|
removeXMPMetadata: boolean;
|
||||||
|
removeMetadata: boolean;
|
||||||
|
removeLinks: boolean;
|
||||||
|
removeFonts: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultParameters: SanitizeParameters = {
|
||||||
|
removeJavaScript: true,
|
||||||
|
removeEmbeddedFiles: true,
|
||||||
|
removeXMPMetadata: false,
|
||||||
|
removeMetadata: false,
|
||||||
|
removeLinks: false,
|
||||||
|
removeFonts: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSanitizeParameters = () => {
|
||||||
|
const [parameters, setParameters] = useState<SanitizeParameters>(defaultParameters);
|
||||||
|
|
||||||
|
const updateParameter = useCallback(<K extends keyof SanitizeParameters>(
|
||||||
|
key: K,
|
||||||
|
value: SanitizeParameters[K]
|
||||||
|
) => {
|
||||||
|
setParameters(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetParameters = useCallback(() => {
|
||||||
|
setParameters(defaultParameters);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getEndpointName = useCallback(() => 'sanitize-pdf', []);
|
||||||
|
|
||||||
|
const validateParameters = useCallback(() => {
|
||||||
|
// At least one sanitization option must be selected
|
||||||
|
return Object.values(parameters).some(value => value === true);
|
||||||
|
}, [parameters]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
parameters,
|
||||||
|
updateParameter,
|
||||||
|
resetParameters,
|
||||||
|
getEndpointName,
|
||||||
|
validateParameters,
|
||||||
|
};
|
||||||
|
};
|
@ -4,6 +4,7 @@ import ContentCutIcon from "@mui/icons-material/ContentCut";
|
|||||||
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
||||||
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
|
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
|
||||||
import ApiIcon from "@mui/icons-material/Api";
|
import ApiIcon from "@mui/icons-material/Api";
|
||||||
|
import CleaningServicesIcon from "@mui/icons-material/CleaningServices";
|
||||||
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
|
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
|
||||||
import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool";
|
import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool";
|
||||||
|
|
||||||
@ -75,6 +76,15 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|||||||
description: "Extract text from images using OCR",
|
description: "Extract text from images using OCR",
|
||||||
endpoints: ["ocr-pdf"]
|
endpoints: ["ocr-pdf"]
|
||||||
},
|
},
|
||||||
|
sanitize: {
|
||||||
|
id: "sanitize",
|
||||||
|
icon: <CleaningServicesIcon />,
|
||||||
|
component: React.lazy(() => import("../tools/Sanitize")),
|
||||||
|
maxFiles: 1,
|
||||||
|
category: "security",
|
||||||
|
description: "Remove potentially harmful elements from PDF files",
|
||||||
|
endpoints: ["sanitize-pdf"]
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
154
frontend/src/tools/Sanitize.tsx
Normal file
154
frontend/src/tools/Sanitize.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { Button, Stack, Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
|
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||||
|
|
||||||
|
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
||||||
|
import OperationButton from "../components/tools/shared/OperationButton";
|
||||||
|
import ErrorNotification from "../components/tools/shared/ErrorNotification";
|
||||||
|
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
|
||||||
|
import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings";
|
||||||
|
|
||||||
|
import { useSanitizeParameters } from "../hooks/tools/sanitize/useSanitizeParameters";
|
||||||
|
import { useSanitizeOperation } from "../hooks/tools/sanitize/useSanitizeOperation";
|
||||||
|
import { BaseToolProps } from "../types/tool";
|
||||||
|
|
||||||
|
const generateSanitizedFileName = (originalFileName?: string): string => {
|
||||||
|
const baseName = originalFileName?.replace(/\.[^/.]+$/, '') || 'document';
|
||||||
|
return `${baseName}_sanitized.pdf`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { setCurrentMode } = useFileContext();
|
||||||
|
const { selectedFiles } = useToolFileSelection();
|
||||||
|
|
||||||
|
const sanitizeParams = useSanitizeParameters();
|
||||||
|
const sanitizeOperation = useSanitizeOperation();
|
||||||
|
|
||||||
|
// Endpoint validation
|
||||||
|
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(
|
||||||
|
sanitizeParams.getEndpointName()
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sanitizeOperation.resetResults();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
}, [sanitizeParams.parameters, selectedFiles]);
|
||||||
|
|
||||||
|
const handleSanitize = async () => {
|
||||||
|
try {
|
||||||
|
await sanitizeOperation.executeOperation(
|
||||||
|
sanitizeParams.parameters,
|
||||||
|
selectedFiles
|
||||||
|
);
|
||||||
|
if (sanitizeOperation.downloadUrl && onComplete) {
|
||||||
|
// Create a File object from the download URL for completion callback
|
||||||
|
const response = await fetch(sanitizeOperation.downloadUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const sanitizedFileName = generateSanitizedFileName(selectedFiles[0]?.name);
|
||||||
|
const file = new File([blob], sanitizedFileName, {
|
||||||
|
type: 'application/pdf'
|
||||||
|
});
|
||||||
|
onComplete([file]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (onError) {
|
||||||
|
onError(error instanceof Error ? error.message : t('sanitize.error.generic', 'Sanitization failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSettingsReset = () => {
|
||||||
|
sanitizeOperation.resetResults();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
// JB: Does this need setCurrentMode()?
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
const hasResults = sanitizeOperation.downloadUrl !== null;
|
||||||
|
const filesCollapsed = hasFiles;
|
||||||
|
const settingsCollapsed = hasResults;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolStepContainer>
|
||||||
|
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}>
|
||||||
|
{/* Files Step */}
|
||||||
|
<ToolStep
|
||||||
|
title={t('sanitize.steps.files', 'Files')}
|
||||||
|
isVisible={true}
|
||||||
|
isCollapsed={filesCollapsed}
|
||||||
|
isCompleted={filesCollapsed}
|
||||||
|
completedMessage={hasFiles ? t('sanitize.files.selected', 'Selected: {{filename}}', { filename: selectedFiles[0]?.name }) : undefined}
|
||||||
|
>
|
||||||
|
<FileStatusIndicator
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
placeholder={t('sanitize.files.placeholder', 'Select a PDF file in the main view to get started')}
|
||||||
|
/>
|
||||||
|
</ToolStep>
|
||||||
|
|
||||||
|
{/* Settings Step */}
|
||||||
|
<ToolStep
|
||||||
|
title={t('sanitize.steps.settings', 'Settings')}
|
||||||
|
isVisible={hasFiles}
|
||||||
|
isCollapsed={settingsCollapsed}
|
||||||
|
isCompleted={settingsCollapsed}
|
||||||
|
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
|
||||||
|
completedMessage={settingsCollapsed ? t('sanitize.completed', 'Sanitization completed') : undefined}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<SanitizeSettings
|
||||||
|
parameters={sanitizeParams.parameters}
|
||||||
|
onParameterChange={sanitizeParams.updateParameter}
|
||||||
|
disabled={endpointLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OperationButton
|
||||||
|
onClick={handleSanitize}
|
||||||
|
isLoading={sanitizeOperation.isLoading}
|
||||||
|
disabled={!sanitizeParams.validateParameters() || !hasFiles || !endpointEnabled}
|
||||||
|
loadingText={t("loading")}
|
||||||
|
submitText={t("sanitize.submit", "Sanitize PDF")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</ToolStep>
|
||||||
|
|
||||||
|
{/* Results Step */}
|
||||||
|
<ToolStep
|
||||||
|
title={t('sanitize.steps.results', 'Results')}
|
||||||
|
isVisible={hasResults}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{sanitizeOperation.status && (
|
||||||
|
<Text size="sm" c="dimmed">{sanitizeOperation.status}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ErrorNotification
|
||||||
|
error={sanitizeOperation.errorMessage}
|
||||||
|
onClose={sanitizeOperation.clearError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{sanitizeOperation.downloadUrl && (
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={sanitizeOperation.downloadUrl}
|
||||||
|
download={generateSanitizedFileName(selectedFiles[0]?.name)}
|
||||||
|
leftSection={<DownloadIcon />}
|
||||||
|
color="green"
|
||||||
|
fullWidth
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
{t("download", "Download")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ToolStep>
|
||||||
|
</Stack>
|
||||||
|
</ToolStepContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sanitize;
|
Loading…
Reference in New Issue
Block a user