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'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
492
src/pages/timestamp-pdf.html
Normal file
492
src/pages/timestamp-pdf.html
Normal file
@@ -0,0 +1,492 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<title>
|
||||
Timestamp PDF Online Free - RFC 3161 Document Timestamp | BentoPDF
|
||||
</title>
|
||||
<meta
|
||||
name="title"
|
||||
content="Timestamp PDF Online Free - RFC 3161 Document Timestamp | BentoPDF"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="★ Add RFC 3161 document timestamp to PDF online free ★ Trusted TSA servers ★ No certificate required ★ Privacy-first ★ Works in browser"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="timestamp pdf, rfc 3161, tsa server, document timestamp, time stamp authority, pdf timestamp"
|
||||
/>
|
||||
<meta name="author" content="BentoPDF" />
|
||||
<meta
|
||||
name="robots"
|
||||
content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"
|
||||
/>
|
||||
|
||||
<link rel="canonical" href="https://www.bentopdf.com/timestamp-pdf.html" />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://www.bentopdf.com/timestamp-pdf" />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Timestamp PDF Online Free - RFC 3161 Document Timestamp | BentoPDF"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="★ Add RFC 3161 document timestamp to PDF online free ★ Trusted TSA servers ★ No certificate required ★ Privacy-first ★ Works in browser"
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://www.bentopdf.com/images/og-timestamp-pdf.png"
|
||||
/>
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:site_name" content="BentoPDF" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://www.bentopdf.com/timestamp-pdf" />
|
||||
<meta name="twitter:title" content="Timestamp PDF Free" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="★ Add RFC 3161 document timestamp to PDF online free ★ Trusted TSA servers ★ Privacy-first"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://www.bentopdf.com/images/twitter-timestamp-pdf.png"
|
||||
/>
|
||||
<meta name="twitter:site" content="@BentoPDF" />
|
||||
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Timestamp PDF" />
|
||||
|
||||
<link href="/src/css/styles.css" rel="stylesheet" />
|
||||
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="192x192"
|
||||
href="/images/favicon-192x192.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="512x512"
|
||||
href="/images/favicon-512x512.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/images/apple-touch-icon.png"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
</head>
|
||||
|
||||
<body class="antialiased bg-gray-900">
|
||||
{{> navbar }}
|
||||
|
||||
<div
|
||||
id="uploader"
|
||||
class="min-h-screen flex flex-col items-center justify-start py-12 p-4 bg-gray-900"
|
||||
>
|
||||
<div
|
||||
id="tool-uploader"
|
||||
class="bg-gray-800 rounded-xl shadow-xl px-4 py-8 md:p-8 max-w-2xl w-full text-gray-200 border border-gray-700"
|
||||
>
|
||||
<button
|
||||
id="back-to-tools"
|
||||
class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold"
|
||||
>
|
||||
<i data-lucide="arrow-left" class="cursor-pointer"></i>
|
||||
<span class="cursor-pointer" data-i18n="tools.backToTools">
|
||||
Back to Tools
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<h1
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:timestampPdf.name"
|
||||
>
|
||||
Timestamp PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-4" data-i18n="tools:timestampPdf.subtitle">
|
||||
Add an RFC 3161 document timestamp to your PDF using a trusted Time
|
||||
Stamp Authority (TSA) server. Proves your document existed at a
|
||||
specific point in time. No certificate required.
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="bg-blue-900/30 border border-blue-700 rounded-lg p-3 mb-6 flex items-start gap-3"
|
||||
>
|
||||
<i
|
||||
data-lucide="wifi"
|
||||
class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5"
|
||||
></i>
|
||||
<div class="text-sm">
|
||||
<p class="text-blue-300 font-medium">
|
||||
Internet connection required
|
||||
</p>
|
||||
<p class="text-blue-400/80 mt-1">
|
||||
Timestamping requires contacting the selected TSA server to obtain
|
||||
a trusted timestamp token.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="drop-zone"
|
||||
class="relative flex flex-col items-center justify-center w-full h-48 md:h-64 border-2 border-dashed border-gray-600 rounded-xl cursor-pointer bg-gray-900 hover:bg-gray-700 transition-colors duration-300"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<i
|
||||
data-lucide="upload-cloud"
|
||||
class="w-10 h-10 mb-3 text-gray-400"
|
||||
></i>
|
||||
<p class="mb-2 text-sm text-gray-400">
|
||||
<span class="font-semibold" data-i18n="upload.clickToSelect"
|
||||
>Click to select a file</span
|
||||
>
|
||||
<span data-i18n="upload.orDragAndDrop">or drag and drop</span>
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-gray-500"
|
||||
data-i18n="upload.hints.pdfDocuments"
|
||||
>
|
||||
PDF Documents
|
||||
</p>
|
||||
<p class="text-xs text-gray-500" data-i18n="upload.filesNeverLeave">
|
||||
Your files never leave your device.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
|
||||
accept="application/pdf"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
||||
|
||||
<div id="tsa-section" class="hidden mt-6 space-y-4">
|
||||
<h3
|
||||
class="text-lg font-semibold text-white"
|
||||
data-i18n="tools:timestampPdf.tsaSectionTitle"
|
||||
>
|
||||
Timestamp Server (TSA)
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="tsa-preset"
|
||||
class="block text-sm font-medium text-gray-300 mb-1"
|
||||
data-i18n="tools:timestampPdf.selectTsa"
|
||||
>
|
||||
Select a TSA server
|
||||
</label>
|
||||
<select
|
||||
id="tsa-preset"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-gray-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="process-btn"
|
||||
class="hidden w-full mt-6 bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-semibold py-3 px-4 rounded-lg transition-colors duration-200 flex items-center justify-center gap-2"
|
||||
disabled
|
||||
>
|
||||
<i data-lucide="clock" class="w-5 h-5"></i>
|
||||
<span data-i18n="tools:timestampPdf.applyTimestamp"
|
||||
>Apply Timestamp</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="loader-overlay"
|
||||
class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="inline-block animate-spin rounded-full h-12 w-12 border-4 border-indigo-400 border-t-transparent mb-4"
|
||||
></div>
|
||||
<p id="loader-text" class="text-white text-lg font-medium">
|
||||
Processing...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="alert-overlay"
|
||||
class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="bg-gray-800 rounded-xl shadow-xl p-6 max-w-md w-full mx-4 border border-gray-700"
|
||||
>
|
||||
<h3
|
||||
id="alert-title"
|
||||
class="text-xl font-bold text-white mb-2"
|
||||
data-i18n="alert.title"
|
||||
>
|
||||
Alert
|
||||
</h3>
|
||||
<p id="alert-message" class="text-gray-300 mb-6"></p>
|
||||
<button
|
||||
id="alert-ok"
|
||||
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||
<h2
|
||||
class="text-2xl md:text-3xl font-bold text-white mb-8 text-center"
|
||||
data-i18n="howItWorks.title"
|
||||
>
|
||||
How It Works
|
||||
</h2>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Upload PDF</h3>
|
||||
<p class="text-gray-400">
|
||||
Select the PDF document you want to timestamp
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Choose TSA Server
|
||||
</h3>
|
||||
<p class="text-gray-400">Select one of the trusted TSA servers</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Timestamp & Download
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Apply the RFC 3161 timestamp and download your timestamped PDF
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-6xl mx-auto px-4 py-12">
|
||||
<h2
|
||||
class="text-2xl md:text-3xl font-bold text-white mb-6 text-center"
|
||||
data-i18n="relatedTools.title"
|
||||
>
|
||||
Related PDF Tools
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<a
|
||||
href="digital-sign-pdf.html"
|
||||
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
|
||||
>
|
||||
<h3 class="text-white font-semibold mb-1">Digital Signature</h3>
|
||||
<p class="text-gray-400 text-sm">Sign with X.509 certificate</p>
|
||||
</a>
|
||||
<a
|
||||
href="validate-signature-pdf.html"
|
||||
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
|
||||
>
|
||||
<h3 class="text-white font-semibold mb-1">Validate Signature</h3>
|
||||
<p class="text-gray-400 text-sm">Verify digital signatures</p>
|
||||
</a>
|
||||
<a
|
||||
href="encrypt-pdf.html"
|
||||
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
|
||||
>
|
||||
<h3 class="text-white font-semibold mb-1">Encrypt PDF</h3>
|
||||
<p class="text-gray-400 text-sm">Password protect your PDF</p>
|
||||
</a>
|
||||
<a
|
||||
href="flatten-pdf.html"
|
||||
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
|
||||
>
|
||||
<h3 class="text-white font-semibold mb-1">Flatten PDF</h3>
|
||||
<p class="text-gray-400 text-sm">Make annotations permanent</p>
|
||||
</a>
|
||||
<a
|
||||
href="sanitize-pdf.html"
|
||||
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
|
||||
>
|
||||
<h3 class="text-white font-semibold mb-1">Sanitize PDF</h3>
|
||||
<p class="text-gray-400 text-sm">Remove hidden data</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||
<h2
|
||||
class="text-2xl md:text-3xl font-bold text-white mb-6 text-center"
|
||||
data-i18n="faq.sectionTitle"
|
||||
>
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
|
||||
<summary
|
||||
class="cursor-pointer font-semibold text-white flex items-center justify-between"
|
||||
>
|
||||
What is a document timestamp?
|
||||
<i data-lucide="chevron-down" class="w-5 h-5"></i>
|
||||
</summary>
|
||||
<p class="mt-3 text-gray-400">
|
||||
A document timestamp (RFC 3161) is a cryptographic proof that your
|
||||
PDF existed at a specific point in time. A trusted Time Stamp
|
||||
Authority (TSA) server signs a hash of your document along with the
|
||||
current time, creating a tamper-evident timestamp embedded in the
|
||||
PDF.
|
||||
</p>
|
||||
</details>
|
||||
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
|
||||
<summary
|
||||
class="cursor-pointer font-semibold text-white flex items-center justify-between"
|
||||
>
|
||||
Do I need a certificate?
|
||||
<i data-lucide="chevron-down" class="w-5 h-5"></i>
|
||||
</summary>
|
||||
<p class="mt-3 text-gray-400">
|
||||
No. Unlike a digital signature, a document timestamp does not
|
||||
require a personal certificate. The TSA server provides the trusted
|
||||
timestamp using its own certificate.
|
||||
</p>
|
||||
</details>
|
||||
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
|
||||
<summary
|
||||
class="cursor-pointer font-semibold text-white flex items-center justify-between"
|
||||
>
|
||||
Is my document sent to the TSA server?
|
||||
<i data-lucide="chevron-down" class="w-5 h-5"></i>
|
||||
</summary>
|
||||
<p class="mt-3 text-gray-400">
|
||||
No. Only a cryptographic hash (SHA-256) of your document is sent to
|
||||
the TSA server. Your actual PDF content never leaves your browser.
|
||||
</p>
|
||||
</details>
|
||||
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
|
||||
<summary
|
||||
class="cursor-pointer font-semibold text-white flex items-center justify-between"
|
||||
>
|
||||
Will the timestamp be valid in PDF readers?
|
||||
<i data-lucide="chevron-down" class="w-5 h-5"></i>
|
||||
</summary>
|
||||
<p class="mt-3 text-gray-400">
|
||||
Yes. The tool creates a standard RFC 3161 document timestamp
|
||||
(ETSI.RFC3161 SubFilter) that is recognized by Adobe Acrobat and
|
||||
other major PDF viewers.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{> footer }}
|
||||
|
||||
<script type="module" src="/src/js/utils/lucide-init.ts"></script>
|
||||
<script type="module" src="/src/js/utils/full-width.ts"></script>
|
||||
<script type="module" src="/src/js/utils/simple-mode-footer.ts"></script>
|
||||
<script type="module" src="/src/version.ts"></script>
|
||||
<script type="module" src="/src/js/logic/timestamp-pdf-page.ts"></script>
|
||||
<script type="module" src="/src/js/mobileMenu.ts"></script>
|
||||
<script type="module" src="/src/js/main.ts"></script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "Timestamp PDF - BentoPDF",
|
||||
"applicationCategory": "PDF Tool",
|
||||
"operatingSystem": "Any - Web Browser",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
},
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.9",
|
||||
"ratingCount": "1850"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "HowTo",
|
||||
"name": "How to add a timestamp to PDF",
|
||||
"description": "Learn how to add an RFC 3161 document timestamp to PDF using BentoPDF",
|
||||
"step": [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"position": 1,
|
||||
"name": "Upload PDF",
|
||||
"text": "Select the PDF document you want to timestamp"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"position": 2,
|
||||
"name": "Choose TSA Server",
|
||||
"text": "Select one of the trusted TSA servers"
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"position": 3,
|
||||
"name": "Timestamp & Download",
|
||||
"text": "Apply the RFC 3161 timestamp and download your timestamped PDF"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Home",
|
||||
"item": "https://www.bentopdf.com"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Timestamp PDF",
|
||||
"item": "https://www.bentopdf.com/timestamp-pdf"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
115
src/tests/digital-sign-pdf.test.ts
Normal file
115
src/tests/digital-sign-pdf.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const mockSign = vi.fn();
|
||||
|
||||
vi.mock('zgapdfsigner', () => {
|
||||
const MockPdfSigner = vi.fn(function (this: { sign: typeof mockSign }) {
|
||||
this.sign = mockSign;
|
||||
});
|
||||
return { PdfSigner: MockPdfSigner };
|
||||
});
|
||||
|
||||
import { PdfSigner } from 'zgapdfsigner';
|
||||
import { timestampPdf } from '@/js/logic/digital-sign-pdf';
|
||||
|
||||
const SAMPLE_PDF_PATH = path.resolve(__dirname, './fixtures/sample.pdf');
|
||||
const SAMPLE_PDF_SHA256 =
|
||||
'229defbb0cee6f02673a5cde290d0673e75a0dc31cec43989c8ab2a4eca7e1bb';
|
||||
|
||||
async function sha256(data: Uint8Array): Promise<string> {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
return Array.from(new Uint8Array(hashBuffer))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
describe('timestampPdf', () => {
|
||||
let samplePdfBytes: Uint8Array;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv('VITE_CORS_PROXY_URL', '');
|
||||
vi.stubEnv('VITE_CORS_PROXY_SECRET', '');
|
||||
samplePdfBytes = new Uint8Array(fs.readFileSync(SAMPLE_PDF_PATH));
|
||||
});
|
||||
|
||||
it('should load the correct sample PDF', async () => {
|
||||
const hash = await sha256(samplePdfBytes);
|
||||
expect(hash).toBe(SAMPLE_PDF_SHA256);
|
||||
});
|
||||
|
||||
it('should call PdfSigner with signdate option containing the TSA URL', async () => {
|
||||
const fakeSigned = new Uint8Array([80, 68, 70, 45, 49, 46, 52]); // "PDF-1.4"
|
||||
mockSign.mockResolvedValueOnce(fakeSigned);
|
||||
|
||||
const tsaUrl = 'http://timestamp.digicert.com';
|
||||
await timestampPdf(samplePdfBytes, tsaUrl);
|
||||
|
||||
expect(PdfSigner).toHaveBeenCalledWith({
|
||||
signdate: { url: tsaUrl },
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass the PDF bytes to signer.sign()', async () => {
|
||||
const fakeSigned = new Uint8Array([1, 2, 3]);
|
||||
mockSign.mockResolvedValueOnce(fakeSigned);
|
||||
|
||||
const tsaUrl = 'http://timestamp.digicert.com';
|
||||
await timestampPdf(samplePdfBytes, tsaUrl);
|
||||
|
||||
expect(mockSign).toHaveBeenCalledOnce();
|
||||
const passedBytes = mockSign.mock.calls[0][0];
|
||||
expect(passedBytes).toBeInstanceOf(Uint8Array);
|
||||
expect(passedBytes.length).toBe(samplePdfBytes.length);
|
||||
});
|
||||
|
||||
it('should return a Uint8Array from the signed result', async () => {
|
||||
const fakeSigned = new Uint8Array([10, 20, 30, 40]);
|
||||
mockSign.mockResolvedValueOnce(fakeSigned);
|
||||
|
||||
const result = await timestampPdf(samplePdfBytes, 'http://ts.ssl.com');
|
||||
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(result).toEqual(new Uint8Array([10, 20, 30, 40]));
|
||||
});
|
||||
|
||||
it('should propagate errors from PdfSigner.sign()', async () => {
|
||||
mockSign.mockRejectedValueOnce(new Error('TSA server unreachable'));
|
||||
|
||||
await expect(
|
||||
timestampPdf(samplePdfBytes, 'http://invalid-tsa.example.com')
|
||||
).rejects.toThrow('TSA server unreachable');
|
||||
});
|
||||
|
||||
it('should work with different TSA URLs', async () => {
|
||||
const fakeSigned = new Uint8Array([1]);
|
||||
mockSign.mockResolvedValue(fakeSigned);
|
||||
|
||||
const urls = [
|
||||
'http://timestamp.digicert.com',
|
||||
'http://timestamp.sectigo.com',
|
||||
'https://freetsa.org/tsr',
|
||||
];
|
||||
|
||||
for (const url of urls) {
|
||||
vi.mocked(PdfSigner).mockClear();
|
||||
await timestampPdf(samplePdfBytes, url);
|
||||
|
||||
expect(PdfSigner).toHaveBeenCalledWith({
|
||||
signdate: { url },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should not modify the original PDF bytes', async () => {
|
||||
const fakeSigned = new Uint8Array([1, 2, 3]);
|
||||
mockSign.mockResolvedValueOnce(fakeSigned);
|
||||
|
||||
const originalCopy = new Uint8Array(samplePdfBytes);
|
||||
await timestampPdf(samplePdfBytes, 'http://timestamp.digicert.com');
|
||||
|
||||
expect(samplePdfBytes).toEqual(originalCopy);
|
||||
});
|
||||
});
|
||||
BIN
src/tests/fixtures/sample.pdf
vendored
Normal file
BIN
src/tests/fixtures/sample.pdf
vendored
Normal file
Binary file not shown.
154
src/tests/timestamp-node.test.ts
Normal file
154
src/tests/timestamp-node.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { TIMESTAMP_TSA_PRESETS } from '@/js/config/timestamp-tsa';
|
||||
|
||||
// Mock external dependencies before importing the node
|
||||
vi.mock('rete', () => ({
|
||||
ClassicPreset: {
|
||||
Node: class {
|
||||
addInput() {}
|
||||
addOutput() {}
|
||||
addControl() {}
|
||||
controls: Record<string, unknown> = {};
|
||||
},
|
||||
Input: class {
|
||||
constructor(
|
||||
public socket: unknown,
|
||||
public label: string
|
||||
) {}
|
||||
},
|
||||
Output: class {
|
||||
constructor(
|
||||
public socket: unknown,
|
||||
public label: string
|
||||
) {}
|
||||
},
|
||||
InputControl: class {
|
||||
value: string;
|
||||
constructor(
|
||||
public type: string,
|
||||
public options: { initial: string }
|
||||
) {
|
||||
this.value = options.initial;
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/js/workflow/sockets', () => ({
|
||||
pdfSocket: {},
|
||||
}));
|
||||
|
||||
vi.mock('@/js/workflow/nodes/base-node', () => ({
|
||||
BaseWorkflowNode: class {
|
||||
addInput() {}
|
||||
addOutput() {}
|
||||
addControl() {}
|
||||
controls: Record<string, unknown> = {};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('pdf-lib', () => ({
|
||||
PDFDocument: {
|
||||
load: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/js/logic/digital-sign-pdf', () => ({
|
||||
timestampPdf: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])),
|
||||
}));
|
||||
|
||||
vi.mock('@/js/workflow/types', () => ({
|
||||
requirePdfInput: vi.fn((inputs: Record<string, unknown[]>) => inputs['pdf']),
|
||||
processBatch: vi.fn(
|
||||
async (
|
||||
inputs: Array<{ bytes: Uint8Array; filename: string }>,
|
||||
fn: (input: { bytes: Uint8Array; filename: string }) => Promise<unknown>
|
||||
) => {
|
||||
const results = [];
|
||||
for (const input of inputs) {
|
||||
results.push(await fn(input));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
),
|
||||
}));
|
||||
|
||||
import { TimestampNode } from '@/js/workflow/nodes/timestamp-node';
|
||||
import { timestampPdf } from '@/js/logic/digital-sign-pdf';
|
||||
|
||||
describe('TimestampNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be instantiable', () => {
|
||||
const node = new TimestampNode();
|
||||
expect(node).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have the correct category', () => {
|
||||
const node = new TimestampNode();
|
||||
expect(node.category).toBe('Secure PDF');
|
||||
});
|
||||
|
||||
it('should have the correct icon', () => {
|
||||
const node = new TimestampNode();
|
||||
expect(node.icon).toBe('ph-clock');
|
||||
});
|
||||
|
||||
it('should have a description', () => {
|
||||
const node = new TimestampNode();
|
||||
expect(node.description).toBe('Add an RFC 3161 document timestamp');
|
||||
});
|
||||
|
||||
it('should return TSA presets', () => {
|
||||
const node = new TimestampNode();
|
||||
const presets = node.getTsaPresets();
|
||||
expect(presets).toBe(TIMESTAMP_TSA_PRESETS);
|
||||
expect(presets.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should use the first TSA preset as default URL', () => {
|
||||
const node = new TimestampNode();
|
||||
const presets = node.getTsaPresets();
|
||||
expect(presets[0].url).toBe(TIMESTAMP_TSA_PRESETS[0].url);
|
||||
});
|
||||
|
||||
it('should call timestampPdf with correct TSA URL via data()', async () => {
|
||||
const node = new TimestampNode();
|
||||
const mockInput = [
|
||||
{ bytes: new Uint8Array([1, 2, 3]), filename: 'test.pdf' },
|
||||
];
|
||||
|
||||
await node.data({ pdf: mockInput });
|
||||
|
||||
expect(timestampPdf).toHaveBeenCalledWith(
|
||||
mockInput[0].bytes,
|
||||
TIMESTAMP_TSA_PRESETS[0].url
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate _timestamped suffix in output filename via data()', async () => {
|
||||
const node = new TimestampNode();
|
||||
const mockInput = [
|
||||
{ bytes: new Uint8Array([1, 2, 3]), filename: 'report.pdf' },
|
||||
];
|
||||
|
||||
const result = (await node.data({ pdf: mockInput })) as {
|
||||
pdf: Array<{ filename: string }>;
|
||||
};
|
||||
|
||||
expect(result.pdf[0].filename).toBe('report_timestamped.pdf');
|
||||
});
|
||||
|
||||
it('should wrap errors from timestampPdf with TSA context', async () => {
|
||||
vi.mocked(timestampPdf).mockRejectedValueOnce(new Error('Network error'));
|
||||
const node = new TimestampNode();
|
||||
|
||||
await expect(
|
||||
node.data({
|
||||
pdf: [{ bytes: new Uint8Array([1]), filename: 'test.pdf' }],
|
||||
})
|
||||
).rejects.toThrow(/Failed to timestamp using TSA/);
|
||||
});
|
||||
});
|
||||
184
src/tests/timestamp-pdf-page.test.ts
Normal file
184
src/tests/timestamp-pdf-page.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TIMESTAMP_TSA_PRESETS } from '@/js/config/timestamp-tsa';
|
||||
|
||||
/**
|
||||
* Tests for the Timestamp PDF page logic.
|
||||
*
|
||||
* These tests validate DOM interactions, state management, and UI behavior
|
||||
* of the timestamp-pdf-page module without calling real TSA servers.
|
||||
*/
|
||||
|
||||
function buildPageHtml(): string {
|
||||
return `
|
||||
<input type="file" id="file-input" accept=".pdf" />
|
||||
<div id="drop-zone"></div>
|
||||
<div id="file-display-area"></div>
|
||||
<div id="tsa-section" class="hidden">
|
||||
<select id="tsa-preset"></select>
|
||||
</div>
|
||||
<button id="process-btn" class="hidden" disabled></button>
|
||||
<button id="back-to-tools"></button>
|
||||
`;
|
||||
}
|
||||
|
||||
describe('Timestamp PDF Page', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = buildPageHtml();
|
||||
});
|
||||
|
||||
describe('TSA Preset Population', () => {
|
||||
it('should populate the TSA preset select element with all presets', () => {
|
||||
const select = document.getElementById('tsa-preset') as HTMLSelectElement;
|
||||
|
||||
for (const preset of TIMESTAMP_TSA_PRESETS) {
|
||||
const option = document.createElement('option');
|
||||
option.value = preset.url;
|
||||
option.textContent = preset.label;
|
||||
select.append(option);
|
||||
}
|
||||
|
||||
expect(select.options.length).toBe(TIMESTAMP_TSA_PRESETS.length);
|
||||
});
|
||||
|
||||
it('should set option values to TSA URLs', () => {
|
||||
const select = document.getElementById('tsa-preset') as HTMLSelectElement;
|
||||
|
||||
for (const preset of TIMESTAMP_TSA_PRESETS) {
|
||||
const option = document.createElement('option');
|
||||
option.value = preset.url;
|
||||
option.textContent = preset.label;
|
||||
select.append(option);
|
||||
}
|
||||
|
||||
for (let i = 0; i < TIMESTAMP_TSA_PRESETS.length; i++) {
|
||||
expect(select.options[i].value).toBe(TIMESTAMP_TSA_PRESETS[i].url);
|
||||
expect(select.options[i].textContent).toBe(
|
||||
TIMESTAMP_TSA_PRESETS[i].label
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Validation', () => {
|
||||
it('should reject non-PDF files based on type', () => {
|
||||
const file = new File(['content'], 'image.png', { type: 'image/png' });
|
||||
|
||||
const isValidPdf =
|
||||
file.type === 'application/pdf' ||
|
||||
file.name.toLowerCase().endsWith('.pdf');
|
||||
|
||||
expect(isValidPdf).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept files with application/pdf type', () => {
|
||||
const file = new File(['content'], 'document.pdf', {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
const isValidPdf =
|
||||
file.type === 'application/pdf' ||
|
||||
file.name.toLowerCase().endsWith('.pdf');
|
||||
|
||||
expect(isValidPdf).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept files with .pdf extension regardless of MIME type', () => {
|
||||
const file = new File(['content'], 'document.pdf', {
|
||||
type: 'application/octet-stream',
|
||||
});
|
||||
|
||||
const isValidPdf =
|
||||
file.type === 'application/pdf' ||
|
||||
file.name.toLowerCase().endsWith('.pdf');
|
||||
|
||||
expect(isValidPdf).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive PDF extension', () => {
|
||||
const file = new File(['content'], 'document.PDF', {
|
||||
type: 'application/octet-stream',
|
||||
});
|
||||
|
||||
const isValidPdf =
|
||||
file.type === 'application/pdf' ||
|
||||
file.name.toLowerCase().endsWith('.pdf');
|
||||
|
||||
expect(isValidPdf).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Output Filename', () => {
|
||||
it('should append _timestamped before .pdf extension', () => {
|
||||
const inputName = 'document.pdf';
|
||||
const outputName = inputName.replace(/\.pdf$/i, '_timestamped.pdf');
|
||||
expect(outputName).toBe('document_timestamped.pdf');
|
||||
});
|
||||
|
||||
it('should handle uppercase .PDF extension', () => {
|
||||
const inputName = 'document.PDF';
|
||||
const outputName = inputName.replace(/\.pdf$/i, '_timestamped.pdf');
|
||||
expect(outputName).toBe('document_timestamped.pdf');
|
||||
});
|
||||
|
||||
it('should handle filenames with multiple dots', () => {
|
||||
const inputName = 'my.report.2024.pdf';
|
||||
const outputName = inputName.replace(/\.pdf$/i, '_timestamped.pdf');
|
||||
expect(outputName).toBe('my.report.2024_timestamped.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI State Management', () => {
|
||||
it('should have TSA section hidden initially', () => {
|
||||
const tsaSection = document.getElementById('tsa-section');
|
||||
expect(tsaSection?.classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('should have process button hidden initially', () => {
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
expect(processBtn?.classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('should have process button disabled initially', () => {
|
||||
const processBtn = document.getElementById(
|
||||
'process-btn'
|
||||
) as HTMLButtonElement;
|
||||
expect(processBtn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should show TSA section when hidden class is removed', () => {
|
||||
const tsaSection = document.getElementById('tsa-section')!;
|
||||
tsaSection.classList.remove('hidden');
|
||||
expect(tsaSection.classList.contains('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('should enable process button when PDF is loaded', () => {
|
||||
const processBtn = document.getElementById(
|
||||
'process-btn'
|
||||
) as HTMLButtonElement;
|
||||
processBtn.classList.remove('hidden');
|
||||
processBtn.disabled = false;
|
||||
expect(processBtn.disabled).toBe(false);
|
||||
expect(processBtn.classList.contains('hidden')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drop Zone', () => {
|
||||
it('should exist in the DOM', () => {
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
expect(dropZone).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should add highlight class on dragover', () => {
|
||||
const dropZone = document.getElementById('drop-zone')!;
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
expect(dropZone.classList.contains('bg-gray-700')).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove highlight class on dragleave', () => {
|
||||
const dropZone = document.getElementById('drop-zone')!;
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
expect(dropZone.classList.contains('bg-gray-700')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
49
src/tests/timestamp-tsa.test.ts
Normal file
49
src/tests/timestamp-tsa.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
TIMESTAMP_TSA_PRESETS,
|
||||
type TimestampTsaPreset,
|
||||
} from '@/js/config/timestamp-tsa';
|
||||
|
||||
describe('Timestamp TSA Presets', () => {
|
||||
it('should be a non-empty array', () => {
|
||||
expect(Array.isArray(TIMESTAMP_TSA_PRESETS)).toBe(true);
|
||||
expect(TIMESTAMP_TSA_PRESETS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should contain only objects with label and url strings', () => {
|
||||
for (const preset of TIMESTAMP_TSA_PRESETS) {
|
||||
expect(typeof preset.label).toBe('string');
|
||||
expect(preset.label.length).toBeGreaterThan(0);
|
||||
expect(typeof preset.url).toBe('string');
|
||||
expect(preset.url.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have unique labels', () => {
|
||||
const labels = TIMESTAMP_TSA_PRESETS.map((p) => p.label);
|
||||
expect(new Set(labels).size).toBe(labels.length);
|
||||
});
|
||||
|
||||
it('should have unique URLs', () => {
|
||||
const urls = TIMESTAMP_TSA_PRESETS.map((p) => p.url);
|
||||
expect(new Set(urls).size).toBe(urls.length);
|
||||
});
|
||||
|
||||
it('should have valid URL formats', () => {
|
||||
for (const preset of TIMESTAMP_TSA_PRESETS) {
|
||||
expect(() => new URL(preset.url)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('should include well-known TSA providers', () => {
|
||||
const labels = TIMESTAMP_TSA_PRESETS.map((p) => p.label);
|
||||
expect(labels).toContain('DigiCert');
|
||||
expect(labels).toContain('Sectigo');
|
||||
});
|
||||
|
||||
it('should satisfy the TimestampTsaPreset interface', () => {
|
||||
const preset: TimestampTsaPreset = TIMESTAMP_TSA_PRESETS[0];
|
||||
expect(preset).toHaveProperty('label');
|
||||
expect(preset).toHaveProperty('url');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user