refactor pdf decrypt
This commit is contained in:
@@ -2,9 +2,9 @@ import { showAlert } from '../ui.js';
|
|||||||
import {
|
import {
|
||||||
downloadFile,
|
downloadFile,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
initializeQpdf,
|
|
||||||
readFileAsArrayBuffer,
|
readFileAsArrayBuffer,
|
||||||
} from '../utils/helpers.js';
|
} from '../utils/helpers.js';
|
||||||
|
import { decryptPdfBytes } from '../utils/pdf-decrypt.js';
|
||||||
import { icons, createIcons } from 'lucide';
|
import { icons, createIcons } from 'lucide';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { DecryptPdfState } from '@/types';
|
import { DecryptPdfState } from '@/types';
|
||||||
@@ -115,79 +115,39 @@ async function decryptPdf() {
|
|||||||
|
|
||||||
const loaderModal = document.getElementById('loader-modal');
|
const loaderModal = document.getElementById('loader-modal');
|
||||||
const loaderText = document.getElementById('loader-text');
|
const loaderText = document.getElementById('loader-text');
|
||||||
let qpdf: any;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (loaderModal) loaderModal.classList.remove('hidden');
|
if (loaderModal) loaderModal.classList.remove('hidden');
|
||||||
if (loaderText) loaderText.textContent = 'Initializing decryption...';
|
if (loaderText) loaderText.textContent = 'Initializing decryption...';
|
||||||
|
|
||||||
qpdf = await initializeQpdf();
|
|
||||||
|
|
||||||
if (pageState.files.length === 1) {
|
if (pageState.files.length === 1) {
|
||||||
// Single file: decrypt and download directly
|
// Single file: decrypt and download directly
|
||||||
const file = pageState.files[0];
|
const file = pageState.files[0];
|
||||||
const inputPath = '/input.pdf';
|
if (loaderText) loaderText.textContent = 'Reading encrypted PDF...';
|
||||||
const outputPath = '/output.pdf';
|
const fileBuffer = await readFileAsArrayBuffer(file);
|
||||||
|
const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer);
|
||||||
|
|
||||||
try {
|
if (loaderText) loaderText.textContent = 'Decrypting PDF...';
|
||||||
if (loaderText) loaderText.textContent = 'Reading encrypted PDF...';
|
const { bytes: decryptedBytes } = await decryptPdfBytes(
|
||||||
const fileBuffer = await readFileAsArrayBuffer(file);
|
uint8Array,
|
||||||
const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer);
|
password
|
||||||
qpdf.FS.writeFile(inputPath, uint8Array);
|
);
|
||||||
|
|
||||||
if (loaderText) loaderText.textContent = 'Decrypting PDF...';
|
if (loaderText) loaderText.textContent = 'Preparing download...';
|
||||||
const args = [
|
const blob = new Blob([decryptedBytes.slice().buffer], {
|
||||||
inputPath,
|
type: 'application/pdf',
|
||||||
'--password=' + password,
|
});
|
||||||
'--decrypt',
|
downloadFile(blob, `unlocked-${file.name}`);
|
||||||
outputPath,
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
if (loaderModal) loaderModal.classList.add('hidden');
|
||||||
qpdf.callMain(args);
|
showAlert(
|
||||||
} catch (qpdfError: any) {
|
'Success',
|
||||||
if (
|
'PDF decrypted successfully! Your download has started.',
|
||||||
qpdfError.message?.includes('invalid password') ||
|
'success',
|
||||||
qpdfError.message?.includes('password')
|
() => {
|
||||||
) {
|
resetState();
|
||||||
throw new Error('INVALID_PASSWORD');
|
|
||||||
}
|
|
||||||
throw qpdfError;
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
if (loaderText) loaderText.textContent = 'Preparing download...';
|
|
||||||
const outputFile = qpdf.FS.readFile(outputPath, { encoding: 'binary' });
|
|
||||||
|
|
||||||
if (outputFile.length === 0) {
|
|
||||||
throw new Error('Decryption resulted in an empty file.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([new Uint8Array(outputFile)], {
|
|
||||||
type: 'application/pdf',
|
|
||||||
});
|
|
||||||
downloadFile(blob, `unlocked-${file.name}`);
|
|
||||||
|
|
||||||
if (loaderModal) loaderModal.classList.add('hidden');
|
|
||||||
showAlert(
|
|
||||||
'Success',
|
|
||||||
'PDF decrypted successfully! Your download has started.',
|
|
||||||
'success',
|
|
||||||
() => {
|
|
||||||
resetState();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
if (qpdf?.FS) {
|
|
||||||
if (qpdf.FS.analyzePath(inputPath).exists)
|
|
||||||
qpdf.FS.unlink(inputPath);
|
|
||||||
if (qpdf.FS.analyzePath(outputPath).exists)
|
|
||||||
qpdf.FS.unlink(outputPath);
|
|
||||||
}
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('Failed to cleanup WASM FS:', cleanupError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Multiple files: decrypt all and download as ZIP
|
// Multiple files: decrypt all and download as ZIP
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
@@ -196,8 +156,6 @@ async function decryptPdf() {
|
|||||||
|
|
||||||
for (let i = 0; i < pageState.files.length; i++) {
|
for (let i = 0; i < pageState.files.length; i++) {
|
||||||
const file = pageState.files[i];
|
const file = pageState.files[i];
|
||||||
const inputPath = `/input_${i}.pdf`;
|
|
||||||
const outputPath = `/output_${i}.pdf`;
|
|
||||||
|
|
||||||
if (loaderText)
|
if (loaderText)
|
||||||
loaderText.textContent = `Decrypting ${file.name} (${i + 1}/${pageState.files.length})...`;
|
loaderText.textContent = `Decrypting ${file.name} (${i + 1}/${pageState.files.length})...`;
|
||||||
@@ -205,55 +163,16 @@ async function decryptPdf() {
|
|||||||
try {
|
try {
|
||||||
const fileBuffer = await readFileAsArrayBuffer(file);
|
const fileBuffer = await readFileAsArrayBuffer(file);
|
||||||
const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer);
|
const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer);
|
||||||
qpdf.FS.writeFile(inputPath, uint8Array);
|
const { bytes: decryptedBytes } = await decryptPdfBytes(
|
||||||
|
uint8Array,
|
||||||
|
password
|
||||||
|
);
|
||||||
|
|
||||||
const args = [
|
zip.file(`unlocked-${file.name}`, decryptedBytes, { binary: true });
|
||||||
inputPath,
|
|
||||||
'--password=' + password,
|
|
||||||
'--decrypt',
|
|
||||||
outputPath,
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
|
||||||
qpdf.callMain(args);
|
|
||||||
} catch (qpdfError: any) {
|
|
||||||
if (
|
|
||||||
qpdfError.message?.includes('invalid password') ||
|
|
||||||
qpdfError.message?.includes('password')
|
|
||||||
) {
|
|
||||||
throw new Error(`Invalid password for ${file.name}`);
|
|
||||||
}
|
|
||||||
throw qpdfError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputFile = qpdf.FS.readFile(outputPath, {
|
|
||||||
encoding: 'binary',
|
|
||||||
});
|
|
||||||
if (!outputFile || outputFile.length === 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Decryption resulted in an empty file for ${file.name}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
zip.file(`unlocked-${file.name}`, outputFile, { binary: true });
|
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (fileError: any) {
|
} catch (fileError: any) {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
console.error(`Failed to decrypt ${file.name}:`, fileError);
|
console.error(`Failed to decrypt ${file.name}:`, fileError);
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
if (qpdf?.FS) {
|
|
||||||
if (qpdf.FS.analyzePath(inputPath).exists)
|
|
||||||
qpdf.FS.unlink(inputPath);
|
|
||||||
if (qpdf.FS.analyzePath(outputPath).exists)
|
|
||||||
qpdf.FS.unlink(outputPath);
|
|
||||||
}
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn(
|
|
||||||
`Failed to cleanup WASM FS for ${file.name}:`,
|
|
||||||
cleanupError
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
172
src/js/utils/pdf-decrypt.ts
Normal file
172
src/js/utils/pdf-decrypt.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { getCpdf, isCpdfAvailable } from './cpdf-helper';
|
||||||
|
import { isPyMuPDFAvailable, loadPyMuPDF } from './pymupdf-loader';
|
||||||
|
|
||||||
|
export type PdfDecryptEngine = 'cpdf' | 'pymupdf';
|
||||||
|
|
||||||
|
export interface PdfDecryptResult {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
engine: PdfDecryptEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DECRYPT_LOG_PREFIX = '[PDF Decrypt]';
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeErrorMessage(error: unknown): string {
|
||||||
|
const message = getErrorMessage(error).trim();
|
||||||
|
const cpdfReasonMatch = message.match(/Pdf\.PDFError\("([^"]+)"\)/);
|
||||||
|
if (cpdfReasonMatch?.[1]) {
|
||||||
|
return cpdfReasonMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const trailingErrorMatch = message.match(/ERROR:\s*[^:]+:\s*(.+)$/);
|
||||||
|
if (trailingErrorMatch?.[1]) {
|
||||||
|
return trailingErrorMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyBytes(bytes: Uint8Array): Uint8Array {
|
||||||
|
return Uint8Array.from(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupCpdfDocument(cpdf: unknown, pdf: unknown): void {
|
||||||
|
if (!cpdf || !pdf || typeof cpdf !== 'object' || !('deletePdf' in cpdf)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
(cpdf as { deletePdf: (document: unknown) => void }).deletePdf(pdf);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn(
|
||||||
|
`${DECRYPT_LOG_PREFIX} Failed to cleanup CoherentPDF document: ${normalizeErrorMessage(cleanupError)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptWithCpdf(
|
||||||
|
inputBytes: Uint8Array,
|
||||||
|
password: string
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const cpdf = await getCpdf();
|
||||||
|
cpdf.setSlow();
|
||||||
|
|
||||||
|
let pdf: unknown | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
pdf = cpdf.fromMemory(new Uint8Array(inputBytes), password);
|
||||||
|
|
||||||
|
if (cpdf.isEncrypted(pdf)) {
|
||||||
|
try {
|
||||||
|
cpdf.decryptPdf(pdf, password);
|
||||||
|
} catch {
|
||||||
|
cpdf.decryptPdfOwner(pdf, password);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputBytes = cpdf.toMemory(pdf, false, false);
|
||||||
|
pdf = null;
|
||||||
|
|
||||||
|
if (!(outputBytes instanceof Uint8Array) || outputBytes.length === 0) {
|
||||||
|
throw new Error('CoherentPDF produced an empty decrypted file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return copyBytes(outputBytes);
|
||||||
|
} catch (error) {
|
||||||
|
if (pdf) {
|
||||||
|
cleanupCpdfDocument(cpdf, pdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(normalizeErrorMessage(error), { cause: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptWithPyMuPDF(
|
||||||
|
inputBytes: Uint8Array,
|
||||||
|
password: string
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const pymupdf = await loadPyMuPDF();
|
||||||
|
const document = await pymupdf.open(
|
||||||
|
new Blob([new Uint8Array(inputBytes)], { type: 'application/pdf' })
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (document.needsPass || document.isEncrypted) {
|
||||||
|
const authenticated = document.authenticate(password);
|
||||||
|
if (!authenticated) {
|
||||||
|
throw new Error('Invalid PDF password.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputBytes = document.save();
|
||||||
|
if (!(outputBytes instanceof Uint8Array) || outputBytes.length === 0) {
|
||||||
|
throw new Error('PyMuPDF produced an empty decrypted file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return copyBytes(outputBytes);
|
||||||
|
} finally {
|
||||||
|
document.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptPdfBytes(
|
||||||
|
inputBytes: Uint8Array,
|
||||||
|
password: string
|
||||||
|
): Promise<PdfDecryptResult> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (isCpdfAvailable()) {
|
||||||
|
console.info(`${DECRYPT_LOG_PREFIX} Trying CoherentPDF decryption`);
|
||||||
|
try {
|
||||||
|
const result: PdfDecryptResult = {
|
||||||
|
bytes: await decryptWithCpdf(inputBytes, password),
|
||||||
|
engine: 'cpdf',
|
||||||
|
};
|
||||||
|
console.info(
|
||||||
|
`${DECRYPT_LOG_PREFIX} Decryption succeeded with CoherentPDF`
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = normalizeErrorMessage(error);
|
||||||
|
console.warn(
|
||||||
|
`${DECRYPT_LOG_PREFIX} Decryption with CoherentPDF failed. Falling back to PyMuPDF. Reason: ${errorMessage}`
|
||||||
|
);
|
||||||
|
errors.push(`CoherentPDF: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.info(
|
||||||
|
`${DECRYPT_LOG_PREFIX} CoherentPDF is not configured, skipping to PyMuPDF`
|
||||||
|
);
|
||||||
|
errors.push('CoherentPDF: not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPyMuPDFAvailable()) {
|
||||||
|
console.info(`${DECRYPT_LOG_PREFIX} Trying PyMuPDF decryption`);
|
||||||
|
try {
|
||||||
|
const result: PdfDecryptResult = {
|
||||||
|
bytes: await decryptWithPyMuPDF(inputBytes, password),
|
||||||
|
engine: 'pymupdf',
|
||||||
|
};
|
||||||
|
console.info(`${DECRYPT_LOG_PREFIX} Decryption succeeded with PyMuPDF`);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = normalizeErrorMessage(error);
|
||||||
|
console.warn(
|
||||||
|
`${DECRYPT_LOG_PREFIX} PyMuPDF decryption failed: ${errorMessage}`
|
||||||
|
);
|
||||||
|
errors.push(`PyMuPDF: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`${DECRYPT_LOG_PREFIX} PyMuPDF is not configured`);
|
||||||
|
errors.push('PyMuPDF: not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errors.join('\n'));
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { pdfSocket } from '../sockets';
|
|||||||
import type { SocketData } from '../types';
|
import type { SocketData } from '../types';
|
||||||
import { requirePdfInput, processBatch } from '../types';
|
import { requirePdfInput, processBatch } from '../types';
|
||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
import { initializeQpdf } from '../../utils/helpers.js';
|
import { decryptPdfBytes } from '../../utils/pdf-decrypt.js';
|
||||||
|
|
||||||
export class DecryptNode extends BaseWorkflowNode {
|
export class DecryptNode extends BaseWorkflowNode {
|
||||||
readonly category = 'Secure PDF' as const;
|
readonly category = 'Secure PDF' as const;
|
||||||
@@ -33,35 +33,13 @@ export class DecryptNode extends BaseWorkflowNode {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
pdf: await processBatch(pdfInputs, async (input) => {
|
pdf: await processBatch(pdfInputs, async (input) => {
|
||||||
const qpdf = await initializeQpdf();
|
const { bytes: resultBytes } = await decryptPdfBytes(
|
||||||
const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
input.bytes,
|
||||||
const inputPath = `/tmp/input_decrypt_${uid}.pdf`;
|
password
|
||||||
const outputPath = `/tmp/output_decrypt_${uid}.pdf`;
|
);
|
||||||
|
const document = await PDFDocument.load(resultBytes, {
|
||||||
let decryptedData: Uint8Array;
|
throwOnInvalidObject: false,
|
||||||
try {
|
});
|
||||||
qpdf.FS.writeFile(inputPath, input.bytes);
|
|
||||||
const args = password
|
|
||||||
? [inputPath, '--password=' + password, '--decrypt', outputPath]
|
|
||||||
: [inputPath, '--decrypt', outputPath];
|
|
||||||
qpdf.callMain(args);
|
|
||||||
|
|
||||||
decryptedData = qpdf.FS.readFile(outputPath, { encoding: 'binary' });
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
qpdf.FS.unlink(inputPath);
|
|
||||||
} catch {
|
|
||||||
/* cleanup */
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
qpdf.FS.unlink(outputPath);
|
|
||||||
} catch {
|
|
||||||
/* cleanup */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultBytes = new Uint8Array(decryptedData);
|
|
||||||
const document = await PDFDocument.load(resultBytes);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'pdf',
|
type: 'pdf',
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { BaseWorkflowNode } from './base-node';
|
|||||||
import { pdfSocket } from '../sockets';
|
import { pdfSocket } from '../sockets';
|
||||||
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
import type { PDFData, SocketData, MultiPDFData } from '../types';
|
||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
import { readFileAsArrayBuffer, initializeQpdf } from '../../utils/helpers.js';
|
import { readFileAsArrayBuffer } from '../../utils/helpers.js';
|
||||||
|
import { decryptPdfBytes } from '../../utils/pdf-decrypt.js';
|
||||||
|
|
||||||
export class EncryptedPDFError extends Error {
|
export class EncryptedPDFError extends Error {
|
||||||
constructor(public readonly filename: string) {
|
constructor(public readonly filename: string) {
|
||||||
@@ -63,44 +64,16 @@ export class PDFInputNode extends BaseWorkflowNode {
|
|||||||
async addDecryptedFile(file: File, password: string): Promise<void> {
|
async addDecryptedFile(file: File, password: string): Promise<void> {
|
||||||
const arrayBuffer = await readFileAsArrayBuffer(file);
|
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||||
const bytes = new Uint8Array(arrayBuffer as ArrayBuffer);
|
const bytes = new Uint8Array(arrayBuffer as ArrayBuffer);
|
||||||
const qpdf = await initializeQpdf();
|
const { bytes: decryptedBytes } = await decryptPdfBytes(bytes, password);
|
||||||
const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
const document = await PDFDocument.load(decryptedBytes, {
|
||||||
const inputPath = `/tmp/input_decrypt_${uid}.pdf`;
|
throwOnInvalidObject: false,
|
||||||
const outputPath = `/tmp/output_decrypt_${uid}.pdf`;
|
});
|
||||||
|
this.files.push({
|
||||||
try {
|
type: 'pdf',
|
||||||
qpdf.FS.writeFile(inputPath, bytes);
|
document,
|
||||||
qpdf.callMain([
|
bytes: decryptedBytes,
|
||||||
inputPath,
|
filename: file.name,
|
||||||
'--password=' + password,
|
});
|
||||||
'--decrypt',
|
|
||||||
outputPath,
|
|
||||||
]);
|
|
||||||
const decryptedData = qpdf.FS.readFile(outputPath, {
|
|
||||||
encoding: 'binary',
|
|
||||||
});
|
|
||||||
const decryptedBytes = new Uint8Array(decryptedData);
|
|
||||||
const document = await PDFDocument.load(decryptedBytes, {
|
|
||||||
throwOnInvalidObject: false,
|
|
||||||
});
|
|
||||||
this.files.push({
|
|
||||||
type: 'pdf',
|
|
||||||
document,
|
|
||||||
bytes: decryptedBytes,
|
|
||||||
filename: file.name,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
qpdf.FS.unlink(inputPath);
|
|
||||||
} catch {
|
|
||||||
/* cleanup */
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
qpdf.FS.unlink(outputPath);
|
|
||||||
} catch {
|
|
||||||
/* cleanup */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setFile(file: File): Promise<void> {
|
async setFile(file: File): Promise<void> {
|
||||||
|
|||||||
276
src/tests/pdf-decrypt.test.ts
Normal file
276
src/tests/pdf-decrypt.test.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockGetCpdf,
|
||||||
|
mockIsCpdfAvailable,
|
||||||
|
mockLoadPyMuPDF,
|
||||||
|
mockIsPyMuPDFAvailable,
|
||||||
|
mockPdfDocumentLoad,
|
||||||
|
mockReadFileAsArrayBuffer,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockGetCpdf: vi.fn(),
|
||||||
|
mockIsCpdfAvailable: vi.fn(),
|
||||||
|
mockLoadPyMuPDF: vi.fn(),
|
||||||
|
mockIsPyMuPDFAvailable: vi.fn(),
|
||||||
|
mockPdfDocumentLoad: vi.fn(),
|
||||||
|
mockReadFileAsArrayBuffer: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../js/utils/cpdf-helper', () => ({
|
||||||
|
getCpdf: mockGetCpdf,
|
||||||
|
isCpdfAvailable: mockIsCpdfAvailable,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../js/utils/pymupdf-loader', () => ({
|
||||||
|
loadPyMuPDF: mockLoadPyMuPDF,
|
||||||
|
isPyMuPDFAvailable: mockIsPyMuPDFAvailable,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('pdf-lib', () => ({
|
||||||
|
PDFDocument: {
|
||||||
|
load: mockPdfDocumentLoad,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../js/utils/helpers.js', () => ({
|
||||||
|
readFileAsArrayBuffer: mockReadFileAsArrayBuffer,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as pdfDecryptModule from '../js/utils/pdf-decrypt';
|
||||||
|
|
||||||
|
describe('pdf decrypt', () => {
|
||||||
|
let infoSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||||
|
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
mockIsCpdfAvailable.mockReturnValue(true);
|
||||||
|
mockIsPyMuPDFAvailable.mockReturnValue(true);
|
||||||
|
mockPdfDocumentLoad.mockResolvedValue({ loaded: true });
|
||||||
|
mockReadFileAsArrayBuffer.mockResolvedValue(
|
||||||
|
new Uint8Array([1, 2, 3]).buffer
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses CoherentPDF first when it succeeds', async () => {
|
||||||
|
const cpdf = {
|
||||||
|
setSlow: vi.fn(),
|
||||||
|
fromMemory: vi.fn().mockReturnValue({ pdf: true }),
|
||||||
|
isEncrypted: vi.fn().mockReturnValue(true),
|
||||||
|
decryptPdf: vi.fn(),
|
||||||
|
decryptPdfOwner: vi.fn(),
|
||||||
|
toMemory: vi.fn().mockReturnValue(new Uint8Array([4, 5, 6])),
|
||||||
|
deletePdf: vi.fn(),
|
||||||
|
};
|
||||||
|
mockGetCpdf.mockResolvedValue(cpdf);
|
||||||
|
|
||||||
|
const result = await pdfDecryptModule.decryptPdfBytes(
|
||||||
|
new Uint8Array([9]),
|
||||||
|
'1234'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
bytes: new Uint8Array([4, 5, 6]),
|
||||||
|
engine: 'cpdf',
|
||||||
|
});
|
||||||
|
expect(infoSpy).toHaveBeenCalledWith(
|
||||||
|
'[PDF Decrypt] Decryption succeeded with CoherentPDF'
|
||||||
|
);
|
||||||
|
expect(cpdf.decryptPdf).toHaveBeenCalledWith({ pdf: true }, '1234');
|
||||||
|
expect(mockLoadPyMuPDF).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to PyMuPDF when CoherentPDF fails', async () => {
|
||||||
|
const cpdf = {
|
||||||
|
setSlow: vi.fn(),
|
||||||
|
fromMemory: vi.fn().mockImplementation(() => {
|
||||||
|
throw new Error('cpdf failed');
|
||||||
|
}),
|
||||||
|
isEncrypted: vi.fn(),
|
||||||
|
decryptPdf: vi.fn(),
|
||||||
|
decryptPdfOwner: vi.fn(),
|
||||||
|
toMemory: vi.fn(),
|
||||||
|
deletePdf: vi.fn(),
|
||||||
|
};
|
||||||
|
const pymupdfDocument = {
|
||||||
|
needsPass: true,
|
||||||
|
isEncrypted: true,
|
||||||
|
authenticate: vi.fn().mockReturnValue(true),
|
||||||
|
save: vi.fn().mockReturnValue(new Uint8Array([7, 8, 9])),
|
||||||
|
close: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetCpdf.mockResolvedValue(cpdf);
|
||||||
|
mockLoadPyMuPDF.mockResolvedValue({
|
||||||
|
open: vi.fn().mockResolvedValue(pymupdfDocument),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await pdfDecryptModule.decryptPdfBytes(
|
||||||
|
new Uint8Array([9]),
|
||||||
|
'1234'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
bytes: new Uint8Array([7, 8, 9]),
|
||||||
|
engine: 'pymupdf',
|
||||||
|
});
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
'[PDF Decrypt] Decryption with CoherentPDF failed. Falling back to PyMuPDF. Reason: cpdf failed'
|
||||||
|
);
|
||||||
|
expect(infoSpy).toHaveBeenCalledWith(
|
||||||
|
'[PDF Decrypt] Decryption succeeded with PyMuPDF'
|
||||||
|
);
|
||||||
|
expect(pymupdfDocument.authenticate).toHaveBeenCalledWith('1234');
|
||||||
|
expect(pymupdfDocument.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes raw CoherentPDF error strings before fallback logging', async () => {
|
||||||
|
const cpdf = {
|
||||||
|
setSlow: vi.fn(),
|
||||||
|
fromMemory: vi.fn().mockImplementation(() => {
|
||||||
|
throw '0,248,Exports.CPDFError,32,ERROR: decryptPdfOwner: Pdf.PDFError("Bad or missing /P entry")';
|
||||||
|
}),
|
||||||
|
isEncrypted: vi.fn(),
|
||||||
|
decryptPdf: vi.fn(),
|
||||||
|
decryptPdfOwner: vi.fn(),
|
||||||
|
toMemory: vi.fn(),
|
||||||
|
deletePdf: vi.fn(),
|
||||||
|
};
|
||||||
|
const pymupdfDocument = {
|
||||||
|
needsPass: false,
|
||||||
|
isEncrypted: false,
|
||||||
|
authenticate: vi.fn(),
|
||||||
|
save: vi.fn().mockReturnValue(new Uint8Array([7, 8, 9])),
|
||||||
|
close: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetCpdf.mockResolvedValue(cpdf);
|
||||||
|
mockLoadPyMuPDF.mockResolvedValue({
|
||||||
|
open: vi.fn().mockResolvedValue(pymupdfDocument),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await pdfDecryptModule.decryptPdfBytes(
|
||||||
|
new Uint8Array([9]),
|
||||||
|
'1234'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.engine).toBe('pymupdf');
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
'[PDF Decrypt] Decryption with CoherentPDF failed. Falling back to PyMuPDF. Reason: Bad or missing /P entry'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates engine errors when decryption fails', async () => {
|
||||||
|
const cpdf = {
|
||||||
|
setSlow: vi.fn(),
|
||||||
|
fromMemory: vi.fn().mockImplementation(() => {
|
||||||
|
throw new Error('cpdf failed');
|
||||||
|
}),
|
||||||
|
isEncrypted: vi.fn(),
|
||||||
|
decryptPdf: vi.fn(),
|
||||||
|
decryptPdfOwner: vi.fn(),
|
||||||
|
toMemory: vi.fn(),
|
||||||
|
deletePdf: vi.fn(),
|
||||||
|
};
|
||||||
|
const pymupdfDocument = {
|
||||||
|
needsPass: true,
|
||||||
|
isEncrypted: true,
|
||||||
|
authenticate: vi.fn().mockReturnValue(false),
|
||||||
|
save: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetCpdf.mockResolvedValue(cpdf);
|
||||||
|
mockLoadPyMuPDF.mockResolvedValue({
|
||||||
|
open: vi.fn().mockResolvedValue(pymupdfDocument),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
pdfDecryptModule.decryptPdfBytes(new Uint8Array([9]), '1234')
|
||||||
|
).rejects.toThrow(
|
||||||
|
'CoherentPDF: cpdf failed\nPyMuPDF: Invalid PDF password.'
|
||||||
|
);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
'[PDF Decrypt] PyMuPDF decryption failed: Invalid PDF password.'
|
||||||
|
);
|
||||||
|
expect(pymupdfDocument.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the shared decrypt helper in the workflow decrypt node', async () => {
|
||||||
|
const decryptSpy = vi
|
||||||
|
.spyOn(pdfDecryptModule, 'decryptPdfBytes')
|
||||||
|
.mockResolvedValue({
|
||||||
|
bytes: new Uint8Array([8, 8, 8]),
|
||||||
|
engine: 'cpdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { DecryptNode } = await import('../js/workflow/nodes/decrypt-node');
|
||||||
|
|
||||||
|
const node = new DecryptNode();
|
||||||
|
const passwordControl = node.controls['password'] as { value?: string };
|
||||||
|
passwordControl.value = 'secret';
|
||||||
|
|
||||||
|
const result = await node.data({
|
||||||
|
pdf: [
|
||||||
|
{
|
||||||
|
type: 'pdf',
|
||||||
|
document: {} as never,
|
||||||
|
bytes: new Uint8Array([1, 2, 3]),
|
||||||
|
filename: 'locked.pdf',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decryptSpy).toHaveBeenCalledWith(
|
||||||
|
new Uint8Array([1, 2, 3]),
|
||||||
|
'secret'
|
||||||
|
);
|
||||||
|
expect(mockPdfDocumentLoad).toHaveBeenCalledWith(
|
||||||
|
new Uint8Array([8, 8, 8]),
|
||||||
|
{
|
||||||
|
throwOnInvalidObject: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(result.pdf).toEqual({
|
||||||
|
type: 'pdf',
|
||||||
|
document: { loaded: true },
|
||||||
|
bytes: new Uint8Array([8, 8, 8]),
|
||||||
|
filename: 'locked_decrypted.pdf',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the shared decrypt helper when adding a decrypted workflow input file', async () => {
|
||||||
|
const decryptSpy = vi
|
||||||
|
.spyOn(pdfDecryptModule, 'decryptPdfBytes')
|
||||||
|
.mockResolvedValue({
|
||||||
|
bytes: new Uint8Array([6, 6, 6]),
|
||||||
|
engine: 'cpdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { PDFInputNode } =
|
||||||
|
await import('../js/workflow/nodes/pdf-input-node');
|
||||||
|
|
||||||
|
const node = new PDFInputNode();
|
||||||
|
const file = new File([new Uint8Array([1, 2, 3])], 'locked.pdf', {
|
||||||
|
type: 'application/pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
await node.addDecryptedFile(file, 'secret');
|
||||||
|
|
||||||
|
expect(mockReadFileAsArrayBuffer).toHaveBeenCalledWith(file);
|
||||||
|
expect(decryptSpy).toHaveBeenCalledWith(
|
||||||
|
new Uint8Array([1, 2, 3]),
|
||||||
|
'secret'
|
||||||
|
);
|
||||||
|
expect(mockPdfDocumentLoad).toHaveBeenCalledWith(
|
||||||
|
new Uint8Array([6, 6, 6]),
|
||||||
|
{
|
||||||
|
throwOnInvalidObject: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(node.getFileCount()).toBe(1);
|
||||||
|
expect(node.getFilenames()).toEqual(['locked.pdf']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user