Merge pull request #576 from InstaZDLL/feat/timestamp-pdf

feat: add Timestamp PDF tool with RFC 3161 support
This commit is contained in:
Alam
2026-03-27 11:03:57 +05:30
committed by GitHub
31 changed files with 1644 additions and 11 deletions

View File

@@ -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;

View 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);