Initial commit of Sanitize UI

This commit is contained in:
James 2025-08-05 15:22:56 +01:00
parent 90f0c5826a
commit 13ee46745b
6 changed files with 418 additions and 1 deletions

View File

@ -387,6 +387,10 @@
"title": "Compress",
"desc": "Compress PDFs to reduce their file size."
},
"sanitize": {
"title": "Sanitize",
"desc": "Remove potentially harmful elements from PDF files."
},
"unlockPDFForms": {
"title": "Unlock PDF Forms",
"desc": "Remove read-only property of form fields in a PDF document."
@ -1612,6 +1616,38 @@
"pdfaOptions": "PDF/A Options",
"outputFormat": "Output Format",
"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"
}
}
}
}

View 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;

View 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,
};
};

View 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,
};
};

View File

@ -4,6 +4,7 @@ import ContentCutIcon from "@mui/icons-material/ContentCut";
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import ApiIcon from "@mui/icons-material/Api";
import CleaningServicesIcon from "@mui/icons-material/CleaningServices";
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool";
@ -75,6 +76,15 @@ const toolDefinitions: Record<string, ToolDefinition> = {
description: "Extract text from images using OCR",
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"]
},
};

View 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;