diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index 10ac8b5954..ecf0d75d43 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -38,6 +38,7 @@ public class GeneralUtils { Set.of( "OCR images.json", "Prepare-pdfs-for-email.json", + "Pre-publish-sanitization.json", "split-rotate-auto-rename.json"); private final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs"; diff --git a/app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/Pre-publish-sanitization.json b/app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/Pre-publish-sanitization.json new file mode 100644 index 0000000000..2024f30032 --- /dev/null +++ b/app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/Pre-publish-sanitization.json @@ -0,0 +1,54 @@ +{ + "name": "Pre-publish-sanitization", + "pipeline": [ + { + "operation": "/api/v1/security/sanitize-pdf", + "parameters": { + "removeJavaScript": true, + "removeEmbeddedFiles": true, + "removeXMPMetadata": true, + "removeMetadata": true, + "removeLinks": true, + "removeFonts": false + } + }, + { + "operation": "/api/v1/misc/flatten", + "parameters": { + "flattenOnlyForms": true + } + }, + { + "operation": "/api/v1/general/remove-annotations", + "parameters": {} + }, + { + "operation": "/api/v1/misc/update-metadata", + "parameters": { + "deleteAll": true, + "author": "", + "creationDate": "", + "creator": "", + "keywords": "", + "modificationDate": "", + "producer": "", + "subject": "", + "title": "", + "trapped": "" + } + }, + { + "operation": "/api/v1/misc/compress-pdf", + "parameters": { + "optimizeLevel": 3, + "expectedOutputSize": "" + } + } + ], + "_examples": { + "outputDir": "{outputFolder}/{folderName}", + "outputFileName": "{filename}-{pipelineName}-{date}-{time}" + }, + "outputDir": "{outputFolder}", + "outputFileName": "pre_publish_{filename}.PDF" +} diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 9458e01a80..dcaaed9742 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -918,6 +918,11 @@ }, "error": { "failed": "An error occurred while merging the PDFs." + }, + "tooltip": { + "header": { + "title": "Merge Settings Overview" + } } }, "split": { @@ -2372,6 +2377,14 @@ "title": "About Remove Annotations", "description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents." }, + "tooltip": { + "header": { + "title": "About Remove Annotations" + }, + "description": { + "title": "What it does" + } + }, "error": { "failed": "An error occurred while removing annotations from the PDF." } @@ -2834,6 +2847,9 @@ "header": { "title": "How Auto-Rename Works" }, + "description": { + "title": "What it does" + }, "howItWorks": { "title": "Smart Renaming", "text": "Automatically finds the title from your PDF content and uses it as the filename.", @@ -2841,6 +2857,9 @@ "bullet2": "Creates a clean, valid filename from the detected title", "bullet3": "Keeps the original name if no suitable title is found" } + }, + "settings": { + "title": "About" } }, "adjust-contrast": { @@ -4841,7 +4860,9 @@ "secureWorkflow": "Security Workflow", "secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access. Password is set to 'password' by default.", "processImages": "Process Images", - "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." + "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images.", + "prePublishSanitization": "Pre-publish Sanitization", + "prePublishSanitizationDesc": "Sanitization workflow that removes all hidden metadata, JavaScript, embedded files, annotations, and flattens forms to prevent data leakage before publishing PDFs online." } }, "colorPicker": { @@ -4933,6 +4954,14 @@ "addMoreFiles": "Add more files...", "selectedFiles": "Selected Files", "submit": "Add Attachments", + "tooltip": { + "header": { + "title": "About Add Attachments" + }, + "description": { + "title": "What it does" + } + }, "results": { "title": "Attachment Results" }, @@ -5558,6 +5587,24 @@ "starting": "Backend starting up...", "wait": "Please wait for the backend to finish launching and try again." }, + "encryptedPdfUnlock": { + "unlockPrompt": "Unlock PDF to continue", + "title": "Remove password to continue", + "description": "This PDF is password protected. Enter the password so you can continue working with it.", + "password": { + "label": "PDF password", + "placeholder": "Enter the PDF password" + }, + "skip": "Skip for now", + "unlock": "Unlock & Continue", + "incorrectPassword": "Incorrect password", + "missingFile": "The selected file is no longer available.", + "emptyResponse": "Password removal did not produce a file.", + "required": "Enter the password to continue.", + "successTitle": "Password removed", + "successBodyWithName": "Password removed from {{fileName}}", + "successBody": "Password removed successfully." + }, "setup": { "welcome": "Welcome to Stirling PDF", "description": "Get started by choosing how you want to use Stirling PDF", diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index e268b941ce..7e734b42fc 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -9,6 +9,7 @@ import VisibilityIcon from '@mui/icons-material/Visibility'; import UnarchiveIcon from '@mui/icons-material/Unarchive'; import PushPinIcon from '@mui/icons-material/PushPin'; import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; +import LockOpenIcon from '@mui/icons-material/LockOpen'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { StirlingFileStub } from '@app/types/fileContext'; @@ -56,7 +57,14 @@ const FileEditorThumbnail = ({ isSupported = true, }: FileEditorThumbnailProps) => { const { t } = useTranslation(); - const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext(); + const { + pinFile, + unpinFile, + isFilePinned, + activeFiles, + actions: fileActions, + openEncryptedUnlockPrompt, + } = useFileContext(); const { state } = useFileState(); const hasError = state.ui.errorFileIds.includes(file.id); @@ -77,6 +85,7 @@ const FileEditorThumbnail = ({ const isZipFile = zipFileService.isZipFileStub(file); const pageCount = file.processedFile?.totalPages || 0; + const isEncrypted = Boolean(file.processedFile?.isEncrypted); const handleRef = useRef(null); @@ -301,6 +310,21 @@ const FileEditorThumbnail = ({ {/* Action buttons group */}
+ {isEncrypted && ( + + { + e.stopPropagation(); + openEncryptedUnlockPrompt(file.id); + }} + > + + + + )} {/* Pin/Unpin icon */} void; + onUnlock: () => void; + onSkip: () => void; +} + +const EncryptedPdfUnlockModal = ({ + opened, + fileName, + password, + errorMessage, + isProcessing, + onPasswordChange, + onUnlock, + onSkip, +}: EncryptedPdfUnlockModalProps) => { + const { t } = useTranslation(); + + const handleKeyDown: KeyboardEventHandler = (event) => { + if (event.key === 'Enter' && !isProcessing && password.trim().length > 0) { + onUnlock(); + } + }; + + return ( + + + {fileName} + + {t( + 'encryptedPdfUnlock.description', + 'This PDF is password protected. Enter the password so you can continue working with it.' + )} + + + + onPasswordChange(event.currentTarget.value)} + onKeyDown={handleKeyDown} + disabled={isProcessing} + autoFocus + /> + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + + + + + + + ); +}; + +export default EncryptedPdfUnlockModal; diff --git a/frontend/src/core/components/tools/addAttachments/AddAttachmentsSettings.tsx b/frontend/src/core/components/tools/addAttachments/AddAttachmentsSettings.tsx index 80078d0d74..33d9e8b33e 100644 --- a/frontend/src/core/components/tools/addAttachments/AddAttachmentsSettings.tsx +++ b/frontend/src/core/components/tools/addAttachments/AddAttachmentsSettings.tsx @@ -4,7 +4,7 @@ * Allows selecting files to attach to PDFs. */ -import { Stack, Text, Group, ActionIcon, Alert, ScrollArea, Button } from "@mantine/core"; +import { Stack, Text, Group, ActionIcon, ScrollArea, Button } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { AddAttachmentsParameters } from "@app/hooks/tools/addAttachments/useAddAttachmentsParameters"; import LocalIcon from "@app/components/shared/LocalIcon"; @@ -20,16 +20,7 @@ const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = fals return ( - - - {t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")} - - - - - {t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")} - { - const { t } = useTranslation(); - return ( - } - title={t('removeAnnotations.info.title', 'About Remove Annotations')} - color="blue" - variant="light" - > - - {t('removeAnnotations.info.description', - 'This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents.' - )} - - + {/* No settings needed for this tool - description is in tooltip */} ); }; diff --git a/frontend/src/core/components/tooltips/useAddAttachmentsTips.ts b/frontend/src/core/components/tooltips/useAddAttachmentsTips.ts new file mode 100644 index 0000000000..50f924501b --- /dev/null +++ b/frontend/src/core/components/tooltips/useAddAttachmentsTips.ts @@ -0,0 +1,18 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '@app/types/tips'; + +export const useAddAttachmentsTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("AddAttachmentsRequest.tooltip.header.title", "About Add Attachments") + }, + tips: [ + { + title: t("AddAttachmentsRequest.tooltip.description.title", "What it does"), + description: t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel."), + } + ] + }; +}; diff --git a/frontend/src/core/components/tooltips/useAutoRenameTips.ts b/frontend/src/core/components/tooltips/useAutoRenameTips.ts index 8b77e39b70..d46c86c75f 100644 --- a/frontend/src/core/components/tooltips/useAutoRenameTips.ts +++ b/frontend/src/core/components/tooltips/useAutoRenameTips.ts @@ -9,6 +9,10 @@ export const useAutoRenameTips = (): TooltipContent => { title: t("auto-rename.tooltip.header.title", "How Auto-Rename Works") }, tips: [ + { + title: t("auto-rename.tooltip.description.title", "What it does"), + description: t("auto-rename.description", "Automatically finds the title from your PDF content and uses it as the filename."), + }, { title: t("auto-rename.tooltip.howItWorks.title", "Smart Renaming"), bullets: [ diff --git a/frontend/src/core/components/tooltips/useMergeTips.tsx b/frontend/src/core/components/tooltips/useMergeTips.tsx index decd109ef0..741adccce5 100644 --- a/frontend/src/core/components/tooltips/useMergeTips.tsx +++ b/frontend/src/core/components/tooltips/useMergeTips.tsx @@ -5,6 +5,9 @@ export const useMergeTips = (): TooltipContent => { const { t } = useTranslation(); return { + header: { + title: t('merge.tooltip.header.title', 'Merge Settings Overview') + }, tips: [ { title: t('merge.removeDigitalSignature.tooltip.title', 'Remove Digital Signature'), diff --git a/frontend/src/core/components/tooltips/useRemoveAnnotationsTips.ts b/frontend/src/core/components/tooltips/useRemoveAnnotationsTips.ts new file mode 100644 index 0000000000..edaa039236 --- /dev/null +++ b/frontend/src/core/components/tooltips/useRemoveAnnotationsTips.ts @@ -0,0 +1,20 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '@app/types/tips'; + +export const useRemoveAnnotationsTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("removeAnnotations.tooltip.header.title", "About Remove Annotations") + }, + tips: [ + { + title: t("removeAnnotations.tooltip.description.title", "What it does"), + description: t('removeAnnotations.info.description', + 'This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents.' + ), + } + ] + }; +}; diff --git a/frontend/src/core/contexts/FileContext.tsx b/frontend/src/core/contexts/FileContext.tsx index 56f1dcabde..378d43050e 100644 --- a/frontend/src/core/contexts/FileContext.tsx +++ b/frontend/src/core/contexts/FileContext.tsx @@ -12,7 +12,7 @@ * Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation). */ -import { useReducer, useCallback, useEffect, useRef, useMemo } from 'react'; +import { useReducer, useCallback, useEffect, useRef, useMemo, useState } from 'react'; import { FileContextProviderProps, FileContextSelectors, @@ -22,17 +22,27 @@ import { FileId, StirlingFileStub, StirlingFile, + createStirlingFile, } from '@app/types/fileContext'; // Import modular components import { fileContextReducer, initialFileContextState } from '@app/contexts/file/FileReducer'; import { createFileSelectors } from '@app/contexts/file/fileSelectors'; -import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions } from '@app/contexts/file/fileActions'; +import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions, createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions'; import { FileLifecycleManager } from '@app/contexts/file/lifecycle'; import { FileStateContext, FileActionsContext } from '@app/contexts/file/contexts'; import { IndexedDBProvider, useIndexedDB } from '@app/contexts/IndexedDBContext'; import { useZipConfirmation } from '@app/hooks/useZipConfirmation'; import ZipWarningModal from '@app/components/shared/ZipWarningModal'; +import EncryptedPdfUnlockModal from '@app/components/shared/EncryptedPdfUnlockModal'; +import { useTranslation } from 'react-i18next'; +import { alert } from '@app/components/toast'; +import { buildRemovePasswordFormData } from '@app/hooks/tools/removePassword/buildRemovePasswordFormData'; +import type { RemovePasswordParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters'; +import apiClient from '@app/services/apiClient'; +import { processResponse } from '@app/utils/toolResponseProcessor'; +import { ToolOperation } from '@app/types/file'; +import { handlePasswordError } from '@app/utils/toolErrorHandler'; const DEBUG = process.env.NODE_ENV === 'development'; @@ -63,6 +73,98 @@ function FileContextInner({ lifecycleManagerRef.current = new FileLifecycleManager(filesRef, dispatch); } const lifecycleManager = lifecycleManagerRef.current; + const { t } = useTranslation(); + + const [encryptedQueue, setEncryptedQueue] = useState([]); + const [activeEncryptedFileId, setActiveEncryptedFileId] = useState(null); + const [unlockPassword, setUnlockPassword] = useState(''); + const [unlockError, setUnlockError] = useState(null); + const [isUnlocking, setIsUnlocking] = useState(false); + const dismissedEncryptedFilesRef = useRef>(new Set()); + const observedFileIdsRef = useRef>(new Set()); + + const enqueueEncryptedFiles = useCallback((fileIds: FileId[]) => { + if (fileIds.length === 0) return; + setEncryptedQueue(prevQueue => { + const existing = new Set(prevQueue); + const next = [...prevQueue]; + for (const id of fileIds) { + if (dismissedEncryptedFilesRef.current.has(id)) continue; + if (id === activeEncryptedFileId) continue; + if (existing.has(id)) continue; + existing.add(id); + next.push(id); + } + return next; + }); + }, [activeEncryptedFileId]); + + useEffect(() => { + const previousIds = observedFileIdsRef.current; + const nextIds = new Set(state.files.ids); + const newEncryptedIds: FileId[] = []; + + for (const id of state.files.ids) { + if (!previousIds.has(id)) { + const stub = state.files.byId[id]; + if ((stub?.versionNumber ?? 1) <= 1 && stub?.processedFile?.isEncrypted) { + newEncryptedIds.push(id); + } + } + } + + if (newEncryptedIds.length > 0) { + enqueueEncryptedFiles(newEncryptedIds); + } + + observedFileIdsRef.current = nextIds; + }, [state.files.ids, state.files.byId, enqueueEncryptedFiles]); + + useEffect(() => { + if (!activeEncryptedFileId && encryptedQueue.length > 0) { + setActiveEncryptedFileId(encryptedQueue[0]); + setEncryptedQueue(prev => prev.slice(1)); + } + }, [activeEncryptedFileId, encryptedQueue]); + + useEffect(() => { + if (activeEncryptedFileId && !state.files.ids.includes(activeEncryptedFileId)) { + setActiveEncryptedFileId(null); + } + }, [activeEncryptedFileId, state.files.ids]); + + useEffect(() => { + setUnlockPassword(''); + setUnlockError(null); + }, [activeEncryptedFileId]); + + const handleUnlockSkip = useCallback(() => { + if (activeEncryptedFileId) { + dismissedEncryptedFilesRef.current.add(activeEncryptedFileId); + } + setActiveEncryptedFileId(null); + }, [activeEncryptedFileId]); + + const promptEncryptedUnlock = useCallback((fileId: FileId) => { + const stub = stateRef.current.files.byId[fileId]; + if (!stub?.processedFile?.isEncrypted) { + return; + } + + dismissedEncryptedFilesRef.current.delete(fileId); + + setEncryptedQueue(prevQueue => prevQueue.filter(id => id !== fileId)); + + setActiveEncryptedFileId(currentActiveId => { + if (currentActiveId && currentActiveId !== fileId) { + setEncryptedQueue(prevQueue => { + const withoutDuplicates = prevQueue.filter(id => id !== currentActiveId && id !== fileId); + return [currentActiveId, ...withoutDuplicates]; + }); + } + return fileId; + }); + }, []); // Create stable selectors (memoized once to avoid re-renders) const selectors = useMemo(() => @@ -131,6 +233,80 @@ function FileContextInner({ return consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs, filesRef, dispatch); }, []); + const runAutomaticPasswordRemoval = useCallback(async (fileId: FileId, password: string): Promise => { + const file = filesRef.current.get(fileId); + const parentStub = stateRef.current.files.byId[fileId]; + + if (!file || !parentStub) { + throw new Error(t('encryptedPdfUnlock.missingFile', 'The selected file is no longer available.')); + } + + const params: RemovePasswordParameters = { password }; + const formData = buildRemovePasswordFormData(params, file); + + const response = await apiClient.post('/api/v1/security/remove-password', formData, { + responseType: 'blob', + suppressErrorToast: true // Handle errors in modal UI instead of toast + }); + const responseFiles = await processResponse(response.data, [file]); + + const unlockedFile = responseFiles[0]; + if (!unlockedFile) { + throw new Error(t('encryptedPdfUnlock.emptyResponse', 'Password removal did not produce a file.')); + } + + const processedMetadata = await generateProcessedFileMetadata(unlockedFile); + const thumbnail = processedMetadata?.thumbnailUrl; + + const operation: ToolOperation = { + toolId: 'removePassword', + timestamp: Date.now() + }; + + const childStub = createChildStub(parentStub, operation, unlockedFile, thumbnail, processedMetadata); + const stirlingUnlockedFile = createStirlingFile(unlockedFile, childStub.id); + + await consumeFilesWrapper([fileId], [stirlingUnlockedFile], [childStub]); + }, [consumeFilesWrapper, t]); + + const handleUnlockSubmit = useCallback(async () => { + if (!activeEncryptedFileId) return; + if (!unlockPassword.trim()) { + setUnlockError(t('encryptedPdfUnlock.required', 'Enter the password to continue.')); + return; + } + + setIsUnlocking(true); + setUnlockError(null); + try { + await runAutomaticPasswordRemoval(activeEncryptedFileId, unlockPassword.trim()); + const fileName = stateRef.current.files.byId[activeEncryptedFileId]?.name; + alert({ + alertType: 'success', + title: t('encryptedPdfUnlock.successTitle', 'Password removed'), + body: fileName + ? t('encryptedPdfUnlock.successBodyWithName', { + defaultValue: 'Removed password from {{fileName}}', + fileName, + }) + : t('encryptedPdfUnlock.successBody', 'Password removed successfully.'), + expandable: false, + isPersistentPopup: false, + }); + dismissedEncryptedFilesRef.current.delete(activeEncryptedFileId); + setActiveEncryptedFileId(null); + } catch (error) { + const errorMessage = await handlePasswordError( + error, + t('encryptedPdfUnlock.incorrectPassword', 'Incorrect password'), + t('removePassword.error.failed', 'An error occurred while removing the password from the PDF.') + ); + setUnlockError(errorMessage); + } finally { + setIsUnlocking(false); + } + }, [activeEncryptedFileId, unlockPassword, runAutomaticPasswordRemoval, t]); + const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise => { return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB); }, [indexedDB]); @@ -199,7 +375,8 @@ function FileContextInner({ trackBlobUrl: lifecycleManager.trackBlobUrl, cleanupFile: (fileId: FileId) => lifecycleManager.cleanupFile(fileId, stateRef), scheduleCleanup: (fileId: FileId, delay?: number) => - lifecycleManager.scheduleCleanup(fileId, delay, stateRef) + lifecycleManager.scheduleCleanup(fileId, delay, stateRef), + openEncryptedUnlockPrompt: promptEncryptedUnlock }), [ baseActions, addRawFiles, @@ -211,7 +388,8 @@ function FileContextInner({ pinFileWrapper, unpinFileWrapper, indexedDB, - enablePersistence + enablePersistence, + promptEncryptedUnlock ]); // Split context values to minimize re-renders @@ -225,6 +403,9 @@ function FileContextInner({ dispatch }), [actions]); + const activeEncryptedStub = activeEncryptedFileId ? state.files.byId[activeEncryptedFileId] : undefined; + const isUnlockModalOpen = Boolean(activeEncryptedFileId && activeEncryptedStub); + // Persistence loading disabled - files only loaded on explicit user action // useEffect(() => { // if (!enablePersistence || !indexedDB) return; @@ -251,6 +432,16 @@ function FileContextInner({ fileCount={confirmationState.fileCount} zipFileName={confirmationState.fileName} /> + ); diff --git a/frontend/src/core/contexts/file/fileActions.ts b/frontend/src/core/contexts/file/fileActions.ts index d209781c89..40f2a313d0 100644 --- a/frontend/src/core/contexts/file/fileActions.ts +++ b/frontend/src/core/contexts/file/fileActions.ts @@ -63,7 +63,7 @@ export function createProcessedFile( thumbnail?: string, pageRotations?: number[], pageDimensions?: Array<{ width: number; height: number }> -) { +): ProcessedFileMetadata { return { totalPages: pageCount, pages: Array.from({ length: pageCount }, (_, index) => ({ @@ -106,6 +106,10 @@ export async function generateProcessedFileMetadata(file: File): Promise React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' }); const SecurityIcon = () => React.createElement(LocalIcon, { icon: 'security', width: '1.5rem', height: '1.5rem' }); const StarIcon = () => React.createElement(LocalIcon, { icon: 'star', width: '1.5rem', height: '1.5rem' }); +const PrivacyIcon = () => React.createElement(LocalIcon, { icon: 'shield-lock', width: '1.5rem', height: '1.5rem' }); export function useSuggestedAutomations(): SuggestedAutomation[] { const { t } = useTranslation(); @@ -67,6 +68,63 @@ export function useSuggestedAutomations(): SuggestedAutomation[] { updatedAt: now, icon: SecurityIcon, }, + { + id: "pre-publish-sanitization", + name: t("automation.suggested.prePublishSanitization", "Pre-publish Sanitization"), + description: t("automation.suggested.prePublishSanitizationDesc", "Sanitization workflow that removes all hidden metadata, JavaScript, embedded files, annotations, and flattens forms to prevent data leakage before publishing PDFs online."), + operations: [ + { + operation: "sanitize", + parameters: { + removeJavaScript: true, + removeEmbeddedFiles: true, + removeXMPMetadata: true, + removeMetadata: true, + removeLinks: true, + removeFonts: false, + } + }, + { + operation: "flatten", + parameters: { + flattenOnlyForms: true, + } + }, + { + operation: "removeAnnotations", + parameters: {} + }, + { + operation: "changeMetadata", + parameters: { + deleteAll: true, + author: '', + creationDate: '', + creator: '', + keywords: '', + modificationDate: '', + producer: '', + subject: '', + title: '', + trapped: '', + } + }, + { + operation: "compress", + parameters: { + compressionLevel: 3, + grayscale: false, + expectedSize: '', + compressionMethod: 'quality', + fileSizeValue: '', + fileSizeUnit: 'MB', + } + }, + ], + createdAt: now, + updatedAt: now, + icon: PrivacyIcon, + }, { id: "email-preparation", name: t("automation.suggested.emailPreparation", "Email Preparation"), diff --git a/frontend/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.ts b/frontend/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.ts index 828fa01ab5..b0692e5d6a 100644 --- a/frontend/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.ts +++ b/frontend/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.ts @@ -40,13 +40,15 @@ export const buildChangeMetadataFormData = (parameters: ChangeMetadataParameters // Custom metadata - backend expects them as values to 'allRequestParams[customKeyX/customValueX]' let keyNumber = 0; - parameters.customMetadata.forEach((entry) => { - if (entry.key.trim() && entry.value.trim()) { - keyNumber += 1; - formData.append(`allRequestParams[customKey${keyNumber}]`, entry.key.trim()); - formData.append(`allRequestParams[customValue${keyNumber}]`, entry.value.trim()); - } - }); + if (parameters.customMetadata && Array.isArray(parameters.customMetadata)) { + parameters.customMetadata.forEach((entry) => { + if (entry.key.trim() && entry.value.trim()) { + keyNumber += 1; + formData.append(`allRequestParams[customKey${keyNumber}]`, entry.key.trim()); + formData.append(`allRequestParams[customValue${keyNumber}]`, entry.value.trim()); + } + }); + } return formData; }; diff --git a/frontend/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts b/frontend/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts new file mode 100644 index 0000000000..cf3619afc3 --- /dev/null +++ b/frontend/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts @@ -0,0 +1,12 @@ +import { RemovePasswordParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters'; + +/** + * Builds FormData for remove password API request. + * Separated from operation config to avoid circular dependencies with FileContext. + */ +export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters, file: File): FormData => { + const formData = new FormData(); + formData.append("fileInput", file); + formData.append("password", parameters.password); + return formData; +}; diff --git a/frontend/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts b/frontend/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts index ce6140730e..4f31e8078f 100644 --- a/frontend/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts +++ b/frontend/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts @@ -2,14 +2,10 @@ import { useTranslation } from 'react-i18next'; import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation'; import { createStandardErrorHandler } from '@app/utils/toolErrorHandler'; import { RemovePasswordParameters, defaultParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters'; +import { buildRemovePasswordFormData } from '@app/hooks/tools/removePassword/buildRemovePasswordFormData'; -// Static function that can be used by both the hook and automation executor -export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters, file: File): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - formData.append("password", parameters.password); - return formData; -}; +// Re-export for backwards compatibility with any other imports +export { buildRemovePasswordFormData }; // Static configuration object export const removePasswordOperationConfig = { diff --git a/frontend/src/core/tools/AddAttachments.tsx b/frontend/src/core/tools/AddAttachments.tsx index c248eab961..199ea1d0ee 100644 --- a/frontend/src/core/tools/AddAttachments.tsx +++ b/frontend/src/core/tools/AddAttachments.tsx @@ -8,10 +8,12 @@ import { useAddAttachmentsParameters } from "@app/hooks/tools/addAttachments/use import { useAddAttachmentsOperation } from "@app/hooks/tools/addAttachments/useAddAttachmentsOperation"; import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps"; import AddAttachmentsSettings from "@app/components/tools/addAttachments/AddAttachmentsSettings"; +import { useAddAttachmentsTips } from "@app/components/tooltips/useAddAttachmentsTips"; const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); const { selectedFiles } = useFileSelection(); + const addAttachmentsTips = useAddAttachmentsTips(); const params = useAddAttachmentsParameters(); const operation = useAddAttachmentsOperation(); @@ -64,6 +66,7 @@ const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) = isCollapsed: accordion.getCollapsedState(AddAttachmentsStep.ATTACHMENTS), onCollapsedClick: () => accordion.handleStepToggle(AddAttachmentsStep.ATTACHMENTS), isVisible: true, + tooltip: addAttachmentsTips, content: ( { const { t } = useTranslation(); + const autoRenameTips = useAutoRenameTips(); const base = useBaseTool( - '"auto-rename-pdf-file', + 'auto-rename-pdf-file', useAutoRenameParameters, useAutoRenameOperation, props ); return createToolFlow({ - title: { title:t("auto-rename.title", "Auto Rename PDF"), description: t("auto-rename.description", "Auto Rename PDF"), tooltip: useAutoRenameTips()}, files: { selectedFiles: base.selectedFiles, isCollapsed: base.hasResults, }, - steps: [], + steps: [ + { + title: t("auto-rename.settings.title", "About"), + isCollapsed: false, + tooltip: autoRenameTips, + content: null, + }, + ], executeButton: { text: t("auto-rename.submit", "Auto Rename"), isVisible: !base.hasResults, diff --git a/frontend/src/core/tools/RemoveAnnotations.tsx b/frontend/src/core/tools/RemoveAnnotations.tsx index 82d2f47b8a..e2bf6c3f45 100644 --- a/frontend/src/core/tools/RemoveAnnotations.tsx +++ b/frontend/src/core/tools/RemoveAnnotations.tsx @@ -5,9 +5,11 @@ import { useRemoveAnnotationsParameters } from "@app/hooks/tools/removeAnnotatio import { useRemoveAnnotationsOperation } from "@app/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation"; import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; +import { useRemoveAnnotationsTips } from "@app/components/tooltips/useRemoveAnnotationsTips"; const RemoveAnnotations = (props: BaseToolProps) => { const { t } = useTranslation(); + const removeAnnotationsTips = useRemoveAnnotationsTips(); const base = useBaseTool( 'removeAnnotations', @@ -26,6 +28,7 @@ const RemoveAnnotations = (props: BaseToolProps) => { title: t("removeAnnotations.settings.title", "Settings"), isCollapsed: base.settingsCollapsed, onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + tooltip: removeAnnotationsTips, content: , }, ], diff --git a/frontend/src/core/types/fileContext.ts b/frontend/src/core/types/fileContext.ts index ed2734039c..d28ea3a0de 100644 --- a/frontend/src/core/types/fileContext.ts +++ b/frontend/src/core/types/fileContext.ts @@ -23,6 +23,7 @@ export interface ProcessedFileMetadata { pages: ProcessedFilePage[]; totalPages?: number; lastProcessed?: number; + isEncrypted?: boolean; [key: string]: any; } @@ -301,6 +302,7 @@ export interface FileContextActions { trackBlobUrl: (url: string) => void; scheduleCleanup: (fileId: FileId, delay?: number) => void; cleanupFile: (fileId: FileId) => void; + openEncryptedUnlockPrompt: (fileId: FileId) => void; } // File selectors (separate from actions to avoid re-renders) diff --git a/frontend/src/core/utils/thumbnailUtils.ts b/frontend/src/core/utils/thumbnailUtils.ts index 88c4aeaefb..eda7825500 100644 --- a/frontend/src/core/utils/thumbnailUtils.ts +++ b/frontend/src/core/utils/thumbnailUtils.ts @@ -5,6 +5,7 @@ export interface ThumbnailWithMetadata { pageCount: number; pageRotations?: number[]; // Rotation for each page (0, 90, 180, 270) pageDimensions?: Array<{ width: number; height: number }>; + isEncrypted?: boolean; } interface ColorScheme { @@ -451,7 +452,7 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b if (error instanceof Error && error.name === "PasswordException") { // Handle encrypted PDFs const thumbnail = generateEncryptedPDFThumbnail(file); - return { thumbnail, pageCount: 1 }; + return { thumbnail, pageCount: 1, isEncrypted: true }; } const thumbnail = generatePlaceholderThumbnail(file); diff --git a/frontend/src/core/utils/toolErrorHandler.ts b/frontend/src/core/utils/toolErrorHandler.ts index 637970adf0..836f1e00c2 100644 --- a/frontend/src/core/utils/toolErrorHandler.ts +++ b/frontend/src/core/utils/toolErrorHandler.ts @@ -2,6 +2,8 @@ * Standardized error handling utilities for tool operations */ +import { normalizeAxiosErrorData } from '@app/services/errorUtils'; + /** * Default error extractor that follows the standard pattern */ @@ -30,4 +32,36 @@ export const createStandardErrorHandler = (fallbackMessage: string) => { } return fallbackMessage; }; +}; + +/** + * Handles password-related errors with status code checking + * @param error - The error object from axios + * @param incorrectPasswordMessage - Message to show for incorrect password (typically 500 status) + * @param fallbackMessage - Message to show for other errors + * @returns Error message string + */ +export const handlePasswordError = async ( + error: any, + incorrectPasswordMessage: string, + fallbackMessage: string +): Promise => { + const status = error?.response?.status; + + // Handle specific error cases with user-friendly messages + if (status === 500) { + // 500 typically means incorrect password for encrypted PDFs + return incorrectPasswordMessage; + } + + // For other errors, try to extract the message + const normalizedData = await normalizeAxiosErrorData(error?.response?.data); + const errorWithNormalizedData = { + ...error, + response: { + ...error?.response, + data: normalizedData + } + }; + return extractErrorMessage(errorWithNormalizedData) || fallbackMessage; }; \ No newline at end of file