Fix encrypted pdf handling (#6088)

Fix and improve encrypted pdf handling
This commit is contained in:
Reece Browne
2026-04-13 13:20:43 +01:00
committed by GitHub
parent d53beb9bce
commit 76aa5c7e2f
12 changed files with 527 additions and 67 deletions
@@ -9,8 +9,10 @@ interface EncryptedPdfUnlockModalProps {
password: string;
errorMessage?: string | null;
isProcessing: boolean;
remainingCount: number;
onPasswordChange: (value: string) => void;
onUnlock: () => void;
onUnlockAll: () => void;
onSkip: () => void;
}
@@ -20,8 +22,10 @@ const EncryptedPdfUnlockModal = ({
password,
errorMessage,
isProcessing,
remainingCount,
onPasswordChange,
onUnlock,
onUnlockAll,
onSkip,
}: EncryptedPdfUnlockModalProps) => {
const { t } = useTranslation();
@@ -75,9 +79,16 @@ const EncryptedPdfUnlockModal = ({
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={onSkip} disabled={isProcessing}>
{t("encryptedPdfUnlock.skip", "Skip for now")}
</Button>
<Button onClick={onUnlock} loading={isProcessing} disabled={password.trim().length === 0}>
{t("encryptedPdfUnlock.unlock", "Unlock & Continue")}
</Button>
<Group gap="xs">
{remainingCount > 0 && (
<Button variant="light" onClick={onUnlockAll} loading={isProcessing} disabled={password.trim().length === 0}>
{t("encryptedPdfUnlock.unlockAll", "Use for all ({{count}})", { count: remainingCount + 1 })}
</Button>
)}
<Button onClick={onUnlock} loading={isProcessing} disabled={password.trim().length === 0}>
{t("encryptedPdfUnlock.unlock", "Unlock & Continue")}
</Button>
</Group>
</Group>
</Stack>
</Modal>
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Box, Center, Text, ActionIcon } from "@mantine/core";
import { Box, Center, Text, ActionIcon, Button, Stack } from "@mantine/core";
import CloseIcon from "@mui/icons-material/Close";
import LockIcon from "@mui/icons-material/Lock";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import { useFileWithUrl } from "@app/hooks/useFileWithUrl";
@@ -350,6 +351,13 @@ const EmbedPdfViewerContent = ({
}
}, [previewFile, fileWithUrl]);
// Check if the current file is encrypted (gate the viewer to prevent PDFium crash)
const isCurrentFileEncrypted = React.useMemo(() => {
if (!currentFile || !isStirlingFile(currentFile)) return false;
const stub = selectors.getStirlingFileStub(currentFile.fileId);
return stub?.processedFile?.isEncrypted === true;
}, [currentFile, selectors]);
const bookmarkCacheKey = React.useMemo(() => {
if (currentFile && isStirlingFile(currentFile)) {
return currentFile.fileId;
@@ -1045,7 +1053,7 @@ const EmbedPdfViewerContent = ({
console.log("[FormFill] Fetching form fields for:", currentFileId);
fetchFormFields(currentFile, currentFileId ?? undefined);
}
}, [isFormFillToolActive, currentFile, currentFileId, fetchFormFields]);
}, [isFormFillToolActive, currentFile, currentFileId, fetchFormFields, isCurrentFileEncrypted]);
const sidebarWidthRem = 15;
const commentsSidebarWidthRem = 18;
@@ -1087,6 +1095,23 @@ const EmbedPdfViewerContent = ({
<Center style={{ flex: 1 }}>
<Text c="red">Error: No file provided to viewer</Text>
</Center>
) : isCurrentFileEncrypted ? (
<Center style={{ flex: 1 }}>
<Stack align="center" gap="md">
<LockIcon style={{ fontSize: 48, opacity: 0.5 }} />
<Text fw={500}>This PDF is password-protected</Text>
<Button
variant="filled"
onClick={() => {
if (currentFile && isStirlingFile(currentFile)) {
actions.openEncryptedUnlockPrompt(currentFile.fileId);
}
}}
>
Unlock
</Button>
</Stack>
</Center>
) : (
<>
{/* EmbedPDF Viewer */}
@@ -371,6 +371,66 @@ function FileContextInner({ children, enablePersistence = true }: FileContextPro
}
}, [activeEncryptedFileId, unlockPassword, runAutomaticPasswordRemoval, t]);
const handleUnlockAll = useCallback(async () => {
if (!activeEncryptedFileId) return;
const pw = unlockPassword.trim();
if (!pw) {
setUnlockError(t("encryptedPdfUnlock.required", "Enter the password to continue."));
return;
}
setIsUnlocking(true);
setUnlockError(null);
const allIds = [activeEncryptedFileId, ...encryptedQueue];
let successCount = 0;
const failedNames: string[] = [];
for (const fileId of allIds) {
try {
await runAutomaticPasswordRemoval(fileId, pw);
dismissedEncryptedFilesRef.current.delete(fileId);
successCount++;
} catch {
const name = stateRef.current.files.byId[fileId]?.name ?? fileId;
failedNames.push(name);
}
}
if (successCount > 0) {
alert({
alertType: "success",
title: t("encryptedPdfUnlock.successTitle", "Password removed"),
body: t("encryptedPdfUnlock.unlockAllSuccess", {
defaultValue: "Unlocked {{count}} file(s).",
count: successCount,
}),
expandable: false,
isPersistentPopup: false,
});
}
if (failedNames.length > 0) {
setUnlockError(
t("encryptedPdfUnlock.unlockAllPartialFail", {
defaultValue: "Wrong password for: {{names}}",
names: failedNames.join(", "),
}),
);
const failedIds = allIds.filter((id) => {
const name = stateRef.current.files.byId[id]?.name ?? id;
return failedNames.includes(name);
});
setEncryptedQueue(failedIds.slice(1));
setActiveEncryptedFileId(failedIds[0]);
} else {
setEncryptedQueue([]);
setActiveEncryptedFileId(null);
}
setIsUnlocking(false);
}, [activeEncryptedFileId, encryptedQueue, unlockPassword, runAutomaticPasswordRemoval, t]);
const undoConsumeFilesWrapper = useCallback(
async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB);
@@ -520,8 +580,10 @@ function FileContextInner({ children, enablePersistence = true }: FileContextPro
password={unlockPassword}
errorMessage={unlockError}
isProcessing={isUnlocking}
remainingCount={encryptedQueue.length}
onPasswordChange={setUnlockPassword}
onUnlock={handleUnlockSubmit}
onUnlockAll={handleUnlockAll}
onSkip={handleUnlockSkip}
/>
</FileActionsContext.Provider>
@@ -19,6 +19,7 @@ import { buildQuickKeySet } from "@app/contexts/file/fileSelectors";
import { StirlingFile } from "@app/types/fileContext";
import { fileStorage } from "@app/services/fileStorage";
import { zipFileService } from "@app/services/zipFileService";
import { FileAnalyzer } from "@app/services/fileAnalyzer";
const DEBUG = process.env.NODE_ENV === "development";
const HYDRATION_CONCURRENCY = 2;
let activeHydrations = 0;
@@ -328,6 +329,21 @@ export async function addFiles(
// Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously
const fileStub = createNewStirlingFileStub(file, fileId);
// Early encryption detection for PDFs — set the flag before dispatch so the
// viewer gate and modal queue pick it up immediately instead of after hydration
if (file.type === "application/pdf") {
try {
if (await FileAnalyzer.isPDFUserPasswordProtected(file)) {
fileStub.processedFile = (fileStub.processedFile || { pages: [] }) as any;
fileStub.processedFile!.isEncrypted = true;
}
} catch (error) {
// Never block upload on analysis failure — but log so it's debuggable
// if an unencrypted file later appears to "hang" during processing.
console.warn("[FileActions] Early encryption detection failed for", file.name, error);
}
}
// Check for pending file path mapping from Tauri file dialog (desktop only)
try {
const { pendingFilePathMappings } = await import("@app/services/pendingFilePathMappings");
@@ -116,6 +116,25 @@ export const useToolOperation = <TParams>(config: ToolOperationConfig<TParams>):
return;
}
// Block encrypted files from being sent to backend tools
const encryptedFiles = validFiles.filter((f) => {
const stub = selectors.getStirlingFileStub(f.fileId);
return stub?.processedFile?.isEncrypted === true;
});
if (encryptedFiles.length > 0) {
for (const ef of encryptedFiles) {
fileActions.openEncryptedUnlockPrompt(ef.fileId);
}
actions.setError(
encryptedFiles.length === 1
? t("encryptedFileBlocked", "File is password-protected. Unlock it first.")
: t("encryptedFilesBlocked", "{{count}} files are password-protected. Unlock them first.", {
count: encryptedFiles.length,
}),
);
return;
}
// Resolve the runtime endpoint from params (static string or function result).
// Custom processors may omit endpoint entirely — result is undefined in that case.
const runtimeEndpoint: string | undefined = config.endpoint
+56 -6
View File
@@ -1,5 +1,24 @@
import { FileAnalysis, ProcessingStrategy } from "@app/types/processing";
import { pdfWorkerManager } from "@app/services/pdfWorkerManager";
import type { PDFDocumentProxy } from "pdfjs-dist";
// Scan the last ~8KB of the PDF for an /Encrypt entry. The trailer lives near
// the tail of the file, so this is enough in practice while staying cheap.
// For files smaller than the window, the whole file is scanned.
function hasEncryptMarker(buffer: ArrayBuffer): boolean {
const TAIL_BYTES = 8 * 1024;
const offset = Math.max(0, buffer.byteLength - TAIL_BYTES);
const view = new Uint8Array(buffer, offset);
// "/Encrypt" as ASCII bytes
const needle = [0x2f, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74];
outer: for (let i = 0; i <= view.length - needle.length; i++) {
for (let j = 0; j < needle.length; j++) {
if (view[i + j] !== needle[j]) continue outer;
}
return true;
}
return false;
}
export class FileAnalyzer {
private static readonly SIZE_THRESHOLDS = {
@@ -55,30 +74,59 @@ export class FileAnalyzer {
/**
* Quick PDF analysis without full processing
*/
/**
* Cheap encryption-only probe for the upload-time detection path.
*
* Looks for a /Encrypt entry in the last 8KB of the file (where the PDF
* trailer lives). If absent, the file is definitely not encrypted and we
* can skip a full pdf.js parse. If present, falls back to pdf.js so we can
* distinguish user-password (blocks open) from owner-password-only (opens
* fine) — only the former should prompt.
*/
static async isPDFUserPasswordProtected(file: File): Promise<boolean> {
const arrayBuffer = await file.arrayBuffer();
if (!hasEncryptMarker(arrayBuffer)) return false;
let pdf: PDFDocumentProxy | undefined;
try {
pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
stopAtErrors: false,
verbosity: 0,
});
// pdf.js opened it — owner-password-only case, no prompt needed.
return false;
} catch (error) {
const errorMessage = error instanceof Error ? error.message.toLowerCase() : "";
return errorMessage.includes("password") || errorMessage.includes("encrypted");
} finally {
if (pdf) pdfWorkerManager.destroyDocument(pdf);
}
}
static async quickPDFAnalysis(file: File): Promise<{
pageCount: number;
isEncrypted: boolean;
isCorrupted: boolean;
}> {
let pdf: PDFDocumentProxy | undefined;
try {
// For small files, read the whole file
// For large files, try the whole file first (PDF.js needs the complete structure)
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
stopAtErrors: false, // Don't stop at minor errors
verbosity: 0, // Suppress PDF.js warnings
});
const pageCount = pdf.numPages;
const isEncrypted = (pdf as any).isEncrypted;
// Clean up using worker manager
pdfWorkerManager.destroyDocument(pdf);
// If pdf.js opened the document successfully, the user can view it — even if
// the PDF carries encryption dictionaries (owner-password-only case). We only
// flag isEncrypted when pdf.js *fails* to open the file (caught below).
return {
pageCount,
isEncrypted,
isEncrypted: false,
isCorrupted: false,
};
} catch (error) {
@@ -91,6 +139,8 @@ export class FileAnalyzer {
isEncrypted,
isCorrupted: !isEncrypted, // If not encrypted, probably corrupted
};
} finally {
if (pdf) pdfWorkerManager.destroyDocument(pdf);
}
}
@@ -0,0 +1,285 @@
/**
* End-to-End Tests for Encrypted PDF Password Prompting
*
* Tests the EncryptedPdfUnlockModal flow when uploading password-protected PDFs.
* All backend API calls are mocked via page.route() — no real backend required.
* The Vite dev server must be running (handled by playwright.config.ts webServer).
*/
import { test, expect, type Page } from "@playwright/test";
import path from "path";
import fs from "fs";
const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
const ENCRYPTED_PDF = path.join(FIXTURES_DIR, "encrypted.pdf");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
// Minimal valid PDF returned by the mocked remove-password endpoint
const FAKE_UNLOCKED_PDF = Buffer.from(
"%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" +
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" +
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n" +
"xref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n" +
"0000000115 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n190\n%%EOF",
);
// ---------------------------------------------------------------------------
// Helper: mock all standard app APIs needed to load the main UI
// ---------------------------------------------------------------------------
async function mockAppApis(page: Page) {
await page.route("**/api/v1/info/status", (route) => route.fulfill({ json: { status: "UP" } }));
await page.route("**/api/v1/config/app-config", (route) =>
route.fulfill({
json: { enableLogin: false, languages: ["en-GB"], defaultLocale: "en-GB" },
}),
);
await page.route("**/api/v1/auth/me", (route) =>
route.fulfill({
json: { id: 1, username: "testuser", email: "[email protected]", roles: ["ROLE_USER"] },
}),
);
await page.route("**/api/v1/config/endpoints-availability", (route) => route.fulfill({ json: {} }));
await page.route("**/api/v1/config/endpoint-enabled*", (route) => route.fulfill({ json: true }));
await page.route("**/api/v1/config/group-enabled*", (route) => route.fulfill({ json: true }));
await page.route("**/api/v1/ui-data/footer-info", (route) => route.fulfill({ json: {} }));
await page.route("**/api/v1/proprietary/**", (route) => route.fulfill({ json: {} }));
}
// ---------------------------------------------------------------------------
// Helper: mock the remove-password endpoint to succeed
// ---------------------------------------------------------------------------
function mockRemovePasswordSuccess(page: Page) {
return page.route("**/api/v1/security/remove-password", (route) =>
route.fulfill({
status: 200,
contentType: "application/pdf",
headers: { "Content-Disposition": 'attachment; filename="encrypted.pdf"' },
body: FAKE_UNLOCKED_PDF,
}),
);
}
// ---------------------------------------------------------------------------
// Helper: mock the remove-password endpoint to fail with wrong password
// ---------------------------------------------------------------------------
function mockRemovePasswordWrongPassword(page: Page) {
return page.route("**/api/v1/security/remove-password", (route) =>
route.fulfill({
status: 400,
contentType: "application/problem+json",
body: JSON.stringify({
type: "/errors/pdf-password",
title: "PDF password incorrect",
status: 400,
detail: "The PDF is passworded and requires the correct password to open.",
}),
}),
);
}
// ---------------------------------------------------------------------------
// Helper: upload a file through the Files modal and wait for it to close
// ---------------------------------------------------------------------------
async function uploadFile(page: Page, filePath: string) {
await page.getByTestId("files-button").click();
await page.waitForSelector(".mantine-Modal-overlay", { state: "visible", timeout: 5000 });
await page.locator('[data-testid="file-input"]').setInputFiles(filePath);
// Modal auto-closes after file is selected
await page.waitForSelector(".mantine-Modal-overlay", { state: "hidden", timeout: 10000 });
}
// ---------------------------------------------------------------------------
// Helper: upload encrypted file — the Files modal closes, then the unlock
// modal should appear on top. We don't wait for the Files modal to vanish
// since the unlock modal may appear while it is still closing.
// ---------------------------------------------------------------------------
async function uploadEncryptedFile(page: Page, filePath: string) {
await page.getByTestId("files-button").click();
await page.waitForSelector(".mantine-Modal-overlay", { state: "visible", timeout: 5000 });
await page.locator('[data-testid="file-input"]').setInputFiles(filePath);
}
// ---------------------------------------------------------------------------
// Selectors for the unlock modal (Mantine Modal with known text content)
// ---------------------------------------------------------------------------
const MODAL_TITLE = "Remove password to continue";
const PASSWORD_PLACEHOLDER = "Enter the PDF password";
const UNLOCK_BUTTON_TEXT = "Unlock & Continue";
const SKIP_BUTTON_TEXT = "Skip for now";
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe.configure({ mode: "serial" });
test.describe("Encrypted PDF Unlock Modal", () => {
test.beforeEach(async ({ page }) => {
await mockAppApis(page);
await page.goto("/?bypassOnboarding=true");
await page.waitForSelector('[data-testid="files-button"]', { timeout: 10000 });
// Dismiss onboarding tooltip if it appears (can block clicks in Firefox/WebKit)
const tooltip = page.locator('button:has-text("Close tooltip")');
if (await tooltip.isVisible({ timeout: 1000 }).catch(() => false)) {
await tooltip.click();
}
});
test("uploading an encrypted PDF shows the unlock modal", async ({ page }) => {
await uploadEncryptedFile(page, ENCRYPTED_PDF);
// The unlock modal should appear with the expected title
await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 });
await expect(page.getByPlaceholder(PASSWORD_PLACEHOLDER)).toBeVisible();
await expect(page.getByRole("button", { name: UNLOCK_BUTTON_TEXT })).toBeVisible();
await expect(page.getByRole("button", { name: SKIP_BUTTON_TEXT })).toBeVisible();
});
test("unlock button is disabled when password field is empty", async ({ page }) => {
await uploadEncryptedFile(page, ENCRYPTED_PDF);
await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 });
const unlockBtn = page.getByRole("button", { name: UNLOCK_BUTTON_TEXT });
await expect(unlockBtn).toBeDisabled();
});
test("unlock button becomes enabled after entering a password", async ({ page }) => {
await uploadEncryptedFile(page, ENCRYPTED_PDF);
await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 });
const passwordInput = page.getByPlaceholder(PASSWORD_PLACEHOLDER);
await passwordInput.fill("somepassword");
const unlockBtn = page.getByRole("button", { name: UNLOCK_BUTTON_TEXT });
await expect(unlockBtn).toBeEnabled();
});
test("successful unlock removes the modal and shows success alert", async ({ page }) => {
await mockRemovePasswordSuccess(page);
await uploadEncryptedFile(page, ENCRYPTED_PDF);
await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 });
await page.getByPlaceholder(PASSWORD_PLACEHOLDER).fill("testpass123");
await page.getByRole("button", { name: UNLOCK_BUTTON_TEXT }).click();
// Modal should close after successful unlock
await expect(page.getByText(MODAL_TITLE)).toBeHidden({ timeout: 10000 });
// Success alert should appear
await expect(page.getByText("Password removed", { exact: true })).toBeVisible({ timeout: 5000 });
});
test("incorrect password shows error message in modal", async ({ page }) => {
await mockRemovePasswordWrongPassword(page);
await uploadEncryptedFile(page, ENCRYPTED_PDF);
await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 });
await page.getByPlaceholder(PASSWORD_PLACEHOLDER).fill("wrongpassword");
await page.getByRole("button", { name: UNLOCK_BUTTON_TEXT }).click();
// Error message should appear within the modal
await expect(page.getByText("Incorrect password")).toBeVisible({ timeout: 5000 });
// Modal should remain open
await expect(page.getByText(MODAL_TITLE)).toBeVisible();
});
test("skip button closes the modal without unlocking", async ({ page }) => {
await uploadEncryptedFile(page, ENCRYPTED_PDF);
await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 });
await page.getByRole("button", { name: SKIP_BUTTON_TEXT }).click();
// Modal should close
await expect(page.getByText(MODAL_TITLE)).toBeHidden({ timeout: 5000 });
});
test("pressing Enter in password field triggers unlock", async ({ page }) => {
await mockRemovePasswordSuccess(page);
await uploadEncryptedFile(page, ENCRYPTED_PDF);
await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 });
const passwordInput = page.getByPlaceholder(PASSWORD_PLACEHOLDER);
await passwordInput.fill("testpass123");
await passwordInput.press("Enter");
// Modal should close after successful unlock via Enter key
await expect(page.getByText(MODAL_TITLE)).toBeHidden({ timeout: 10000 });
});
test("uploading a normal PDF does not show the unlock modal", async ({ page }) => {
await uploadFile(page, SAMPLE_PDF);
// Wait for the file to finish processing, then verify no unlock modal appeared
await page.waitForTimeout(3000);
await expect(page.getByText(MODAL_TITLE)).toBeHidden();
});
test("unlock all button is hidden with only one encrypted file", async ({ page }) => {
await uploadEncryptedFile(page, ENCRYPTED_PDF);
await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 });
// The "Use for all" button should NOT appear with only one file
await expect(page.getByRole("button", { name: /Use for all/ })).toBeHidden();
});
test("unlock all button appears with multiple encrypted files and unlocks all", async ({ page }) => {
await mockRemovePasswordSuccess(page);
// Upload two encrypted files at once (different names to avoid deduplication)
await page.getByTestId("files-button").click();
await page.waitForSelector(".mantine-Modal-overlay", { state: "visible", timeout: 5000 });
await page.locator('[data-testid="file-input"]').setInputFiles([
{ name: "encrypted-a.pdf", mimeType: "application/pdf", buffer: fs.readFileSync(ENCRYPTED_PDF) },
{ name: "encrypted-b.pdf", mimeType: "application/pdf", buffer: fs.readFileSync(ENCRYPTED_PDF) },
]);
// The unlock modal should appear for the first file with "Use for all" visible
await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 });
const unlockAllBtn = page.getByRole("button", { name: /Use for all/ });
await expect(unlockAllBtn).toBeVisible({ timeout: 10000 });
// Enter password and click unlock all
await page.getByPlaceholder(PASSWORD_PLACEHOLDER).fill("testpass123");
await unlockAllBtn.click();
// Modal should close — all files unlocked
await expect(page.getByText(MODAL_TITLE)).toBeHidden({ timeout: 10000 });
});
test("unlock all with wrong password shows which files failed", async ({ page }) => {
await mockRemovePasswordWrongPassword(page);
// Upload two encrypted files at once (different names to avoid deduplication)
await page.getByTestId("files-button").click();
await page.waitForSelector(".mantine-Modal-overlay", { state: "visible", timeout: 5000 });
await page.locator('[data-testid="file-input"]').setInputFiles([
{ name: "encrypted-a.pdf", mimeType: "application/pdf", buffer: fs.readFileSync(ENCRYPTED_PDF) },
{ name: "encrypted-b.pdf", mimeType: "application/pdf", buffer: fs.readFileSync(ENCRYPTED_PDF) },
]);
// The unlock modal should appear with "Use for all"
await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 });
const unlockAllBtn = page.getByRole("button", { name: /Use for all/ });
await expect(unlockAllBtn).toBeVisible({ timeout: 10000 });
await page.getByPlaceholder(PASSWORD_PLACEHOLDER).fill("wrongpassword");
await unlockAllBtn.click();
// Modal should remain open with error about failed files
await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 5000 });
await expect(page.getByText(/Wrong password for/)).toBeVisible({ timeout: 5000 });
});
});
Binary file not shown.
+3 -6
View File
@@ -554,11 +554,8 @@ async function generatePDFThumbnail(arrayBuffer: ArrayBuffer, file: File, scale:
pdfWorkerManager.destroyDocument(pdf);
return thumbnail;
} catch (error) {
if (error instanceof Error) {
// Check if PDF is encrypted
if (error.name === "PasswordException") {
return generateEncryptedPDFThumbnail(file);
}
if (error && typeof error === "object" && (error as any).name === "PasswordException") {
return generateEncryptedPDFThumbnail(file);
}
throw error; // Not an encryption issue, re-throw
}
@@ -693,7 +690,7 @@ export async function generateThumbnailWithMetadata(
pdfWorkerManager.destroyDocument(pdf);
return { thumbnail, pageCount, pageRotations, pageDimensions };
} catch (error) {
if (error instanceof Error && error.name === "PasswordException") {
if (error && typeof error === "object" && (error as any).name === "PasswordException") {
// Handle encrypted PDFs
const thumbnail = generateEncryptedPDFThumbnail(file);
return { thumbnail, pageCount: 1, isEncrypted: true };
+20 -1
View File
@@ -98,10 +98,29 @@ export const handlePasswordError = async (
const status = error?.response?.status;
// Handle specific error cases with user-friendly messages
// Backend returns 400 with PdfPasswordException for incorrect/missing PDF passwords
if (status === 500) {
// 500 typically means incorrect password for encrypted PDFs
return incorrectPasswordMessage;
}
if (status === 400) {
const data = error?.response?.data;
// ProblemDetail JSON has type "/errors/pdf-password", blob needs parsing
const isPasswordError = await (async () => {
if (data instanceof Blob) {
try {
const text = await data.text();
return text.includes("pdf-password") || text.includes("passworded");
} catch {
return false;
}
}
const type = data?.type ?? "";
return type.includes("pdf-password");
})();
if (isPasswordError) {
return incorrectPasswordMessage;
}
}
// For other errors, try to extract the message
const normalizedData = await normalizeAxiosErrorData(error?.response?.data);