From c87da6d5cccaac150f09c547c451eee6745770a8 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 20 Nov 2025 14:54:41 +0000 Subject: [PATCH 1/3] Add automatic unlock prompt for encrypted PDFs (#4912) ## Summary - propagate an `isEncrypted` flag from thumbnail generation into processed file metadata so uploads know when a password is still present - add queueing logic inside `FileContext` that detects encrypted uploads, prompts the user via a new modal, and automatically runs the Remove Password endpoint to replace the file and preserve history - introduce a dedicated `EncryptedPdfUnlockModal` component that mirrors existing styling and messaging for unlocking PDFs ## Testing - npm run typecheck:core ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_6919a0a418bc8328b886ec76a28170b7) --- .../public/locales/en-GB/translation.json | 18 ++ .../fileEditor/FileEditorThumbnail.tsx | 26 ++- .../shared/EncryptedPdfUnlockModal.tsx | 85 ++++++++ frontend/src/core/contexts/FileContext.tsx | 199 +++++++++++++++++- .../src/core/contexts/file/fileActions.ts | 6 +- frontend/src/core/contexts/file/fileHooks.ts | 1 + .../buildRemovePasswordFormData.ts | 12 ++ .../useRemovePasswordOperation.ts | 10 +- frontend/src/core/types/fileContext.ts | 2 + frontend/src/core/utils/thumbnailUtils.ts | 3 +- frontend/src/core/utils/toolErrorHandler.ts | 34 +++ 11 files changed, 382 insertions(+), 14 deletions(-) create mode 100644 frontend/src/core/components/shared/EncryptedPdfUnlockModal.tsx create mode 100644 frontend/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 2b70c36caa..321129b670 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -5557,6 +5557,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/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 { + 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/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 From 06af6be14b26a81bc33220aa75dbdb1610be0cb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:59:13 +0100 Subject: [PATCH 2/3] [V2] feat(pipeline): add pre-publish sanitization workflow (#4910) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes TLDR - Created `Pre-publish-sanitization.json` default pipeline configuration - Added sanitization operations removing metadata, JavaScript, embedded files, and annotations - Registered new pipeline in `GeneralUtils` - Included "Pre-publish Sanitization" in the suggested automations list This pull request introduces a new "Pre-publish Sanitization" workflow for PDF files, designed to help users remove sensitive metadata and content before publishing documents online. The changes include backend and frontend updates to support this workflow, as well as a minor bug fix in form data handling. **New Pre-publish Sanitization Workflow:** * Added a new default configuration file `Pre-publish-sanitization.json` that defines a pipeline for sanitizing PDFs by removing JavaScript, embedded files, metadata, annotations, flattening forms, and compressing the document. * Registered the new `Pre-publish-sanitization.json` config in the set of default web UI configurations in `GeneralUtils.java`, making it available in the application. **Frontend Integration:** * Added a new suggested automation called "Pre-publish Sanitization" in the `useSuggestedAutomations` hook, including its name, description, operations, and a new privacy icon for better UI representation. --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: Balázs Szücs --- .../software/common/util/GeneralUtils.java | 1 + .../Pre-publish-sanitization.json | 54 +++++++++++++++++ .../public/locales/en-GB/translation.json | 4 +- .../tools/automate/useSuggestedAutomations.ts | 58 +++++++++++++++++++ .../useChangeMetadataOperation.ts | 16 ++--- 5 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/Pre-publish-sanitization.json 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 321129b670..0a115f1c68 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -4840,7 +4840,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": { diff --git a/frontend/src/core/hooks/tools/automate/useSuggestedAutomations.ts b/frontend/src/core/hooks/tools/automate/useSuggestedAutomations.ts index 3783800500..c13ab3211e 100644 --- a/frontend/src/core/hooks/tools/automate/useSuggestedAutomations.ts +++ b/frontend/src/core/hooks/tools/automate/useSuggestedAutomations.ts @@ -9,6 +9,7 @@ import { SPLIT_METHODS } from '@app/constants/splitConstants'; const CompressIcon = () => 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; }; From 76f2fd3b76317383475a7cb4c4739c14db62fcf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 20 Nov 2025 16:00:16 +0100 Subject: [PATCH 3/3] [V2] refactor(tooltips): add merge tooltips header for consistency, and update other tooltips (#4895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Note on tooltips: I'll do more PRs on tooltips, however this has priority, hence I submitted this for now. #### Before: image image image image #### After: image image image image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: Balázs Szücs --- .../public/locales/en-GB/translation.json | 27 +++++++++++++++++++ .../addAttachments/AddAttachmentsSettings.tsx | 11 +------- .../RemoveAnnotationsSettings.tsx | 19 ++----------- .../tooltips/useAddAttachmentsTips.ts | 18 +++++++++++++ .../components/tooltips/useAutoRenameTips.ts | 4 +++ .../core/components/tooltips/useMergeTips.tsx | 3 +++ .../tooltips/useRemoveAnnotationsTips.ts | 20 ++++++++++++++ frontend/src/core/tools/AddAttachments.tsx | 3 +++ frontend/src/core/tools/AutoRename.tsx | 13 ++++++--- frontend/src/core/tools/RemoveAnnotations.tsx | 3 +++ 10 files changed, 91 insertions(+), 30 deletions(-) create mode 100644 frontend/src/core/components/tooltips/useAddAttachmentsTips.ts create mode 100644 frontend/src/core/components/tooltips/useRemoveAnnotationsTips.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 0a115f1c68..7ff36357c7 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": { @@ -4934,6 +4953,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" }, 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/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: , }, ],