mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-07 08:31:01 +01:00
fix(security): harden sanitize, flatten, redaction, signature validation, CSP, proxies and CI
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
downloadFile,
|
||||
} from '../utils/helpers.js';
|
||||
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
||||
import { sanitizeTranslationHtml } from '../utils/translation-html.js';
|
||||
import { t } from '../i18n/i18n';
|
||||
import {
|
||||
signPdf,
|
||||
@@ -404,7 +405,7 @@ async function handleCertFile(file: File): Promise<void> {
|
||||
|
||||
const certStatus = getElement<HTMLDivElement>('cert-status');
|
||||
if (certStatus) {
|
||||
certStatus.innerHTML = `${t('tools:digitalSignPdf.certificateLoaded')} <i data-lucide="check" class="inline w-4 h-4"></i>`;
|
||||
certStatus.innerHTML = `${sanitizeTranslationHtml(t('tools:digitalSignPdf.certificateLoaded'))} <i data-lucide="check" class="inline w-4 h-4"></i>`;
|
||||
createIcons({ icons });
|
||||
certStatus.className = 'text-xs text-green-400';
|
||||
}
|
||||
@@ -559,7 +560,7 @@ async function handlePasswordInput(): Promise<void> {
|
||||
|
||||
const certStatus = getElement<HTMLDivElement>('cert-status');
|
||||
if (certStatus) {
|
||||
certStatus.innerHTML = `${t('tools:digitalSignPdf.certificateUnlocked')} <i data-lucide="check-circle" class="inline w-4 h-4"></i>`;
|
||||
certStatus.innerHTML = `${sanitizeTranslationHtml(t('tools:digitalSignPdf.certificateUnlocked'))} <i data-lucide="check-circle" class="inline w-4 h-4"></i>`;
|
||||
createIcons({ icons });
|
||||
certStatus.className = 'text-xs text-green-400';
|
||||
}
|
||||
|
||||
@@ -15,13 +15,164 @@ const embedPdfWasmUrl = new URL(
|
||||
|
||||
import type { EmbedPdfContainer } from 'bentopdf-viewer';
|
||||
import type {
|
||||
ActiveDocumentSource,
|
||||
AnnotationPluginLite,
|
||||
AppliedRedactionTracker,
|
||||
DocManagerPlugin,
|
||||
FreeTextSystemFontAnnotation,
|
||||
RedactionPluginLite,
|
||||
} from '@/types';
|
||||
|
||||
const FREETEXT_SUBTYPE = 3;
|
||||
|
||||
export function trackAppliedRedactions(
|
||||
redaction: RedactionPluginLite
|
||||
): AppliedRedactionTracker {
|
||||
const pendingByDoc = new Map<string, Map<string, string | undefined>>();
|
||||
const appliedByDoc = new Map<string, Map<string, string | undefined>>();
|
||||
const lastLeftPendingByDoc = new Map<string, string[]>();
|
||||
const getAppliedRedactions = (documentId: string) => {
|
||||
let applied = appliedByDoc.get(documentId);
|
||||
if (!applied) {
|
||||
applied = new Map();
|
||||
appliedByDoc.set(documentId, applied);
|
||||
}
|
||||
return applied;
|
||||
};
|
||||
|
||||
redaction.onPendingChange(({ documentId, pending }) => {
|
||||
const previous = pendingByDoc.get(documentId) ?? new Map();
|
||||
const next = new Map<string, string | undefined>();
|
||||
for (const items of Object.values(pending)) {
|
||||
for (const item of items) {
|
||||
next.set(item.id, item.text ?? previous.get(item.id));
|
||||
}
|
||||
}
|
||||
const applied = getAppliedRedactions(documentId);
|
||||
const leftPending: string[] = [];
|
||||
for (const [id, text] of previous) {
|
||||
if (!next.has(id)) {
|
||||
applied.set(id, text);
|
||||
leftPending.push(id);
|
||||
}
|
||||
}
|
||||
for (const id of next.keys()) applied.delete(id);
|
||||
pendingByDoc.set(documentId, next);
|
||||
lastLeftPendingByDoc.set(documentId, leftPending);
|
||||
});
|
||||
|
||||
redaction.onRedactionEvent((event) => {
|
||||
const applied = appliedByDoc.get(event.documentId);
|
||||
if (event.type === 'remove' && event.id) {
|
||||
applied?.delete(event.id);
|
||||
} else if (event.type === 'clear') {
|
||||
for (const id of lastLeftPendingByDoc.get(event.documentId) ?? []) {
|
||||
applied?.delete(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
appliedTexts: (documentId) => [
|
||||
...(appliedByDoc.get(documentId)?.values() ?? []),
|
||||
],
|
||||
forget: (documentId) => {
|
||||
pendingByDoc.delete(documentId);
|
||||
appliedByDoc.delete(documentId);
|
||||
lastLeftPendingByDoc.delete(documentId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function redactionScrubTerms(
|
||||
texts: Iterable<string | undefined>
|
||||
): string[] {
|
||||
const terms = new Map<string, string>();
|
||||
for (const text of texts) {
|
||||
for (const line of text?.split(/[\r\n]+/) ?? []) {
|
||||
const term = line.trim();
|
||||
const key = term.toLowerCase();
|
||||
if (term && !terms.has(key)) terms.set(key, term);
|
||||
}
|
||||
}
|
||||
return [...terms.values()].sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
export async function scrubRedactedText(
|
||||
bytes: Uint8Array,
|
||||
terms: string[]
|
||||
): Promise<Uint8Array> {
|
||||
const { redactDocumentData } =
|
||||
await import('../utils/redact-document-data.js');
|
||||
return redactDocumentData(bytes, terms);
|
||||
}
|
||||
|
||||
function confirmChoice(
|
||||
title: string,
|
||||
message: string,
|
||||
confirmLabel: string
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const previousActive = document.activeElement as HTMLElement | null;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className =
|
||||
'fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-labelledby', 'edit-pdf-confirm-title');
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className =
|
||||
'bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full border border-gray-700';
|
||||
|
||||
const heading = document.createElement('h3');
|
||||
heading.id = 'edit-pdf-confirm-title';
|
||||
heading.className = 'text-xl font-bold text-white mb-2';
|
||||
heading.textContent = title;
|
||||
|
||||
const body = document.createElement('p');
|
||||
body.className = 'text-gray-300 mb-6';
|
||||
body.textContent = message;
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.className =
|
||||
'flex-1 bg-gray-700 hover:bg-gray-600 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.type = 'button';
|
||||
confirmBtn.className =
|
||||
'flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200';
|
||||
confirmBtn.textContent = confirmLabel;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'flex gap-3';
|
||||
actions.append(cancelBtn, confirmBtn);
|
||||
panel.append(heading, body, actions);
|
||||
overlay.append(panel);
|
||||
|
||||
const close = (result: boolean) => {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
previousActive?.focus();
|
||||
resolve(result);
|
||||
};
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close(false);
|
||||
};
|
||||
|
||||
cancelBtn.addEventListener('click', () => close(false));
|
||||
confirmBtn.addEventListener('click', () => close(true));
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) close(false);
|
||||
});
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
document.body.append(overlay);
|
||||
cancelBtn.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function collectAllFreeTexts(
|
||||
annotationPlugin: AnnotationPluginLite | null
|
||||
): FreeTextSystemFontAnnotation[] {
|
||||
@@ -177,7 +328,10 @@ async function handleFiles(files: FileList) {
|
||||
pdfWrapper.classList.remove('hidden');
|
||||
|
||||
const { default: EmbedPDF } = await import('bentopdf-viewer');
|
||||
const disabledCategories = getEditorDisabledCategories();
|
||||
const disabledCategories = [
|
||||
...getEditorDisabledCategories(),
|
||||
'document-export',
|
||||
];
|
||||
viewerInstance = EmbedPDF.init({
|
||||
disabledCategories,
|
||||
type: 'container',
|
||||
@@ -198,9 +352,24 @@ async function handleFiles(files: FileList) {
|
||||
docManagerPlugin = registry
|
||||
.getPlugin('document-manager')
|
||||
.provides() as unknown as DocManagerPlugin;
|
||||
const activeDocuments =
|
||||
docManagerPlugin as unknown as ActiveDocumentSource;
|
||||
|
||||
let redactionPlugin: RedactionPluginLite | null = null;
|
||||
try {
|
||||
redactionPlugin = registry
|
||||
.getPlugin('redaction')
|
||||
.provides() as unknown as RedactionPluginLite;
|
||||
} catch {
|
||||
redactionPlugin = null;
|
||||
}
|
||||
const redactionTracker = redactionPlugin
|
||||
? trackAppliedRedactions(redactionPlugin)
|
||||
: null;
|
||||
|
||||
docManagerPlugin.onDocumentClosed((data: { id?: string }) => {
|
||||
const docId = data?.id || '';
|
||||
redactionTracker?.forget(docId);
|
||||
removeFileEntry(docId);
|
||||
});
|
||||
|
||||
@@ -258,6 +427,32 @@ async function handleFiles(files: FileList) {
|
||||
|
||||
downloadBtn.onclick = async () => {
|
||||
try {
|
||||
const documentId = activeDocuments.getActiveDocumentId();
|
||||
const pendingCount = redactionPlugin?.getState().pendingCount ?? 0;
|
||||
if (redactionPlugin && pendingCount > 0) {
|
||||
const marks =
|
||||
pendingCount === 1
|
||||
? "1 redaction mark that hasn't"
|
||||
: `${pendingCount} redaction marks that haven't`;
|
||||
const shouldApply = await confirmChoice(
|
||||
'Apply redactions?',
|
||||
`This document has ${marks} been applied. Until a mark is applied, the content under it is still in the file. Apply all marks and download?`,
|
||||
'Apply and download'
|
||||
);
|
||||
if (!shouldApply) return;
|
||||
try {
|
||||
await redactionPlugin.commitAllPending().toPromise();
|
||||
} catch (err) {
|
||||
console.error('Applying redactions failed:', err);
|
||||
}
|
||||
if (redactionPlugin.getState().pendingCount > 0) {
|
||||
showAlert(
|
||||
'Redaction Failed',
|
||||
'Some redaction marks could not be applied, so the file was not downloaded. Apply or remove them in the editor and try again.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const exportPlugin = registry.getPlugin('export').provides();
|
||||
const arrayBuffer = await exportPlugin.saveAsCopy().toPromise();
|
||||
let outBytes = new Uint8Array(arrayBuffer);
|
||||
@@ -301,6 +496,42 @@ async function handleFiles(files: FileList) {
|
||||
console.error('Font embed pass failed:', err);
|
||||
}
|
||||
}
|
||||
const appliedTexts =
|
||||
documentId && redactionTracker
|
||||
? redactionTracker.appliedTexts(documentId)
|
||||
: [];
|
||||
if (appliedTexts.length > 0) {
|
||||
const { PDFDocument } = await import('pdf-lib');
|
||||
const exported = await PDFDocument.load(outBytes, {
|
||||
ignoreEncryption: true,
|
||||
updateMetadata: false,
|
||||
});
|
||||
if (exported.isEncrypted) {
|
||||
const shouldDownload = await confirmChoice(
|
||||
'Encrypted file',
|
||||
"Your redactions are applied to the pages, but this file is encrypted, so BentoPDF can't also remove copies of the redacted text from its properties, bookmarks, and attachments. For a full cleanup, redact an unencrypted copy and add a password afterwards with Protect PDF. Download anyway?",
|
||||
'Download anyway'
|
||||
);
|
||||
if (!shouldDownload) return;
|
||||
} else {
|
||||
showLoader('Removing redacted text from document data...');
|
||||
try {
|
||||
outBytes = await scrubRedactedText(
|
||||
outBytes,
|
||||
redactionScrubTerms(appliedTexts)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Redaction cleanup failed:', err);
|
||||
showAlert(
|
||||
'Error',
|
||||
'Could not remove the redacted text from the document data, so the file was not downloaded.'
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
}
|
||||
const blob = new Blob([new Uint8Array(outBytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { showAlert } from '../ui.js';
|
||||
import { downloadFile, formatBytes } from '../utils/helpers.js';
|
||||
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { flattenAnnotations } from '../utils/flatten-annotations.js';
|
||||
import {
|
||||
flattenAnnotations,
|
||||
flattenFormsInDoc,
|
||||
} from '../utils/flatten-annotations.js';
|
||||
import { icons, createIcons } from 'lucide';
|
||||
import JSZip from 'jszip';
|
||||
import { deduplicateFileName } from '../utils/deduplicate-filename.js';
|
||||
@@ -16,11 +18,6 @@ const pageState: FlattenPdfState = {
|
||||
files: [],
|
||||
};
|
||||
|
||||
function flattenFormsInDoc(pdfDoc: PDFDocument) {
|
||||
const form = pdfDoc.getForm();
|
||||
form.flatten();
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
pageState.files = [];
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
templateNameExists,
|
||||
deleteTemplate,
|
||||
} from '@/js/workflow/serialization';
|
||||
import { getAvailableTesseractLanguageEntries } from '@/js/utils/tesseract-language-availability.js';
|
||||
import { getControlSpec } from '@/js/workflow/nodes/control-specs';
|
||||
import { isToolDisabled } from '@/js/utils/disabled-tools.js';
|
||||
import { IMAGE_ACCEPT } from '@/js/utils/image-input-utils.js';
|
||||
|
||||
@@ -1124,181 +1124,8 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dropdownOptions: Record<string, { label: string; value: string }[]> = {
|
||||
format: [
|
||||
{ label: 'JPG', value: 'jpg' },
|
||||
{ label: 'PNG', value: 'png' },
|
||||
{ label: 'WebP', value: 'webp' },
|
||||
{ label: 'SVG', value: 'svg' },
|
||||
],
|
||||
position: [
|
||||
{ label: 'Bottom Center', value: 'bottom-center' },
|
||||
{ label: 'Bottom Left', value: 'bottom-left' },
|
||||
{ label: 'Bottom Right', value: 'bottom-right' },
|
||||
{ label: 'Top Center', value: 'top-center' },
|
||||
{ label: 'Top Left', value: 'top-left' },
|
||||
{ label: 'Top Right', value: 'top-right' },
|
||||
],
|
||||
orientation: [
|
||||
{ label: 'Auto (Keep Original)', value: 'auto' },
|
||||
{ label: 'Portrait', value: 'portrait' },
|
||||
{ label: 'Landscape', value: 'landscape' },
|
||||
],
|
||||
direction: [
|
||||
{ label: 'Vertical', value: 'vertical' },
|
||||
{ label: 'Horizontal', value: 'horizontal' },
|
||||
],
|
||||
pagesPerSheet: [
|
||||
{ label: '2', value: '2' },
|
||||
{ label: '4', value: '4' },
|
||||
{ label: '9', value: '9' },
|
||||
{ label: '16', value: '16' },
|
||||
],
|
||||
fontFamily: [
|
||||
{ label: 'Helvetica', value: 'helv' },
|
||||
{ label: 'Times Roman', value: 'times' },
|
||||
{ label: 'Courier', value: 'cour' },
|
||||
{ label: 'Times Italic', value: 'tiro' },
|
||||
],
|
||||
pageSize: [
|
||||
{ label: 'A4', value: 'a4' },
|
||||
{ label: 'Letter', value: 'letter' },
|
||||
{ label: 'Legal', value: 'legal' },
|
||||
],
|
||||
targetSize: [
|
||||
{ label: 'A4', value: 'A4' },
|
||||
{ label: 'Letter', value: 'Letter' },
|
||||
{ label: 'Legal', value: 'Legal' },
|
||||
{ label: 'A3', value: 'A3' },
|
||||
{ label: 'A5', value: 'A5' },
|
||||
{ label: 'Tabloid', value: 'Tabloid' },
|
||||
{ label: 'Custom', value: 'Custom' },
|
||||
],
|
||||
scalingMode: [
|
||||
{ label: 'Fit (keep full page visible)', value: 'fit' },
|
||||
{ label: 'Fill (cover full target page)', value: 'fill' },
|
||||
],
|
||||
customUnits: [
|
||||
{ label: 'Millimeters (mm)', value: 'mm' },
|
||||
{ label: 'Inches (in)', value: 'in' },
|
||||
],
|
||||
numberFormat: [
|
||||
{ label: 'Simple (1, 2, 3)', value: 'simple' },
|
||||
{ label: 'Page X of Y', value: 'page_x_of_y' },
|
||||
],
|
||||
angle: [
|
||||
{ label: '90° Clockwise', value: '90' },
|
||||
{ label: '180°', value: '180' },
|
||||
{ label: '90° Counter-clockwise', value: '270' },
|
||||
],
|
||||
blankPosition: [
|
||||
{ label: 'End', value: 'end' },
|
||||
{ label: 'Beginning', value: 'start' },
|
||||
{ label: 'After Page...', value: 'after' },
|
||||
],
|
||||
resolution: [
|
||||
{ label: 'Standard (192 DPI)', value: '2.0' },
|
||||
{ label: 'High (288 DPI)', value: '3.0' },
|
||||
{ label: 'Ultra (384 DPI)', value: '4.0' },
|
||||
],
|
||||
language: getAvailableTesseractLanguageEntries().map(([code, name]) => ({
|
||||
label: name,
|
||||
value: code,
|
||||
})),
|
||||
gridMode: [
|
||||
{ label: '1x2 (Booklet)', value: '1x2' },
|
||||
{ label: '2x2 (4-up)', value: '2x2' },
|
||||
{ label: '2x4 (8-up)', value: '2x4' },
|
||||
{ label: '4x4 (16-up)', value: '4x4' },
|
||||
],
|
||||
paperSize: [
|
||||
{ label: 'Letter', value: 'Letter' },
|
||||
{ label: 'A4', value: 'A4' },
|
||||
{ label: 'A3', value: 'A3' },
|
||||
{ label: 'Tabloid', value: 'Tabloid' },
|
||||
{ label: 'Legal', value: 'Legal' },
|
||||
],
|
||||
rasterizeDpi: [
|
||||
{ label: '72 (Screen)', value: '72' },
|
||||
{ label: '150 (Default)', value: '150' },
|
||||
{ label: '200 (Good)', value: '200' },
|
||||
{ label: '300 (Print)', value: '300' },
|
||||
{ label: '600 (High Quality)', value: '600' },
|
||||
],
|
||||
imageFormat: [
|
||||
{ label: 'PNG (Lossless)', value: 'png' },
|
||||
{ label: 'JPEG (Smaller file size)', value: 'jpeg' },
|
||||
],
|
||||
skewThreshold: [
|
||||
{ label: '0.1° (Very Sensitive)', value: '0.1' },
|
||||
{ label: '0.5° (Default)', value: '0.5' },
|
||||
{ label: '1.0° (Normal)', value: '1.0' },
|
||||
{ label: '2.0° (Less Sensitive)', value: '2.0' },
|
||||
],
|
||||
processingDpi: [
|
||||
{ label: '100 (Fast)', value: '100' },
|
||||
{ label: '150 (Default)', value: '150' },
|
||||
{ label: '200 (Better)', value: '200' },
|
||||
{ label: '300 (Best Quality)', value: '300' },
|
||||
],
|
||||
level: [
|
||||
{ label: 'PDF/A-1b (Strict, no transparency)', value: 'PDF/A-1b' },
|
||||
{ label: 'PDF/A-2b (Recommended)', value: 'PDF/A-2b' },
|
||||
{ label: 'PDF/A-3b (Modern, allows attachments)', value: 'PDF/A-3b' },
|
||||
],
|
||||
algorithm: [
|
||||
{ label: 'Condense (Smart, requires PyMuPDF)', value: 'condense' },
|
||||
{ label: 'Photon (Rasterize pages)', value: 'photon' },
|
||||
],
|
||||
compressionLevel: [
|
||||
{ label: 'Light', value: 'light' },
|
||||
{ label: 'Balanced', value: 'balanced' },
|
||||
{ label: 'Aggressive', value: 'aggressive' },
|
||||
{ label: 'Extreme', value: 'extreme' },
|
||||
],
|
||||
redactMode: [
|
||||
{ label: 'Search Text', value: 'text' },
|
||||
{ label: 'Area (Coordinates)', value: 'area' },
|
||||
],
|
||||
};
|
||||
|
||||
const booleanControls = new Set([
|
||||
'grayscale',
|
||||
'border',
|
||||
'margins',
|
||||
'separator',
|
||||
'sepia',
|
||||
'includeCcBcc',
|
||||
'includeAttachments',
|
||||
'binarize',
|
||||
'preFlatten',
|
||||
'flattenForms',
|
||||
'removeMetadata',
|
||||
'removeAnnotations',
|
||||
'removeJavascript',
|
||||
'removeEmbeddedFiles',
|
||||
'removeLayers',
|
||||
'removeLinks',
|
||||
'removeStructureTree',
|
||||
'removeMarkInfo',
|
||||
'removeFonts',
|
||||
'subsetFonts',
|
||||
'convertToGrayscale',
|
||||
'removeThumbnails',
|
||||
'retainPageLabels',
|
||||
]);
|
||||
const multiSelectDropdowns = new Set(['language']);
|
||||
const advancedControls = new Set(['resolution', 'binarize', 'whitelist']);
|
||||
|
||||
const colorControls = new Set([
|
||||
'color',
|
||||
'borderColor',
|
||||
'backgroundColor',
|
||||
'separatorColor',
|
||||
'fontColor',
|
||||
'fillColor',
|
||||
]);
|
||||
|
||||
const controlHints: Record<string, string> = {
|
||||
pages: 'e.g. 1-3, 5, 7-9',
|
||||
whitelist: 'Limit recognized characters (leave empty for all)',
|
||||
@@ -1333,15 +1160,17 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
controlWrappers[key] = wrapper;
|
||||
const ctrl = control as { value?: unknown; type?: string };
|
||||
const currentValue = String(ctrl.value ?? '');
|
||||
const spec = getControlSpec(node.nodeType, key);
|
||||
|
||||
const controlLabel = document.createElement('label');
|
||||
controlLabel.className = 'block text-xs text-gray-400 mb-1';
|
||||
controlLabel.textContent = formatLabel(key);
|
||||
wrapper.appendChild(controlLabel);
|
||||
|
||||
if (dropdownOptions[key] && multiSelectDropdowns.has(key)) {
|
||||
if (spec?.kind === 'multiselect') {
|
||||
const { options, separator } = spec;
|
||||
const selectedValues = new Set(
|
||||
currentValue ? currentValue.split('+') : []
|
||||
currentValue ? currentValue.split(separator) : []
|
||||
);
|
||||
const container = document.createElement('div');
|
||||
container.className = 'flex flex-col gap-1';
|
||||
@@ -1364,7 +1193,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
function updateTags() {
|
||||
tagsDiv.innerHTML = '';
|
||||
for (const val of selectedValues) {
|
||||
const opt = dropdownOptions[key].find((o) => o.value === val);
|
||||
const opt = options.find((o) => o.value === val);
|
||||
if (!opt) continue;
|
||||
const tag = document.createElement('span');
|
||||
tag.className =
|
||||
@@ -1388,13 +1217,13 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
|
||||
function updateCtrl() {
|
||||
(ctrl as { value: string }).value =
|
||||
Array.from(selectedValues).join('+');
|
||||
Array.from(selectedValues).join(separator);
|
||||
}
|
||||
|
||||
function renderList(filter: string) {
|
||||
listDiv.innerHTML = '';
|
||||
const lowerFilter = filter.toLowerCase();
|
||||
for (const opt of dropdownOptions[key]) {
|
||||
for (const opt of options) {
|
||||
if (lowerFilter && !opt.label.toLowerCase().includes(lowerFilter))
|
||||
continue;
|
||||
const label = document.createElement('label');
|
||||
@@ -1427,10 +1256,10 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
updateTags();
|
||||
renderList('');
|
||||
wrapper.appendChild(container);
|
||||
} else if (dropdownOptions[key]) {
|
||||
} else if (spec?.kind === 'select') {
|
||||
const select = document.createElement('select');
|
||||
select.className = inputClass;
|
||||
for (const opt of dropdownOptions[key]) {
|
||||
for (const opt of spec.options) {
|
||||
const option = document.createElement('option');
|
||||
option.value = opt.value;
|
||||
option.textContent = opt.label;
|
||||
@@ -1438,13 +1267,14 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
select.appendChild(option);
|
||||
}
|
||||
select.addEventListener('change', () => {
|
||||
(ctrl as { value: string }).value = select.value;
|
||||
ctrl.value =
|
||||
typeof ctrl.value === 'number' ? Number(select.value) : select.value;
|
||||
if (conditionalVisibility[key]) {
|
||||
applyConditionalVisibility(key, select.value);
|
||||
}
|
||||
});
|
||||
wrapper.appendChild(select);
|
||||
} else if (booleanControls.has(key)) {
|
||||
} else if (spec?.kind === 'boolean') {
|
||||
const toggle = document.createElement('button');
|
||||
toggle.type = 'button';
|
||||
const isOn = currentValue === 'true';
|
||||
@@ -1461,7 +1291,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
dot.className = `pointer-events-none absolute top-[3px] left-[3px] h-[18px] w-[18px] rounded-full bg-white shadow-md transition-transform duration-200 ${on ? 'translate-x-5' : 'translate-x-0'}`;
|
||||
});
|
||||
wrapper.appendChild(toggle);
|
||||
} else if (colorControls.has(key)) {
|
||||
} else if (spec?.kind === 'color') {
|
||||
const colorRow = document.createElement('div');
|
||||
colorRow.className = 'flex items-center gap-2';
|
||||
const colorInput = document.createElement('input');
|
||||
@@ -1489,18 +1319,25 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = inputClass;
|
||||
if (spec?.kind === 'number') {
|
||||
input.min = String(spec.min);
|
||||
input.max = String(spec.max);
|
||||
input.step = spec.step === undefined ? 'any' : String(spec.step);
|
||||
}
|
||||
input.value = currentValue;
|
||||
input.addEventListener('input', () => {
|
||||
const num = parseFloat(input.value);
|
||||
if (!isNaN(num)) {
|
||||
(ctrl as { value: number }).value = num;
|
||||
ctrl.value = node.sanitizeControlValue(key, num);
|
||||
}
|
||||
});
|
||||
input.addEventListener('change', () => {
|
||||
input.value = String(ctrl.value ?? '');
|
||||
});
|
||||
wrapper.appendChild(input);
|
||||
} else {
|
||||
const input = document.createElement('input');
|
||||
const isPasswordField = key === 'password' || key === 'ownerPassword';
|
||||
input.type = isPasswordField ? 'password' : 'text';
|
||||
input.type = spec?.kind === 'password' ? 'password' : 'text';
|
||||
input.className = inputClass;
|
||||
input.value = currentValue;
|
||||
input.addEventListener('input', () => {
|
||||
|
||||
@@ -149,14 +149,25 @@ async function runSanitize() {
|
||||
new Blob([new Uint8Array(result.bytes)], { type: 'application/pdf' }),
|
||||
pageState.file?.name || 'document.pdf'
|
||||
);
|
||||
showAlert(
|
||||
'Success',
|
||||
'PDF has been sanitized and downloaded.',
|
||||
'success',
|
||||
() => {
|
||||
resetState();
|
||||
}
|
||||
);
|
||||
if (result.skipped.length > 0) {
|
||||
showAlert(
|
||||
'Sanitized With Warnings',
|
||||
`The PDF was downloaded, but these steps could not be completed: ${result.skipped.join(', ')}. The content they cover may still be in the file.`,
|
||||
'warning',
|
||||
() => {
|
||||
resetState();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
showAlert(
|
||||
'Success',
|
||||
'PDF has been sanitized and downloaded.',
|
||||
'success',
|
||||
() => {
|
||||
resetState();
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error('Sanitization Error:', e);
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
|
||||
@@ -7,8 +7,14 @@ import {
|
||||
} from '../utils/helpers.js';
|
||||
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
||||
import { t } from '../i18n/i18n';
|
||||
import { loadPdfDocument } from '../utils/load-pdf-document.js';
|
||||
import { flattenAnnotations } from '../utils/flatten-annotations.js';
|
||||
import {
|
||||
loadPdfDocument,
|
||||
removeUnreferencedObjects,
|
||||
} from '../utils/load-pdf-document.js';
|
||||
import {
|
||||
flattenAnnotations,
|
||||
flattenFormsInDoc,
|
||||
} from '../utils/flatten-annotations.js';
|
||||
import type { SignState, PDFViewerWindow } from '@/types';
|
||||
|
||||
const signState: SignState = {
|
||||
@@ -285,7 +291,7 @@ async function applyAndSaveSignatures() {
|
||||
const pdfBytes = new Uint8Array(rawPdfBytes);
|
||||
const pdfDoc = await loadPdfDocument(pdfBytes);
|
||||
try {
|
||||
pdfDoc.getForm().flatten();
|
||||
flattenFormsInDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (!msg.includes('getForm')) {
|
||||
@@ -298,7 +304,9 @@ async function applyAndSaveSignatures() {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.warn('Could not flatten annotations:', msg);
|
||||
}
|
||||
const flattenedPdfBytes = await pdfDoc.save();
|
||||
const flattenedPdfBytes = await removeUnreferencedObjects(
|
||||
await pdfDoc.save()
|
||||
);
|
||||
|
||||
const blob = new Blob([new Uint8Array(flattenedPdfBytes)], {
|
||||
type: 'application/pdf',
|
||||
|
||||
@@ -377,9 +377,9 @@ function createSignatureCard(
|
||||
result.cryptoVerified === true &&
|
||||
result.coverageStatus === 'partial'
|
||||
) {
|
||||
statusColor = 'text-red-400';
|
||||
statusIcon = 'x-circle';
|
||||
statusText = 'Invalid — Modified After Signing (partial coverage)';
|
||||
statusColor = 'text-yellow-400';
|
||||
statusIcon = 'alert-triangle';
|
||||
statusText = 'Modified After Signing (signed part intact)';
|
||||
} else {
|
||||
statusColor = 'text-red-400';
|
||||
statusIcon = 'x-circle';
|
||||
|
||||
@@ -1,82 +1,296 @@
|
||||
import forge from 'node-forge';
|
||||
import { ExtractedSignature, SignatureValidationResult } from '@/types';
|
||||
import { SignatureValidationResult } from '@/types';
|
||||
import type { ExtractedSignatureWithError, PdfValueSpan } from '@/types';
|
||||
|
||||
const INSECURE_DIGEST_OIDS = new Set<string>([
|
||||
'1.2.840.113549.2.5',
|
||||
'1.3.14.3.2.26',
|
||||
]);
|
||||
|
||||
export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
|
||||
const signatures: ExtractedSignature[] = [];
|
||||
const PDF_WHITESPACE = new Set('\0\t\n\f\r ');
|
||||
const PDF_TOKEN_TERMINATORS = new Set('\0\t\n\f\r ()<>[]{}/%');
|
||||
const PDF_STRING_ESCAPES: Record<string, string> = {
|
||||
n: '\n',
|
||||
r: '\r',
|
||||
t: '\t',
|
||||
b: '\b',
|
||||
f: '\f',
|
||||
'\r\n': '',
|
||||
'\r': '',
|
||||
'\n': '',
|
||||
};
|
||||
|
||||
export function extractSignatures(
|
||||
pdfBytes: Uint8Array
|
||||
): ExtractedSignatureWithError[] {
|
||||
const signatures: ExtractedSignatureWithError[] = [];
|
||||
const pdfString = new TextDecoder('latin1').decode(pdfBytes);
|
||||
|
||||
// Find all signature objects for /Type /Sig
|
||||
const sigRegex = /\/Type\s*\/Sig\b/g;
|
||||
let sigMatch;
|
||||
let sigIndex = 0;
|
||||
const objectHeader = /\d+\s+\d+\s+obj\b/g;
|
||||
let endobj = -1;
|
||||
|
||||
while ((sigMatch = sigRegex.exec(pdfString)) !== null) {
|
||||
try {
|
||||
const searchStart = Math.max(0, sigMatch.index - 5000);
|
||||
const searchEnd = Math.min(pdfString.length, sigMatch.index + 10000);
|
||||
const context = pdfString.substring(searchStart, searchEnd);
|
||||
const byteRangeMatch = context.match(
|
||||
/\/ByteRange\s*\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\]/
|
||||
);
|
||||
if (!byteRangeMatch) continue;
|
||||
while (objectHeader.exec(pdfString) !== null) {
|
||||
const start = objectHeader.lastIndex;
|
||||
if (endobj < start) {
|
||||
endobj = pdfString.indexOf('endobj', start);
|
||||
if (endobj < 0) endobj = pdfString.length;
|
||||
}
|
||||
const body = pdfString.slice(0, endobj);
|
||||
const dictionaries: Map<string, PdfValueSpan>[] = [];
|
||||
const end = parsePdfValue(
|
||||
body,
|
||||
skipPdfWhitespace(body, start),
|
||||
dictionaries,
|
||||
0
|
||||
);
|
||||
objectHeader.lastIndex = end < 0 ? endobj : end;
|
||||
if (end < 0) continue;
|
||||
|
||||
const byteRange = [
|
||||
parseInt(byteRangeMatch[1], 10),
|
||||
parseInt(byteRangeMatch[2], 10),
|
||||
parseInt(byteRangeMatch[3], 10),
|
||||
parseInt(byteRangeMatch[4], 10),
|
||||
];
|
||||
|
||||
const contentsMatch = context.match(/\/Contents\s*<([0-9A-Fa-f]+)>/);
|
||||
if (!contentsMatch) continue;
|
||||
|
||||
const hexContents = contentsMatch[1];
|
||||
const contentsBytes = hexToBytes(hexContents);
|
||||
|
||||
const reasonMatch = context.match(/\/Reason\s*\(([^)]*)\)/);
|
||||
const locationMatch = context.match(/\/Location\s*\(([^)]*)\)/);
|
||||
const contactMatch = context.match(/\/ContactInfo\s*\(([^)]*)\)/);
|
||||
const nameMatch = context.match(/\/Name\s*\(([^)]*)\)/);
|
||||
const timeMatch = context.match(
|
||||
/\/M\s*\(D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/
|
||||
);
|
||||
|
||||
let signingTime: string | undefined;
|
||||
if (timeMatch) {
|
||||
signingTime = `${timeMatch[1]}-${timeMatch[2]}-${timeMatch[3]}T${timeMatch[4]}:${timeMatch[5]}:${timeMatch[6]}`;
|
||||
for (const entries of dictionaries) {
|
||||
const type = entries.get('Type');
|
||||
if (type && pdfString.slice(type.start, type.end) === '/Sig') {
|
||||
signatures.push(
|
||||
readSignatureDictionary(
|
||||
pdfString,
|
||||
pdfBytes,
|
||||
entries,
|
||||
signatures.length
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
signatures.push({
|
||||
index: sigIndex++,
|
||||
contents: contentsBytes,
|
||||
byteRange,
|
||||
reason: reasonMatch
|
||||
? decodeURIComponent(escape(reasonMatch[1]))
|
||||
: undefined,
|
||||
location: locationMatch
|
||||
? decodeURIComponent(escape(locationMatch[1]))
|
||||
: undefined,
|
||||
contactInfo: contactMatch
|
||||
? decodeURIComponent(escape(contactMatch[1]))
|
||||
: undefined,
|
||||
name: nameMatch ? decodeURIComponent(escape(nameMatch[1])) : undefined,
|
||||
signingTime,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Error extracting signature at index', sigIndex, e);
|
||||
}
|
||||
}
|
||||
|
||||
return signatures;
|
||||
}
|
||||
|
||||
function readSignatureDictionary(
|
||||
pdfString: string,
|
||||
pdfBytes: Uint8Array,
|
||||
entries: Map<string, PdfValueSpan>,
|
||||
index: number
|
||||
): ExtractedSignatureWithError {
|
||||
const text = (key: string) => decodePdfText(pdfBytes, entries.get(key));
|
||||
const timeMatch = text('M')?.match(
|
||||
/^D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/
|
||||
);
|
||||
const signature: ExtractedSignatureWithError = {
|
||||
index,
|
||||
contents: new Uint8Array(0),
|
||||
byteRange: [],
|
||||
reason: text('Reason'),
|
||||
location: text('Location'),
|
||||
contactInfo: text('ContactInfo'),
|
||||
name: text('Name'),
|
||||
signingTime: timeMatch
|
||||
? `${timeMatch[1]}-${timeMatch[2]}-${timeMatch[3]}T${timeMatch[4]}:${timeMatch[5]}:${timeMatch[6]}`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const rangeSpan = entries.get('ByteRange');
|
||||
const rangeMatch = rangeSpan
|
||||
? /^\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\]$/.exec(
|
||||
pdfString.slice(rangeSpan.start, rangeSpan.end)
|
||||
)
|
||||
: null;
|
||||
if (!rangeMatch) {
|
||||
signature.extractionError =
|
||||
'Signature has a missing or malformed /ByteRange';
|
||||
return signature;
|
||||
}
|
||||
|
||||
signature.byteRange = rangeMatch.slice(1).map(Number);
|
||||
const byteRangeError = getByteRangeError(
|
||||
signature.byteRange,
|
||||
pdfBytes.length
|
||||
);
|
||||
if (byteRangeError) {
|
||||
signature.extractionError = byteRangeError;
|
||||
return signature;
|
||||
}
|
||||
|
||||
const gapStart = signature.byteRange[0] + signature.byteRange[1];
|
||||
const gapEnd = signature.byteRange[2];
|
||||
const gap = pdfString.slice(gapStart, gapEnd);
|
||||
if (gap.length % 2 !== 0 || !/^<[0-9A-Fa-f]+>$/.test(gap)) {
|
||||
signature.extractionError =
|
||||
'The bytes excluded by /ByteRange are not exactly a hex-encoded signature';
|
||||
return signature;
|
||||
}
|
||||
|
||||
const contentsSpan = entries.get('Contents');
|
||||
if (contentsSpan?.start !== gapStart || contentsSpan.end !== gapEnd) {
|
||||
signature.extractionError =
|
||||
'The bytes excluded by /ByteRange are not the /Contents of this signature';
|
||||
return signature;
|
||||
}
|
||||
|
||||
const contents = hexToBytes(gap.slice(1, -1));
|
||||
const derObjectLength = getDerObjectLength(contents);
|
||||
if (
|
||||
derObjectLength === null ||
|
||||
derObjectLength > contents.length ||
|
||||
contents.subarray(derObjectLength).some((byte) => byte !== 0)
|
||||
) {
|
||||
signature.extractionError =
|
||||
'Signature /Contents is not a single DER object followed by zero padding';
|
||||
return signature;
|
||||
}
|
||||
|
||||
signature.contents = contents.slice(0, derObjectLength);
|
||||
return signature;
|
||||
}
|
||||
|
||||
function getByteRangeError(
|
||||
byteRange: number[],
|
||||
fileLength: number
|
||||
): string | null {
|
||||
if (
|
||||
byteRange.length !== 4 ||
|
||||
!byteRange.every((value) => Number.isSafeInteger(value) && value >= 0)
|
||||
) {
|
||||
return 'Malformed ByteRange';
|
||||
}
|
||||
const [start1, len1, start2, len2] = byteRange;
|
||||
if (start1 !== 0) {
|
||||
return 'ByteRange does not start at the beginning of the file';
|
||||
}
|
||||
if (start1 + len1 > start2 || start2 + len2 > fileLength) {
|
||||
return 'ByteRange out of bounds';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function skipPdfWhitespace(text: string, pos: number): number {
|
||||
while (pos < text.length) {
|
||||
if (text[pos] === '%') {
|
||||
while (pos < text.length && text[pos] !== '\n' && text[pos] !== '\r') {
|
||||
pos++;
|
||||
}
|
||||
} else if (PDF_WHITESPACE.has(text[pos])) {
|
||||
pos++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
function findTokenEnd(text: string, pos: number): number {
|
||||
while (pos < text.length && !PDF_TOKEN_TERMINATORS.has(text[pos])) pos++;
|
||||
return pos;
|
||||
}
|
||||
|
||||
function findLiteralStringEnd(text: string, start: number): number {
|
||||
let depth = 0;
|
||||
for (let pos = start; pos < text.length; pos++) {
|
||||
if (text[pos] === '\\') {
|
||||
pos++;
|
||||
} else if (text[pos] === '(') {
|
||||
depth++;
|
||||
} else if (text[pos] === ')') {
|
||||
depth--;
|
||||
if (depth === 0) return pos + 1;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parsePdfValue(
|
||||
text: string,
|
||||
start: number,
|
||||
dictionaries: Map<string, PdfValueSpan>[],
|
||||
depth: number
|
||||
): number {
|
||||
if (depth > 32 || start >= text.length) return -1;
|
||||
|
||||
if (text.startsWith('<<', start)) {
|
||||
const entries = new Map<string, PdfValueSpan>();
|
||||
dictionaries.push(entries);
|
||||
let pos = skipPdfWhitespace(text, start + 2);
|
||||
while (!text.startsWith('>>', pos)) {
|
||||
const keyEnd = text[pos] === '/' ? findTokenEnd(text, pos + 1) : pos;
|
||||
const valueStart = skipPdfWhitespace(text, keyEnd);
|
||||
const valueEnd = parsePdfValue(text, valueStart, dictionaries, depth + 1);
|
||||
if (valueEnd < 0) return -1;
|
||||
if (keyEnd > pos) {
|
||||
entries.set(text.slice(pos + 1, keyEnd), {
|
||||
start: valueStart,
|
||||
end: valueEnd,
|
||||
});
|
||||
}
|
||||
pos = skipPdfWhitespace(text, valueEnd);
|
||||
}
|
||||
return pos + 2;
|
||||
}
|
||||
|
||||
if (text[start] === '<') {
|
||||
const end = text.indexOf('>', start);
|
||||
return end < 0 ? -1 : end + 1;
|
||||
}
|
||||
|
||||
if (text[start] === '(') return findLiteralStringEnd(text, start);
|
||||
|
||||
if (text[start] === '[') {
|
||||
let pos = skipPdfWhitespace(text, start + 1);
|
||||
while (text[pos] !== ']') {
|
||||
const end = parsePdfValue(text, pos, dictionaries, depth + 1);
|
||||
if (end < 0) return -1;
|
||||
pos = skipPdfWhitespace(text, end);
|
||||
}
|
||||
return pos + 1;
|
||||
}
|
||||
|
||||
const end = findTokenEnd(text, text[start] === '/' ? start + 1 : start);
|
||||
return end > start ? end : -1;
|
||||
}
|
||||
|
||||
function decodePdfText(
|
||||
pdfBytes: Uint8Array,
|
||||
span: PdfValueSpan | undefined
|
||||
): string | undefined {
|
||||
if (!span) return undefined;
|
||||
const raw = uint8ToLatin1(pdfBytes.subarray(span.start, span.end));
|
||||
let text: string;
|
||||
if (raw.startsWith('(')) {
|
||||
text = raw
|
||||
.slice(1, -1)
|
||||
.replace(/\\([0-7]{1,3}|\r\n|[\s\S])/g, (_, escaped: string) =>
|
||||
/^[0-7]/.test(escaped)
|
||||
? String.fromCharCode(parseInt(escaped, 8) & 0xff)
|
||||
: (PDF_STRING_ESCAPES[escaped] ?? escaped)
|
||||
);
|
||||
} else if (/^<[0-9A-Fa-f\s]*>$/.test(raw)) {
|
||||
const hex = raw.slice(1, -1).replace(/\s/g, '');
|
||||
text = uint8ToLatin1(hexToBytes(hex.length % 2 ? hex + '0' : hex));
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bytes = latin1ToUint8(text);
|
||||
if (text.startsWith('\xfe\xff')) {
|
||||
return new TextDecoder('utf-16be').decode(bytes.subarray(2));
|
||||
}
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function getDerObjectLength(bytes: Uint8Array): number | null {
|
||||
if (bytes.length < 2) return null;
|
||||
if (bytes[1] < 0x80) return 2 + bytes[1];
|
||||
const count = bytes[1] & 0x7f;
|
||||
if (count === 0 || count > 4 || bytes.length < 2 + count) return null;
|
||||
let length = 0;
|
||||
for (let i = 0; i < count; i++) length = length * 256 + bytes[2 + i];
|
||||
return 2 + count + length;
|
||||
}
|
||||
|
||||
export async function validateSignature(
|
||||
signature: ExtractedSignature,
|
||||
signature: ExtractedSignatureWithError,
|
||||
pdfBytes: Uint8Array,
|
||||
trustedCert?: forge.pki.Certificate
|
||||
): Promise<SignatureValidationResult> {
|
||||
@@ -99,6 +313,13 @@ export async function validateSignature(
|
||||
contactInfo: signature.contactInfo,
|
||||
};
|
||||
|
||||
if (signature.extractionError) {
|
||||
result.errorMessage = signature.extractionError;
|
||||
result.cryptoVerified = false;
|
||||
result.cryptoVerificationStatus = 'failed';
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const binaryString = String.fromCharCode.apply(
|
||||
null,
|
||||
@@ -341,13 +562,7 @@ function hexToBytes(hex: string): Uint8Array {
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
}
|
||||
|
||||
let actualLength = bytes.length;
|
||||
while (actualLength > 0 && bytes[actualLength - 1] === 0) {
|
||||
actualLength--;
|
||||
}
|
||||
|
||||
return bytes.slice(0, actualLength);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function getDigestAlgorithmName(oid: string): string {
|
||||
@@ -745,8 +960,9 @@ async function performCryptoVerification(
|
||||
if (!fields) {
|
||||
return { status: 'failed', reason: 'Could not parse signer info' };
|
||||
}
|
||||
if (byteRange.length !== 4) {
|
||||
return { status: 'failed', reason: 'Malformed ByteRange' };
|
||||
const byteRangeError = getByteRangeError(byteRange, pdfBytes.length);
|
||||
if (byteRangeError) {
|
||||
return { status: 'failed', reason: byteRangeError };
|
||||
}
|
||||
|
||||
const md = createMd(fields.digestOid);
|
||||
@@ -758,17 +974,6 @@ async function performCryptoVerification(
|
||||
}
|
||||
|
||||
const [start1, len1, start2, len2] = byteRange;
|
||||
if (
|
||||
start1 < 0 ||
|
||||
len1 < 0 ||
|
||||
start2 < 0 ||
|
||||
len2 < 0 ||
|
||||
start1 + len1 > pdfBytes.length ||
|
||||
start2 + len2 > pdfBytes.length
|
||||
) {
|
||||
return { status: 'failed', reason: 'ByteRange out of bounds' };
|
||||
}
|
||||
|
||||
const signedContent = new Uint8Array(len1 + len2);
|
||||
signedContent.set(pdfBytes.subarray(start1, start1 + len1), 0);
|
||||
signedContent.set(pdfBytes.subarray(start2, start2 + len2), len1);
|
||||
|
||||
+5
-4
@@ -29,6 +29,7 @@ import {
|
||||
setStoredItem,
|
||||
removeStoredItem,
|
||||
} from './utils/safe-storage.js';
|
||||
import { sanitizeTranslationHtml } from './utils/translation-html.js';
|
||||
declare const __BRAND_NAME__: string;
|
||||
|
||||
const init = async () => {
|
||||
@@ -48,9 +49,9 @@ const init = async () => {
|
||||
main.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
|
||||
<i class="ph ph-prohibit text-6xl text-gray-500 mb-4"></i>
|
||||
<h1 class="text-2xl font-bold text-white mb-2">${heading}</h1>
|
||||
<p class="text-gray-400 mb-6">${message}</p>
|
||||
<a href="${import.meta.env.BASE_URL}" class="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition">${backHome}</a>
|
||||
<h1 class="text-2xl font-bold text-white mb-2">${sanitizeTranslationHtml(heading)}</h1>
|
||||
<p class="text-gray-400 mb-6">${sanitizeTranslationHtml(message)}</p>
|
||||
<a href="${import.meta.env.BASE_URL}" class="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition">${sanitizeTranslationHtml(backHome)}</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
@@ -898,7 +899,7 @@ const init = async () => {
|
||||
}
|
||||
|
||||
dom.warningTitle.textContent = title;
|
||||
dom.warningMessage.innerHTML = message;
|
||||
dom.warningMessage.innerHTML = sanitizeTranslationHtml(message);
|
||||
dom.warningModal.classList.remove('hidden');
|
||||
dom.warningModal.classList.add('flex');
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface PendingRedactionItem {
|
||||
id: string;
|
||||
kind: 'text' | 'area';
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface PendingRedactionChange {
|
||||
documentId: string;
|
||||
pending: Record<number, PendingRedactionItem[]>;
|
||||
}
|
||||
|
||||
export interface RedactionPluginEvent {
|
||||
type: string;
|
||||
documentId: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface RedactionPluginLite {
|
||||
getState: () => { pendingCount: number };
|
||||
commitAllPending: () => { toPromise: () => Promise<boolean> };
|
||||
onPendingChange: (
|
||||
listener: (event: PendingRedactionChange) => void
|
||||
) => () => void;
|
||||
onRedactionEvent: (
|
||||
listener: (event: RedactionPluginEvent) => void
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
export interface ActiveDocumentSource {
|
||||
getActiveDocumentId: () => string | null;
|
||||
}
|
||||
|
||||
export interface AppliedRedactionTracker {
|
||||
appliedTexts: (documentId: string) => (string | undefined)[];
|
||||
forget: (documentId: string) => void;
|
||||
}
|
||||
@@ -66,3 +66,7 @@ export * from './ui-type.ts';
|
||||
export * from './markdown-editor-type.ts';
|
||||
export * from './sanitize-type.ts';
|
||||
export * from './overlay-pdf-type.ts';
|
||||
export * from './workflow-control-spec-type.ts';
|
||||
export * from './edit-pdf-redaction-type.ts';
|
||||
export * from './validate-signature-extraction-type.ts';
|
||||
export * from './pdf-content-tokens-type.ts';
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface ContentToken {
|
||||
kind: 'name' | 'dict' | 'operand' | 'operator';
|
||||
value: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface ContentOperation {
|
||||
operator: string;
|
||||
operands: ContentToken[];
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ExtractedSignature } from './validate-signature-type.ts';
|
||||
|
||||
export interface PdfValueSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface ExtractedSignatureWithError extends ExtractedSignature {
|
||||
extractionError?: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface WorkflowControlOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type WorkflowControlSpec =
|
||||
| { kind: 'select'; options: WorkflowControlOption[] }
|
||||
| { kind: 'multiselect'; options: WorkflowControlOption[]; separator: string }
|
||||
| { kind: 'number'; min: number; max: number; step?: number }
|
||||
| { kind: 'boolean' }
|
||||
| { kind: 'color' }
|
||||
| { kind: 'password' };
|
||||
@@ -7,8 +7,65 @@ import {
|
||||
PDFNumber,
|
||||
PDFOperator,
|
||||
PDFOperatorNames,
|
||||
PDFObject,
|
||||
PDFTextField,
|
||||
} from 'pdf-lib';
|
||||
|
||||
const HIDDEN_ANNOTATION_FLAGS = 0x02 | 0x20;
|
||||
|
||||
function isHiddenAnnotation(annot: PDFDict): boolean {
|
||||
const flags = annot.lookup(PDFName.of('F'));
|
||||
return (
|
||||
flags instanceof PDFNumber &&
|
||||
(flags.asNumber() & HIDDEN_ANNOTATION_FLAGS) !== 0
|
||||
);
|
||||
}
|
||||
|
||||
function detachAnnotations(pdfDoc: PDFDocument, refs: Set<PDFObject>) {
|
||||
for (const page of pdfDoc.getPages()) {
|
||||
const annots = page.node.Annots();
|
||||
if (!annots) continue;
|
||||
for (let i = annots.size() - 1; i >= 0; i--) {
|
||||
if (refs.has(annots.get(i))) annots.remove(i);
|
||||
}
|
||||
}
|
||||
for (const ref of refs) {
|
||||
if (ref instanceof PDFRef) pdfDoc.context.delete(ref);
|
||||
}
|
||||
}
|
||||
|
||||
function removeHiddenFormFields(pdfDoc: PDFDocument) {
|
||||
const form = pdfDoc.getForm();
|
||||
const removedRefs = new Set<PDFObject>();
|
||||
for (const field of form.getFields()) {
|
||||
const removeAll = field instanceof PDFTextField && field.isPassword();
|
||||
const kids = field.acroField.Kids();
|
||||
if (kids) {
|
||||
for (let i = kids.size() - 1; i >= 0; i--) {
|
||||
const widget = kids.lookup(i);
|
||||
if (
|
||||
removeAll ||
|
||||
(widget instanceof PDFDict && isHiddenAnnotation(widget))
|
||||
) {
|
||||
removedRefs.add(kids.get(i));
|
||||
kids.remove(i);
|
||||
}
|
||||
}
|
||||
if (kids.size() > 0) continue;
|
||||
} else if (!removeAll && !isHiddenAnnotation(field.acroField.dict)) {
|
||||
continue;
|
||||
}
|
||||
form.acroForm.removeField(field.acroField);
|
||||
removedRefs.add(field.ref);
|
||||
}
|
||||
if (removedRefs.size > 0) detachAnnotations(pdfDoc, removedRefs);
|
||||
}
|
||||
|
||||
export function flattenFormsInDoc(pdfDoc: PDFDocument): void {
|
||||
removeHiddenFormFields(pdfDoc);
|
||||
pdfDoc.getForm().flatten();
|
||||
}
|
||||
|
||||
function extractNumbers(arr: PDFArray, count: number): number[] | null {
|
||||
if (arr.size() < count) return null;
|
||||
const result: number[] = [];
|
||||
@@ -68,9 +125,7 @@ export function flattenAnnotations(pdfDoc: PDFDocument): void {
|
||||
continue;
|
||||
}
|
||||
|
||||
const flagsObj = annot.get(PDFName.of('F'));
|
||||
const flags = flagsObj instanceof PDFNumber ? flagsObj.asNumber() : 0;
|
||||
if (flags & 0x02) {
|
||||
if (isHiddenAnnotation(annot)) {
|
||||
hasChanges = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ export async function convertToPdfA(
|
||||
}
|
||||
|
||||
const args = [
|
||||
'-dNOSAFER',
|
||||
'-dSAFER',
|
||||
'-dBATCH',
|
||||
'-dNOPAUSE',
|
||||
'-sDEVICE=pdfwrite',
|
||||
@@ -418,7 +418,7 @@ export async function convertFontsToOutlines(
|
||||
onProgress?.('Converting fonts to outlines...');
|
||||
|
||||
const args = [
|
||||
'-dNOSAFER',
|
||||
'-dSAFER',
|
||||
'-dBATCH',
|
||||
'-dNOPAUSE',
|
||||
'-sDEVICE=pdfwrite',
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import type { ContentOperation, ContentToken } from '@/types';
|
||||
|
||||
const charCode = (char: string) => char.charCodeAt(0);
|
||||
|
||||
const BACKSLASH = charCode('\\');
|
||||
const OPEN_PAREN = charCode('(');
|
||||
const CLOSE_PAREN = charCode(')');
|
||||
const LESS_THAN = charCode('<');
|
||||
const GREATER_THAN = charCode('>');
|
||||
const OPEN_BRACKET = charCode('[');
|
||||
const CLOSE_BRACKET = charCode(']');
|
||||
const SLASH = charCode('/');
|
||||
const PERCENT = charCode('%');
|
||||
const LINE_FEED = charCode('\n');
|
||||
const CARRIAGE_RETURN = charCode('\r');
|
||||
const LETTER_E = charCode('E');
|
||||
const LETTER_I = charCode('I');
|
||||
|
||||
const WHITESPACE = new Set([0x00, 0x09, 0x0a, 0x0c, 0x0d, 0x20]);
|
||||
const DELIMITERS = new Set([...'()<>[]{}/%'].map(charCode));
|
||||
const OPERAND_KEYWORDS = new Set(['true', 'false', 'null']);
|
||||
const TEXT_CHECK_LENGTH = 16;
|
||||
|
||||
const COMPONENTS_PER_COLOR_SPACE = new Map([
|
||||
['G', 1],
|
||||
['DeviceGray', 1],
|
||||
['RGB', 3],
|
||||
['DeviceRGB', 3],
|
||||
['CMYK', 4],
|
||||
['DeviceCMYK', 4],
|
||||
['I', 1],
|
||||
['Indexed', 1],
|
||||
]);
|
||||
|
||||
function isRegularChar(byte: number): boolean {
|
||||
return !WHITESPACE.has(byte) && !DELIMITERS.has(byte);
|
||||
}
|
||||
|
||||
function isHexDigit(byte: number): boolean {
|
||||
return (
|
||||
(byte >= charCode('0') && byte <= charCode('9')) ||
|
||||
(byte >= charCode('A') && byte <= charCode('F')) ||
|
||||
(byte >= charCode('a') && byte <= charCode('f'))
|
||||
);
|
||||
}
|
||||
|
||||
function bytesToString(bytes: Uint8Array, start: number, end: number): string {
|
||||
let text = '';
|
||||
for (let i = start; i < end; i++) text += String.fromCharCode(bytes[i]);
|
||||
return text;
|
||||
}
|
||||
|
||||
function skipWhitespaceAndComments(bytes: Uint8Array, pos: number): number {
|
||||
while (pos < bytes.length) {
|
||||
if (WHITESPACE.has(bytes[pos])) {
|
||||
pos++;
|
||||
} else if (bytes[pos] === PERCENT) {
|
||||
while (
|
||||
pos < bytes.length &&
|
||||
bytes[pos] !== LINE_FEED &&
|
||||
bytes[pos] !== CARRIAGE_RETURN
|
||||
) {
|
||||
pos++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
function findLiteralStringEnd(bytes: Uint8Array, start: number): number {
|
||||
let depth = 0;
|
||||
for (let i = start; i < bytes.length; i++) {
|
||||
const byte = bytes[i];
|
||||
if (byte === BACKSLASH) {
|
||||
i++;
|
||||
} else if (byte === OPEN_PAREN) {
|
||||
depth++;
|
||||
} else if (byte === CLOSE_PAREN) {
|
||||
depth--;
|
||||
if (depth === 0) return i + 1;
|
||||
}
|
||||
}
|
||||
throw new Error('Unterminated string in content stream');
|
||||
}
|
||||
|
||||
function findHexStringEnd(bytes: Uint8Array, start: number): number {
|
||||
for (let i = start + 1; i < bytes.length; i++) {
|
||||
const byte = bytes[i];
|
||||
if (byte === GREATER_THAN) return i + 1;
|
||||
if (!isHexDigit(byte) && !WHITESPACE.has(byte)) {
|
||||
throw new Error('Invalid hex string in content stream');
|
||||
}
|
||||
}
|
||||
throw new Error('Unterminated hex string in content stream');
|
||||
}
|
||||
|
||||
function findClosingDelimiter(
|
||||
bytes: Uint8Array,
|
||||
start: number,
|
||||
closing: number[]
|
||||
): number {
|
||||
let pos = start;
|
||||
while (true) {
|
||||
pos = skipWhitespaceAndComments(bytes, pos);
|
||||
if (pos >= bytes.length) {
|
||||
throw new Error('Unterminated array or dictionary in content stream');
|
||||
}
|
||||
if (closing.every((byte, offset) => bytes[pos + offset] === byte)) {
|
||||
return pos + closing.length;
|
||||
}
|
||||
pos = readToken(bytes, pos).end;
|
||||
}
|
||||
}
|
||||
|
||||
function readToken(bytes: Uint8Array, start: number): ContentToken {
|
||||
const byte = bytes[start];
|
||||
|
||||
if (byte === OPEN_PAREN) {
|
||||
const end = findLiteralStringEnd(bytes, start);
|
||||
return { kind: 'operand', value: '', start, end };
|
||||
}
|
||||
if (byte === LESS_THAN && bytes[start + 1] === LESS_THAN) {
|
||||
const end = findClosingDelimiter(bytes, start + 2, [
|
||||
GREATER_THAN,
|
||||
GREATER_THAN,
|
||||
]);
|
||||
return { kind: 'dict', value: '', start, end };
|
||||
}
|
||||
if (byte === LESS_THAN) {
|
||||
const end = findHexStringEnd(bytes, start);
|
||||
return { kind: 'operand', value: '', start, end };
|
||||
}
|
||||
if (byte === OPEN_BRACKET) {
|
||||
const end = findClosingDelimiter(bytes, start + 1, [CLOSE_BRACKET]);
|
||||
return { kind: 'operand', value: '', start, end };
|
||||
}
|
||||
if (byte === SLASH) {
|
||||
let end = start + 1;
|
||||
while (end < bytes.length && isRegularChar(bytes[end])) end++;
|
||||
const name = bytesToString(bytes, start + 1, end);
|
||||
return { kind: 'name', value: name, start, end };
|
||||
}
|
||||
if (DELIMITERS.has(byte)) {
|
||||
throw new Error('Unexpected delimiter in content stream');
|
||||
}
|
||||
|
||||
let end = start;
|
||||
while (end < bytes.length && isRegularChar(bytes[end])) end++;
|
||||
const word = bytesToString(bytes, start, end);
|
||||
const isNumber = /^[\d+.-]/.test(word);
|
||||
const kind = isNumber || OPERAND_KEYWORDS.has(word) ? 'operand' : 'operator';
|
||||
return { kind, value: word, start, end };
|
||||
}
|
||||
|
||||
function isFollowedByText(bytes: Uint8Array, start: number): boolean {
|
||||
const end = Math.min(bytes.length, start + TEXT_CHECK_LENGTH);
|
||||
for (let i = start; i < end; i++) {
|
||||
const byte = bytes[i];
|
||||
const isPrintable = byte >= 0x20 && byte <= 0x7e;
|
||||
if (!isPrintable && !WHITESPACE.has(byte)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getImageDataLength(
|
||||
bytes: Uint8Array,
|
||||
imageDict: Map<string, ContentToken>
|
||||
): number | undefined {
|
||||
const lookup = (shortKey: string, longKey: string) =>
|
||||
imageDict.get(shortKey) ?? imageDict.get(longKey);
|
||||
const toInteger = (token: ContentToken | undefined) =>
|
||||
token?.kind === 'operand' && /^\d+$/.test(token.value)
|
||||
? Number(token.value)
|
||||
: undefined;
|
||||
|
||||
if (lookup('F', 'Filter')) return undefined;
|
||||
|
||||
const isImageMask = lookup('IM', 'ImageMask')?.value === 'true';
|
||||
const colorSpace = lookup('CS', 'ColorSpace');
|
||||
const isIndexedColor =
|
||||
colorSpace?.kind === 'operand' &&
|
||||
/^\[\s*\/(I|Indexed)[\s/]/.test(
|
||||
bytesToString(bytes, colorSpace.start, colorSpace.end)
|
||||
);
|
||||
|
||||
let componentsPerPixel: number | undefined;
|
||||
if (isImageMask || isIndexedColor) {
|
||||
componentsPerPixel = 1;
|
||||
} else if (colorSpace?.kind === 'name') {
|
||||
componentsPerPixel = COMPONENTS_PER_COLOR_SPACE.get(colorSpace.value);
|
||||
}
|
||||
|
||||
const width = toInteger(lookup('W', 'Width'));
|
||||
const height = toInteger(lookup('H', 'Height'));
|
||||
const bitsPerComponent = isImageMask
|
||||
? 1
|
||||
: toInteger(lookup('BPC', 'BitsPerComponent'));
|
||||
|
||||
if (
|
||||
width === undefined ||
|
||||
height === undefined ||
|
||||
bitsPerComponent === undefined ||
|
||||
componentsPerPixel === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const bytesPerRow = Math.ceil(
|
||||
(width * bitsPerComponent * componentsPerPixel) / 8
|
||||
);
|
||||
return height * bytesPerRow;
|
||||
}
|
||||
|
||||
function isEndImageOperator(bytes: Uint8Array, pos: number): boolean {
|
||||
return (
|
||||
bytes[pos] === LETTER_E &&
|
||||
bytes[pos + 1] === LETTER_I &&
|
||||
(pos + 2 >= bytes.length || !isRegularChar(bytes[pos + 2]))
|
||||
);
|
||||
}
|
||||
|
||||
function skipInlineImage(bytes: Uint8Array, start: number): number {
|
||||
const imageDict = new Map<string, ContentToken>();
|
||||
let pendingKey: ContentToken | undefined;
|
||||
let pos = start;
|
||||
|
||||
while (true) {
|
||||
pos = skipWhitespaceAndComments(bytes, pos);
|
||||
if (pos >= bytes.length) {
|
||||
throw new Error('Unterminated inline image in content stream');
|
||||
}
|
||||
const token = readToken(bytes, pos);
|
||||
pos = token.end;
|
||||
if (token.kind === 'operator' && token.value === 'ID') break;
|
||||
if (pendingKey) {
|
||||
imageDict.set(pendingKey.value, token);
|
||||
pendingKey = undefined;
|
||||
} else if (token.kind === 'name') {
|
||||
pendingKey = token;
|
||||
} else {
|
||||
throw new Error('Malformed inline image in content stream');
|
||||
}
|
||||
}
|
||||
|
||||
const dataStart = WHITESPACE.has(bytes[pos]) ? pos + 1 : pos;
|
||||
const dataLength = getImageDataLength(bytes, imageDict);
|
||||
|
||||
if (dataLength !== undefined) {
|
||||
let end = dataStart + dataLength;
|
||||
while (end < bytes.length && WHITESPACE.has(bytes[end])) end++;
|
||||
if (isEndImageOperator(bytes, end)) return end + 2;
|
||||
throw new Error('Malformed inline image in content stream');
|
||||
}
|
||||
|
||||
for (let i = dataStart; i + 1 < bytes.length; i++) {
|
||||
if (!WHITESPACE.has(bytes[i - 1]) || !isEndImageOperator(bytes, i)) {
|
||||
continue;
|
||||
}
|
||||
if (!isFollowedByText(bytes, i + 2)) break;
|
||||
return i + 2;
|
||||
}
|
||||
throw new Error('Unterminated inline image in content stream');
|
||||
}
|
||||
|
||||
export function parseContentOperations(bytes: Uint8Array): ContentOperation[] {
|
||||
const operations: ContentOperation[] = [];
|
||||
let operands: ContentToken[] = [];
|
||||
let pos = 0;
|
||||
|
||||
while (true) {
|
||||
pos = skipWhitespaceAndComments(bytes, pos);
|
||||
if (pos >= bytes.length) break;
|
||||
const token = readToken(bytes, pos);
|
||||
pos = token.end;
|
||||
if (token.kind !== 'operator') {
|
||||
operands.push(token);
|
||||
continue;
|
||||
}
|
||||
if (token.value === 'BI') pos = skipInlineImage(bytes, pos);
|
||||
operations.push({
|
||||
operator: token.value,
|
||||
operands,
|
||||
start: operands.length > 0 ? operands[0].start : token.start,
|
||||
end: pos,
|
||||
});
|
||||
operands = [];
|
||||
}
|
||||
|
||||
if (operands.length > 0) {
|
||||
operations.push({
|
||||
operator: '',
|
||||
operands,
|
||||
start: operands[0].start,
|
||||
end: operands[operands.length - 1].end,
|
||||
});
|
||||
}
|
||||
return operations;
|
||||
}
|
||||
@@ -17,6 +17,15 @@ import {
|
||||
} from './load-pdf-document.js';
|
||||
|
||||
const ANNOTATION_TEXT_KEYS = ['Contents', 'RC', 'T', 'Subj'];
|
||||
const NON_TEXT_KEYS = new Set(
|
||||
['CreationDate', 'ModDate', 'M', 'ID'].map((key) => PDFName.of(key))
|
||||
);
|
||||
const SIGNATURE_TYPES = new Set(
|
||||
['Sig', 'DocTimeStamp'].map((type) => PDFName.of(type))
|
||||
);
|
||||
const SIGNATURE_BINARY_KEYS = new Set(
|
||||
['Contents', 'Cert'].map((key) => PDFName.of(key))
|
||||
);
|
||||
|
||||
function decodeText(value: PDFObject | undefined): string | null {
|
||||
if (value instanceof PDFString || value instanceof PDFHexString) {
|
||||
@@ -33,11 +42,20 @@ function decodeStream(stream: PDFRawStream): Uint8Array | null {
|
||||
}
|
||||
}
|
||||
|
||||
function removeTextFromDocument(doc: PDFDocument, term: string): void {
|
||||
function removeTextFromDocument(doc: PDFDocument, terms: string[]): void {
|
||||
const context = doc.context;
|
||||
const needle = term.toLowerCase();
|
||||
const pattern = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||
const contains = (text: string) => text.toLowerCase().includes(needle);
|
||||
const needles = terms.map((term) => term.toLowerCase());
|
||||
const pattern = new RegExp(
|
||||
[...terms]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('|'),
|
||||
'gi'
|
||||
);
|
||||
const contains = (text: string) => {
|
||||
const lowered = text.toLowerCase();
|
||||
return needles.some((needle) => lowered.includes(needle));
|
||||
};
|
||||
const valueContains = (value: PDFObject | undefined): boolean => {
|
||||
if (value instanceof PDFArray) return value.asArray().some(valueContains);
|
||||
const text = decodeText(value);
|
||||
@@ -180,7 +198,11 @@ function removeTextFromDocument(doc: PDFDocument, term: string): void {
|
||||
|
||||
const scrubContainer = (container: PDFDict | PDFArray) => {
|
||||
if (container instanceof PDFDict) {
|
||||
const type = container.get(PDFName.of('Type'));
|
||||
const isSignature = type instanceof PDFName && SIGNATURE_TYPES.has(type);
|
||||
for (const [key, value] of container.entries()) {
|
||||
if (NON_TEXT_KEYS.has(key)) continue;
|
||||
if (isSignature && SIGNATURE_BINARY_KEYS.has(key)) continue;
|
||||
const replacement = scrubValue(value);
|
||||
if (replacement) container.set(key, replacement);
|
||||
}
|
||||
@@ -218,11 +240,12 @@ function removeTextFromDocument(doc: PDFDocument, term: string): void {
|
||||
|
||||
export async function redactDocumentData(
|
||||
pdf: Uint8Array,
|
||||
term?: string
|
||||
terms: readonly string[] = []
|
||||
): Promise<Uint8Array> {
|
||||
const doc = await loadPdfDocument(pdf);
|
||||
if (term) {
|
||||
removeTextFromDocument(doc, term);
|
||||
const searchTerms = terms.filter((term) => term.length > 0);
|
||||
if (searchTerms.length > 0) {
|
||||
removeTextFromDocument(doc, searchTerms);
|
||||
} else {
|
||||
for (const page of doc.getPages()) page.node.delete(PDFName.of('Thumb'));
|
||||
}
|
||||
|
||||
+521
-128
@@ -1,9 +1,24 @@
|
||||
import { PDFDocument, PDFName, PDFDict, PDFArray, PDFStream } from 'pdf-lib';
|
||||
import {
|
||||
PDFDocument,
|
||||
PDFName,
|
||||
PDFDict,
|
||||
PDFArray,
|
||||
PDFStream,
|
||||
PDFObject,
|
||||
PDFRef,
|
||||
PDFRawStream,
|
||||
PDFFlateStream,
|
||||
PDFPageLeaf,
|
||||
PDFContext,
|
||||
decodePDFRawStream,
|
||||
} from 'pdf-lib';
|
||||
import {
|
||||
loadPdfDocument,
|
||||
removeUnreferencedObjects,
|
||||
} from './load-pdf-document.js';
|
||||
import type { PDFDocumentInternal } from '@/types';
|
||||
import { flattenFormsInDoc } from './flatten-annotations.js';
|
||||
import { parseContentOperations } from './pdf-content-tokens.js';
|
||||
import type { ContentOperation, PDFDocumentInternal } from '@/types';
|
||||
|
||||
function getCatalogDict(pdfDoc: PDFDocument): PDFDict {
|
||||
return pdfDoc.catalog;
|
||||
@@ -33,6 +48,22 @@ function getErrorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
function forEachDict(pdfDoc: PDFDocument, visit: (dict: PDFDict) => void) {
|
||||
const walk = (value: PDFObject) => {
|
||||
if (value instanceof PDFStream) {
|
||||
walk(value.dict);
|
||||
} else if (value instanceof PDFDict) {
|
||||
visit(value);
|
||||
for (const [, entry] of value.entries()) walk(entry);
|
||||
} else if (value instanceof PDFArray) {
|
||||
for (const entry of value.asArray()) walk(entry);
|
||||
}
|
||||
};
|
||||
for (const [, object] of pdfDoc.context.enumerateIndirectObjects()) {
|
||||
walk(object);
|
||||
}
|
||||
}
|
||||
|
||||
export interface SanitizeOptions {
|
||||
flattenForms: boolean;
|
||||
removeMetadata: boolean;
|
||||
@@ -74,10 +105,11 @@ function removeMetadataFromDoc(pdfDoc: PDFDocument) {
|
||||
pdfDoc.setProducer('');
|
||||
|
||||
try {
|
||||
const catalogDict = getCatalogDict(pdfDoc);
|
||||
if (catalogDict.has(PDFName.of('Metadata'))) {
|
||||
catalogDict.delete(PDFName.of('Metadata'));
|
||||
}
|
||||
forEachDict(pdfDoc, (dict) => {
|
||||
dict.delete(PDFName.of('Metadata'));
|
||||
dict.delete(PDFName.of('PieceInfo'));
|
||||
dict.delete(PDFName.of('Thumb'));
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not remove XMP metadata:', getErrorMessage(e));
|
||||
}
|
||||
@@ -90,15 +122,6 @@ function removeMetadataFromDoc(pdfDoc: PDFDocument) {
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not remove document IDs:', getErrorMessage(e));
|
||||
}
|
||||
|
||||
try {
|
||||
const catalogDict = getCatalogDict(pdfDoc);
|
||||
if (catalogDict.has(PDFName.of('PieceInfo'))) {
|
||||
catalogDict.delete(PDFName.of('PieceInfo'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not remove PieceInfo:', getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
function removeAnnotationsFromDoc(pdfDoc: PDFDocument) {
|
||||
@@ -115,9 +138,72 @@ function removeAnnotationsFromDoc(pdfDoc: PDFDocument) {
|
||||
}
|
||||
}
|
||||
|
||||
function flattenFormsInDoc(pdfDoc: PDFDocument) {
|
||||
const form = pdfDoc.getForm();
|
||||
form.flatten();
|
||||
const DANGEROUS_ACTIONS = new Set([
|
||||
'JavaScript',
|
||||
'Launch',
|
||||
'ImportData',
|
||||
'SubmitForm',
|
||||
'GoToE',
|
||||
'Rendition',
|
||||
]);
|
||||
|
||||
function isDangerousAction(action: PDFDict): boolean {
|
||||
const type = action.lookup(PDFName.of('S'));
|
||||
return (
|
||||
action.has(PDFName.of('JS')) ||
|
||||
(type instanceof PDFName && DANGEROUS_ACTIONS.has(type.decodeText()))
|
||||
);
|
||||
}
|
||||
|
||||
function keepSafeActions(
|
||||
context: PDFContext,
|
||||
value: PDFObject,
|
||||
seen: Set<PDFObject>
|
||||
): PDFObject[] {
|
||||
const resolved = context.lookup(value);
|
||||
if (resolved instanceof PDFArray) {
|
||||
if (seen.has(resolved)) return [];
|
||||
seen.add(resolved);
|
||||
return resolved
|
||||
.asArray()
|
||||
.flatMap((item) => keepSafeActions(context, item, seen));
|
||||
}
|
||||
if (!(resolved instanceof PDFDict) || !isDangerousAction(resolved)) {
|
||||
return [value];
|
||||
}
|
||||
if (seen.has(resolved)) return [];
|
||||
seen.add(resolved);
|
||||
const next = resolved.get(PDFName.of('Next'));
|
||||
return next ? keepSafeActions(context, next, seen) : [];
|
||||
}
|
||||
|
||||
function removeDangerousActions(context: PDFContext, dict: PDFDict) {
|
||||
dict.delete(PDFName.of('AA'));
|
||||
const keys = ['A', 'OpenAction', 'PA'];
|
||||
if (dict.has(PDFName.of('S'))) keys.push('Next');
|
||||
for (const key of keys) {
|
||||
const name = PDFName.of(key);
|
||||
const value = dict.get(name);
|
||||
if (value === undefined) continue;
|
||||
const resolved = context.lookup(value);
|
||||
if (key !== 'Next' && !(resolved instanceof PDFDict)) continue;
|
||||
const safeActions = keepSafeActions(context, value, new Set());
|
||||
const originalActions =
|
||||
resolved instanceof PDFArray ? resolved.asArray() : [value];
|
||||
if (
|
||||
safeActions.length === originalActions.length &&
|
||||
safeActions.every((item, i) => item === originalActions[i])
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (safeActions.length === 0) {
|
||||
dict.delete(name);
|
||||
} else if (key === 'Next' && safeActions.length > 1) {
|
||||
dict.set(name, context.obj(safeActions));
|
||||
} else {
|
||||
dict.set(name, safeActions[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeJavascriptFromDoc(pdfDoc: PDFDocument) {
|
||||
@@ -140,92 +226,13 @@ function removeJavascriptFromDoc(pdfDoc: PDFDocument) {
|
||||
}
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('OpenAction'))) {
|
||||
catalogDict.delete(PDFName.of('OpenAction'));
|
||||
const acroForm = catalogDict.lookup(PDFName.of('AcroForm'));
|
||||
if (acroForm instanceof PDFDict) {
|
||||
acroForm.delete(PDFName.of('XFA'));
|
||||
catalogDict.delete(PDFName.of('NeedsRendering'));
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('AA'))) {
|
||||
catalogDict.delete(PDFName.of('AA'));
|
||||
}
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
try {
|
||||
const pageDict = page.node;
|
||||
|
||||
if (pageDict.has(PDFName.of('AA'))) {
|
||||
pageDict.delete(PDFName.of('AA'));
|
||||
}
|
||||
|
||||
const annotRefs = pageDict.Annots()?.asArray() || [];
|
||||
for (const annotRef of annotRefs) {
|
||||
try {
|
||||
const annot = lookupAsDict(pdfDoc, annotRef);
|
||||
if (!annot) continue;
|
||||
|
||||
if (annot.has(PDFName.of('A'))) {
|
||||
const actionRef = annot.get(PDFName.of('A'));
|
||||
try {
|
||||
const actionDict = lookupAsDict(pdfDoc, actionRef);
|
||||
const actionType = actionDict
|
||||
?.get(PDFName.of('S'))
|
||||
?.toString()
|
||||
.substring(1);
|
||||
|
||||
if (actionType === 'JavaScript') {
|
||||
annot.delete(PDFName.of('A'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not read action:', getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
if (annot.has(PDFName.of('AA'))) {
|
||||
annot.delete(PDFName.of('AA'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn(
|
||||
'Could not process annotation for JS:',
|
||||
getErrorMessage(e)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not remove page actions:', getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const acroFormRef = catalogDict.get(PDFName.of('AcroForm'));
|
||||
if (acroFormRef) {
|
||||
const acroFormDict = lookupAsDict(pdfDoc, acroFormRef);
|
||||
const fieldsRef = acroFormDict?.get(PDFName.of('Fields'));
|
||||
|
||||
if (fieldsRef) {
|
||||
const fieldsArray = lookupAsArray(pdfDoc, fieldsRef);
|
||||
const fields = fieldsArray?.asArray() || [];
|
||||
|
||||
for (const fieldRef of fields) {
|
||||
try {
|
||||
const field = lookupAsDict(pdfDoc, fieldRef);
|
||||
if (!field) continue;
|
||||
|
||||
if (field.has(PDFName.of('A'))) {
|
||||
field.delete(PDFName.of('A'));
|
||||
}
|
||||
|
||||
if (field.has(PDFName.of('AA'))) {
|
||||
field.delete(PDFName.of('AA'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not process field for JS:', getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not process form fields for JS:', getErrorMessage(e));
|
||||
}
|
||||
forEachDict(pdfDoc, (dict) => removeDangerousActions(pdfDoc.context, dict));
|
||||
}
|
||||
|
||||
function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) {
|
||||
@@ -302,37 +309,410 @@ function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeLayersFromDoc(pdfDoc: PDFDocument) {
|
||||
const catalogDict = getCatalogDict(pdfDoc);
|
||||
function createOptionalContentVisibility(pdfDoc: PDFDocument) {
|
||||
const context = pdfDoc.context;
|
||||
const ocProperties = pdfDoc.catalog.lookup(PDFName.of('OCProperties'));
|
||||
const config =
|
||||
ocProperties instanceof PDFDict
|
||||
? ocProperties.lookup(PDFName.of('D'))
|
||||
: undefined;
|
||||
const getConfigGroups = (key: string) => {
|
||||
const list =
|
||||
config instanceof PDFDict ? config.lookup(PDFName.of(key)) : undefined;
|
||||
return new Set(
|
||||
list instanceof PDFArray
|
||||
? list.asArray().map((item) => context.lookup(item))
|
||||
: []
|
||||
);
|
||||
};
|
||||
const onGroups = getConfigGroups('ON');
|
||||
const offGroups = getConfigGroups('OFF');
|
||||
const hiddenByDefault =
|
||||
config instanceof PDFDict &&
|
||||
config.lookup(PDFName.of('BaseState')) === PDFName.of('OFF');
|
||||
const isGroupVisible = (group: PDFObject) =>
|
||||
!offGroups.has(group) && (!hiddenByDefault || onGroups.has(group));
|
||||
const expressionCache = new Map<PDFObject, boolean | undefined>();
|
||||
|
||||
if (catalogDict.has(PDFName.of('OCProperties'))) {
|
||||
catalogDict.delete(PDFName.of('OCProperties'));
|
||||
const isExpressionVisible = (
|
||||
value: PDFObject | undefined,
|
||||
depth: number
|
||||
): boolean | undefined => {
|
||||
const resolved = context.lookup(value);
|
||||
if (resolved instanceof PDFDict) return isGroupVisible(resolved);
|
||||
if (!(resolved instanceof PDFArray) || resolved.size() < 2 || depth > 32) {
|
||||
return undefined;
|
||||
}
|
||||
if (expressionCache.has(resolved)) return expressionCache.get(resolved);
|
||||
const operator = resolved.lookup(0);
|
||||
const results = resolved
|
||||
.asArray()
|
||||
.slice(1)
|
||||
.map((item) => isExpressionVisible(item, depth + 1));
|
||||
let result: boolean | undefined;
|
||||
if (!results.every((item) => item !== undefined)) {
|
||||
result = undefined;
|
||||
} else if (operator === PDFName.of('And')) {
|
||||
result = results.every(Boolean);
|
||||
} else if (operator === PDFName.of('Or')) {
|
||||
result = results.some(Boolean);
|
||||
} else if (operator === PDFName.of('Not') && results.length === 1) {
|
||||
result = !results[0];
|
||||
}
|
||||
expressionCache.set(resolved, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
return (value: PDFObject | undefined): boolean => {
|
||||
const dict = context.lookup(value);
|
||||
if (!(dict instanceof PDFDict)) return true;
|
||||
if (dict.lookup(PDFName.of('Type')) !== PDFName.of('OCMD')) {
|
||||
return isGroupVisible(dict);
|
||||
}
|
||||
if (dict.has(PDFName.of('VE'))) {
|
||||
return isExpressionVisible(dict.get(PDFName.of('VE')), 0) === true;
|
||||
}
|
||||
const groups = dict.lookup(PDFName.of('OCGs'));
|
||||
const states = (
|
||||
groups instanceof PDFArray
|
||||
? groups.asArray().map((group) => context.lookup(group))
|
||||
: [groups]
|
||||
)
|
||||
.filter((group) => group instanceof PDFDict)
|
||||
.map((group) => isGroupVisible(group));
|
||||
if (states.length === 0) return true;
|
||||
const policy = dict.lookup(PDFName.of('P'));
|
||||
if (policy === undefined || policy === PDFName.of('AnyOn')) {
|
||||
return states.some(Boolean);
|
||||
}
|
||||
if (policy === PDFName.of('AllOn')) return states.every(Boolean);
|
||||
if (policy === PDFName.of('AnyOff')) return !states.every(Boolean);
|
||||
if (policy === PDFName.of('AllOff')) return !states.some(Boolean);
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
const STATE_OPERATORS = new Set(
|
||||
'q Q cm w J j M d ri i gs CS cs SC SCN sc scn G g RG rg K k BT ET Tc Tw Tz TL Tf Tr Ts Td TD Tm T* BX EX'.split(
|
||||
' '
|
||||
)
|
||||
);
|
||||
const PATH_OPERATORS = new Set('m l c v y h re W W*'.split(' '));
|
||||
const PAINT_OPERATORS = new Set('S s f F f* B B* b b* n'.split(' '));
|
||||
|
||||
function readStreamBytes(stream: PDFStream): Uint8Array {
|
||||
if (stream instanceof PDFRawStream) {
|
||||
return decodePDFRawStream(stream).decode();
|
||||
}
|
||||
if (stream instanceof PDFFlateStream) return stream.getUnencodedContents();
|
||||
return stream.getContents();
|
||||
}
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
try {
|
||||
const pageDict = page.node;
|
||||
const NEWLINE = new Uint8Array([0x0a]);
|
||||
|
||||
if (pageDict.has(PDFName.of('OCProperties'))) {
|
||||
pageDict.delete(PDFName.of('OCProperties'));
|
||||
function concatBytes(pieces: Uint8Array[]): Uint8Array {
|
||||
const result = new Uint8Array(
|
||||
pieces.reduce((size, piece) => size + piece.length, 0)
|
||||
);
|
||||
let offset = 0;
|
||||
for (const piece of pieces) {
|
||||
result.set(piece, offset);
|
||||
offset += piece.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function readPageContent(page: PDFPageLeaf): Uint8Array | undefined {
|
||||
const contents = page.Contents();
|
||||
if (!contents) return undefined;
|
||||
const streams =
|
||||
contents instanceof PDFArray ? contents.asArray() : [contents];
|
||||
return concatBytes(
|
||||
streams.flatMap((item) => {
|
||||
const stream = page.context.lookup(item);
|
||||
return stream instanceof PDFStream
|
||||
? [readStreamBytes(stream), NEWLINE]
|
||||
: [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isContentStream(dict: PDFDict): boolean {
|
||||
const subtype = dict.lookup(PDFName.of('Subtype'));
|
||||
return (
|
||||
subtype === PDFName.of('Form') ||
|
||||
(subtype === undefined &&
|
||||
dict.has(PDFName.of('BBox')) &&
|
||||
!dict.has(PDFName.of('ShadingType')))
|
||||
);
|
||||
}
|
||||
|
||||
function removeHiddenContent(
|
||||
context: PDFContext,
|
||||
bytes: Uint8Array,
|
||||
resources: PDFDict | undefined,
|
||||
isVisible: (value: PDFObject | undefined) => boolean
|
||||
): Uint8Array | undefined {
|
||||
const hiddenXObjects = new Set<PDFName>();
|
||||
const xObjects = resources?.lookup(PDFName.of('XObject'));
|
||||
if (xObjects instanceof PDFDict) {
|
||||
for (const [name, value] of xObjects.entries()) {
|
||||
const xObject = context.lookup(value);
|
||||
if (
|
||||
xObject instanceof PDFStream &&
|
||||
xObject.dict.has(PDFName.of('OC')) &&
|
||||
!isVisible(xObject.dict.get(PDFName.of('OC')))
|
||||
) {
|
||||
hiddenXObjects.add(name);
|
||||
}
|
||||
|
||||
const resourcesRef = pageDict.get(PDFName.of('Resources'));
|
||||
if (resourcesRef) {
|
||||
try {
|
||||
const resourcesDict = lookupAsDict(pdfDoc, resourcesRef);
|
||||
if (resourcesDict?.has(PDFName.of('Properties'))) {
|
||||
resourcesDict.delete(PDFName.of('Properties'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not access Resources:', getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not remove page layers:', getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
if (
|
||||
hiddenXObjects.size === 0 &&
|
||||
!new TextDecoder('latin1').decode(bytes).includes('BDC')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const properties = resources?.lookup(PDFName.of('Properties'));
|
||||
const startsHiddenSection = (operation: ContentOperation) => {
|
||||
const [tag, property] = operation.operands;
|
||||
if (tag?.kind !== 'name' || PDFName.of(tag.value) !== PDFName.of('OC')) {
|
||||
return false;
|
||||
}
|
||||
const value =
|
||||
property?.kind === 'name' && properties instanceof PDFDict
|
||||
? properties.get(PDFName.of(property.value))
|
||||
: undefined;
|
||||
if (value === undefined) {
|
||||
throw new Error('Unresolved optional content reference');
|
||||
}
|
||||
return !isVisible(value);
|
||||
};
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const pieces: Uint8Array[] = [];
|
||||
const write = (...parts: Uint8Array[]) => pieces.push(NEWLINE, ...parts);
|
||||
const writeOperation = (operation: ContentOperation) =>
|
||||
write(bytes.subarray(operation.start, operation.end));
|
||||
const openSections: boolean[] = [];
|
||||
let hiddenDepth = 0;
|
||||
let path: ContentOperation[] = [];
|
||||
let changed = false;
|
||||
let previousEnd = 0;
|
||||
|
||||
for (const operation of parseContentOperations(bytes)) {
|
||||
const { operator, operands } = operation;
|
||||
const hidden = hiddenDepth > 0;
|
||||
const gapStart = previousEnd;
|
||||
previousEnd = operation.end;
|
||||
const keepOriginal = () =>
|
||||
pieces.push(bytes.subarray(gapStart, operation.end));
|
||||
if (operator === 'BMC' || operator === 'BDC') {
|
||||
const startsHidden =
|
||||
!hidden && operator === 'BDC' && startsHiddenSection(operation);
|
||||
openSections.push(startsHidden);
|
||||
if (startsHidden) hiddenDepth++;
|
||||
if (hidden || startsHidden) {
|
||||
changed = true;
|
||||
} else {
|
||||
keepOriginal();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (operator === 'EMC') {
|
||||
if (openSections.pop()) hiddenDepth--;
|
||||
if (hidden) {
|
||||
changed = true;
|
||||
if (hiddenDepth === 0) path = [];
|
||||
} else {
|
||||
keepOriginal();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!hidden) {
|
||||
const target = operands[0];
|
||||
if (
|
||||
operator === 'Do' &&
|
||||
target?.kind === 'name' &&
|
||||
hiddenXObjects.has(PDFName.of(target.value))
|
||||
) {
|
||||
changed = true;
|
||||
} else {
|
||||
keepOriginal();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
if (STATE_OPERATORS.has(operator)) {
|
||||
writeOperation(operation);
|
||||
} else if (PATH_OPERATORS.has(operator)) {
|
||||
path.push(operation);
|
||||
} else if (PAINT_OPERATORS.has(operator)) {
|
||||
if (
|
||||
path.some((item) => item.operator === 'W' || item.operator === 'W*')
|
||||
) {
|
||||
path.forEach(writeOperation);
|
||||
write(encoder.encode('n'));
|
||||
}
|
||||
path = [];
|
||||
} else if (operator === "'") {
|
||||
write(encoder.encode('T*'));
|
||||
} else if (operator === '"' && operands.length === 3) {
|
||||
write(
|
||||
bytes.subarray(operands[0].start, operands[0].end),
|
||||
encoder.encode(' Tw '),
|
||||
bytes.subarray(operands[1].start, operands[1].end),
|
||||
encoder.encode(' Tc T*')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return undefined;
|
||||
if (hiddenDepth === 0) pieces.push(bytes.subarray(previousEnd));
|
||||
return concatBytes(pieces);
|
||||
}
|
||||
|
||||
function mergePageResources(pdfDoc: PDFDocument): PDFDict {
|
||||
const context = pdfDoc.context;
|
||||
const merged = new Map<string, Map<string, PDFObject>>();
|
||||
const conflicts = new Set<string>();
|
||||
|
||||
for (const page of pdfDoc.getPages()) {
|
||||
const resources = page.node.Resources();
|
||||
for (const category of ['XObject', 'Properties']) {
|
||||
const entries = resources?.lookup(PDFName.of(category));
|
||||
if (!(entries instanceof PDFDict)) continue;
|
||||
const mergedEntries = merged.get(category) ?? new Map();
|
||||
merged.set(category, mergedEntries);
|
||||
for (const [name, value] of entries.entries()) {
|
||||
const key = `${category}/${name.decodeText()}`;
|
||||
const existing = mergedEntries.get(name.decodeText());
|
||||
if (existing && existing.toString() !== value.toString()) {
|
||||
conflicts.add(key);
|
||||
}
|
||||
mergedEntries.set(name.decodeText(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resources = context.obj({});
|
||||
for (const [category, entries] of merged) {
|
||||
const dict = context.obj({});
|
||||
for (const [name, value] of entries) {
|
||||
if (!conflicts.has(`${category}/${name}`)) {
|
||||
dict.set(PDFName.of(name), value);
|
||||
}
|
||||
}
|
||||
resources.set(PDFName.of(category), dict);
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
function removeLayersFromDoc(pdfDoc: PDFDocument) {
|
||||
const catalogDict = getCatalogDict(pdfDoc);
|
||||
const context = pdfDoc.context;
|
||||
const pageContents: [PDFPageLeaf, Uint8Array][] = [];
|
||||
const streamContents: [PDFRef, PDFStream, Uint8Array][] = [];
|
||||
const hiddenAnnots = new Set<PDFObject>();
|
||||
const hiddenXObjects = new Set<PDFObject>();
|
||||
|
||||
if (catalogDict.has(PDFName.of('OCProperties'))) {
|
||||
const isVisible = createOptionalContentVisibility(pdfDoc);
|
||||
|
||||
for (const page of pdfDoc.getPages()) {
|
||||
const content = readPageContent(page.node);
|
||||
const filtered =
|
||||
content &&
|
||||
removeHiddenContent(context, content, page.node.Resources(), isVisible);
|
||||
if (filtered) pageContents.push([page.node, filtered]);
|
||||
|
||||
for (const annotRef of page.node.Annots()?.asArray() ?? []) {
|
||||
const annot = context.lookup(annotRef);
|
||||
if (
|
||||
annot instanceof PDFDict &&
|
||||
annot.has(PDFName.of('OC')) &&
|
||||
!isVisible(annot.get(PDFName.of('OC')))
|
||||
) {
|
||||
hiddenAnnots.add(annotRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inheritedResources = mergePageResources(pdfDoc);
|
||||
for (const [ref, object] of context.enumerateIndirectObjects()) {
|
||||
if (!(object instanceof PDFStream)) continue;
|
||||
if (
|
||||
object.dict.has(PDFName.of('OC')) &&
|
||||
!isVisible(object.dict.get(PDFName.of('OC')))
|
||||
) {
|
||||
hiddenXObjects.add(ref);
|
||||
}
|
||||
if (!isContentStream(object.dict)) continue;
|
||||
const resources = object.dict.lookup(PDFName.of('Resources'));
|
||||
const filtered = removeHiddenContent(
|
||||
context,
|
||||
readStreamBytes(object),
|
||||
resources instanceof PDFDict ? resources : inheritedResources,
|
||||
isVisible
|
||||
);
|
||||
if (filtered) streamContents.push([ref, object, filtered]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [page, content] of pageContents) {
|
||||
page.set(
|
||||
PDFName.of('Contents'),
|
||||
context.register(context.flateStream(content))
|
||||
);
|
||||
}
|
||||
|
||||
for (const [ref, stream, content] of streamContents) {
|
||||
const replacement = context.flateStream(content);
|
||||
for (const [key, value] of stream.dict.entries()) {
|
||||
if (
|
||||
!['Filter', 'DecodeParms', 'Length', 'DL'].includes(key.decodeText())
|
||||
) {
|
||||
replacement.dict.set(key, value);
|
||||
}
|
||||
}
|
||||
context.assign(ref, replacement);
|
||||
}
|
||||
|
||||
for (const page of pdfDoc.getPages()) {
|
||||
const annots = page.node.Annots();
|
||||
if (!annots) continue;
|
||||
for (let i = annots.size() - 1; i >= 0; i--) {
|
||||
if (hiddenAnnots.has(annots.get(i))) annots.remove(i);
|
||||
}
|
||||
}
|
||||
|
||||
catalogDict.delete(PDFName.of('OCProperties'));
|
||||
|
||||
forEachDict(pdfDoc, (dict) => {
|
||||
dict.delete(PDFName.of('OCProperties'));
|
||||
dict.delete(PDFName.of('OC'));
|
||||
|
||||
const xObjects = dict.lookup(PDFName.of('XObject'));
|
||||
if (xObjects instanceof PDFDict) {
|
||||
for (const [name, value] of xObjects.entries()) {
|
||||
if (hiddenXObjects.has(value)) xObjects.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
const properties = dict.lookup(PDFName.of('Properties'));
|
||||
if (properties instanceof PDFDict) {
|
||||
for (const [name, value] of properties.entries()) {
|
||||
const type = lookupAsDict(pdfDoc, value)?.lookup(PDFName.of('Type'));
|
||||
if (type === PDFName.of('OCG') || type === PDFName.of('OCMD')) {
|
||||
properties.delete(name);
|
||||
}
|
||||
}
|
||||
if (properties.entries().length === 0) {
|
||||
dict.delete(PDFName.of('Properties'));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeLinksFromDoc(pdfDoc: PDFDocument) {
|
||||
@@ -565,8 +945,9 @@ function removeFontsFromDoc(pdfDoc: PDFDocument) {
|
||||
export async function sanitizePdf(
|
||||
pdfBytes: Uint8Array,
|
||||
options: SanitizeOptions
|
||||
): Promise<{ pdfDoc: PDFDocument; bytes: Uint8Array }> {
|
||||
): Promise<{ pdfDoc: PDFDocument; bytes: Uint8Array; skipped: string[] }> {
|
||||
const pdfDoc = await loadPdfDocument(pdfBytes);
|
||||
const skipped: string[] = [];
|
||||
|
||||
if (options.flattenForms) {
|
||||
try {
|
||||
@@ -583,6 +964,7 @@ export async function sanitizePdf(
|
||||
'Could not remove AcroForm:',
|
||||
getErrorMessage(removeError)
|
||||
);
|
||||
skipped.push('flatten forms');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -600,6 +982,7 @@ export async function sanitizePdf(
|
||||
removeJavascriptFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove JavaScript: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove JavaScript');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,6 +991,7 @@ export async function sanitizePdf(
|
||||
removeEmbeddedFilesFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove embedded files: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove embedded files');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,6 +1000,7 @@ export async function sanitizePdf(
|
||||
removeLayersFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove layers: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove layers');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,6 +1009,7 @@ export async function sanitizePdf(
|
||||
removeLinksFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove links: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove links');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,6 +1018,7 @@ export async function sanitizePdf(
|
||||
removeStructureTreeFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove structure tree: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove the structure tree');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,6 +1027,7 @@ export async function sanitizePdf(
|
||||
removeMarkInfoFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove MarkInfo: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove MarkInfo');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,9 +1036,14 @@ export async function sanitizePdf(
|
||||
removeFontsFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove fonts: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove fonts');
|
||||
}
|
||||
}
|
||||
|
||||
const savedBytes = await pdfDoc.save();
|
||||
return { pdfDoc, bytes: await removeUnreferencedObjects(savedBytes) };
|
||||
return {
|
||||
pdfDoc,
|
||||
bytes: await removeUnreferencedObjects(savedBytes),
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
const ALLOWED_TAGS = ['br', 'strong', 'em'];
|
||||
|
||||
export function sanitizeTranslationHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR: [] });
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import type { NodeCategory, NodeMeta, SocketData } from '../types';
|
||||
import { getControlSpec, sanitizeWithControlSpec } from './control-specs';
|
||||
|
||||
export abstract class BaseWorkflowNode extends ClassicPreset.Node {
|
||||
abstract readonly category: NodeCategory;
|
||||
@@ -41,6 +42,10 @@ export abstract class BaseWorkflowNode extends ClassicPreset.Node {
|
||||
if (typeof value !== typeof current) {
|
||||
return current;
|
||||
}
|
||||
return value;
|
||||
return sanitizeWithControlSpec(
|
||||
getControlSpec(this.nodeType, key),
|
||||
value,
|
||||
current
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import type { WorkflowControlOption, WorkflowControlSpec } from '@/types';
|
||||
import { getAvailableTesseractLanguageEntries } from '../../utils/tesseract-language-availability.js';
|
||||
|
||||
export const MAX_TEXT_CONTROL_LENGTH = 1000;
|
||||
|
||||
const BOOLEAN: WorkflowControlSpec = { kind: 'boolean' };
|
||||
const COLOR: WorkflowControlSpec = { kind: 'color' };
|
||||
const PASSWORD: WorkflowControlSpec = { kind: 'password' };
|
||||
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
function toOptions(entries: [string, string][]): WorkflowControlOption[] {
|
||||
return entries.map(([value, label]) => ({ value, label }));
|
||||
}
|
||||
|
||||
function selectSpec(...entries: [string, string][]): WorkflowControlSpec {
|
||||
return { kind: 'select', options: toOptions(entries) };
|
||||
}
|
||||
|
||||
function numberSpec(
|
||||
min: number,
|
||||
max: number,
|
||||
step?: number
|
||||
): WorkflowControlSpec {
|
||||
return { kind: 'number', min, max, step };
|
||||
}
|
||||
|
||||
const PAGE_ORIENTATION = selectSpec(
|
||||
['auto', 'Auto (Keep Original)'],
|
||||
['portrait', 'Portrait'],
|
||||
['landscape', 'Landscape']
|
||||
);
|
||||
|
||||
const PAGE_COORDINATE = numberSpec(0, 14400);
|
||||
|
||||
const CONTROL_SPECS: Record<string, Record<string, WorkflowControlSpec>> = {
|
||||
AddBlankPageNode: {
|
||||
blankPosition: selectSpec(
|
||||
['end', 'End'],
|
||||
['start', 'Beginning'],
|
||||
['after', 'After Page...']
|
||||
),
|
||||
afterPage: numberSpec(1, 100000, 1),
|
||||
count: numberSpec(1, 100, 1),
|
||||
},
|
||||
AdjustColorsNode: {
|
||||
brightness: numberSpec(-100, 100, 1),
|
||||
contrast: numberSpec(-100, 100, 1),
|
||||
saturation: numberSpec(-100, 100, 1),
|
||||
hueShift: numberSpec(0, 360, 1),
|
||||
temperature: numberSpec(-50, 50, 1),
|
||||
tint: numberSpec(-50, 50, 1),
|
||||
gamma: numberSpec(0.2, 5, 0.1),
|
||||
sepia: numberSpec(0, 100, 1),
|
||||
},
|
||||
AlternateMergeNode: {
|
||||
retainPageLabels: BOOLEAN,
|
||||
},
|
||||
BackgroundColorNode: {
|
||||
color: COLOR,
|
||||
},
|
||||
BookletNode: {
|
||||
gridMode: selectSpec(
|
||||
['1x2', '1x2 (Booklet)'],
|
||||
['2x2', '2x2 (4-up)'],
|
||||
['2x4', '2x4 (8-up)'],
|
||||
['4x4', '4x4 (16-up)']
|
||||
),
|
||||
paperSize: selectSpec(
|
||||
['Letter', 'Letter'],
|
||||
['A4', 'A4'],
|
||||
['A3', 'A3'],
|
||||
['Tabloid', 'Tabloid'],
|
||||
['Legal', 'Legal']
|
||||
),
|
||||
orientation: PAGE_ORIENTATION,
|
||||
},
|
||||
CombineSinglePageNode: {
|
||||
orientation: selectSpec(
|
||||
['vertical', 'Vertical'],
|
||||
['horizontal', 'Horizontal']
|
||||
),
|
||||
spacing: numberSpec(0, 1000),
|
||||
backgroundColor: COLOR,
|
||||
separator: BOOLEAN,
|
||||
separatorThickness: numberSpec(0.5, 50, 0.5),
|
||||
separatorColor: COLOR,
|
||||
},
|
||||
CompressNode: {
|
||||
algorithm: selectSpec(
|
||||
['condense', 'Condense (Smart, requires PyMuPDF)'],
|
||||
['photon', 'Photon (Rasterize pages)']
|
||||
),
|
||||
compressionLevel: selectSpec(
|
||||
['light', 'Light'],
|
||||
['balanced', 'Balanced'],
|
||||
['aggressive', 'Aggressive'],
|
||||
['extreme', 'Extreme']
|
||||
),
|
||||
imageQuality: numberSpec(1, 100, 1),
|
||||
dpiTarget: numberSpec(36, 300, 1),
|
||||
dpiThreshold: numberSpec(72, 600, 1),
|
||||
removeMetadata: BOOLEAN,
|
||||
subsetFonts: BOOLEAN,
|
||||
convertToGrayscale: BOOLEAN,
|
||||
removeThumbnails: BOOLEAN,
|
||||
},
|
||||
CropNode: {
|
||||
top: PAGE_COORDINATE,
|
||||
bottom: PAGE_COORDINATE,
|
||||
left: PAGE_COORDINATE,
|
||||
right: PAGE_COORDINATE,
|
||||
},
|
||||
DecryptNode: {
|
||||
password: PASSWORD,
|
||||
},
|
||||
DeskewNode: {
|
||||
skewThreshold: selectSpec(
|
||||
['0.1', '0.1° (Very Sensitive)'],
|
||||
['0.5', '0.5° (Default)'],
|
||||
['1.0', '1.0° (Normal)'],
|
||||
['2.0', '2.0° (Less Sensitive)']
|
||||
),
|
||||
processingDpi: selectSpec(
|
||||
['100', '100 (Fast)'],
|
||||
['150', '150 (Default)'],
|
||||
['200', '200 (Better)'],
|
||||
['300', '300 (Best Quality)']
|
||||
),
|
||||
},
|
||||
DividePagesNode: {
|
||||
direction: selectSpec(
|
||||
['vertical', 'Vertical'],
|
||||
['horizontal', 'Horizontal']
|
||||
),
|
||||
},
|
||||
EmailToPdfNode: {
|
||||
pageSize: selectSpec(
|
||||
['a4', 'A4'],
|
||||
['letter', 'Letter'],
|
||||
['legal', 'Legal']
|
||||
),
|
||||
includeCcBcc: BOOLEAN,
|
||||
includeAttachments: BOOLEAN,
|
||||
},
|
||||
EncryptNode: {
|
||||
userPassword: PASSWORD,
|
||||
ownerPassword: PASSWORD,
|
||||
},
|
||||
FixPageSizeNode: {
|
||||
targetSize: selectSpec(
|
||||
['A4', 'A4'],
|
||||
['Letter', 'Letter'],
|
||||
['Legal', 'Legal'],
|
||||
['A3', 'A3'],
|
||||
['A5', 'A5'],
|
||||
['Tabloid', 'Tabloid'],
|
||||
['Custom', 'Custom']
|
||||
),
|
||||
orientation: PAGE_ORIENTATION,
|
||||
scalingMode: selectSpec(
|
||||
['fit', 'Fit (keep full page visible)'],
|
||||
['fill', 'Fill (cover full target page)']
|
||||
),
|
||||
backgroundColor: COLOR,
|
||||
customWidth: numberSpec(1, 5080),
|
||||
customHeight: numberSpec(1, 5080),
|
||||
customUnits: selectSpec(['mm', 'Millimeters (mm)'], ['in', 'Inches (in)']),
|
||||
},
|
||||
HeaderFooterNode: {
|
||||
fontSize: numberSpec(4, 72, 1),
|
||||
color: COLOR,
|
||||
},
|
||||
MergeNode: {
|
||||
retainPageLabels: BOOLEAN,
|
||||
},
|
||||
NUpNode: {
|
||||
pagesPerSheet: selectSpec(['2', '2'], ['4', '4'], ['9', '9'], ['16', '16']),
|
||||
orientation: PAGE_ORIENTATION,
|
||||
margins: BOOLEAN,
|
||||
border: BOOLEAN,
|
||||
borderColor: COLOR,
|
||||
},
|
||||
OCRNode: {
|
||||
language: {
|
||||
kind: 'multiselect',
|
||||
options: toOptions(getAvailableTesseractLanguageEntries()),
|
||||
separator: '+',
|
||||
},
|
||||
resolution: selectSpec(
|
||||
['2.0', 'Standard (192 DPI)'],
|
||||
['3.0', 'High (288 DPI)'],
|
||||
['4.0', 'Ultra (384 DPI)']
|
||||
),
|
||||
binarize: BOOLEAN,
|
||||
},
|
||||
PageNumbersNode: {
|
||||
position: selectSpec(
|
||||
['bottom-center', 'Bottom Center'],
|
||||
['bottom-left', 'Bottom Left'],
|
||||
['bottom-right', 'Bottom Right'],
|
||||
['top-center', 'Top Center'],
|
||||
['top-left', 'Top Left'],
|
||||
['top-right', 'Top Right']
|
||||
),
|
||||
fontSize: numberSpec(4, 72, 1),
|
||||
numberFormat: selectSpec(
|
||||
['simple', 'Simple (1, 2, 3)'],
|
||||
['page_x_of_y', 'Page X of Y']
|
||||
),
|
||||
color: COLOR,
|
||||
},
|
||||
PdfToImagesNode: {
|
||||
format: selectSpec(
|
||||
['jpg', 'JPG'],
|
||||
['png', 'PNG'],
|
||||
['webp', 'WebP'],
|
||||
['svg', 'SVG']
|
||||
),
|
||||
quality: numberSpec(10, 100, 1),
|
||||
dpi: numberSpec(72, 600, 1),
|
||||
},
|
||||
PdfToPdfANode: {
|
||||
level: selectSpec(
|
||||
['PDF/A-1b', 'PDF/A-1b (Strict, no transparency)'],
|
||||
['PDF/A-2b', 'PDF/A-2b (Recommended)'],
|
||||
['PDF/A-3b', 'PDF/A-3b (Modern, allows attachments)']
|
||||
),
|
||||
preFlatten: BOOLEAN,
|
||||
},
|
||||
PosterizeNode: {
|
||||
rows: numberSpec(1, 8, 1),
|
||||
cols: numberSpec(1, 8, 1),
|
||||
},
|
||||
RasterizeNode: {
|
||||
rasterizeDpi: selectSpec(
|
||||
['72', '72 (Screen)'],
|
||||
['150', '150 (Default)'],
|
||||
['200', '200 (Good)'],
|
||||
['300', '300 (Print)'],
|
||||
['600', '600 (High Quality)']
|
||||
),
|
||||
imageFormat: selectSpec(
|
||||
['png', 'PNG (Lossless)'],
|
||||
['jpeg', 'JPEG (Smaller file size)']
|
||||
),
|
||||
grayscale: BOOLEAN,
|
||||
},
|
||||
RedactNode: {
|
||||
redactMode: selectSpec(
|
||||
['text', 'Search Text'],
|
||||
['area', 'Area (Coordinates)']
|
||||
),
|
||||
x0: PAGE_COORDINATE,
|
||||
y0: PAGE_COORDINATE,
|
||||
x1: PAGE_COORDINATE,
|
||||
y1: PAGE_COORDINATE,
|
||||
fillColor: COLOR,
|
||||
},
|
||||
RemoveBlankPagesNode: {
|
||||
threshold: numberSpec(0.1, 5, 0.1),
|
||||
},
|
||||
RotateNode: {
|
||||
angle: selectSpec(
|
||||
['90', '90° Clockwise'],
|
||||
['180', '180°'],
|
||||
['270', '90° Counter-clockwise']
|
||||
),
|
||||
},
|
||||
SanitizeNode: {
|
||||
flattenForms: BOOLEAN,
|
||||
removeMetadata: BOOLEAN,
|
||||
removeAnnotations: BOOLEAN,
|
||||
removeJavascript: BOOLEAN,
|
||||
removeEmbeddedFiles: BOOLEAN,
|
||||
removeLayers: BOOLEAN,
|
||||
removeLinks: BOOLEAN,
|
||||
removeStructureTree: BOOLEAN,
|
||||
removeMarkInfo: BOOLEAN,
|
||||
removeFonts: BOOLEAN,
|
||||
},
|
||||
ScannerEffectNode: {
|
||||
grayscale: BOOLEAN,
|
||||
border: BOOLEAN,
|
||||
rotation: numberSpec(-5, 5, 0.1),
|
||||
rotationVariance: numberSpec(0, 3, 0.1),
|
||||
brightness: numberSpec(-50, 50, 1),
|
||||
contrast: numberSpec(-50, 50, 1),
|
||||
blur: numberSpec(0, 3, 0.1),
|
||||
noise: numberSpec(0, 50, 1),
|
||||
yellowish: numberSpec(0, 50, 1),
|
||||
resolution: numberSpec(72, 300, 1),
|
||||
},
|
||||
TableOfContentsNode: {
|
||||
fontSize: numberSpec(10, 72, 1),
|
||||
fontFamily: selectSpec(
|
||||
['0', 'Times Roman'],
|
||||
['1', 'Times Bold'],
|
||||
['2', 'Times Italic'],
|
||||
['3', 'Times Bold Italic'],
|
||||
['4', 'Helvetica'],
|
||||
['5', 'Helvetica Bold'],
|
||||
['6', 'Helvetica Oblique'],
|
||||
['7', 'Helvetica Bold Oblique'],
|
||||
['8', 'Courier'],
|
||||
['9', 'Courier Bold'],
|
||||
['10', 'Courier Oblique'],
|
||||
['11', 'Courier Bold Oblique']
|
||||
),
|
||||
},
|
||||
TextToPdfNode: {
|
||||
fontSize: numberSpec(6, 72, 1),
|
||||
fontFamily: selectSpec(
|
||||
['helv', 'Helvetica'],
|
||||
['times', 'Times Roman'],
|
||||
['cour', 'Courier'],
|
||||
['tiro', 'Times Italic']
|
||||
),
|
||||
fontColor: COLOR,
|
||||
},
|
||||
WatermarkNode: {
|
||||
fontSize: numberSpec(10, 200, 1),
|
||||
color: COLOR,
|
||||
opacity: numberSpec(0, 100, 1),
|
||||
angle: numberSpec(-180, 180, 1),
|
||||
position: selectSpec(
|
||||
['center', 'Center'],
|
||||
['top-left', 'Top Left'],
|
||||
['top', 'Top'],
|
||||
['top-right', 'Top Right'],
|
||||
['left', 'Left'],
|
||||
['right', 'Right'],
|
||||
['bottom-left', 'Bottom Left'],
|
||||
['bottom', 'Bottom'],
|
||||
['bottom-right', 'Bottom Right']
|
||||
),
|
||||
tileGapX: numberSpec(0, 200, 1),
|
||||
tileGapY: numberSpec(0, 200, 1),
|
||||
},
|
||||
};
|
||||
|
||||
export function getControlSpec(
|
||||
nodeType: string,
|
||||
key: string
|
||||
): WorkflowControlSpec | undefined {
|
||||
if (!Object.hasOwn(CONTROL_SPECS, nodeType)) return undefined;
|
||||
const specs = CONTROL_SPECS[nodeType];
|
||||
return Object.hasOwn(specs, key) ? specs[key] : undefined;
|
||||
}
|
||||
|
||||
export function sanitizeWithControlSpec(
|
||||
spec: WorkflowControlSpec | undefined,
|
||||
value: unknown,
|
||||
fallback: unknown
|
||||
): unknown {
|
||||
switch (spec?.kind) {
|
||||
case 'password':
|
||||
return '';
|
||||
case 'select':
|
||||
return spec.options.some((option) => option.value === String(value))
|
||||
? value
|
||||
: fallback;
|
||||
case 'multiselect': {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
const allowed = new Set(spec.options.map((option) => option.value));
|
||||
const kept = [...new Set(value.split(spec.separator))].filter((part) =>
|
||||
allowed.has(part)
|
||||
);
|
||||
return kept.length > 0 ? kept.join(spec.separator) : fallback;
|
||||
}
|
||||
case 'number':
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.min(spec.max, Math.max(spec.min, value))
|
||||
: fallback;
|
||||
case 'boolean':
|
||||
return value === 'true' || value === 'false' ? value : fallback;
|
||||
case 'color':
|
||||
return typeof value === 'string' && HEX_COLOR.test(value)
|
||||
? value
|
||||
: fallback;
|
||||
default:
|
||||
if (typeof value === 'string') {
|
||||
return value.slice(0, MAX_TEXT_CONTROL_LENGTH);
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { flattenAnnotations } from '../../utils/flatten-annotations.js';
|
||||
import {
|
||||
flattenAnnotations,
|
||||
flattenFormsInDoc,
|
||||
} from '../../utils/flatten-annotations.js';
|
||||
import {
|
||||
loadPdfDocument,
|
||||
removeUnreferencedObjects,
|
||||
@@ -30,8 +33,7 @@ export class FlattenNode extends BaseWorkflowNode {
|
||||
const pdfDoc = await loadPdfDocument(input.bytes);
|
||||
|
||||
try {
|
||||
const form = pdfDoc.getForm();
|
||||
form.flatten();
|
||||
flattenFormsInDoc(pdfDoc);
|
||||
} catch (err) {
|
||||
console.error('Flatten form error (may have no forms):', err);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export class RedactNode extends BaseWorkflowNode {
|
||||
|
||||
const resultBytes = await redactDocumentData(
|
||||
redactedBytes,
|
||||
mode === 'text' ? searchText : undefined
|
||||
mode === 'text' ? [searchText] : []
|
||||
);
|
||||
|
||||
const resultDoc = await loadPdfDocument(resultBytes);
|
||||
|
||||
@@ -698,8 +698,8 @@ export const nodeRegistry: Record<string, NodeRegistryEntry> = {
|
||||
};
|
||||
|
||||
export function createNodeByType(type: string): BaseWorkflowNode | null {
|
||||
if (!Object.hasOwn(nodeRegistry, type)) return null;
|
||||
const entry = nodeRegistry[type];
|
||||
if (!entry) return null;
|
||||
const node = entry.factory();
|
||||
node.nodeType = type;
|
||||
node.label = translateNodeLabel(type, entry);
|
||||
|
||||
@@ -19,7 +19,7 @@ export class RemoveBlankPagesNode extends BaseWorkflowNode {
|
||||
this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF'));
|
||||
this.addControl(
|
||||
'threshold',
|
||||
new ClassicPreset.InputControl('number', { initial: 250 })
|
||||
new ClassicPreset.InputControl('number', { initial: 5 })
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,11 @@ export class SanitizeNode extends BaseWorkflowNode {
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const result = await sanitizePdf(input.bytes, options);
|
||||
if (result.skipped.length > 0) {
|
||||
console.warn(
|
||||
`[Sanitize] ${input.filename}: could not ${result.skipped.join(', ')}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: result.pdfDoc,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AreaPlugin } from 'rete-area-plugin';
|
||||
import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin';
|
||||
import type { BaseWorkflowNode } from './nodes/base-node';
|
||||
import { createNodeByType } from './nodes/registry';
|
||||
import { getControlSpec } from './nodes/control-specs';
|
||||
import { ClassicPreset } from 'rete';
|
||||
import type {
|
||||
SerializedWorkflow,
|
||||
@@ -18,6 +19,24 @@ function getNodeType(node: BaseWorkflowNode): string | null {
|
||||
return node.nodeType || null;
|
||||
}
|
||||
|
||||
function clearPasswordControls(workflow: SerializedWorkflow): boolean {
|
||||
let cleared = false;
|
||||
for (const node of Array.isArray(workflow?.nodes) ? workflow.nodes : []) {
|
||||
const controls = node?.controls;
|
||||
if (!controls || typeof controls !== 'object') continue;
|
||||
for (const key of Object.keys(controls)) {
|
||||
if (
|
||||
controls[key] !== '' &&
|
||||
getControlSpec(String(node.type), key)?.kind === 'password'
|
||||
) {
|
||||
controls[key] = '';
|
||||
cleared = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
|
||||
function serializeWorkflow(
|
||||
editor: NodeEditor<ClassicScheme>,
|
||||
area: AreaPlugin<ClassicScheme, AreaExtra>
|
||||
@@ -56,11 +75,13 @@ function serializeWorkflow(
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: WORKFLOW_VERSION,
|
||||
nodes,
|
||||
connections,
|
||||
} as SerializedWorkflow;
|
||||
};
|
||||
clearPasswordControls(workflow);
|
||||
return workflow;
|
||||
}
|
||||
|
||||
async function deserializeWorkflow(
|
||||
@@ -96,6 +117,7 @@ async function deserializeWorkflow(
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(serializedNode.controls || {})) {
|
||||
if (!Object.hasOwn(node.controls, key)) continue;
|
||||
const control = node.controls[key];
|
||||
if (control && 'value' in control) {
|
||||
(control as { value: unknown }).value = node.sanitizeControlValue(
|
||||
@@ -144,7 +166,19 @@ function getStoredTemplates(): StoredTemplates {
|
||||
const json = localStorage.getItem(TEMPLATES_KEY);
|
||||
if (!json) return {};
|
||||
try {
|
||||
return JSON.parse(json) as StoredTemplates;
|
||||
const templates = JSON.parse(json) as StoredTemplates;
|
||||
let cleared = false;
|
||||
for (const template of Object.values(templates)) {
|
||||
if (clearPasswordControls(template)) cleared = true;
|
||||
}
|
||||
if (cleared) {
|
||||
try {
|
||||
localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates));
|
||||
} catch (e) {
|
||||
console.warn('Failed to clear stored workflow passwords:', e);
|
||||
}
|
||||
}
|
||||
return templates;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -350,9 +350,14 @@
|
||||
<i data-lucide="plus" class="faq-d-icon"></i>
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
Yes. Applying a redaction removes the content underneath the marked
|
||||
area from the file rather than covering it with a box, so the text
|
||||
can't be recovered by copying or searching. Open the downloaded file
|
||||
Yes, once it's applied. Marking text or an area only flags it.
|
||||
Applying a redaction removes the content underneath the marked area
|
||||
from the file rather than covering it with a box, so the text can't
|
||||
be recovered by copying or searching. If you download with marks
|
||||
that haven't been applied yet, the editor offers to apply them
|
||||
first. When you redact selected text, copies of it in the file's
|
||||
properties, bookmarks, comments, and attachments are removed on
|
||||
download too, unless the file is encrypted. Open the downloaded file
|
||||
and check before you share it.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -401,12 +401,13 @@
|
||||
<i data-lucide="plus" class="faq-d-icon"></i>
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
It means bytes were appended after this signature was applied, and
|
||||
the tool reports it as Invalid with the note Modified After Signing
|
||||
(partial coverage). That is not proof of tampering by itself: a
|
||||
second signature or an incremental save produces the same result,
|
||||
and only the last signature added can cover the entire file. Treat
|
||||
it as a reason to look at what was added.
|
||||
It means bytes were appended after this signature was applied. The
|
||||
part that was signed is still intact, so the tool shows a Modified
|
||||
After Signing warning instead of marking the signature invalid. That
|
||||
is not proof of tampering by itself: a second signature or an
|
||||
incremental save produces the same result, and only the last
|
||||
signature added can cover the entire file. Treat it as a reason to
|
||||
look at what was added.
|
||||
</p>
|
||||
</details>
|
||||
<details class="faq-d">
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildSecurityHeaders,
|
||||
DOCS_INLINE_SCRIPT_HASHES,
|
||||
} from '../../scripts/generate-security-headers.mjs';
|
||||
import { WasmProvider } from '../js/utils/wasm-provider';
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
const read = (file: string) => fs.readFileSync(path.join(ROOT, file), 'utf8');
|
||||
|
||||
const defaults = buildSecurityHeaders({});
|
||||
|
||||
function directive(policy: string, name: string): string[] {
|
||||
const found = policy
|
||||
.split(';')
|
||||
.map((d) => d.trim().split(/\s+/))
|
||||
.find(([n]) => n === name);
|
||||
return found ? found.slice(1) : [];
|
||||
}
|
||||
|
||||
function allowedBy(sources: string[], url: string): boolean {
|
||||
return sources.some((source) =>
|
||||
source.endsWith('/')
|
||||
? url.startsWith(source)
|
||||
: new URL(url).origin === source
|
||||
);
|
||||
}
|
||||
|
||||
function headerRuleCsp(file: string, source: string): string | undefined {
|
||||
const config = JSON.parse(read(file)) as {
|
||||
headers: Array<{
|
||||
source: string;
|
||||
headers: Array<{ key: string; value: string }>;
|
||||
}>;
|
||||
};
|
||||
return config.headers
|
||||
.find((rule) => rule.source === source)
|
||||
?.headers.find((h) => h.key === 'Content-Security-Policy')?.value;
|
||||
}
|
||||
|
||||
describe('CSP script-src', () => {
|
||||
const scriptSrc = directive(defaults.csp, 'script-src');
|
||||
|
||||
it('allows jsDelivr only through version-pinned package prefixes', () => {
|
||||
const jsdelivr = scriptSrc.filter((s) => s.includes('cdn.jsdelivr.net'));
|
||||
expect(jsdelivr.length).toBeGreaterThan(0);
|
||||
for (const source of jsdelivr) {
|
||||
expect(source.startsWith('https://cdn.jsdelivr.net/npm/'), source).toBe(
|
||||
true
|
||||
);
|
||||
expect(source.endsWith('/'), source).toBe(true);
|
||||
expect(source).toMatch(/@v?\d+\.\d+\.\d+\//);
|
||||
}
|
||||
});
|
||||
|
||||
it('covers every script the app loads from its default CDN URLs', () => {
|
||||
const { pymupdf, ghostscript, cpdf } = WasmProvider.getAllProviders();
|
||||
const tesseract = JSON.parse(
|
||||
read('node_modules/tesseract.js/package.json')
|
||||
) as { version: string; dependencies: Record<string, string> };
|
||||
const core = tesseract.dependencies['tesseract.js-core'].substring(1);
|
||||
const urls = [
|
||||
`${pymupdf}dist/index.js`,
|
||||
`${pymupdf}assets/pyodide.js`,
|
||||
`${pymupdf}assets/pyodide.asm.js`,
|
||||
`${ghostscript}gs.js`,
|
||||
`${cpdf}coherentpdf.browser.min.js`,
|
||||
`https://cdn.jsdelivr.net/npm/tesseract.js@v${tesseract.version}/dist/worker.min.js`,
|
||||
`https://cdn.jsdelivr.net/npm/tesseract.js-core@v${core}/tesseract-core-simd-lstm.wasm.js`,
|
||||
];
|
||||
for (const url of urls) {
|
||||
expect(allowedBy(scriptSrc, url), url).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects arbitrary jsDelivr packages and GitHub repos', () => {
|
||||
for (const url of [
|
||||
'https://cdn.jsdelivr.net/gh/attacker/repo@main/x.js',
|
||||
'https://cdn.jsdelivr.net/npm/evil-package@1.0.0/x.js',
|
||||
'https://cdn.jsdelivr.net/npm/coherentpdf@2.5.6/dist/coherentpdf.browser.min.js',
|
||||
'https://cdn.jsdelivr.net/combine/npm/evil@1',
|
||||
]) {
|
||||
expect(allowedBy(scriptSrc, url), url).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps self-hosted VITE_* URLs working', () => {
|
||||
const custom = buildSecurityHeaders({
|
||||
VITE_WASM_PYMUPDF_URL: 'https://internal.example.com/wasm/pymupdf/',
|
||||
VITE_WASM_GS_URL:
|
||||
'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets',
|
||||
VITE_WASM_CPDF_URL: 'https://cdn.jsdelivr.net/npm/x@1.0.0/a;b,c/',
|
||||
VITE_TESSERACT_WORKER_URL:
|
||||
'https://cdn.jsdelivr.net/npm/tesseract.js@v7.0.0/dist/worker.min.js',
|
||||
VITE_TESSERACT_CORE_URL: 'https://ocr.example.com/core',
|
||||
VITE_TESSERACT_LANG_URL: 'https://ocr.example.com/lang',
|
||||
});
|
||||
expect(custom.scriptSources).toEqual([
|
||||
'https://internal.example.com',
|
||||
'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/',
|
||||
'https://cdn.jsdelivr.net/npm/x@1.0.0/a%3Bb%2Cc/',
|
||||
'https://cdn.jsdelivr.net/npm/tesseract.js@v7.0.0/dist/',
|
||||
'https://ocr.example.com',
|
||||
]);
|
||||
expect(directive(custom.csp, 'connect-src')).toEqual(
|
||||
expect.arrayContaining([
|
||||
'https://internal.example.com',
|
||||
'https://cdn.jsdelivr.net',
|
||||
'https://ocr.example.com',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('only allows the pinned docs inline scripts', () => {
|
||||
const sources = directive(defaults.docsCsp, 'script-src');
|
||||
expect(sources).toEqual([
|
||||
"'self'",
|
||||
...DOCS_INLINE_SCRIPT_HASHES.map((hash) => `'${hash}'`),
|
||||
]);
|
||||
expect(sources).not.toContain("'unsafe-inline'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('deployment configs match the generator', () => {
|
||||
it('vercel.json', () => {
|
||||
expect(headerRuleCsp('vercel.json', '/(.*)')).toBe(defaults.csp);
|
||||
expect(headerRuleCsp('vercel.json', '/docs')).toBe(defaults.docsCsp);
|
||||
expect(headerRuleCsp('vercel.json', '/docs/:path*')).toBe(defaults.docsCsp);
|
||||
});
|
||||
|
||||
it('serve.json', () => {
|
||||
expect(headerRuleCsp('serve.json', '**/*')).toBe(defaults.csp);
|
||||
expect(headerRuleCsp('serve.json', 'docs/**')).toBe(defaults.docsCsp);
|
||||
});
|
||||
|
||||
it('public/_headers for Netlify and Cloudflare Pages', () => {
|
||||
const file = read('public/_headers');
|
||||
expect(file).toBe(defaults.staticHeaders);
|
||||
expect(file).toContain(
|
||||
`/docs/*\n ! Content-Security-Policy\n Content-Security-Policy: ${defaults.docsCsp}\n`
|
||||
);
|
||||
for (const line of file.split('\n')) {
|
||||
expect(line.length).toBeLessThanOrEqual(2000);
|
||||
}
|
||||
});
|
||||
|
||||
it('nginx docs config and .htaccess', () => {
|
||||
expect(read('security-headers-docs.conf')).toBe(defaults.nginxDocs);
|
||||
expect(read('.htaccess')).toContain(
|
||||
`Header always set Content-Security-Policy "${defaults.csp}"`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type {
|
||||
PendingRedactionChange,
|
||||
PendingRedactionItem,
|
||||
RedactionPluginEvent,
|
||||
RedactionPluginLite,
|
||||
} from '../js/types/edit-pdf-redaction-type.js';
|
||||
|
||||
const { redactDocumentData } = vi.hoisted(() => ({
|
||||
redactDocumentData: vi.fn(
|
||||
async (pdf: Uint8Array, terms: readonly string[] = []) =>
|
||||
Uint8Array.from([...pdf, terms.length])
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../js/utils/redact-document-data.js', () => ({
|
||||
redactDocumentData,
|
||||
}));
|
||||
|
||||
vi.mock('../js/utils/disabled-tools.js', () => ({
|
||||
getEditorDisabledCategories: (): string[] => [],
|
||||
}));
|
||||
|
||||
const { trackAppliedRedactions, redactionScrubTerms, scrubRedactedText } =
|
||||
await import('../js/logic/edit-pdf-page.js');
|
||||
|
||||
function mockRedactionPlugin() {
|
||||
const pendingListeners = new Set<(event: PendingRedactionChange) => void>();
|
||||
const eventListeners = new Set<(event: RedactionPluginEvent) => void>();
|
||||
const plugin: RedactionPluginLite = {
|
||||
getState: () => ({ pendingCount: 0 }),
|
||||
commitAllPending: () => ({ toPromise: async () => true }),
|
||||
onPendingChange: (listener) => {
|
||||
pendingListeners.add(listener);
|
||||
return () => pendingListeners.delete(listener);
|
||||
},
|
||||
onRedactionEvent: (listener) => {
|
||||
eventListeners.add(listener);
|
||||
return () => eventListeners.delete(listener);
|
||||
},
|
||||
};
|
||||
const pending = new Map<string, PendingRedactionItem[]>();
|
||||
const publish = (documentId: string) => {
|
||||
const items = pending.get(documentId) ?? [];
|
||||
for (const listener of pendingListeners) {
|
||||
listener({ documentId, pending: items.length ? { 0: items } : {} });
|
||||
}
|
||||
};
|
||||
const emit = (event: RedactionPluginEvent) => {
|
||||
for (const listener of eventListeners) listener(event);
|
||||
};
|
||||
return {
|
||||
plugin,
|
||||
mark(documentId: string, item: PendingRedactionItem) {
|
||||
pending.set(documentId, [...(pending.get(documentId) ?? []), item]);
|
||||
publish(documentId);
|
||||
emit({ type: 'add', documentId });
|
||||
},
|
||||
unmark(documentId: string, id: string) {
|
||||
pending.set(
|
||||
documentId,
|
||||
(pending.get(documentId) ?? []).filter((item) => item.id !== id)
|
||||
);
|
||||
publish(documentId);
|
||||
emit({ type: 'remove', documentId, id });
|
||||
},
|
||||
clearAll(documentId: string) {
|
||||
pending.set(documentId, []);
|
||||
publish(documentId);
|
||||
emit({ type: 'clear', documentId });
|
||||
},
|
||||
apply(documentId: string, ids?: string[]) {
|
||||
pending.set(
|
||||
documentId,
|
||||
ids
|
||||
? (pending.get(documentId) ?? []).filter(
|
||||
(item) => !ids.includes(item.id)
|
||||
)
|
||||
: []
|
||||
);
|
||||
publish(documentId);
|
||||
emit({ type: 'commit', documentId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('trackAppliedRedactions', () => {
|
||||
it('reports nothing when no redaction was applied', () => {
|
||||
const viewer = mockRedactionPlugin();
|
||||
const tracker = trackAppliedRedactions(viewer.plugin);
|
||||
viewer.mark('doc', { id: 'a', kind: 'text', text: 'Jane Doe' });
|
||||
expect(tracker.appliedTexts('doc')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the text of applied text marks and skips removed marks', () => {
|
||||
const viewer = mockRedactionPlugin();
|
||||
const tracker = trackAppliedRedactions(viewer.plugin);
|
||||
viewer.mark('doc', { id: 'a', kind: 'text', text: 'Jane Doe' });
|
||||
viewer.mark('doc', { id: 'b', kind: 'text', text: 'Removed Mark' });
|
||||
viewer.unmark('doc', 'b');
|
||||
viewer.mark('doc', { id: 'c', kind: 'area' });
|
||||
viewer.apply('doc');
|
||||
expect(tracker.appliedTexts('doc')).toEqual(['Jane Doe', undefined]);
|
||||
});
|
||||
|
||||
it('does not count marks removed with Clear all as applied', () => {
|
||||
const viewer = mockRedactionPlugin();
|
||||
const tracker = trackAppliedRedactions(viewer.plugin);
|
||||
viewer.mark('doc', { id: 'a', kind: 'text', text: 'Applied Text' });
|
||||
viewer.apply('doc');
|
||||
viewer.mark('doc', { id: 'b', kind: 'text', text: 'Acme Corp' });
|
||||
viewer.clearAll('doc');
|
||||
expect(tracker.appliedTexts('doc')).toEqual(['Applied Text']);
|
||||
});
|
||||
|
||||
it('counts a single applied mark while others stay pending', () => {
|
||||
const viewer = mockRedactionPlugin();
|
||||
const tracker = trackAppliedRedactions(viewer.plugin);
|
||||
viewer.mark('doc', { id: 'a', kind: 'text', text: 'First' });
|
||||
viewer.mark('doc', { id: 'b', kind: 'text', text: 'Second' });
|
||||
viewer.apply('doc', ['a']);
|
||||
expect(tracker.appliedTexts('doc')).toEqual(['First']);
|
||||
viewer.apply('doc', ['b']);
|
||||
expect(tracker.appliedTexts('doc')).toEqual(['First', 'Second']);
|
||||
});
|
||||
|
||||
it('keeps applied marks separate per document and forgets closed ones', () => {
|
||||
const viewer = mockRedactionPlugin();
|
||||
const tracker = trackAppliedRedactions(viewer.plugin);
|
||||
viewer.mark('one', { id: 'a', kind: 'text', text: 'Alpha' });
|
||||
viewer.mark('two', { id: 'b', kind: 'area' });
|
||||
viewer.apply('one');
|
||||
expect(tracker.appliedTexts('one')).toEqual(['Alpha']);
|
||||
expect(tracker.appliedTexts('two')).toEqual([]);
|
||||
tracker.forget('one');
|
||||
expect(tracker.appliedTexts('one')).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats a mark that comes back after a reload as pending again', () => {
|
||||
const viewer = mockRedactionPlugin();
|
||||
const tracker = trackAppliedRedactions(viewer.plugin);
|
||||
const item: PendingRedactionItem = { id: 'a', kind: 'text', text: 'X' };
|
||||
viewer.mark('doc', item);
|
||||
viewer.apply('doc');
|
||||
viewer.mark('doc', item);
|
||||
expect(tracker.appliedTexts('doc')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactionScrubTerms', () => {
|
||||
it('splits lines, trims, dedupes case-insensitively and sorts longest first', () => {
|
||||
expect(
|
||||
redactionScrubTerms([
|
||||
'John',
|
||||
undefined,
|
||||
' John Smith \r\nAcme Corp',
|
||||
'john',
|
||||
'\n',
|
||||
])
|
||||
).toEqual(['John Smith', 'Acme Corp', 'John']);
|
||||
});
|
||||
|
||||
it('returns no terms for area-only redactions', () => {
|
||||
expect(redactionScrubTerms([undefined, undefined])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrubRedactedText', () => {
|
||||
beforeEach(() => {
|
||||
redactDocumentData.mockClear();
|
||||
});
|
||||
|
||||
it('runs one termless pass when no text is known', async () => {
|
||||
const out = await scrubRedactedText(Uint8Array.from([1]), []);
|
||||
expect(redactDocumentData).toHaveBeenCalledTimes(1);
|
||||
expect(redactDocumentData.mock.calls[0][1]).toEqual([]);
|
||||
expect([...out]).toEqual([1, 0]);
|
||||
});
|
||||
|
||||
it('removes every term in a single pass', async () => {
|
||||
const out = await scrubRedactedText(Uint8Array.from([1]), ['abc', 'de']);
|
||||
expect(redactDocumentData).toHaveBeenCalledTimes(1);
|
||||
expect(redactDocumentData.mock.calls[0][1]).toEqual(['abc', 'de']);
|
||||
expect([...out]).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import {
|
||||
PDFArray,
|
||||
PDFDict,
|
||||
PDFDocument,
|
||||
PDFName,
|
||||
PDFRawStream,
|
||||
decodePDFRawStream,
|
||||
} from 'pdf-lib';
|
||||
|
||||
vi.mock('../js/utils/helpers.js', async () => {
|
||||
const { default: createModule } = await import('@neslinesli93/qpdf-wasm');
|
||||
const wasmPath = resolve(
|
||||
'node_modules/@neslinesli93/qpdf-wasm/dist/qpdf.wasm'
|
||||
);
|
||||
const options = {
|
||||
locateFile: () => wasmPath,
|
||||
wasmBinary: readFileSync(wasmPath),
|
||||
print: () => {},
|
||||
printErr: () => {},
|
||||
};
|
||||
const qpdf = await createModule(options);
|
||||
return { initializeQpdf: async () => qpdf };
|
||||
});
|
||||
|
||||
vi.mock('rete', () => ({
|
||||
ClassicPreset: {
|
||||
Input: class {},
|
||||
Output: class {},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/js/workflow/sockets', () => ({ pdfSocket: {} }));
|
||||
|
||||
vi.mock('@/js/workflow/nodes/base-node', () => ({
|
||||
BaseWorkflowNode: class {
|
||||
addInput() {}
|
||||
addOutput() {}
|
||||
},
|
||||
}));
|
||||
|
||||
const { flattenAnnotations, flattenFormsInDoc } =
|
||||
await import('../js/utils/flatten-annotations.js');
|
||||
const { removeUnreferencedObjects } =
|
||||
await import('../js/utils/load-pdf-document.js');
|
||||
const { sanitizePdf } = await import('../js/utils/sanitize.js');
|
||||
const { FlattenNode } = await import('../js/workflow/nodes/flatten-node.js');
|
||||
|
||||
const SECRETS = [
|
||||
'HIDDEN-VALUE',
|
||||
'NOVIEW-VALUE',
|
||||
'PASSWORD-SECRET',
|
||||
'GROUP-SECRET',
|
||||
];
|
||||
|
||||
async function buildFormDocument(): Promise<Uint8Array> {
|
||||
const doc = await PDFDocument.create();
|
||||
const page = doc.addPage([400, 400]);
|
||||
const form = doc.getForm();
|
||||
let y = 10;
|
||||
const addField = (name: string, value: string, flags?: number) => {
|
||||
const field = form.createTextField(name);
|
||||
field.setText(value);
|
||||
field.addToPage(page, { x: 10, y, width: 200, height: 20 });
|
||||
y += 30;
|
||||
if (flags !== undefined) {
|
||||
const widgets = field.acroField.getWidgets();
|
||||
widgets[widgets.length - 1].setFlags(flags);
|
||||
}
|
||||
return field;
|
||||
};
|
||||
|
||||
addField('visible', 'VISIBLE-VALUE');
|
||||
addField('hiddenField', 'HIDDEN-VALUE', 2);
|
||||
addField('noViewField', 'NOVIEW-VALUE', 32 | 4);
|
||||
addField('group.secret', 'GROUP-SECRET', 2);
|
||||
|
||||
const password = addField('pw', 'PASSWORD-SECRET');
|
||||
password.enablePassword();
|
||||
password.acroField.getWidgets()[0].dict.delete(PDFName.of('AP'));
|
||||
|
||||
const multi = addField('multi', 'MULTI-VALUE');
|
||||
multi.addToPage(page, { x: 250, y: 10, width: 120, height: 20 });
|
||||
multi.acroField.getWidgets()[1].setFlags(2);
|
||||
|
||||
const ctx = doc.context;
|
||||
const appearance = (text: string) =>
|
||||
ctx.register(
|
||||
ctx.flateStream(`BT /Helv 10 Tf (${text}) Tj ET`, {
|
||||
Type: 'XObject',
|
||||
Subtype: 'Form',
|
||||
BBox: [0, 0, 100, 20],
|
||||
})
|
||||
);
|
||||
const annots = page.node.Annots() as PDFArray;
|
||||
annots.push(
|
||||
ctx.register(
|
||||
ctx.obj({
|
||||
Type: 'Annot',
|
||||
Subtype: 'Square',
|
||||
Rect: [10, 300, 110, 320],
|
||||
F: 32 | 4,
|
||||
AP: { N: appearance('NOVIEW-ANNOT') },
|
||||
})
|
||||
)
|
||||
);
|
||||
annots.push(
|
||||
ctx.register(
|
||||
ctx.obj({
|
||||
Type: 'Annot',
|
||||
Subtype: 'Square',
|
||||
Rect: [10, 330, 110, 350],
|
||||
AP: { N: appearance('SHOWN-ANNOT') },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
function hex(text: string): string {
|
||||
return Buffer.from(text).toString('hex');
|
||||
}
|
||||
|
||||
async function inspect(bytes: Uint8Array) {
|
||||
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||
const streams: string[] = [];
|
||||
for (const [, object] of doc.context.enumerateIndirectObjects()) {
|
||||
if (object instanceof PDFRawStream) {
|
||||
streams.push(
|
||||
Buffer.from(decodePDFRawStream(object).decode()).toString('latin1')
|
||||
);
|
||||
}
|
||||
}
|
||||
const text = [Buffer.from(bytes).toString('latin1'), ...streams]
|
||||
.join('\n')
|
||||
.toLowerCase();
|
||||
const count = (value: string) =>
|
||||
text.split(value.toLowerCase()).length -
|
||||
1 +
|
||||
text.split(hex(value)).length -
|
||||
1;
|
||||
return { doc, count };
|
||||
}
|
||||
|
||||
async function expectHiddenFieldsGone(bytes: Uint8Array) {
|
||||
const { doc, count } = await inspect(bytes);
|
||||
for (const secret of SECRETS) expect(count(secret)).toBe(0);
|
||||
expect(count('VISIBLE-VALUE')).toBe(1);
|
||||
expect(count('MULTI-VALUE')).toBe(1);
|
||||
const acroForm = doc.catalog.lookup(PDFName.of('AcroForm'));
|
||||
const fields =
|
||||
acroForm instanceof PDFDict
|
||||
? acroForm.lookup(PDFName.of('Fields'), PDFArray)
|
||||
: undefined;
|
||||
expect(fields?.size() ?? 0).toBe(0);
|
||||
return count;
|
||||
}
|
||||
|
||||
describe('flattenFormsInDoc', () => {
|
||||
it('does not draw hidden, no-view or password fields', async () => {
|
||||
const input = await buildFormDocument();
|
||||
const before = await inspect(input);
|
||||
for (const secret of SECRETS)
|
||||
expect(before.count(secret)).toBeGreaterThan(0);
|
||||
|
||||
const doc = await PDFDocument.load(input);
|
||||
flattenFormsInDoc(doc);
|
||||
flattenAnnotations(doc);
|
||||
const bytes = await removeUnreferencedObjects(await doc.save());
|
||||
|
||||
const count = await expectHiddenFieldsGone(bytes);
|
||||
expect(count('NOVIEW-ANNOT')).toBe(0);
|
||||
expect(count('SHOWN-ANNOT')).toBe(1);
|
||||
});
|
||||
|
||||
it('is used by the sanitize flatten option', async () => {
|
||||
const { bytes } = await sanitizePdf(await buildFormDocument(), {
|
||||
flattenForms: true,
|
||||
removeMetadata: false,
|
||||
removeAnnotations: false,
|
||||
removeJavascript: false,
|
||||
removeEmbeddedFiles: false,
|
||||
removeLayers: false,
|
||||
removeLinks: false,
|
||||
removeStructureTree: false,
|
||||
removeMarkInfo: false,
|
||||
removeFonts: false,
|
||||
});
|
||||
await expectHiddenFieldsGone(bytes);
|
||||
});
|
||||
|
||||
it('is used by the workflow flatten node', async () => {
|
||||
const node = new FlattenNode();
|
||||
const result = await node.data({
|
||||
pdf: [
|
||||
{
|
||||
type: 'pdf',
|
||||
document: await PDFDocument.create(),
|
||||
bytes: await buildFormDocument(),
|
||||
filename: 'form.pdf',
|
||||
},
|
||||
],
|
||||
});
|
||||
const output = result.pdf;
|
||||
if (output.type !== 'pdf') throw new Error('Expected a single PDF');
|
||||
const count = await expectHiddenFieldsGone(output.bytes);
|
||||
expect(count('NOVIEW-ANNOT')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -184,7 +184,7 @@ describe('redactDocumentData', () => {
|
||||
SECRET.toLowerCase()
|
||||
);
|
||||
|
||||
const output = await redactDocumentData(input, secret());
|
||||
const output = await redactDocumentData(input, [secret()]);
|
||||
const text = await allText(output);
|
||||
|
||||
expect(text.toLowerCase()).not.toContain(SECRET.toLowerCase());
|
||||
@@ -200,6 +200,62 @@ describe('redactDocumentData', () => {
|
||||
expect(result.getPages()[0].node.get(PDFName.of('Thumb'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps dates intact when the redacted text is short', async () => {
|
||||
const doc = await PDFDocument.create();
|
||||
doc.addPage();
|
||||
const created = new Date(Date.UTC(2026, 0, 2, 3, 4, 5));
|
||||
doc.setCreationDate(created);
|
||||
doc.setModificationDate(created);
|
||||
doc.setTitle('Invoice 20 of 2026');
|
||||
const output = await redactDocumentData(await doc.save(), ['20']);
|
||||
const result = await PDFDocument.load(output, { updateMetadata: false });
|
||||
expect(result.getCreationDate()?.toISOString()).toBe(created.toISOString());
|
||||
expect(Number.isNaN(result.getModificationDate()?.getTime())).toBe(false);
|
||||
expect(result.getTitle()).toBe('Invoice of 26');
|
||||
});
|
||||
|
||||
it('removes the text from signature fields but keeps the signature value', async () => {
|
||||
const doc = await PDFDocument.create();
|
||||
doc.addPage();
|
||||
const signatureValue = PDFHexString.of('3082ABCDEF01');
|
||||
const signature = doc.context.register(
|
||||
doc.context.obj({
|
||||
Type: 'Sig',
|
||||
Name: PDFString.of(`Signed by ${SECRET}`),
|
||||
Reason: PDFString.of(`Approved by ${SECRET}`),
|
||||
Contents: signatureValue,
|
||||
})
|
||||
);
|
||||
doc.catalog.set(PDFName.of('TestSig'), signature);
|
||||
const output = await redactDocumentData(await doc.save(), [SECRET]);
|
||||
const result = await PDFDocument.load(output, { updateMetadata: false });
|
||||
const saved = result.catalog.lookup(PDFName.of('TestSig'), PDFDict);
|
||||
expect(saved.lookup(PDFName.of('Name'), PDFHexString).decodeText()).toBe(
|
||||
'Signed by '
|
||||
);
|
||||
expect(saved.lookup(PDFName.of('Reason'), PDFHexString).decodeText()).toBe(
|
||||
'Approved by '
|
||||
);
|
||||
expect(
|
||||
Array.from(saved.lookup(PDFName.of('Contents'), PDFHexString).asBytes())
|
||||
).toEqual(Array.from(signatureValue.asBytes()));
|
||||
});
|
||||
|
||||
it('removes several terms in one pass, longest match first', async () => {
|
||||
const doc = await PDFDocument.create();
|
||||
doc.addPage();
|
||||
doc.setTitle('Report by John Smith for Acme and John');
|
||||
doc.setSubject('acme corp');
|
||||
const output = await redactDocumentData(await doc.save(), [
|
||||
'John',
|
||||
'John Smith',
|
||||
'Acme',
|
||||
]);
|
||||
const result = await PDFDocument.load(output, { updateMetadata: false });
|
||||
expect(result.getTitle()).toBe('Report by for and ');
|
||||
expect(result.getSubject()).toBe(' corp');
|
||||
});
|
||||
|
||||
it('only drops thumbnails and leftovers when no search text is given', async () => {
|
||||
const input = await buildDocument();
|
||||
const output = await redactDocumentData(input);
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import {
|
||||
PDFArray,
|
||||
PDFDict,
|
||||
PDFDocument,
|
||||
PDFHexString,
|
||||
PDFName,
|
||||
PDFObject,
|
||||
PDFRawStream,
|
||||
PDFRef,
|
||||
PDFString,
|
||||
decodePDFRawStream,
|
||||
} from 'pdf-lib';
|
||||
|
||||
vi.mock('../js/utils/helpers.js', async () => {
|
||||
const { default: createModule } = await import('@neslinesli93/qpdf-wasm');
|
||||
const wasmPath = resolve(
|
||||
'node_modules/@neslinesli93/qpdf-wasm/dist/qpdf.wasm'
|
||||
);
|
||||
const options = {
|
||||
locateFile: () => wasmPath,
|
||||
wasmBinary: readFileSync(wasmPath),
|
||||
print: () => {},
|
||||
printErr: () => {},
|
||||
};
|
||||
const qpdf = await createModule(options);
|
||||
return { initializeQpdf: async () => qpdf };
|
||||
});
|
||||
|
||||
const { sanitizePdf, defaultSanitizeOptions } =
|
||||
await import('../js/utils/sanitize.js');
|
||||
const { parseContentOperations } =
|
||||
await import('../js/utils/pdf-content-tokens.js');
|
||||
|
||||
const NONE = {
|
||||
flattenForms: false,
|
||||
removeMetadata: false,
|
||||
removeAnnotations: false,
|
||||
removeJavascript: false,
|
||||
removeEmbeddedFiles: false,
|
||||
removeLayers: false,
|
||||
removeLinks: false,
|
||||
removeStructureTree: false,
|
||||
removeMarkInfo: false,
|
||||
removeFonts: false,
|
||||
};
|
||||
|
||||
const latin1 = (bytes: Uint8Array) => Buffer.from(bytes).toString('latin1');
|
||||
|
||||
function collectText(value: PDFObject | undefined, out: string[]) {
|
||||
if (value instanceof PDFString || value instanceof PDFHexString) {
|
||||
out.push(value.decodeText());
|
||||
} else if (value instanceof PDFDict) {
|
||||
for (const [key, v] of value.entries()) {
|
||||
out.push(key.decodeText());
|
||||
collectText(v, out);
|
||||
}
|
||||
} else if (value instanceof PDFArray) {
|
||||
for (const v of value.asArray()) collectText(v, out);
|
||||
}
|
||||
}
|
||||
|
||||
async function allText(bytes: Uint8Array): Promise<string> {
|
||||
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||
const out: string[] = [latin1(bytes)];
|
||||
for (const [, object] of doc.context.enumerateIndirectObjects()) {
|
||||
if (object instanceof PDFRawStream) {
|
||||
collectText(object.dict, out);
|
||||
out.push(latin1(decodePDFRawStream(object).decode()));
|
||||
} else {
|
||||
collectText(object, out);
|
||||
}
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
async function pageContent(doc: PDFDocument): Promise<string> {
|
||||
const contents = doc.getPages()[0].node.Contents();
|
||||
const streams =
|
||||
contents instanceof PDFArray ? contents.asArray() : [contents];
|
||||
return streams
|
||||
.map((item) => doc.context.lookup(item))
|
||||
.map((stream) =>
|
||||
stream instanceof PDFRawStream
|
||||
? latin1(decodePDFRawStream(stream).decode())
|
||||
: ''
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function lookupDict(doc: PDFDocument, value: PDFObject | undefined) {
|
||||
const result = doc.context.lookup(value);
|
||||
return result instanceof PDFDict ? result : undefined;
|
||||
}
|
||||
|
||||
async function buildActionDocument(): Promise<Uint8Array> {
|
||||
const doc = await PDFDocument.create();
|
||||
const page = doc.addPage([300, 300]);
|
||||
const ctx = doc.context;
|
||||
|
||||
const outlines = ctx.nextRef();
|
||||
const first = ctx.nextRef();
|
||||
const second = ctx.nextRef();
|
||||
ctx.assign(
|
||||
first,
|
||||
ctx.obj({
|
||||
Title: PDFString.of('Chapter one'),
|
||||
Parent: outlines,
|
||||
Next: second,
|
||||
A: ctx.register(
|
||||
ctx.obj({
|
||||
S: 'JavaScript',
|
||||
JS: PDFString.of("app.alert('outline-js')"),
|
||||
})
|
||||
),
|
||||
})
|
||||
);
|
||||
ctx.assign(
|
||||
second,
|
||||
ctx.obj({
|
||||
Title: PDFString.of('Chapter two'),
|
||||
Parent: outlines,
|
||||
Prev: first,
|
||||
A: ctx.obj({
|
||||
S: 'Launch',
|
||||
F: PDFString.of('launch-calc.exe'),
|
||||
}),
|
||||
})
|
||||
);
|
||||
ctx.assign(
|
||||
outlines,
|
||||
ctx.obj({ Type: 'Outlines', First: first, Last: second, Count: 2 })
|
||||
);
|
||||
doc.catalog.set(PDFName.of('Outlines'), outlines);
|
||||
doc.catalog.set(
|
||||
PDFName.of('OpenAction'),
|
||||
ctx.obj([page.ref, PDFName.of('Fit')])
|
||||
);
|
||||
|
||||
const nextJs = ctx.register(
|
||||
ctx.obj({ S: 'JavaScript', JS: PDFString.of("app.alert('next-js')") })
|
||||
);
|
||||
const link = ctx.register(
|
||||
ctx.obj({
|
||||
Type: 'Annot',
|
||||
Subtype: 'Link',
|
||||
Rect: [10, 10, 100, 30],
|
||||
A: ctx.obj({
|
||||
S: 'URI',
|
||||
URI: PDFString.of('https://example.com/keep'),
|
||||
Next: nextJs,
|
||||
}),
|
||||
})
|
||||
);
|
||||
const chained = ctx.register(
|
||||
ctx.obj({
|
||||
Type: 'Annot',
|
||||
Subtype: 'Link',
|
||||
Rect: [10, 40, 100, 60],
|
||||
A: ctx.obj({
|
||||
S: 'SubmitForm',
|
||||
F: PDFString.of('https://submit-url.example'),
|
||||
Next: ctx.obj({
|
||||
S: 'URI',
|
||||
URI: PDFString.of('https://example.com/after-submit'),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
const importData = ctx.register(
|
||||
ctx.obj({
|
||||
Type: 'Annot',
|
||||
Subtype: 'Link',
|
||||
Rect: [10, 70, 100, 90],
|
||||
A: ctx.obj({ S: 'ImportData', F: PDFString.of('import-data.fdf') }),
|
||||
})
|
||||
);
|
||||
|
||||
const appearance = ctx.register(
|
||||
ctx.flateStream('0 0 1 rg 0 0 80 20 re f', {
|
||||
Type: 'XObject',
|
||||
Subtype: 'Form',
|
||||
BBox: [0, 0, 80, 20],
|
||||
})
|
||||
);
|
||||
const parentField = ctx.nextRef();
|
||||
const childField = ctx.register(
|
||||
ctx.obj({
|
||||
Type: 'Annot',
|
||||
Subtype: 'Widget',
|
||||
FT: 'Tx',
|
||||
T: PDFString.of('child'),
|
||||
Parent: parentField,
|
||||
Rect: [10, 100, 90, 120],
|
||||
P: page.ref,
|
||||
AP: { N: appearance },
|
||||
AA: {
|
||||
K: ctx.obj({
|
||||
S: 'JavaScript',
|
||||
JS: PDFString.of("app.alert('nested-aa-js')"),
|
||||
}),
|
||||
},
|
||||
})
|
||||
);
|
||||
ctx.assign(
|
||||
parentField,
|
||||
ctx.obj({ T: PDFString.of('parent'), Kids: [childField] })
|
||||
);
|
||||
const xfa = ctx.register(
|
||||
ctx.flateStream('<xdp:xdp><script>xfa-script</script></xdp:xdp>')
|
||||
);
|
||||
doc.catalog.set(
|
||||
PDFName.of('AcroForm'),
|
||||
ctx.obj({ Fields: [parentField], XFA: xfa })
|
||||
);
|
||||
|
||||
page.node.set(
|
||||
PDFName.of('Annots'),
|
||||
ctx.obj([link, chained, importData, childField])
|
||||
);
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
async function buildMetadataDocument(): Promise<Uint8Array> {
|
||||
const doc = await PDFDocument.create();
|
||||
const page = doc.addPage([300, 300]);
|
||||
const ctx = doc.context;
|
||||
const xmp = (text: string) =>
|
||||
ctx.register(
|
||||
ctx.flateStream(`<x:xmpmeta>${text}</x:xmpmeta>`, {
|
||||
Type: 'Metadata',
|
||||
Subtype: 'XML',
|
||||
})
|
||||
);
|
||||
|
||||
const image = ctx.register(
|
||||
ctx.stream(new Uint8Array([0, 128, 255, 64]), {
|
||||
Type: 'XObject',
|
||||
Subtype: 'Image',
|
||||
Width: 2,
|
||||
Height: 2,
|
||||
ColorSpace: 'DeviceGray',
|
||||
BitsPerComponent: 8,
|
||||
Metadata: xmp('image-xmp-secret'),
|
||||
})
|
||||
);
|
||||
const form = ctx.register(
|
||||
ctx.flateStream('0 0 1 rg 0 0 10 10 re f', {
|
||||
Type: 'XObject',
|
||||
Subtype: 'Form',
|
||||
BBox: [0, 0, 10, 10],
|
||||
Metadata: xmp('form-xmp-secret'),
|
||||
PieceInfo: { App: { Private: PDFString.of('form-piece-secret') } },
|
||||
})
|
||||
);
|
||||
page.node.set(
|
||||
PDFName.of('Resources'),
|
||||
ctx.obj({ XObject: { Im1: image, Fm1: form } })
|
||||
);
|
||||
page.node.set(
|
||||
PDFName.of('Contents'),
|
||||
ctx.register(ctx.flateStream('q 100 0 0 100 0 0 cm /Im1 Do Q q /Fm1 Do Q'))
|
||||
);
|
||||
page.node.set(PDFName.of('Metadata'), xmp('page-xmp-secret'));
|
||||
page.node.set(
|
||||
PDFName.of('PieceInfo'),
|
||||
ctx.obj({ App: { Private: PDFString.of('page-piece-secret') } })
|
||||
);
|
||||
page.node.set(
|
||||
PDFName.of('Thumb'),
|
||||
ctx.register(
|
||||
ctx.flateStream('thumb-secret-pixels', {
|
||||
Width: 1,
|
||||
Height: 1,
|
||||
ColorSpace: 'DeviceGray',
|
||||
BitsPerComponent: 8,
|
||||
})
|
||||
)
|
||||
);
|
||||
doc.catalog.set(
|
||||
PDFName.of('PieceInfo'),
|
||||
ctx.obj({ App: { Private: PDFString.of('catalog-piece-secret') } })
|
||||
);
|
||||
doc.catalog.set(PDFName.of('Metadata'), xmp('catalog-xmp-secret'));
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
const PAGE_CONTENT = [
|
||||
'q',
|
||||
'/OC /MCon BDC 0 0 1 rg 10 10 50 50 re f',
|
||||
'BT /F1 12 Tf 20 200 Td (ON-LAYER-TEXT) Tj ET',
|
||||
'BI /W 4 /H 1 /BPC 8 /CS /G ID \x20EI\x80 EI',
|
||||
'EMC',
|
||||
'/OC /MCoff BDC 1 0 0 rg 100 100 m 200 200 l S',
|
||||
'q 0 0 10 10 re W n Q',
|
||||
'BT /F1 12 Tf 20 220 Td (OFF-LAYER-TEXT EMC (inner) \\) tail) Tj',
|
||||
'<4f46462d484558> Tj % EMC in a comment',
|
||||
'/Span <</ActualText (OFF-ACTUAL)>> BDC (x) Tj EMC ET',
|
||||
'BI /W 2 /H 1 /BPC 8 /CS /G ID \x10\xff EI',
|
||||
'/ImOff Do',
|
||||
'EMC',
|
||||
'/OC /MCmd BDC BT /F1 12 Tf 20 240 Td (OCMD-HIDDEN-TEXT) Tj ET EMC',
|
||||
'/OC /MCve BDC BT /F1 12 Tf 20 260 Td (VE-HIDDEN-TEXT) Tj ET EMC',
|
||||
'/FmOff Do',
|
||||
'/FmOn Do',
|
||||
'/ImOff Do',
|
||||
'Q',
|
||||
].join('\n');
|
||||
|
||||
async function buildLayerDocument(content = PAGE_CONTENT): Promise<Uint8Array> {
|
||||
const doc = await PDFDocument.create();
|
||||
const page = doc.addPage([300, 300]);
|
||||
const ctx = doc.context;
|
||||
|
||||
const on = ctx.register(ctx.obj({ Type: 'OCG', Name: PDFString.of('On') }));
|
||||
const off = ctx.register(ctx.obj({ Type: 'OCG', Name: PDFString.of('Off') }));
|
||||
const ocmd = ctx.register(
|
||||
ctx.obj({ Type: 'OCMD', OCGs: [on, off], P: 'AllOn' })
|
||||
);
|
||||
const ocmdVe = ctx.register(
|
||||
ctx.obj({
|
||||
Type: 'OCMD',
|
||||
OCGs: [on],
|
||||
VE: [PDFName.of('And'), on, [PDFName.of('Not'), on]],
|
||||
})
|
||||
);
|
||||
doc.catalog.set(
|
||||
PDFName.of('OCProperties'),
|
||||
ctx.obj({ OCGs: [on, off], D: { Order: [on, off], ON: [on], OFF: [off] } })
|
||||
);
|
||||
|
||||
const font = ctx.register(
|
||||
ctx.obj({ Type: 'Font', Subtype: 'Type1', BaseFont: 'Helvetica' })
|
||||
);
|
||||
const formOff = ctx.register(
|
||||
ctx.flateStream('BT /F1 12 Tf (FORM-OFF-TEXT) Tj ET', {
|
||||
Type: 'XObject',
|
||||
Subtype: 'Form',
|
||||
BBox: [0, 0, 300, 300],
|
||||
OC: off,
|
||||
Resources: { Font: { F1: font } },
|
||||
})
|
||||
);
|
||||
const formOn = ctx.register(
|
||||
ctx.flateStream(
|
||||
'BT /F1 12 Tf (FORM-ON-TEXT) Tj ET /OC /P0 BDC BT /F1 12 Tf (FORM-NESTED-OFF) Tj ET EMC',
|
||||
{
|
||||
Type: 'XObject',
|
||||
Subtype: 'Form',
|
||||
BBox: [0, 0, 300, 300],
|
||||
OC: on,
|
||||
Resources: { Font: { F1: font }, Properties: { P0: off } },
|
||||
}
|
||||
)
|
||||
);
|
||||
const imageOff = ctx.register(
|
||||
ctx.stream('IMAGE-OFF-PIXELS', {
|
||||
Type: 'XObject',
|
||||
Subtype: 'Image',
|
||||
Width: 4,
|
||||
Height: 4,
|
||||
ColorSpace: 'DeviceGray',
|
||||
BitsPerComponent: 8,
|
||||
OC: off,
|
||||
})
|
||||
);
|
||||
|
||||
page.node.set(
|
||||
PDFName.of('Resources'),
|
||||
ctx.obj({
|
||||
Font: { F1: font },
|
||||
Properties: { MCon: on, MCoff: off, MCmd: ocmd, MCve: ocmdVe },
|
||||
XObject: { FmOff: formOff, FmOn: formOn, ImOff: imageOff },
|
||||
})
|
||||
);
|
||||
page.node.set(PDFName.of('Contents'), ctx.register(ctx.flateStream(content)));
|
||||
|
||||
const square = (text: string, oc: PDFRef) =>
|
||||
ctx.register(
|
||||
ctx.obj({
|
||||
Type: 'Annot',
|
||||
Subtype: 'Square',
|
||||
Rect: [10, 10, 50, 50],
|
||||
Contents: PDFString.of(text),
|
||||
OC: oc,
|
||||
})
|
||||
);
|
||||
page.node.set(
|
||||
PDFName.of('Annots'),
|
||||
ctx.obj([square('annot-off-layer', off), square('annot-on-layer', on)])
|
||||
);
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
describe('sanitize remove JavaScript', () => {
|
||||
it('removes every reachable script, launch, submit and XFA but keeps links', async () => {
|
||||
const input = await buildActionDocument();
|
||||
const before = await allText(input);
|
||||
for (const secret of [
|
||||
'outline-js',
|
||||
'launch-calc.exe',
|
||||
'next-js',
|
||||
'nested-aa-js',
|
||||
'xfa-script',
|
||||
'submit-url',
|
||||
'import-data.fdf',
|
||||
]) {
|
||||
expect(before).toContain(secret);
|
||||
}
|
||||
|
||||
const { bytes } = await sanitizePdf(input, {
|
||||
...NONE,
|
||||
removeJavascript: true,
|
||||
});
|
||||
const after = await allText(bytes);
|
||||
for (const secret of [
|
||||
'outline-js',
|
||||
'launch-calc.exe',
|
||||
'next-js',
|
||||
'nested-aa-js',
|
||||
'xfa-script',
|
||||
'submit-url',
|
||||
'import-data.fdf',
|
||||
'JavaScript',
|
||||
'XFA',
|
||||
]) {
|
||||
expect(after).not.toContain(secret);
|
||||
}
|
||||
expect(after).toContain('https://example.com/keep');
|
||||
expect(after).toContain('https://example.com/after-submit');
|
||||
expect(after).toContain('Chapter one');
|
||||
expect(after).toContain('Chapter two');
|
||||
|
||||
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||
const annots = doc.getPages()[0].node.Annots()!;
|
||||
const linkAction = lookupDict(
|
||||
doc,
|
||||
lookupDict(doc, annots.get(0))?.get(PDFName.of('A'))
|
||||
);
|
||||
expect(linkAction?.get(PDFName.of('S'))).toBe(PDFName.of('URI'));
|
||||
expect(linkAction?.has(PDFName.of('Next'))).toBe(false);
|
||||
const promoted = lookupDict(
|
||||
doc,
|
||||
lookupDict(doc, annots.get(1))?.get(PDFName.of('A'))
|
||||
);
|
||||
expect(promoted?.get(PDFName.of('S'))).toBe(PDFName.of('URI'));
|
||||
expect(doc.catalog.get(PDFName.of('OpenAction'))).toBeInstanceOf(PDFArray);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitize remove metadata', () => {
|
||||
it('drops page, XObject and catalog metadata, piece info and thumbnails', async () => {
|
||||
const input = await buildMetadataDocument();
|
||||
const secrets = [
|
||||
'image-xmp-secret',
|
||||
'form-xmp-secret',
|
||||
'form-piece-secret',
|
||||
'page-xmp-secret',
|
||||
'page-piece-secret',
|
||||
'thumb-secret-pixels',
|
||||
'catalog-piece-secret',
|
||||
'catalog-xmp-secret',
|
||||
];
|
||||
const before = await allText(input);
|
||||
for (const secret of secrets) expect(before).toContain(secret);
|
||||
|
||||
const { bytes } = await sanitizePdf(input, {
|
||||
...NONE,
|
||||
removeMetadata: true,
|
||||
});
|
||||
const after = await allText(bytes);
|
||||
for (const secret of secrets) expect(after).not.toContain(secret);
|
||||
|
||||
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||
const xObjects = doc
|
||||
.getPages()[0]
|
||||
.node.Resources()
|
||||
?.lookup(PDFName.of('XObject'), PDFDict);
|
||||
expect(xObjects?.has(PDFName.of('Im1'))).toBe(true);
|
||||
expect(xObjects?.has(PDFName.of('Fm1'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitize remove layers', () => {
|
||||
it('removes content in hidden layers and keeps visible layers', async () => {
|
||||
const input = await buildLayerDocument();
|
||||
const { bytes } = await sanitizePdf(input, {
|
||||
...NONE,
|
||||
removeLayers: true,
|
||||
});
|
||||
|
||||
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||
expect(doc.catalog.has(PDFName.of('OCProperties'))).toBe(false);
|
||||
|
||||
const content = await pageContent(doc);
|
||||
expect(content).toContain('(ON-LAYER-TEXT) Tj');
|
||||
expect(content).toContain('0 0 1 rg 10 10 50 50 re f');
|
||||
expect(content).toContain('BI /W 4 /H 1 /BPC 8 /CS /G ID \x20EI\x80 EI');
|
||||
expect(content).toContain('/FmOn Do');
|
||||
expect(content).toMatch(/0 0 10 10 re\s+W\s+n/);
|
||||
for (const hidden of [
|
||||
'OFF-LAYER-TEXT',
|
||||
'inner',
|
||||
'tail',
|
||||
'4f46462d484558',
|
||||
'OFF-ACTUAL',
|
||||
'OCMD-HIDDEN-TEXT',
|
||||
'VE-HIDDEN-TEXT',
|
||||
'200 200 l',
|
||||
'/FmOff',
|
||||
'/ImOff',
|
||||
'\x10\xff',
|
||||
]) {
|
||||
expect(content).not.toContain(hidden);
|
||||
}
|
||||
expect(content.match(/\bq\b/g)?.length).toBe(
|
||||
content.match(/\bQ\b/g)?.length
|
||||
);
|
||||
|
||||
const text = await allText(bytes);
|
||||
expect(text).toContain('FORM-ON-TEXT');
|
||||
expect(text).toContain('annot-on-layer');
|
||||
for (const hidden of [
|
||||
'FORM-OFF-TEXT',
|
||||
'FORM-NESTED-OFF',
|
||||
'IMAGE-OFF-PIXELS',
|
||||
'annot-off-layer',
|
||||
'OCMD',
|
||||
]) {
|
||||
expect(text).not.toContain(hidden);
|
||||
}
|
||||
expect(doc.getPages()[0].node.Annots()?.size()).toBe(1);
|
||||
});
|
||||
|
||||
it('removes hidden layers with the default options', async () => {
|
||||
const { bytes } = await sanitizePdf(
|
||||
await buildLayerDocument(),
|
||||
defaultSanitizeOptions
|
||||
);
|
||||
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||
expect(doc.catalog.has(PDFName.of('OCProperties'))).toBe(false);
|
||||
const text = await allText(bytes);
|
||||
expect(text).toContain('ON-LAYER-TEXT');
|
||||
expect(text).toContain('FORM-ON-TEXT');
|
||||
for (const hidden of [
|
||||
'OFF-LAYER-TEXT',
|
||||
'OCMD-HIDDEN-TEXT',
|
||||
'FORM-OFF-TEXT',
|
||||
'FORM-NESTED-OFF',
|
||||
'IMAGE-OFF-PIXELS',
|
||||
]) {
|
||||
expect(text).not.toContain(hidden);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the layers when hidden content cannot be parsed safely', async () => {
|
||||
const input = await buildLayerDocument(
|
||||
'BT /F1 12 Tf /OC /MCoff BDC (UNPARSABLE-OFF Tj EMC ET'
|
||||
);
|
||||
const { bytes, skipped } = await sanitizePdf(input, {
|
||||
...NONE,
|
||||
removeLayers: true,
|
||||
});
|
||||
expect(skipped).toEqual(['remove layers']);
|
||||
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||
expect(doc.catalog.has(PDFName.of('OCProperties'))).toBe(true);
|
||||
const properties = doc
|
||||
.getPages()[0]
|
||||
.node.Resources()
|
||||
?.lookup(PDFName.of('Properties'), PDFDict);
|
||||
expect(properties?.has(PDFName.of('MCoff'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitize remove layers edge cases', () => {
|
||||
it('uses page resources for forms without their own and ignores missing content streams', async () => {
|
||||
const doc = await PDFDocument.create();
|
||||
const page = doc.addPage([300, 300]);
|
||||
const ctx = doc.context;
|
||||
const off = ctx.register(
|
||||
ctx.obj({ Type: 'OCG', Name: PDFString.of('Off') })
|
||||
);
|
||||
doc.catalog.set(
|
||||
PDFName.of('OCProperties'),
|
||||
ctx.obj({ OCGs: [off], D: { OFF: [off] } })
|
||||
);
|
||||
const font = ctx.register(
|
||||
ctx.obj({ Type: 'Font', Subtype: 'Type1', BaseFont: 'Helvetica' })
|
||||
);
|
||||
const bareForm = ctx.register(
|
||||
ctx.flateStream(
|
||||
'BT /F1 12 Tf (FORM-VISIBLE-TEXT) Tj ET /OC /MCoff BDC BT /F1 12 Tf (INHERITED-OFF-TEXT) Tj ET EMC',
|
||||
{ Type: 'XObject', Subtype: 'Form', BBox: [0, 0, 300, 300] }
|
||||
)
|
||||
);
|
||||
page.node.set(
|
||||
PDFName.of('Resources'),
|
||||
ctx.obj({
|
||||
Font: { F1: font },
|
||||
Properties: { MCoff: off },
|
||||
XObject: { FmBare: bareForm },
|
||||
})
|
||||
);
|
||||
page.node.set(
|
||||
PDFName.of('Contents'),
|
||||
ctx.obj([ctx.register(ctx.flateStream('/FmBare Do')), ctx.nextRef()])
|
||||
);
|
||||
|
||||
const { bytes, skipped } = await sanitizePdf(await doc.save(), {
|
||||
...NONE,
|
||||
removeLayers: true,
|
||||
});
|
||||
expect(skipped).toEqual([]);
|
||||
const text = await allText(bytes);
|
||||
expect(text).not.toContain('INHERITED-OFF-TEXT');
|
||||
expect(text).toContain('FORM-VISIBLE-TEXT');
|
||||
const result = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||
expect(result.catalog.has(PDFName.of('OCProperties'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('content stream tokenizer', () => {
|
||||
it('handles strings, comments, dictionaries and inline images', () => {
|
||||
const source =
|
||||
'/OC /L1 BDC (a\\) EMC (b)) Tj <45 4d 43> Tj % EMC\n' +
|
||||
'[(x) -20 (EMC)] TJ /P <</MCID 3 /Alt (EMC)>> BDC EMC ' +
|
||||
'BI /W 4 /H 1 /CS /G /BPC 8 ID \x00EI\x7f EI EMC ' +
|
||||
'BI /W 4 /H 1 /CS /G /BPC 8 /F /AHx ID 00ff00ff> EI EMC';
|
||||
const bytes = new Uint8Array([...source].map((c) => c.charCodeAt(0)));
|
||||
const ops = parseContentOperations(bytes);
|
||||
expect(ops.map((op) => op.operator)).toEqual([
|
||||
'BDC',
|
||||
'Tj',
|
||||
'Tj',
|
||||
'TJ',
|
||||
'BDC',
|
||||
'EMC',
|
||||
'BI',
|
||||
'EMC',
|
||||
'BI',
|
||||
'EMC',
|
||||
]);
|
||||
expect(ops[4].operands[1].kind).toBe('dict');
|
||||
});
|
||||
|
||||
it('rejects content it cannot tokenize', () => {
|
||||
const encode = (text: string) =>
|
||||
new Uint8Array([...text].map((c) => c.charCodeAt(0)));
|
||||
expect(() => parseContentOperations(encode('(open Tj'))).toThrow();
|
||||
expect(() => parseContentOperations(encode('<</A 1 BDC'))).toThrow();
|
||||
expect(() => parseContentOperations(encode('BI /W 1 ID \x00'))).toThrow();
|
||||
expect(() => parseContentOperations(encode('<zz> Tj'))).toThrow();
|
||||
expect(() =>
|
||||
parseContentOperations(encode('BI /F /Fl ID x EI\x00\x01 EI'))
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
parseContentOperations(encode('BI /W 9 /H 1 /CS /G /BPC 8 ID ab EI'))
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { serializeJsonLd } from '../../scripts/json-ld.mjs';
|
||||
import { sanitizeTranslationHtml } from '../js/utils/translation-html';
|
||||
|
||||
const LOCALES_DIR = path.resolve(__dirname, '../../public/locales');
|
||||
const PAYLOAD =
|
||||
'Merge</script><script src=https://cdn.jsdelivr.net/gh/a/b/c.js></script>';
|
||||
|
||||
function localeStrings(): string[] {
|
||||
const out: string[] = [];
|
||||
const visit = (value: unknown) => {
|
||||
if (typeof value === 'string') out.push(value);
|
||||
else if (value && typeof value === 'object')
|
||||
Object.values(value).forEach(visit);
|
||||
};
|
||||
for (const lang of fs.readdirSync(LOCALES_DIR)) {
|
||||
const dir = path.join(LOCALES_DIR, lang);
|
||||
if (!fs.statSync(dir).isDirectory()) continue;
|
||||
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.json'))) {
|
||||
visit(JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parse(html: string): Document {
|
||||
return new DOMParser().parseFromString(html, 'text/html');
|
||||
}
|
||||
|
||||
function scriptsAfterRoundTrip(jsonLd: string): HTMLScriptElement[] {
|
||||
const doc = parse('<!doctype html><html><head></head><body></body></html>');
|
||||
const script = doc.createElement('script');
|
||||
script.setAttribute('type', 'application/ld+json');
|
||||
script.textContent = jsonLd;
|
||||
doc.body.appendChild(script);
|
||||
return Array.from(
|
||||
parse(doc.documentElement.outerHTML).querySelectorAll('script')
|
||||
);
|
||||
}
|
||||
|
||||
describe('JSON-LD serialization', () => {
|
||||
it('raw JSON.stringify lets a translated name break out of the script', () => {
|
||||
expect(
|
||||
scriptsAfterRoundTrip(JSON.stringify({ name: PAYLOAD })).length
|
||||
).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('serializeJsonLd keeps the payload inside one ld+json script', () => {
|
||||
const data = { name: `${PAYLOAD}\u2028\u2029<!--` };
|
||||
const scripts = scriptsAfterRoundTrip(serializeJsonLd(data));
|
||||
expect(scripts).toHaveLength(1);
|
||||
expect(scripts[0].textContent).not.toMatch(/[<\u2028\u2029]/);
|
||||
expect(JSON.parse(scripts[0].textContent || '')).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeTranslationHtml', () => {
|
||||
it('keeps the allowed formatting tags', () => {
|
||||
expect(
|
||||
sanitizeTranslationHtml('a<br>b <strong>c</strong> <em>d</em>')
|
||||
).toBe('a<br>b <strong>c</strong> <em>d</em>');
|
||||
});
|
||||
|
||||
it('strips scripts, event handlers, links and attributes', () => {
|
||||
expect(sanitizeTranslationHtml(PAYLOAD)).toBe('Merge');
|
||||
expect(sanitizeTranslationHtml('<img src=x onerror=alert(1)>x')).toBe('x');
|
||||
expect(
|
||||
sanitizeTranslationHtml('<a href="javascript:alert(1)">go</a>')
|
||||
).toBe('go');
|
||||
expect(
|
||||
sanitizeTranslationHtml('<strong onclick="alert(1)" class="x">y</strong>')
|
||||
).toBe('<strong>y</strong>');
|
||||
expect(
|
||||
sanitizeTranslationHtml('<iframe srcdoc="<script>alert(1)</script>">')
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('leaves every shipped locale string intact', () => {
|
||||
const withMarkup = localeStrings().filter((s) => s.includes('<'));
|
||||
expect(withMarkup.length).toBeGreaterThan(0);
|
||||
for (const value of withMarkup) {
|
||||
expect(sanitizeTranslationHtml(value)).toBe(parse(value).body.innerHTML);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import forge from 'node-forge';
|
||||
import { signPdf, parsePemFiles } from '@/js/logic/digital-sign-pdf';
|
||||
import {
|
||||
countSignatures,
|
||||
extractSignatures,
|
||||
validatePdfSignatures,
|
||||
} from '@/js/logic/validate-signature-pdf';
|
||||
import type { CertificateData } from '@/types';
|
||||
|
||||
const RANGE_PLACEHOLDER = '{{RANGE}}';
|
||||
const RANGE_WIDTH = 64;
|
||||
const HEX_LENGTH = 8192;
|
||||
const SIG_HEAD =
|
||||
'%PDF-1.7\n1 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached ';
|
||||
const SIG_TAIL =
|
||||
' /M (D:20260101120000Z) >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n';
|
||||
|
||||
const keys = forge.pki.rsa.generateKeyPair(1024);
|
||||
const cert = forge.pki.createCertificate();
|
||||
cert.publicKey = keys.publicKey;
|
||||
cert.serialNumber = '01';
|
||||
cert.validity.notBefore = new Date(Date.now() - 86400000);
|
||||
cert.validity.notAfter = new Date(Date.now() + 86400000 * 365);
|
||||
cert.setSubject([{ name: 'commonName', value: 'Test Signer' }]);
|
||||
cert.setIssuer([{ name: 'commonName', value: 'Test Signer' }]);
|
||||
cert.sign(keys.privateKey, forge.md.sha256.create());
|
||||
|
||||
const certificateData: CertificateData = parsePemFiles(
|
||||
forge.pki.certificateToPem(cert),
|
||||
forge.pki.privateKeyToPem(keys.privateKey)
|
||||
);
|
||||
|
||||
const samplePdf = new Uint8Array(
|
||||
fs.readFileSync(path.resolve(__dirname, './fixtures/sample.pdf'))
|
||||
);
|
||||
|
||||
function signedCmsHex(content: string): string {
|
||||
const p7 = forge.pkcs7.createSignedData();
|
||||
p7.content = forge.util.createBuffer(content);
|
||||
p7.addCertificate(cert);
|
||||
p7.addSigner({
|
||||
key: keys.privateKey,
|
||||
certificate: cert,
|
||||
digestAlgorithm: forge.pki.oids.sha256,
|
||||
authenticatedAttributes: [
|
||||
{ type: forge.pki.oids.contentType, value: forge.pki.oids.data },
|
||||
{ type: forge.pki.oids.messageDigest },
|
||||
],
|
||||
});
|
||||
p7.sign({ detached: true });
|
||||
return forge.util.bytesToHex(forge.asn1.toDer(p7.toAsn1()).getBytes());
|
||||
}
|
||||
|
||||
function buildSignedPdf(layout: {
|
||||
prefix?: string;
|
||||
head: string;
|
||||
gap: (hex: string) => string;
|
||||
tail: string;
|
||||
}): Uint8Array {
|
||||
const prefix = layout.prefix ?? '';
|
||||
const headLength =
|
||||
layout.head.length - RANGE_PLACEHOLDER.length + RANGE_WIDTH;
|
||||
const gapLength = layout.gap('0'.repeat(HEX_LENGTH)).length;
|
||||
const byteRange = [
|
||||
prefix.length,
|
||||
headLength,
|
||||
prefix.length + headLength + gapLength,
|
||||
layout.tail.length,
|
||||
];
|
||||
const head = layout.head.replace(
|
||||
RANGE_PLACEHOLDER,
|
||||
`/ByteRange [${byteRange.join(' ')}]`.padEnd(RANGE_WIDTH)
|
||||
);
|
||||
const hex = signedCmsHex(head + layout.tail).padEnd(HEX_LENGTH, '0');
|
||||
return new TextEncoder().encode(
|
||||
prefix + head + layout.gap(hex) + layout.tail
|
||||
);
|
||||
}
|
||||
|
||||
function appendIncrementalUpdate(pdfBytes: Uint8Array): Uint8Array {
|
||||
const update = new TextEncoder().encode(
|
||||
'\n2 0 obj\n<< /Type /Annot /Subtype /Text /Contents (Added later) >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n'
|
||||
);
|
||||
const updated = new Uint8Array(pdfBytes.length + update.length);
|
||||
updated.set(pdfBytes, 0);
|
||||
updated.set(update, pdfBytes.length);
|
||||
return updated;
|
||||
}
|
||||
|
||||
describe('validatePdfSignatures extraction and coverage', () => {
|
||||
it('validates a signature made by the app signer with full coverage', async () => {
|
||||
const signed = await signPdf(samplePdf, certificateData, {
|
||||
signatureInfo: {
|
||||
reason: 'Approved (final) é',
|
||||
location: '東京',
|
||||
contactInfo: 'signer@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
const results = await validatePdfSignatures(signed);
|
||||
|
||||
expect(countSignatures(signed)).toBe(1);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].cryptoVerified).toBe(true);
|
||||
expect(results[0].coverageStatus).toBe('full');
|
||||
expect(results[0].isValid).toBe(true);
|
||||
expect(results[0].errorMessage).toBeUndefined();
|
||||
expect(results[0].signerName).toBe('Test Signer');
|
||||
expect(results[0].reason).toBe('Approved (final) é');
|
||||
expect(results[0].location).toBe('東京');
|
||||
expect(results[0].contactInfo).toBe('signer@example.com');
|
||||
expect(results[0].signatureDate).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('validates a hand-assembled signature whose gap is exactly its /Contents', async () => {
|
||||
const pdfBytes = buildSignedPdf({
|
||||
head: `${SIG_HEAD}${RANGE_PLACEHOLDER} /Prop_Build << /App << /Name /Builder >> >> /Name (Real Signer) /Contents `,
|
||||
gap: (hex) => `<${hex}>`,
|
||||
tail: SIG_TAIL,
|
||||
});
|
||||
|
||||
const [result] = await validatePdfSignatures(pdfBytes);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.coverageStatus).toBe('full');
|
||||
expect(result.signatureDate).toBeInstanceOf(Date);
|
||||
expect(extractSignatures(pdfBytes)[0].name).toBe('Real Signer');
|
||||
});
|
||||
|
||||
it('reports an intact signature as partial when an incremental update follows', async () => {
|
||||
const signed = await signPdf(samplePdf, certificateData, {
|
||||
signatureInfo: { reason: 'Original' },
|
||||
});
|
||||
|
||||
const [result] = await validatePdfSignatures(
|
||||
appendIncrementalUpdate(signed)
|
||||
);
|
||||
|
||||
expect(result.cryptoVerified).toBe(true);
|
||||
expect(result.cryptoVerificationStatus).toBe('verified');
|
||||
expect(result.coverageStatus).toBe('partial');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errorMessage).toBeUndefined();
|
||||
expect(result.reason).toBe('Original');
|
||||
});
|
||||
|
||||
it('keeps the earlier signature partial and the latest one full in a multi-signed file', async () => {
|
||||
const first = await signPdf(samplePdf, certificateData, {
|
||||
signatureInfo: { reason: 'First' },
|
||||
});
|
||||
const second = await signPdf(first, certificateData, {
|
||||
signatureInfo: { reason: 'Second' },
|
||||
});
|
||||
|
||||
const results = await validatePdfSignatures(second);
|
||||
|
||||
expect(countSignatures(second)).toBe(2);
|
||||
expect(results.map((r) => r.reason)).toEqual(['First', 'Second']);
|
||||
expect(results.map((r) => r.coverageStatus)).toEqual(['partial', 'full']);
|
||||
expect(results.map((r) => r.cryptoVerified)).toEqual([true, true]);
|
||||
expect(results.map((r) => r.errorMessage)).toEqual([undefined, undefined]);
|
||||
expect(results[1].isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a decoy /Contents whose gap holds a different signature', async () => {
|
||||
const pdfBytes = buildSignedPdf({
|
||||
head: `${SIG_HEAD}/Contents <> ${RANGE_PLACEHOLDER} /Decoy /Contents `,
|
||||
gap: (hex) => `<${hex}>`,
|
||||
tail: SIG_TAIL,
|
||||
});
|
||||
|
||||
const results = await validatePdfSignatures(pdfBytes);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].isValid).toBe(false);
|
||||
expect(results[0].cryptoVerified).toBe(false);
|
||||
expect(results[0].coverageStatus).toBe('unknown');
|
||||
expect(results[0].errorMessage).toMatch(
|
||||
/not the \/Contents of this signature/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a ByteRange that does not start at byte 0', async () => {
|
||||
const pdfBytes = buildSignedPdf({
|
||||
prefix:
|
||||
'%PDF-1.7\n9 0 obj\n<< /Type /Catalog /Unsigned true >>\nendobj\n',
|
||||
head: `${SIG_HEAD}${RANGE_PLACEHOLDER} /Contents `,
|
||||
gap: (hex) => `<${hex}>`,
|
||||
tail: SIG_TAIL,
|
||||
});
|
||||
|
||||
const results = await validatePdfSignatures(pdfBytes);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].isValid).toBe(false);
|
||||
expect(results[0].cryptoVerified).toBe(false);
|
||||
expect(results[0].errorMessage).toMatch(
|
||||
/does not start at the beginning of the file/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a gap that carries unsigned bytes after the hex signature', async () => {
|
||||
const pdfBytes = buildSignedPdf({
|
||||
head: `${SIG_HEAD}${RANGE_PLACEHOLDER} /Contents `,
|
||||
gap: (hex) => `<${hex}> /Reason (Approved by the CEO)`,
|
||||
tail: SIG_TAIL,
|
||||
});
|
||||
|
||||
const results = await validatePdfSignatures(pdfBytes);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].isValid).toBe(false);
|
||||
expect(results[0].cryptoVerified).toBe(false);
|
||||
expect(results[0].errorMessage).toMatch(
|
||||
/not exactly a hex-encoded signature/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createNodeByType, nodeRegistry } from '@/js/workflow/nodes/registry';
|
||||
import type { BaseWorkflowNode } from '@/js/workflow/nodes/base-node';
|
||||
import {
|
||||
getControlSpec,
|
||||
MAX_TEXT_CONTROL_LENGTH,
|
||||
} from '@/js/workflow/nodes/control-specs';
|
||||
import {
|
||||
exportWorkflow,
|
||||
getSavedTemplateNames,
|
||||
loadWorkflow,
|
||||
saveWorkflow,
|
||||
} from '@/js/workflow/serialization';
|
||||
|
||||
vi.mock('heic2any', () => ({ default: vi.fn() }));
|
||||
|
||||
type Editor = Parameters<typeof loadWorkflow>[0];
|
||||
type Area = Parameters<typeof loadWorkflow>[1];
|
||||
|
||||
const TEMPLATES_KEY = 'bento-pdf-workflow-templates';
|
||||
|
||||
function createFakeEditor(initial: BaseWorkflowNode[] = []) {
|
||||
const nodes = new Map<string, BaseWorkflowNode>(
|
||||
initial.map((node) => [node.id, node])
|
||||
);
|
||||
const editor = {
|
||||
getNodes: () => [...nodes.values()],
|
||||
getConnections: (): unknown[] => [],
|
||||
getNode: (id: string) => nodes.get(id),
|
||||
addNode: async (node: BaseWorkflowNode) => {
|
||||
nodes.set(node.id, node);
|
||||
return true;
|
||||
},
|
||||
removeNode: async (id: string) => nodes.delete(id),
|
||||
removeConnection: async () => true,
|
||||
addConnection: async () => true,
|
||||
};
|
||||
const area = {
|
||||
nodeViews: new Map(),
|
||||
translate: async () => true,
|
||||
};
|
||||
return {
|
||||
nodes,
|
||||
editor: editor as unknown as Editor,
|
||||
area: area as unknown as Area,
|
||||
};
|
||||
}
|
||||
|
||||
function controlValue(node: BaseWorkflowNode, key: string): unknown {
|
||||
const control = node.controls[key];
|
||||
return control && 'value' in control ? control.value : undefined;
|
||||
}
|
||||
|
||||
function setControl(node: BaseWorkflowNode, key: string, value: unknown) {
|
||||
const control = node.controls[key];
|
||||
if (!control || !('value' in control)) throw new Error(`missing ${key}`);
|
||||
control.value = value;
|
||||
}
|
||||
|
||||
function storeTemplate(name: string, json: string) {
|
||||
localStorage.setItem(TEMPLATES_KEY, `{"${name}":${json}}`);
|
||||
}
|
||||
|
||||
function workflowJson(
|
||||
nodes: { type: string; controls: Record<string, unknown> }[]
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
nodes: nodes.map((node, index) => ({
|
||||
id: `n${index}`,
|
||||
type: node.type,
|
||||
position: { x: 0, y: 0 },
|
||||
controls: node.controls,
|
||||
})),
|
||||
connections: [],
|
||||
});
|
||||
}
|
||||
|
||||
async function loadNodes(json: string): Promise<BaseWorkflowNode[]> {
|
||||
storeTemplate('imported', json);
|
||||
const { editor, area, nodes } = createFakeEditor();
|
||||
expect(await loadWorkflow(editor, area, 'imported')).toBe(true);
|
||||
return [...nodes.values()];
|
||||
}
|
||||
|
||||
function findNode(nodes: BaseWorkflowNode[], type: string): BaseWorkflowNode {
|
||||
const node = nodes.find((candidate) => candidate.nodeType === type);
|
||||
if (!node) throw new Error(`missing ${type}`);
|
||||
return node;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('workflow import validation', () => {
|
||||
it('replaces select values outside the option list with the default', async () => {
|
||||
const nodes = await loadNodes(
|
||||
workflowJson([
|
||||
{
|
||||
type: 'RedactNode',
|
||||
controls: { redactMode: 'Area', text: 'secret' },
|
||||
},
|
||||
{ type: 'NUpNode', controls: { pagesPerSheet: 7 } },
|
||||
{ type: 'RotateNode', controls: { angle: '45' } },
|
||||
{ type: 'CompressNode', controls: { algorithm: 'photon' } },
|
||||
])
|
||||
);
|
||||
|
||||
const redact = findNode(nodes, 'RedactNode');
|
||||
expect(controlValue(redact, 'redactMode')).toBe('text');
|
||||
expect(controlValue(redact, 'text')).toBe('secret');
|
||||
expect(controlValue(findNode(nodes, 'NUpNode'), 'pagesPerSheet')).toBe(4);
|
||||
expect(controlValue(findNode(nodes, 'RotateNode'), 'angle')).toBe('90');
|
||||
expect(controlValue(findNode(nodes, 'CompressNode'), 'algorithm')).toBe(
|
||||
'photon'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps only known multi-select values', async () => {
|
||||
const nodes = await loadNodes(
|
||||
workflowJson([
|
||||
{ type: 'OCRNode', controls: { language: 'eng+../../x+fra+eng' } },
|
||||
{ type: 'OCRNode', controls: { language: 'zz+../evil' } },
|
||||
])
|
||||
);
|
||||
|
||||
const [mixed, unknown] = nodes;
|
||||
expect(controlValue(mixed, 'language')).toBe('eng+fra');
|
||||
expect(controlValue(unknown, 'language')).toBe('eng');
|
||||
});
|
||||
|
||||
it('clamps out-of-range numbers and resets non-finite ones', async () => {
|
||||
const json = workflowJson([
|
||||
{
|
||||
type: 'WatermarkNode',
|
||||
controls: { opacity: 5000, fontSize: 1, angle: 0 },
|
||||
},
|
||||
{ type: 'PosterizeNode', controls: { rows: 0, cols: 0 } },
|
||||
{ type: 'RedactNode', controls: { x0: -50, x1: 0 } },
|
||||
])
|
||||
.replace('"angle":0', '"angle":-1e999')
|
||||
.replace('"cols":0', '"cols":1e999')
|
||||
.replace('"x1":0', '"x1":1e999');
|
||||
const nodes = await loadNodes(json);
|
||||
|
||||
const watermark = findNode(nodes, 'WatermarkNode');
|
||||
expect(controlValue(watermark, 'opacity')).toBe(100);
|
||||
expect(controlValue(watermark, 'fontSize')).toBe(10);
|
||||
expect(controlValue(watermark, 'angle')).toBe(-45);
|
||||
const posterize = findNode(nodes, 'PosterizeNode');
|
||||
expect(controlValue(posterize, 'rows')).toBe(1);
|
||||
expect(controlValue(posterize, 'cols')).toBe(2);
|
||||
const redact = findNode(nodes, 'RedactNode');
|
||||
expect(controlValue(redact, 'x0')).toBe(0);
|
||||
expect(controlValue(redact, 'x1')).toBe(200);
|
||||
|
||||
const scanner = createNodeByType('ScannerEffectNode');
|
||||
expect(scanner?.sanitizeControlValue('resolution', Number.NaN)).toBe(150);
|
||||
});
|
||||
|
||||
it('validates booleans, colors and text length', async () => {
|
||||
const nodes = await loadNodes(
|
||||
workflowJson([
|
||||
{ type: 'SanitizeNode', controls: { removeJavascript: 'nope' } },
|
||||
{ type: 'BackgroundColorNode', controls: { color: 'red;x' } },
|
||||
{
|
||||
type: 'EditMetadataNode',
|
||||
controls: { title: 'a'.repeat(MAX_TEXT_CONTROL_LENGTH + 500) },
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
expect(
|
||||
controlValue(findNode(nodes, 'SanitizeNode'), 'removeJavascript')
|
||||
).toBe('true');
|
||||
expect(controlValue(findNode(nodes, 'BackgroundColorNode'), 'color')).toBe(
|
||||
'#ffffff'
|
||||
);
|
||||
expect(
|
||||
String(controlValue(findNode(nodes, 'EditMetadataNode'), 'title'))
|
||||
).toHaveLength(MAX_TEXT_CONTROL_LENGTH);
|
||||
});
|
||||
|
||||
it('skips inherited property names used as node types', async () => {
|
||||
const nodes = await loadNodes(
|
||||
workflowJson([
|
||||
{ type: 'constructor', controls: {} },
|
||||
{ type: 'toString', controls: {} },
|
||||
{ type: '__proto__', controls: {} },
|
||||
{ type: 'hasOwnProperty', controls: {} },
|
||||
{ type: 'RotateNode', controls: { constructor: 'x', angle: '180' } },
|
||||
])
|
||||
);
|
||||
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].nodeType).toBe('RotateNode');
|
||||
expect(controlValue(nodes[0], 'angle')).toBe('180');
|
||||
expect(createNodeByType('constructor')).toBeNull();
|
||||
expect(createNodeByType('toString')).toBeNull();
|
||||
});
|
||||
|
||||
it('covers every registered node control with a valid spec', () => {
|
||||
for (const type of Object.keys(nodeRegistry)) {
|
||||
const node = createNodeByType(type);
|
||||
expect(node).not.toBeNull();
|
||||
if (!node) continue;
|
||||
for (const key of Object.keys(node.controls)) {
|
||||
const initial = controlValue(node, key);
|
||||
const spec = getControlSpec(type, key);
|
||||
if (/password/i.test(key)) {
|
||||
expect(spec?.kind, `${type}.${key}`).toBe('password');
|
||||
continue;
|
||||
}
|
||||
if (typeof initial === 'number') {
|
||||
expect(['number', 'select'], `${type}.${key}`).toContain(spec?.kind);
|
||||
expect(
|
||||
node.sanitizeControlValue(key, Number.POSITIVE_INFINITY),
|
||||
`${type}.${key}`
|
||||
).toBe(initial);
|
||||
}
|
||||
expect(node.sanitizeControlValue(key, initial), `${type}.${key}`).toBe(
|
||||
initial
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflow password controls', () => {
|
||||
function createSecretNodes(): BaseWorkflowNode[] {
|
||||
const encrypt = createNodeByType('EncryptNode');
|
||||
const decrypt = createNodeByType('DecryptNode');
|
||||
if (!encrypt || !decrypt) throw new Error('missing nodes');
|
||||
setControl(encrypt, 'userPassword', 'user-secret-1');
|
||||
setControl(encrypt, 'ownerPassword', 'owner-secret-2');
|
||||
setControl(decrypt, 'password', 'decrypt-secret-3');
|
||||
return [encrypt, decrypt];
|
||||
}
|
||||
|
||||
const secrets = ['user-secret-1', 'owner-secret-2', 'decrypt-secret-3'];
|
||||
|
||||
it('never writes passwords into saved templates', () => {
|
||||
const { editor, area } = createFakeEditor(createSecretNodes());
|
||||
saveWorkflow(editor, area, 'secure');
|
||||
|
||||
const stored = localStorage.getItem(TEMPLATES_KEY) ?? '';
|
||||
for (const secret of secrets) expect(stored).not.toContain(secret);
|
||||
expect(stored).toContain('"userPassword":""');
|
||||
});
|
||||
|
||||
it('never writes passwords into exported workflow JSON', async () => {
|
||||
const blobs: Blob[] = [];
|
||||
vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => {
|
||||
blobs.push(blob as Blob);
|
||||
return 'blob:workflow';
|
||||
});
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
|
||||
const nodes = createSecretNodes();
|
||||
const { editor, area } = createFakeEditor(nodes);
|
||||
exportWorkflow(editor, area);
|
||||
|
||||
expect(blobs).toHaveLength(1);
|
||||
const exported = await blobs[0].text();
|
||||
for (const secret of secrets) expect(exported).not.toContain(secret);
|
||||
expect(controlValue(nodes[0], 'userPassword')).toBe('user-secret-1');
|
||||
});
|
||||
|
||||
it('drops passwords from imported workflows and legacy templates', async () => {
|
||||
storeTemplate(
|
||||
'legacy',
|
||||
workflowJson([
|
||||
{
|
||||
type: 'EncryptNode',
|
||||
controls: {
|
||||
userPassword: 'user-secret-1',
|
||||
ownerPassword: 'owner-secret-2',
|
||||
},
|
||||
},
|
||||
{ type: 'DecryptNode', controls: { password: 'decrypt-secret-3' } },
|
||||
])
|
||||
);
|
||||
|
||||
expect(getSavedTemplateNames()).toEqual(['legacy']);
|
||||
const stored = localStorage.getItem(TEMPLATES_KEY) ?? '';
|
||||
for (const secret of secrets) expect(stored).not.toContain(secret);
|
||||
|
||||
const legacyJson = workflowJson([
|
||||
{ type: 'EncryptNode', controls: { userPassword: 'user-secret-1' } },
|
||||
]);
|
||||
const nodes = await loadNodes(legacyJson);
|
||||
expect(controlValue(nodes[0], 'userPassword')).toBe('');
|
||||
expect(
|
||||
createNodeByType('DecryptNode')?.sanitizeControlValue(
|
||||
'password',
|
||||
'decrypt-secret-3'
|
||||
)
|
||||
).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user