Merge pull request #576 from InstaZDLL/feat/timestamp-pdf
feat: add Timestamp PDF tool with RFC 3161 support
This commit is contained in:
14
src/js/config/timestamp-tsa.ts
Normal file
14
src/js/config/timestamp-tsa.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface TimestampTsaPreset {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
// Some TSA providers only expose HTTP endpoints. RFC 3161 timestamp tokens are
|
||||
// signed at the application layer, so integrity does not depend solely on TLS.
|
||||
export const TIMESTAMP_TSA_PRESETS: TimestampTsaPreset[] = [
|
||||
{ label: 'DigiCert', url: 'http://timestamp.digicert.com' },
|
||||
{ label: 'Sectigo', url: 'http://timestamp.sectigo.com' },
|
||||
{ label: 'SSL.com', url: 'http://ts.ssl.com' },
|
||||
{ label: 'FreeTSA', url: 'https://freetsa.org/tsr' },
|
||||
{ label: 'MeSign', url: 'http://tsa.mesign.com' },
|
||||
];
|
||||
@@ -799,6 +799,13 @@ const baseCategories = [
|
||||
icon: 'ph-seal-check',
|
||||
subtitle: 'Verify digital signatures and view certificate details.',
|
||||
},
|
||||
{
|
||||
href: import.meta.env.BASE_URL + 'timestamp-pdf.html',
|
||||
name: 'Timestamp PDF',
|
||||
icon: 'ph-clock',
|
||||
subtitle:
|
||||
'Add an RFC 3161 document timestamp using a trusted TSA server.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -153,6 +153,20 @@ async function generateProxySignature(
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function buildCorsProxyUrl(url: string): Promise<string> {
|
||||
let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
|
||||
|
||||
if (!CORS_PROXY_SECRET) {
|
||||
return proxyUrl;
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const signature = await generateProxySignature(url, timestamp);
|
||||
proxyUrl += `&t=${timestamp}&sig=${signature}`;
|
||||
|
||||
return proxyUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom fetch wrapper that routes external certificate requests through a CORS proxy.
|
||||
* The zgapdfsigner library tries to fetch issuer certificates from URLs embedded in the
|
||||
@@ -192,20 +206,32 @@ function createCorsAwareFetch(): {
|
||||
url.includes('caIssuers')) &&
|
||||
!url.startsWith(window.location.origin);
|
||||
|
||||
if (isExternalCertificateUrl && CORS_PROXY_URL) {
|
||||
let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
|
||||
const isTsaRequest =
|
||||
(init?.headers &&
|
||||
(init.headers instanceof Headers
|
||||
? init.headers.get('Content-Type') === 'application/timestamp-query'
|
||||
: typeof init.headers === 'object' &&
|
||||
!Array.isArray(init.headers) &&
|
||||
(init.headers as Record<string, string>)['Content-Type'] ===
|
||||
'application/timestamp-query')) ||
|
||||
url.includes('timestamp') ||
|
||||
url.includes('/tsa') ||
|
||||
url.includes('/tsr') ||
|
||||
url.includes('/ts01') ||
|
||||
url.includes('RFC3161');
|
||||
|
||||
const shouldProxy =
|
||||
(isExternalCertificateUrl || isTsaRequest) &&
|
||||
!url.startsWith(window.location.origin);
|
||||
|
||||
if (shouldProxy && CORS_PROXY_URL) {
|
||||
const proxyUrl = await buildCorsProxyUrl(url);
|
||||
if (CORS_PROXY_SECRET) {
|
||||
const timestamp = Date.now();
|
||||
const signature = await generateProxySignature(url, timestamp);
|
||||
proxyUrl += `&t=${timestamp}&sig=${signature}`;
|
||||
console.log(
|
||||
`[CORS Proxy] Routing signed certificate request through proxy: ${url}`
|
||||
`[CORS Proxy] Routing signed request through proxy: ${url}`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[CORS Proxy] Routing certificate request through proxy: ${url}`
|
||||
);
|
||||
console.log(`[CORS Proxy] Routing request through proxy: ${url}`);
|
||||
}
|
||||
|
||||
return originalFetch(proxyUrl, init);
|
||||
@@ -302,6 +328,40 @@ export async function signPdf(
|
||||
}
|
||||
}
|
||||
|
||||
export async function timestampPdf(
|
||||
pdfBytes: Uint8Array,
|
||||
tsaUrl: string
|
||||
): Promise<Uint8Array> {
|
||||
let effectiveUrl = tsaUrl;
|
||||
|
||||
if (CORS_PROXY_URL) {
|
||||
effectiveUrl = await buildCorsProxyUrl(tsaUrl);
|
||||
|
||||
if (CORS_PROXY_SECRET) {
|
||||
console.log(
|
||||
`[Timestamp] Routing signed TSA request through proxy: ${tsaUrl}`
|
||||
);
|
||||
} else {
|
||||
console.log(`[Timestamp] Routing TSA request through proxy: ${tsaUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
const signOptions: SignOption = {
|
||||
signdate: { url: effectiveUrl },
|
||||
};
|
||||
|
||||
const signer = new PdfSigner(signOptions);
|
||||
|
||||
const { restore } = createCorsAwareFetch();
|
||||
|
||||
try {
|
||||
const timestampedPdfBytes = await signer.sign(pdfBytes);
|
||||
return new Uint8Array(timestampedPdfBytes);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
|
||||
export function getCertificateInfo(certificate: forge.pki.Certificate): {
|
||||
subject: string;
|
||||
issuer: string;
|
||||
|
||||
258
src/js/logic/timestamp-pdf-page.ts
Normal file
258
src/js/logic/timestamp-pdf-page.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { showAlert, showLoader, hideLoader } from '../ui.js';
|
||||
import {
|
||||
readFileAsArrayBuffer,
|
||||
formatBytes,
|
||||
downloadFile,
|
||||
getPDFDocument,
|
||||
} from '../utils/helpers.js';
|
||||
import { TIMESTAMP_TSA_PRESETS } from '../config/timestamp-tsa.js';
|
||||
import { timestampPdf } from './digital-sign-pdf.js';
|
||||
|
||||
interface TimestampState {
|
||||
pdfFile: File | null;
|
||||
pdfBytes: Uint8Array | null;
|
||||
}
|
||||
|
||||
const state: TimestampState = {
|
||||
pdfFile: null,
|
||||
pdfBytes: null,
|
||||
};
|
||||
|
||||
function getElement<T extends HTMLElement>(id: string): T | null {
|
||||
return document.getElementById(id) as T | null;
|
||||
}
|
||||
|
||||
function resetState(): void {
|
||||
state.pdfFile = null;
|
||||
state.pdfBytes = null;
|
||||
|
||||
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
|
||||
const fileInput = getElement<HTMLInputElement>('file-input');
|
||||
if (fileInput) fileInput.value = '';
|
||||
|
||||
const tsaSection = getElement<HTMLDivElement>('tsa-section');
|
||||
if (tsaSection) tsaSection.classList.add('hidden');
|
||||
|
||||
updateProcessButton();
|
||||
}
|
||||
|
||||
function initializePage(): void {
|
||||
createIcons({ icons });
|
||||
|
||||
const fileInput = getElement<HTMLInputElement>('file-input');
|
||||
const dropZone = getElement<HTMLDivElement>('drop-zone');
|
||||
const processBtn = getElement<HTMLButtonElement>('process-btn');
|
||||
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
|
||||
const tsaPreset = getElement<HTMLSelectElement>('tsa-preset');
|
||||
|
||||
populateTsaPresets(tsaPreset);
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', handlePdfUpload);
|
||||
fileInput.addEventListener('click', () => {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
const droppedFiles = e.dataTransfer?.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
handlePdfFile(droppedFiles[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', processTimestamp);
|
||||
}
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function populateTsaPresets(tsaPreset: HTMLSelectElement | null): void {
|
||||
if (!tsaPreset) return;
|
||||
|
||||
tsaPreset.replaceChildren();
|
||||
|
||||
for (const preset of TIMESTAMP_TSA_PRESETS) {
|
||||
const option = document.createElement('option');
|
||||
option.value = preset.url;
|
||||
option.textContent = preset.label;
|
||||
tsaPreset.append(option);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePdfUpload(e: Event): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
handlePdfFile(input.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePdfFile(file: File): Promise<void> {
|
||||
if (
|
||||
file.type !== 'application/pdf' &&
|
||||
!file.name.toLowerCase().endsWith('.pdf')
|
||||
) {
|
||||
showAlert('Invalid File', 'Please select a PDF file.');
|
||||
return;
|
||||
}
|
||||
|
||||
state.pdfFile = file;
|
||||
state.pdfBytes = new Uint8Array(
|
||||
(await readFileAsArrayBuffer(file)) as ArrayBuffer
|
||||
);
|
||||
|
||||
updatePdfDisplay();
|
||||
showTsaSection();
|
||||
updateProcessButton();
|
||||
}
|
||||
|
||||
async function updatePdfDisplay(): Promise<void> {
|
||||
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
|
||||
|
||||
if (!fileDisplayArea || !state.pdfFile) return;
|
||||
|
||||
fileDisplayArea.innerHTML = '';
|
||||
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = state.pdfFile.name;
|
||||
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(state.pdfFile.size)} • Loading pages...`;
|
||||
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = () => {
|
||||
state.pdfFile = null;
|
||||
state.pdfBytes = null;
|
||||
fileDisplayArea.innerHTML = '';
|
||||
hideTsaSection();
|
||||
updateProcessButton();
|
||||
};
|
||||
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
|
||||
try {
|
||||
if (state.pdfBytes) {
|
||||
const pdfDoc = await getPDFDocument({
|
||||
data: state.pdfBytes.slice(),
|
||||
}).promise;
|
||||
metaSpan.textContent = `${formatBytes(state.pdfFile.size)} • ${pdfDoc.numPages} pages`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
metaSpan.textContent = `${formatBytes(state.pdfFile.size)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function showTsaSection(): void {
|
||||
const tsaSection = getElement<HTMLDivElement>('tsa-section');
|
||||
if (tsaSection) {
|
||||
tsaSection.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function hideTsaSection(): void {
|
||||
const tsaSection = getElement<HTMLDivElement>('tsa-section');
|
||||
if (tsaSection) {
|
||||
tsaSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function updateProcessButton(): void {
|
||||
const processBtn = getElement<HTMLButtonElement>('process-btn');
|
||||
if (!processBtn) return;
|
||||
|
||||
if (state.pdfBytes) {
|
||||
processBtn.classList.remove('hidden');
|
||||
processBtn.disabled = false;
|
||||
} else {
|
||||
processBtn.classList.add('hidden');
|
||||
processBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function getTsaUrl(): string | null {
|
||||
const tsaPreset = getElement<HTMLSelectElement>('tsa-preset');
|
||||
if (!tsaPreset) return null;
|
||||
|
||||
return tsaPreset.value;
|
||||
}
|
||||
|
||||
async function processTimestamp(): Promise<void> {
|
||||
if (!state.pdfBytes || !state.pdfFile) {
|
||||
showAlert('No File', 'Please upload a PDF file first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const tsaUrl = getTsaUrl();
|
||||
if (!tsaUrl) return;
|
||||
|
||||
showLoader('Applying timestamp...');
|
||||
|
||||
try {
|
||||
const timestampedBytes = await timestampPdf(state.pdfBytes, tsaUrl);
|
||||
|
||||
const outputFilename = state.pdfFile.name.replace(
|
||||
/\.pdf$/i,
|
||||
'_timestamped.pdf'
|
||||
);
|
||||
const blob = new Blob([new Uint8Array(timestampedBytes)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
downloadFile(blob, outputFilename);
|
||||
|
||||
showAlert(
|
||||
'Success',
|
||||
'PDF timestamped successfully! The timestamp can be verified in Adobe Acrobat and other PDF readers.',
|
||||
'success'
|
||||
);
|
||||
|
||||
resetState();
|
||||
} catch (error) {
|
||||
console.error('Timestamp error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
showAlert(
|
||||
'Timestamp Failed',
|
||||
`Failed to timestamp PDF: ${message}\n\nPlease try a different TSA server or check your internet connection.`
|
||||
);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
@@ -216,6 +216,7 @@ const init = async () => {
|
||||
'Deskew PDF': 'tools:deskewPdf',
|
||||
'Digital Signature': 'tools:digitalSignPdf',
|
||||
'Validate Signature': 'tools:validateSignaturePdf',
|
||||
'Timestamp PDF': 'tools:timestampPdf',
|
||||
'Scanner Effect': 'tools:scannerEffect',
|
||||
'Adjust Colors': 'tools:adjustColors',
|
||||
'Markdown to PDF': 'tools:markdownToPdf',
|
||||
|
||||
@@ -35,6 +35,7 @@ import { SanitizeNode } from './sanitize-node';
|
||||
import { EncryptNode } from './encrypt-node';
|
||||
import { DecryptNode } from './decrypt-node';
|
||||
import { DigitalSignNode } from './digital-sign-node';
|
||||
import { TimestampNode } from './timestamp-node';
|
||||
import { RedactNode } from './redact-node';
|
||||
import { RepairNode } from './repair-node';
|
||||
import { PdfToTextNode } from './pdf-to-text-node';
|
||||
@@ -509,6 +510,13 @@ export const nodeRegistry: Record<string, NodeRegistryEntry> = {
|
||||
description: 'Apply a digital signature to PDF',
|
||||
factory: () => new DigitalSignNode(),
|
||||
},
|
||||
TimestampNode: {
|
||||
label: 'Timestamp',
|
||||
category: 'Secure PDF',
|
||||
icon: 'ph-clock',
|
||||
description: 'Add an RFC 3161 document timestamp',
|
||||
factory: () => new TimestampNode(),
|
||||
},
|
||||
RedactNode: {
|
||||
label: 'Redact',
|
||||
category: 'Secure PDF',
|
||||
|
||||
66
src/js/workflow/nodes/timestamp-node.ts
Normal file
66
src/js/workflow/nodes/timestamp-node.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { TIMESTAMP_TSA_PRESETS } from '../../config/timestamp-tsa.js';
|
||||
import { timestampPdf } from '../../logic/digital-sign-pdf.js';
|
||||
|
||||
export class TimestampNode extends BaseWorkflowNode {
|
||||
readonly category = 'Secure PDF' as const;
|
||||
readonly icon = 'ph-clock';
|
||||
readonly description = 'Add an RFC 3161 document timestamp';
|
||||
|
||||
constructor() {
|
||||
super('Timestamp');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput(
|
||||
'pdf',
|
||||
new ClassicPreset.Output(pdfSocket, 'Timestamped PDF')
|
||||
);
|
||||
this.addControl(
|
||||
'tsaUrl',
|
||||
new ClassicPreset.InputControl('text', {
|
||||
initial: TIMESTAMP_TSA_PRESETS[0].url,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getTsaPresets(): { label: string; url: string }[] {
|
||||
return TIMESTAMP_TSA_PRESETS;
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Timestamp');
|
||||
|
||||
const tsaUrlCtrl = this.controls['tsaUrl'] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
const tsaUrl = tsaUrlCtrl?.value || TIMESTAMP_TSA_PRESETS[0].url;
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
let bytes: Uint8Array;
|
||||
try {
|
||||
bytes = await timestampPdf(input.bytes, tsaUrl);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to timestamp using TSA ${tsaUrl}: ${err instanceof Error ? err.message : err}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
const document = await PDFDocument.load(bytes);
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
document,
|
||||
bytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_timestamped.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user