feat: update GitHub Actions workflow for PDF engine and viewer

This commit is contained in:
alam00000
2026-10-19 13:17:07 +05:30
parent 231c012e99
commit 70dc47d25c
16 changed files with 840 additions and 88 deletions
+1 -3
View File
@@ -11,7 +11,6 @@ permissions:
actions: write
contents: write
pull-requests: write
statuses: write
jobs:
cla-check:
@@ -28,10 +27,9 @@ jobs:
)
steps:
- name: "CLA Assistant"
uses: contributor-assistant/github-action@v2.6.1
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_ASSISTANT_TOKEN }}
with:
path-to-signatures: 'signatures/cla.json'
path-to-document: 'https://github.com/${{ github.repository }}/blob/main/ICLA.md'
+90 -18
View File
@@ -6,8 +6,7 @@ on:
- cron: '17 4 * * *'
permissions:
contents: write
pull-requests: write
contents: read
concurrency:
group: update-engine
@@ -18,12 +17,19 @@ env:
HUSKY: 0
jobs:
update:
build:
runs-on: ubuntu-latest
if: github.repository == 'alam00000/bentopdf'
permissions:
contents: read
outputs:
run: ${{ steps.gate.outputs.run }}
version: ${{ steps.gate.outputs.version }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Read current upstream marker
id: current
@@ -40,13 +46,21 @@ jobs:
- name: Should update?
id: gate
env:
CURRENT: ${{ steps.current.outputs.version }}
UPSTREAM: ${{ steps.upstream.outputs.version }}
run: |
if [ "${{ steps.upstream.outputs.version }}" = "${{ steps.current.outputs.version }}" ]; then
if ! [[ "$UPSTREAM" =~ ^[0-9a-f]{12}$ ]]; then
echo "::error::Unexpected fork HEAD: $UPSTREAM"
exit 1
fi
echo "version=$UPSTREAM" >> "$GITHUB_OUTPUT"
if [ "$UPSTREAM" = "$CURRENT" ]; then
echo "run=false" >> "$GITHUB_OUTPUT"
echo "Already on ${{ steps.current.outputs.version }}."
echo "Already on $CURRENT."
else
echo "run=true" >> "$GITHUB_OUTPUT"
echo "Updating ${{ steps.current.outputs.version }} -> ${{ steps.upstream.outputs.version }}"
echo "Updating $CURRENT -> $UPSTREAM"
fi
- name: Setup Node
@@ -73,9 +87,10 @@ jobs:
- name: Pack the engine
if: steps.gate.outputs.run == 'true'
id: engine
env:
SHA: ${{ steps.gate.outputs.version }}
run: |
set -euo pipefail
SHA=${{ steps.upstream.outputs.version }}
VENDOR=./_fork/packages/pdfium/src/vendor
STAGE=$(mktemp -d)/package
mkdir -p "$STAGE"
@@ -107,9 +122,10 @@ jobs:
- name: Pack the viewer without a second engine
if: steps.gate.outputs.run == 'true'
id: viewer
env:
SHA: ${{ steps.gate.outputs.version }}
run: |
set -euo pipefail
SHA=${{ steps.upstream.outputs.version }}
WORK=$(mktemp -d)
(cd ./_fork && npm pack ./viewers/snippet --pack-destination "$WORK" >/dev/null)
TMP=$(mktemp -d)
@@ -137,14 +153,17 @@ jobs:
- name: Point dependencies at the new tarballs
if: steps.gate.outputs.run == 'true'
env:
ENGINE_TARBALL: ${{ steps.engine.outputs.tarball }}
VIEWER_TARBALL: ${{ steps.viewer.outputs.tarball }}
run: |
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.dependencies['bentopdf-pdfium'] = 'file:' + process.argv[1];
pkg.dependencies['bentopdf-viewer'] = 'file:' + process.argv[2];
pkg.dependencies['bentopdf-pdfium'] = 'file:' + process.env.ENGINE_TARBALL;
pkg.dependencies['bentopdf-viewer'] = 'file:' + process.env.VIEWER_TARBALL;
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
" "${{ steps.engine.outputs.tarball }}" "${{ steps.viewer.outputs.tarball }}"
"
- name: Cleanup upstream clone
if: steps.gate.outputs.run == 'true'
@@ -174,19 +193,66 @@ jobs:
- name: Write upstream marker
if: steps.gate.outputs.run == 'true'
env:
SHA: ${{ steps.gate.outputs.version }}
run: |
echo "${{ steps.upstream.outputs.version }}" > vendor/bentopdf-viewer/.upstream-version
echo "${{ steps.upstream.outputs.version }}" > vendor/bentopdf-pdfium/.upstream-version
echo "$SHA" > vendor/bentopdf-viewer/.upstream-version
echo "$SHA" > vendor/bentopdf-pdfium/.upstream-version
- name: Upload update
if: steps.gate.outputs.run == 'true'
uses: actions/upload-artifact@v4
with:
name: engine-update
include-hidden-files: true
retention-days: 1
path: |
package.json
package-lock.json
vendor/bentopdf-pdfium/
vendor/bentopdf-viewer/
pull-request:
runs-on: ubuntu-latest
needs: build
if: needs.build.outputs.run == 'true'
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Download update
uses: actions/download-artifact@v4
with:
name: engine-update
path: ${{ runner.temp }}/engine-update
- name: Apply update
id: apply
env:
UPDATE: ${{ runner.temp }}/engine-update
run: |
set -euo pipefail
rm -rf vendor/bentopdf-pdfium vendor/bentopdf-viewer
mkdir -p vendor
cp -R "$UPDATE/vendor/bentopdf-pdfium" "$UPDATE/vendor/bentopdf-viewer" vendor/
cp "$UPDATE/package.json" "$UPDATE/package-lock.json" .
{
echo 'checksums<<EOF'
sha256sum vendor/bentopdf-pdfium/*.tgz vendor/bentopdf-viewer/*.tgz
echo 'EOF'
} >> "$GITHUB_OUTPUT"
- name: Create Pull Request
if: steps.gate.outputs.run == 'true'
uses: peter-evans/create-pull-request@v6
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
with:
commit-message: 'build(deps): bump PDF engine and viewer to ${{ steps.upstream.outputs.version }}'
title: 'build(deps): bump PDF engine and viewer to ${{ steps.upstream.outputs.version }}'
commit-message: 'build(deps): bump PDF engine and viewer to ${{ needs.build.outputs.version }}'
title: 'build(deps): bump PDF engine and viewer to ${{ needs.build.outputs.version }}'
body: |
Both vendored packages built from one clone of the viewer fork at
`${{ steps.upstream.outputs.version }}`, so the text editor and the
`${{ needs.build.outputs.version }}`, so the text editor and the
PDF editor run the same engine.
- `bentopdf-pdfium` — the engine wasm, packed from the fork's published `src/vendor`
@@ -194,5 +260,11 @@ jobs:
The viewer loads the shared engine through `EmbedPDF.init({ wasmUrl })`.
CI verified a single wasm on disk, typecheck, and the test suite.
Built in a read-only job. Tarball checksums:
```
${{ steps.apply.outputs.checksums }}
```
branch: chore/update-pdf-engine
delete-branch: true
+1 -1
View File
@@ -46,7 +46,7 @@ http {
if ($request_uri ~ ^/en/?(\?.*)?$) {
return 301 /$1;
}
if ($request_uri ~ ^/en/(.+)$) {
if ($request_uri ~ ^/en/([A-Za-z0-9].*)$) {
return 301 /$1;
}
+24 -25
View File
@@ -5,7 +5,7 @@
* Version: 1.1.0
*/
const CACHE_VERSION = 'bentopdf-v12';
const CACHE_VERSION = 'bentopdf-v13';
const CACHE_NAME = `${CACHE_VERSION}-static`;
const trustedCdnOrigins = new Set(['https://cdn.jsdelivr.net']);
@@ -126,7 +126,7 @@ async function cacheFirstStrategyWithDedup(request, isCDN) {
const fileName = url.pathname.split('/').pop();
try {
const cachedResponse = await findCachedFile(fileName, request.url);
const cachedResponse = await findCachedFile(request.url);
if (cachedResponse) {
// console.log('⚡ [Cache HIT] Instant load:', fileName);
return cachedResponse;
@@ -136,7 +136,11 @@ async function cacheFirstStrategyWithDedup(request, isCDN) {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.status === 200) {
if (
networkResponse &&
networkResponse.status === 200 &&
!networkResponse.redirected
) {
const clone = networkResponse.clone();
const buffer = await clone.arrayBuffer();
if (buffer.byteLength > 0) {
@@ -163,8 +167,18 @@ async function cacheFirstStrategyWithDedup(request, isCDN) {
if (localPath) {
const localUrl = `${basePath}${localPath}${fileName}`;
try {
const cachedLocal = await findCachedFile(
new URL(localUrl, location.origin).href
);
if (cachedLocal) {
return cachedLocal;
}
const fallbackResponse = await fetch(localUrl);
if (fallbackResponse && fallbackResponse.status === 200) {
if (
fallbackResponse &&
fallbackResponse.status === 200 &&
!fallbackResponse.redirected
) {
const fbClone = fallbackResponse.clone();
const fbBuffer = await fbClone.arrayBuffer();
if (fbBuffer.byteLength > 0) {
@@ -192,7 +206,7 @@ async function cacheFirstStrategyWithDedup(request, isCDN) {
}
}
async function findCachedFile(fileName, requestUrl) {
async function findCachedFile(requestUrl) {
const cache = await caches.open(CACHE_NAME);
const exactMatch = await cache.match(requestUrl);
@@ -204,25 +218,6 @@ async function findCachedFile(fileName, requestUrl) {
}
await cache.delete(requestUrl);
}
const requests = await cache.keys();
for (const req of requests) {
const reqUrl = new URL(req.url);
if (
reqUrl.origin === location.origin &&
reqUrl.pathname.split('/').pop() === fileName
) {
const response = await cache.match(req);
if (response) {
const clone = response.clone();
const buffer = await clone.arrayBuffer();
if (buffer.byteLength > 0) {
return response;
}
await cache.delete(req);
}
}
}
return null;
}
@@ -248,7 +243,11 @@ async function networkFirstStrategy(request) {
try {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.status === 200) {
if (
networkResponse &&
networkResponse.status === 200 &&
!networkResponse.redirected
) {
const clone = networkResponse.clone();
const buffer = await clone.arrayBuffer();
if (buffer.byteLength > 0) {
+8 -3
View File
@@ -7,7 +7,10 @@ import { icons, createIcons } from 'lucide';
import JSZip from 'jszip';
import { deduplicateFileName } from '../utils/deduplicate-filename.js';
import { FlattenPdfState } from '@/types';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from '../utils/load-pdf-document.js';
const pageState: FlattenPdfState = {
files: [],
@@ -134,7 +137,7 @@ async function flattenPdf() {
console.warn('Could not flatten annotations:', msg);
}
const newPdfBytes = await pdfDoc.save();
const newPdfBytes = await removeUnreferencedObjects(await pdfDoc.save());
downloadFile(
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
`flattened_${file.name}`
@@ -173,7 +176,9 @@ async function flattenPdf() {
console.warn('Could not flatten annotations:', msg);
}
const flattenedBytes = await pdfDoc.save();
const flattenedBytes = await removeUnreferencedObjects(
await pdfDoc.save()
);
const zipEntryName = deduplicateFileName(file.name, usedNames);
zip.file(zipEntryName, flattenedBytes);
processedCount++;
+7 -2
View File
@@ -1,7 +1,10 @@
import { PDFDocument, PDFName } from 'pdf-lib';
import { createIcons, icons } from 'lucide';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from '../utils/load-pdf-document.js';
import { escapeHtml } from '../utils/helpers.js';
// State management
@@ -142,7 +145,9 @@ async function processRemoveAnnotations() {
}
}
const newPdfBytes = await pageState.pdfDoc.save();
const newPdfBytes = await removeUnreferencedObjects(
await pageState.pdfDoc.save()
);
downloadFile(
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
pageState.file?.name || 'document.pdf'
+6 -3
View File
@@ -3,7 +3,10 @@ import { downloadFile, formatBytes } from '../utils/helpers.js';
import { PDFDocument, PDFName } from 'pdf-lib';
import { icons, createIcons } from 'lucide';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from '../utils/load-pdf-document.js';
interface PageState {
file: File | null;
@@ -155,9 +158,9 @@ async function removeMetadata() {
removeMetadataFromDoc(pdfDoc);
const newPdfBytes = await pdfDoc.save();
const newPdfBytes = await removeUnreferencedObjects(await pdfDoc.save());
downloadFile(
new Blob([newPdfBytes as BlobPart], { type: 'application/pdf' }),
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
pageState.file?.name || 'document.pdf'
);
showAlert('Success', 'Metadata removed successfully!', 'success', () => {
+76 -21
View File
@@ -113,6 +113,8 @@ export async function validateSignature(
authenticatedAttributes?: forge.asn1.Asn1[];
signature?: string;
signatureAlgorithm?: forge.asn1.Asn1[];
issuer?: forge.asn1.Asn1;
serial?: string;
};
};
@@ -121,7 +123,12 @@ export async function validateSignature(
return result;
}
const signerCert = p7.certificates[0] as forge.pki.Certificate;
const certificates = p7.certificates as forge.pki.Certificate[];
const signerCert = findSignerCertificate(
certificates,
p7.rawCapture?.issuer,
p7.rawCapture?.serial
);
const subjectCN = signerCert.subject.getField('CN');
const subjectO = signerCert.subject.getField('O');
@@ -148,27 +155,11 @@ export async function validateSignature(
// Check trust against provided certificate (cryptographic, not name/serial match)
if (trustedCert) {
try {
const trustedPem = forge.pki.certificateToPem(trustedCert);
const isSameCert =
forge.pki.certificateToPem(signerCert) === trustedPem;
let cryptoIssued = false;
const chain = [
result.isTrusted = chainsToTrustedCert(
signerCert,
...(p7.certificates as forge.pki.Certificate[]),
];
for (const cert of chain) {
try {
if (trustedCert.verify(cert)) {
cryptoIssued = true;
break;
}
} catch {
continue;
}
}
result.isTrusted = isSameCert || cryptoIssued;
certificates,
trustedCert
);
} catch {
result.isTrusted = false;
}
@@ -267,6 +258,70 @@ export async function validateSignature(
return result;
}
function findSignerCertificate(
certificates: forge.pki.Certificate[],
issuer: forge.asn1.Asn1 | undefined,
serial: string | undefined
): forge.pki.Certificate {
if (issuer && serial) {
const serialHex = forge.util.createBuffer(serial).toHex();
const issuerDer = forge.asn1.toDer(issuer).getBytes();
const match = certificates.find(
(cert) =>
cert.serialNumber === serialHex &&
forge.asn1
.toDer(forge.pki.distinguishedNameToAsn1(cert.issuer))
.getBytes() === issuerDer
);
if (match) return match;
}
return certificates[0];
}
function isIssuedBy(
issuer: forge.pki.Certificate,
child: forge.pki.Certificate
): boolean {
try {
return issuer.verify(child);
} catch {
return false;
}
}
function isCertificateAuthority(cert: forge.pki.Certificate): boolean {
const basicConstraints = cert.getExtension('basicConstraints') as
| { cA?: boolean }
| undefined;
return basicConstraints?.cA === true;
}
function chainsToTrustedCert(
signerCert: forge.pki.Certificate,
certificates: forge.pki.Certificate[],
trustedCert: forge.pki.Certificate
): boolean {
const trustedPem = forge.pki.certificateToPem(trustedCert);
const visited = new Set<string>();
let current = signerCert;
for (let depth = 0; depth < 10; depth++) {
const currentPem = forge.pki.certificateToPem(current);
if (currentPem === trustedPem || isIssuedBy(trustedCert, current)) {
return true;
}
if (visited.has(currentPem)) return false;
visited.add(currentPem);
const parent = certificates.find(
(cert) => isCertificateAuthority(cert) && isIssuedBy(cert, current)
);
if (!parent) return false;
current = parent;
}
return false;
}
export async function validatePdfSignatures(
pdfBytes: Uint8Array,
trustedCert?: forge.pki.Certificate
+22 -2
View File
@@ -4,13 +4,16 @@ import { initializeQpdf } from './helpers.js';
type LoadOptions = Parameters<typeof PDFDocument.load>[1];
type PDFDocumentInstance = Awaited<ReturnType<typeof PDFDocument.load>>;
async function repairPdfBytes(pdf: Uint8Array): Promise<Uint8Array | null> {
async function runQpdf(
pdf: Uint8Array,
args: string[]
): Promise<Uint8Array | null> {
try {
const qpdf = await initializeQpdf();
qpdf.FS.writeFile('/input.pdf', pdf);
try {
qpdf.callMain(['/input.pdf', '--decrypt', '/output.pdf']);
qpdf.callMain(['/input.pdf', ...args, '/output.pdf']);
} catch (e) {
console.warn('[loadPdfDocument] qpdf repair warning:', e);
}
@@ -40,6 +43,23 @@ async function repairPdfBytes(pdf: Uint8Array): Promise<Uint8Array | null> {
}
}
function repairPdfBytes(pdf: Uint8Array): Promise<Uint8Array | null> {
return runQpdf(pdf, ['--decrypt']);
}
export async function removeUnreferencedObjects(
pdf: Uint8Array | ArrayBuffer
): Promise<Uint8Array> {
const cleaned = await runQpdf(
pdf instanceof Uint8Array ? pdf : new Uint8Array(pdf),
[]
);
if (!cleaned) {
throw new Error('Could not remove leftover data from the PDF.');
}
return cleaned;
}
export async function loadPdfDocument(
pdf: Uint8Array | ArrayBuffer,
options?: LoadOptions
+230
View File
@@ -0,0 +1,230 @@
import {
PDFArray,
PDFDict,
PDFHexString,
PDFName,
PDFObject,
PDFRawStream,
PDFRef,
PDFStream,
PDFString,
decodePDFRawStream,
} from 'pdf-lib';
import type { PDFDocument } from 'pdf-lib';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from './load-pdf-document.js';
const ANNOTATION_TEXT_KEYS = ['Contents', 'RC', 'T', 'Subj'];
function decodeText(value: PDFObject | undefined): string | null {
if (value instanceof PDFString || value instanceof PDFHexString) {
return value.decodeText();
}
return null;
}
function decodeStream(stream: PDFRawStream): Uint8Array | null {
try {
return decodePDFRawStream(stream).decode();
} catch {
return null;
}
}
function removeTextFromDocument(doc: PDFDocument, term: 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 valueContains = (value: PDFObject | undefined): boolean => {
if (value instanceof PDFArray) return value.asArray().some(valueContains);
const text = decodeText(value);
return text !== null && contains(text);
};
const resolve = (value: PDFObject | undefined) =>
value instanceof PDFRef ? context.lookup(value) : value;
const matchedFiles = new Set<string>();
for (const [ref, object] of context.enumerateIndirectObjects()) {
if (
!(object instanceof PDFRawStream) ||
object.dict.get(PDFName.of('Type')) !== PDFName.of('EmbeddedFile')
) {
continue;
}
const bytes = decodeStream(object);
if (!bytes) continue;
const latin1 = new TextDecoder('latin1').decode(bytes);
const utf16 = new TextDecoder('utf-16le').decode(bytes);
if (contains(latin1) || contains(utf16)) matchedFiles.add(ref.toString());
}
const isMatchedFileSpec = (value: PDFObject | undefined): boolean => {
const spec = resolve(value);
if (!(spec instanceof PDFDict)) return false;
const ef = resolve(spec.get(PDFName.of('EF')));
if (!(ef instanceof PDFDict)) return false;
return ef
.values()
.some((v) => v instanceof PDFRef && matchedFiles.has(v.toString()));
};
const fieldValueContains = (widget: PDFDict): boolean => {
let node: PDFObject | undefined = widget;
for (let depth = 0; node instanceof PDFDict && depth < 32; depth++) {
if (valueContains(resolve(node.get(PDFName.of('V'))))) return true;
node = resolve(node.get(PDFName.of('Parent')));
}
return false;
};
let clearedFieldAppearance = false;
for (const page of doc.getPages()) {
page.node.delete(PDFName.of('Thumb'));
const annots = page.node.Annots();
if (!annots) continue;
const removed = new Set<string>();
for (const entry of annots.asArray()) {
const annot = resolve(entry);
if (!(annot instanceof PDFDict)) continue;
const subtype = annot.get(PDFName.of('Subtype'));
if (subtype === PDFName.of('Widget')) {
if (fieldValueContains(annot)) {
annot.delete(PDFName.of('AP'));
clearedFieldAppearance = true;
}
continue;
}
const hasText = ANNOTATION_TEXT_KEYS.some((key) =>
valueContains(resolve(annot.get(PDFName.of(key))))
);
const hasFile =
subtype === PDFName.of('FileAttachment') &&
isMatchedFileSpec(annot.get(PDFName.of('FS')));
if (hasText || hasFile) {
removed.add(entry.toString());
const popup = annot.get(PDFName.of('Popup'));
if (popup) removed.add(popup.toString());
}
}
if (removed.size > 0) {
page.node.set(
PDFName.of('Annots'),
context.obj(
annots.asArray().filter((entry) => !removed.has(entry.toString()))
)
);
}
}
if (clearedFieldAppearance) {
const acroForm = resolve(doc.catalog.get(PDFName.of('AcroForm')));
if (acroForm instanceof PDFDict) {
acroForm.set(PDFName.of('NeedAppearances'), context.obj(true));
}
}
const pruneNameTree = (value: PDFObject | undefined, depth = 0) => {
const node = resolve(value);
if (!(node instanceof PDFDict) || depth > 32) return;
const names = resolve(node.get(PDFName.of('Names')));
if (names instanceof PDFArray) {
const items = names.asArray();
const kept: PDFObject[] = [];
for (let i = 0; i + 1 < items.length; i += 2) {
if (!isMatchedFileSpec(items[i + 1])) kept.push(items[i], items[i + 1]);
}
node.set(PDFName.of('Names'), context.obj(kept));
}
const kids = resolve(node.get(PDFName.of('Kids')));
if (kids instanceof PDFArray) {
for (const kid of kids.asArray()) pruneNameTree(kid, depth + 1);
}
};
const pruneAssociatedFiles = (dict: PDFDict) => {
const list = resolve(dict.get(PDFName.of('AF')));
if (!(list instanceof PDFArray)) return;
const kept = list.asArray().filter((spec) => !isMatchedFileSpec(spec));
if (kept.length !== list.size()) {
dict.set(PDFName.of('AF'), context.obj(kept));
}
};
if (matchedFiles.size > 0) {
const names = resolve(doc.catalog.get(PDFName.of('Names')));
if (names instanceof PDFDict) {
pruneNameTree(names.get(PDFName.of('EmbeddedFiles')));
}
for (const [, object] of context.enumerateIndirectObjects()) {
if (object instanceof PDFDict) pruneAssociatedFiles(object);
else if (object instanceof PDFStream) pruneAssociatedFiles(object.dict);
}
}
const scrubValue = (value: PDFObject): PDFObject | null => {
const text = decodeText(value);
if (text !== null && contains(text)) {
return PDFHexString.fromText(text.replace(pattern, ''));
}
if (value instanceof PDFDict || value instanceof PDFArray) {
scrubContainer(value);
}
return null;
};
const scrubContainer = (container: PDFDict | PDFArray) => {
if (container instanceof PDFDict) {
for (const [key, value] of container.entries()) {
const replacement = scrubValue(value);
if (replacement) container.set(key, replacement);
}
} else {
container.asArray().forEach((value, index) => {
const replacement = scrubValue(value);
if (replacement) container.set(index, replacement);
});
}
};
for (const [ref, object] of context.enumerateIndirectObjects()) {
if (object instanceof PDFStream) {
scrubContainer(object.dict);
const isMetadata =
object.dict.get(PDFName.of('Type')) === PDFName.of('Metadata');
if (isMetadata && object instanceof PDFRawStream) {
const bytes = decodeStream(object);
const xml = bytes ? new TextDecoder('utf-8').decode(bytes) : null;
if (xml !== null && contains(xml)) {
context.assign(
ref,
context.flateStream(xml.replace(pattern, ''), {
Type: 'Metadata',
Subtype: 'XML',
})
);
}
}
} else if (object instanceof PDFDict || object instanceof PDFArray) {
scrubContainer(object);
}
}
}
export async function redactDocumentData(
pdf: Uint8Array,
term?: string
): Promise<Uint8Array> {
const doc = await loadPdfDocument(pdf);
if (term) {
removeTextFromDocument(doc, term);
} else {
for (const page of doc.getPages()) page.node.delete(PDFName.of('Thumb'));
}
return removeUnreferencedObjects(await doc.save());
}
+14 -3
View File
@@ -1,5 +1,8 @@
import { PDFDocument, PDFName, PDFDict, PDFArray } from 'pdf-lib';
import { loadPdfDocument } from './load-pdf-document.js';
import { PDFDocument, PDFName, PDFDict, PDFArray, PDFStream } from 'pdf-lib';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from './load-pdf-document.js';
import type { PDFDocumentInternal } from '@/types';
function getCatalogDict(pdfDoc: PDFDocument): PDFDict {
@@ -281,6 +284,14 @@ function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) {
}
}
for (const [, object] of pdfDoc.context.enumerateIndirectObjects()) {
if (object instanceof PDFDict) {
object.delete(PDFName.of('AF'));
} else if (object instanceof PDFStream) {
object.dict.delete(PDFName.of('AF'));
}
}
const pdfDocInternal = pdfDoc as unknown as PDFDocumentInternal;
if (pdfDocInternal.embeddedFiles && pdfDocInternal.embeddedFiles.length > 0) {
pdfDocInternal.embeddedFiles = [];
@@ -641,5 +652,5 @@ export async function sanitizePdf(
}
const savedBytes = await pdfDoc.save();
return { pdfDoc, bytes: new Uint8Array(savedBytes) };
return { pdfDoc, bytes: await removeUnreferencedObjects(savedBytes) };
}
+6 -3
View File
@@ -4,7 +4,10 @@ import { pdfSocket } from '../sockets';
import type { SocketData } from '../types';
import { requirePdfInput, processBatch } from '../types';
import { flattenAnnotations } from '../../utils/flatten-annotations.js';
import { loadPdfDocument } from '../../utils/load-pdf-document.js';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from '../../utils/load-pdf-document.js';
export class FlattenNode extends BaseWorkflowNode {
readonly category = 'Secure PDF' as const;
@@ -39,11 +42,11 @@ export class FlattenNode extends BaseWorkflowNode {
console.error('Flatten annotations error:', err);
}
const pdfBytes = await pdfDoc.save();
const pdfBytes = await removeUnreferencedObjects(await pdfDoc.save());
return {
type: 'pdf',
document: pdfDoc,
bytes: new Uint8Array(pdfBytes),
bytes: pdfBytes,
filename: input.filename.replace(/\.pdf$/i, '_flattened.pdf'),
};
}),
+7 -1
View File
@@ -6,6 +6,7 @@ import { requirePdfInput, processBatch } from '../types';
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
import { hexToRgb } from '../../utils/helpers.js';
import { loadPdfDocument } from '../../utils/load-pdf-document.js';
import { redactDocumentData } from '../../utils/redact-document-data.js';
import { wfError } from '../errors';
export class RedactNode extends BaseWorkflowNode {
@@ -106,9 +107,14 @@ export class RedactNode extends BaseWorkflowNode {
}
}
const resultBytes = new Uint8Array(doc.save());
const redactedBytes = new Uint8Array(doc.save());
doc.close();
const resultBytes = await redactDocumentData(
redactedBytes,
mode === 'text' ? searchText : undefined
);
const resultDoc = await loadPdfDocument(resultBytes);
return {
@@ -4,7 +4,10 @@ import { pdfSocket } from '../sockets';
import type { SocketData } from '../types';
import { requirePdfInput, processBatch } from '../types';
import { PDFName } from 'pdf-lib';
import { loadPdfDocument } from '../../utils/load-pdf-document.js';
import {
loadPdfDocument,
removeUnreferencedObjects,
} from '../../utils/load-pdf-document.js';
export class RemoveAnnotationsNode extends BaseWorkflowNode {
readonly category = 'Edit & Annotate' as const;
@@ -34,11 +37,11 @@ export class RemoveAnnotationsNode extends BaseWorkflowNode {
}
}
const pdfBytes = await pdfDoc.save();
const pdfBytes = await removeUnreferencedObjects(await pdfDoc.save());
return {
type: 'pdf',
document: pdfDoc,
bytes: new Uint8Array(pdfBytes),
bytes: pdfBytes,
filename: input.filename.replace(/\.pdf$/i, '_clean.pdf'),
};
}),
+210
View File
@@ -0,0 +1,210 @@
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
PDFArray,
PDFDict,
PDFDocument,
PDFHexString,
PDFName,
PDFObject,
PDFRawStream,
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 { redactDocumentData } =
await import('../js/utils/redact-document-data.js');
const SECRET = 'SECRET-4471';
async function buildDocument(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([300, 300]);
const ctx = doc.context;
doc.setTitle(`Report for ${SECRET}`);
doc.setAuthor('Keep Author');
doc.catalog.set(
PDFName.of('Metadata'),
ctx.register(
ctx.flateStream(`<x:xmpmeta><dc:title>${SECRET}</dc:title></x:xmpmeta>`, {
Type: 'Metadata',
Subtype: 'XML',
})
)
);
const outlines = ctx.nextRef();
const first = ctx.nextRef();
const second = ctx.nextRef();
ctx.assign(
first,
ctx.obj({
Title: PDFString.of(`${SECRET} chapter`),
Parent: outlines,
Next: second,
})
);
ctx.assign(
second,
ctx.obj({
Title: PDFString.of('Keep chapter'),
Parent: outlines,
Prev: first,
})
);
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('StructTreeRoot'),
ctx.register(
ctx.obj({
Type: 'StructTreeRoot',
K: ctx.obj({
S: 'Figure',
Alt: PDFHexString.fromText(`Photo of ${SECRET}`),
}),
})
)
);
const secretNote = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Text',
Rect: [10, 10, 30, 30],
Contents: PDFString.of(`note about ${secret()}`),
})
);
const keepNote = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Text',
Rect: [40, 10, 60, 30],
Contents: PDFString.of('keep note'),
})
);
const appearance = ctx.register(
ctx.flateStream(`BT /Helv 10 Tf (${SECRET}) Tj ET`, {
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 100, 20],
})
);
const widget = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Widget',
FT: 'Tx',
T: PDFString.of('ssn'),
V: PDFString.of(SECRET),
Rect: [10, 100, 110, 120],
AP: { N: appearance },
})
);
doc.catalog.set(PDFName.of('AcroForm'), ctx.obj({ Fields: [widget] }));
page.node.set(PDFName.of('Annots'), ctx.obj([secretNote, keepNote, widget]));
page.node.set(
PDFName.of('Thumb'),
ctx.register(ctx.flateStream(SECRET, { Width: 1, Height: 1 }))
);
await doc.attach(
new Uint8Array(Buffer.from(`payroll ${SECRET}`)),
'payroll.txt',
{
mimeType: 'text/plain',
}
);
await doc.attach(new Uint8Array(Buffer.from('harmless')), 'readme.txt', {
mimeType: 'text/plain',
});
return doc.save();
}
function secret() {
return SECRET.toLowerCase();
}
function collectText(value: PDFObject, out: string[]) {
if (value instanceof PDFString || value instanceof PDFHexString) {
out.push(value.decodeText());
} else if (value instanceof PDFDict) {
for (const [, v] of value.entries()) 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[] = [new TextDecoder('latin1').decode(bytes)];
for (const [, object] of doc.context.enumerateIndirectObjects()) {
if (object instanceof PDFRawStream) {
collectText(object.dict, out);
try {
out.push(
new TextDecoder('latin1').decode(decodePDFRawStream(object).decode())
);
} catch {
continue;
}
} else {
collectText(object, out);
}
}
return out.join('\n');
}
describe('redactDocumentData', () => {
it('removes the redacted text from every place outside the page content', async () => {
const input = await buildDocument();
expect((await allText(input)).toLowerCase()).toContain(
SECRET.toLowerCase()
);
const output = await redactDocumentData(input, secret());
const text = await allText(output);
expect(text.toLowerCase()).not.toContain(SECRET.toLowerCase());
expect(text).toContain('Keep chapter');
expect(text).toContain('keep note');
expect(text).toContain('Keep Author');
expect(text).toContain('harmless');
expect(text).toContain('readme.txt');
const result = await PDFDocument.load(output, { updateMetadata: false });
const annots = result.getPages()[0].node.Annots();
expect(annots?.size()).toBe(2);
expect(result.getPages()[0].node.get(PDFName.of('Thumb'))).toBeUndefined();
});
it('only drops thumbnails and leftovers when no search text is given', async () => {
const input = await buildDocument();
const output = await redactDocumentData(input);
const result = await PDFDocument.load(output, { updateMetadata: false });
expect(result.getPages()[0].node.get(PDFName.of('Thumb'))).toBeUndefined();
expect(result.getTitle()).toBe(`Report for ${SECRET}`);
});
});
+132
View File
@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest';
import forge from 'node-forge';
import { validateSignature } from '@/js/logic/validate-signature-pdf';
interface Identity {
cert: forge.pki.Certificate;
key: forge.pki.rsa.PrivateKey;
}
let serial = 1;
function makeIdentity(
commonName: string,
isCa: boolean,
issuer?: Identity
): Identity {
const keys = forge.pki.rsa.generateKeyPair(1024);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = (serial++).toString(16).padStart(2, '0');
cert.validity.notBefore = new Date(Date.now() - 86400000);
cert.validity.notAfter = new Date(Date.now() + 86400000 * 365);
const subject = [{ name: 'commonName', value: commonName }];
cert.setSubject(subject);
cert.setIssuer(issuer ? issuer.cert.subject.attributes : subject);
cert.setExtensions([{ name: 'basicConstraints', cA: isCa }]);
cert.sign(issuer ? issuer.key : keys.privateKey, forge.md.sha256.create());
return { cert, key: keys.privateKey };
}
function signContent(
content: Uint8Array,
signer: Identity,
bundle: forge.pki.Certificate[]
): Uint8Array {
const p7 = forge.pkcs7.createSignedData();
p7.content = forge.util.createBuffer(
String.fromCharCode(...Array.from(content))
);
for (const cert of bundle) p7.addCertificate(cert);
p7.addSigner({
key: signer.key,
certificate: signer.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 });
const der = forge.asn1.toDer(p7.toAsn1()).getBytes();
return Uint8Array.from(der, (c) => c.charCodeAt(0));
}
async function check(
signer: Identity,
bundle: forge.pki.Certificate[],
trusted: forge.pki.Certificate
) {
const pdfBytes = new TextEncoder().encode('%PDF-1.7 signed content');
const contents = signContent(pdfBytes, signer, bundle);
return validateSignature(
{
index: 0,
contents,
byteRange: [0, pdfBytes.length, pdfBytes.length, 0],
},
pdfBytes,
trusted
);
}
describe('validateSignature trust chain', () => {
const root = makeIdentity('Company Root CA', true);
const intermediate = makeIdentity('Company Issuing CA', true, root);
const employee = makeIdentity('Real Employee', false, root);
const chainedEmployee = makeIdentity('Chained Employee', false, intermediate);
const attacker = makeIdentity('John Smith', false);
it('does not trust a self-made signer bundled with an unrelated company certificate', async () => {
const result = await check(
attacker,
[attacker.cert, employee.cert],
root.cert
);
expect(result.signerName).toBe('John Smith');
expect(result.cryptoVerified).toBe(true);
expect(result.isTrusted).toBe(false);
});
it('identifies the real signer even when another certificate is listed first', async () => {
const result = await check(
attacker,
[employee.cert, attacker.cert],
root.cert
);
expect(result.signerName).toBe('John Smith');
expect(result.cryptoVerified).toBe(true);
expect(result.isTrusted).toBe(false);
});
it('trusts a signer issued directly by the trusted certificate', async () => {
const result = await check(employee, [employee.cert], root.cert);
expect(result.cryptoVerified).toBe(true);
expect(result.isTrusted).toBe(true);
});
it('trusts a signer that chains to the trusted certificate through an intermediate CA', async () => {
const result = await check(
chainedEmployee,
[chainedEmployee.cert, intermediate.cert],
root.cert
);
expect(result.cryptoVerified).toBe(true);
expect(result.isTrusted).toBe(true);
});
it('does not accept a non-CA certificate as an intermediate', async () => {
const issuedByLeaf = makeIdentity('Issued By Leaf', false, employee);
const result = await check(
issuedByLeaf,
[issuedByLeaf.cert, employee.cert],
root.cert
);
expect(result.isTrusted).toBe(false);
});
it('trusts a signer whose own certificate is the trusted certificate', async () => {
const result = await check(attacker, [attacker.cert], attacker.cert);
expect(result.isTrusted).toBe(true);
});
});