feat: add digital signature PDF tool
- Add new tool to apply cryptographic signatures to PDFs using X.509 certificates - Support PKCS#12 (.pfx, .p12) and PEM certificate formats - Create PKCS#7 detached signatures compatible with all major PDF viewers - Optional visible signature with customizable position, image, and text overlay - Add translations for English, German, Vietnamese, and Chinese
This commit is contained in:
@@ -720,6 +720,12 @@ export const categories = [
|
||||
icon: 'ph-shield-check',
|
||||
subtitle: 'Set or change user permissions on a PDF.',
|
||||
},
|
||||
{
|
||||
href: import.meta.env.BASE_URL + 'digital-sign-pdf.html',
|
||||
name: 'Digital Signature',
|
||||
icon: 'ph-certificate',
|
||||
subtitle: 'Add a cryptographic digital signature using X.509 certificates.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -35,8 +35,7 @@ function resetState() {
|
||||
viewerContainer.style.aspectRatio = ''
|
||||
}
|
||||
|
||||
// Revert container width only if NOT in full width mode
|
||||
const isFullWidth = localStorage.getItem('fullWidthMode') === 'true'
|
||||
const isFullWidth = localStorage.getItem('fullWidthMode') !== 'false'
|
||||
if (toolUploader && !isFullWidth) {
|
||||
toolUploader.classList.remove('max-w-6xl')
|
||||
toolUploader.classList.add('max-w-2xl')
|
||||
@@ -56,8 +55,8 @@ function updateFileList() {
|
||||
fileListDiv.classList.remove('hidden')
|
||||
fileListDiv.innerHTML = ''
|
||||
|
||||
// Expand container width for viewer if NOT in full width mode
|
||||
const isFullWidth = localStorage.getItem('fullWidthMode') === 'true'
|
||||
// Expand container width for viewer if NOT in full width mode (default to true if not set)
|
||||
const isFullWidth = localStorage.getItem('fullWidthMode') !== 'false'
|
||||
if (toolUploader && !isFullWidth) {
|
||||
toolUploader.classList.remove('max-w-2xl')
|
||||
toolUploader.classList.add('max-w-6xl')
|
||||
|
||||
669
src/js/logic/digital-sign-pdf-page.ts
Normal file
669
src/js/logic/digital-sign-pdf-page.ts
Normal file
@@ -0,0 +1,669 @@
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { showAlert, showLoader, hideLoader } from '../ui.js';
|
||||
import { readFileAsArrayBuffer, formatBytes, downloadFile, getPDFDocument } from '../utils/helpers.js';
|
||||
import {
|
||||
signPdf,
|
||||
parsePfxFile,
|
||||
parseCombinedPem,
|
||||
getCertificateInfo,
|
||||
type CertificateData,
|
||||
type SignatureInfo,
|
||||
type VisibleSignatureOptions,
|
||||
} from './digital-sign-pdf.js';
|
||||
|
||||
interface DigitalSignState {
|
||||
pdfFile: File | null;
|
||||
pdfBytes: Uint8Array | null;
|
||||
certFile: File | null;
|
||||
certData: CertificateData | null;
|
||||
sigImageData: ArrayBuffer | null;
|
||||
sigImageType: 'png' | 'jpeg' | 'webp' | null;
|
||||
}
|
||||
|
||||
const state: DigitalSignState = {
|
||||
pdfFile: null,
|
||||
pdfBytes: null,
|
||||
certFile: null,
|
||||
certData: null,
|
||||
sigImageData: null,
|
||||
sigImageType: null,
|
||||
};
|
||||
|
||||
function resetState(): void {
|
||||
state.pdfFile = null;
|
||||
state.pdfBytes = null;
|
||||
state.certFile = null;
|
||||
state.certData = null;
|
||||
state.sigImageData = null;
|
||||
state.sigImageType = null;
|
||||
|
||||
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
|
||||
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
|
||||
if (certDisplayArea) certDisplayArea.innerHTML = '';
|
||||
|
||||
const fileInput = getElement<HTMLInputElement>('file-input');
|
||||
if (fileInput) fileInput.value = '';
|
||||
|
||||
const certInput = getElement<HTMLInputElement>('cert-input');
|
||||
if (certInput) certInput.value = '';
|
||||
|
||||
const sigImageInput = getElement<HTMLInputElement>('sig-image-input');
|
||||
if (sigImageInput) sigImageInput.value = '';
|
||||
|
||||
const sigImagePreview = getElement<HTMLDivElement>('sig-image-preview');
|
||||
if (sigImagePreview) sigImagePreview.classList.add('hidden');
|
||||
|
||||
const certSection = getElement<HTMLDivElement>('certificate-section');
|
||||
if (certSection) certSection.classList.add('hidden');
|
||||
|
||||
hidePasswordSection();
|
||||
hideSignatureOptions();
|
||||
hideCertInfo();
|
||||
updateProcessButton();
|
||||
}
|
||||
|
||||
function getElement<T extends HTMLElement>(id: string): T | null {
|
||||
return document.getElementById(id) as T | null;
|
||||
}
|
||||
|
||||
function initializePage(): void {
|
||||
createIcons({ icons });
|
||||
|
||||
const fileInput = getElement<HTMLInputElement>('file-input');
|
||||
const dropZone = getElement<HTMLDivElement>('drop-zone');
|
||||
const certInput = getElement<HTMLInputElement>('cert-input');
|
||||
const certDropZone = getElement<HTMLDivElement>('cert-drop-zone');
|
||||
const certPassword = getElement<HTMLInputElement>('cert-password');
|
||||
const processBtn = getElement<HTMLButtonElement>('process-btn');
|
||||
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
|
||||
|
||||
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 (certInput) {
|
||||
certInput.addEventListener('change', handleCertUpload);
|
||||
certInput.addEventListener('click', () => {
|
||||
certInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (certDropZone) {
|
||||
certDropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
certDropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
certDropZone.addEventListener('dragleave', () => {
|
||||
certDropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
certDropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
certDropZone.classList.remove('bg-gray-700');
|
||||
const droppedFiles = e.dataTransfer?.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
handleCertFile(droppedFiles[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (certPassword) {
|
||||
certPassword.addEventListener('input', handlePasswordInput);
|
||||
certPassword.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handlePasswordInput();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', processSignature);
|
||||
}
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
const enableVisibleSig = getElement<HTMLInputElement>('enable-visible-sig');
|
||||
const visibleSigOptions = getElement<HTMLDivElement>('visible-sig-options');
|
||||
const sigPage = getElement<HTMLSelectElement>('sig-page');
|
||||
const customPageWrapper = getElement<HTMLDivElement>('custom-page-wrapper');
|
||||
const sigImageInput = getElement<HTMLInputElement>('sig-image-input');
|
||||
const sigImagePreview = getElement<HTMLDivElement>('sig-image-preview');
|
||||
const sigImageThumb = getElement<HTMLImageElement>('sig-image-thumb');
|
||||
const removeSigImage = getElement<HTMLButtonElement>('remove-sig-image');
|
||||
const enableSigText = getElement<HTMLInputElement>('enable-sig-text');
|
||||
const sigTextOptions = getElement<HTMLDivElement>('sig-text-options');
|
||||
|
||||
if (enableVisibleSig && visibleSigOptions) {
|
||||
enableVisibleSig.addEventListener('change', () => {
|
||||
if (enableVisibleSig.checked) {
|
||||
visibleSigOptions.classList.remove('hidden');
|
||||
} else {
|
||||
visibleSigOptions.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (sigPage && customPageWrapper) {
|
||||
sigPage.addEventListener('change', () => {
|
||||
if (sigPage.value === 'custom') {
|
||||
customPageWrapper.classList.remove('hidden');
|
||||
} else {
|
||||
customPageWrapper.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (sigImageInput) {
|
||||
sigImageInput.addEventListener('change', async (e) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
const file = input.files[0];
|
||||
const validTypes = ['image/png', 'image/jpeg', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
showAlert('Invalid Image', 'Please select a PNG, JPG, or WebP image.');
|
||||
return;
|
||||
}
|
||||
state.sigImageData = await readFileAsArrayBuffer(file) as ArrayBuffer;
|
||||
state.sigImageType = file.type.replace('image/', '') as 'png' | 'jpeg' | 'webp';
|
||||
|
||||
if (sigImageThumb && sigImagePreview) {
|
||||
const url = URL.createObjectURL(file);
|
||||
sigImageThumb.src = url;
|
||||
sigImagePreview.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (removeSigImage && sigImagePreview) {
|
||||
removeSigImage.addEventListener('click', () => {
|
||||
state.sigImageData = null;
|
||||
state.sigImageType = null;
|
||||
sigImagePreview.classList.add('hidden');
|
||||
if (sigImageInput) sigImageInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (enableSigText && sigTextOptions) {
|
||||
enableSigText.addEventListener('change', () => {
|
||||
if (enableSigText.checked) {
|
||||
sigTextOptions.classList.remove('hidden');
|
||||
} else {
|
||||
sigTextOptions.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
showCertificateSection();
|
||||
}
|
||||
|
||||
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 = '';
|
||||
hideCertificateSection();
|
||||
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 showCertificateSection(): void {
|
||||
const certSection = getElement<HTMLDivElement>('certificate-section');
|
||||
if (certSection) {
|
||||
certSection.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function hideCertificateSection(): void {
|
||||
const certSection = getElement<HTMLDivElement>('certificate-section');
|
||||
const signatureOptions = getElement<HTMLDivElement>('signature-options');
|
||||
|
||||
if (certSection) {
|
||||
certSection.classList.add('hidden');
|
||||
}
|
||||
if (signatureOptions) {
|
||||
signatureOptions.classList.add('hidden');
|
||||
}
|
||||
|
||||
state.certFile = null;
|
||||
state.certData = null;
|
||||
|
||||
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
|
||||
if (certDisplayArea) {
|
||||
certDisplayArea.innerHTML = '';
|
||||
}
|
||||
|
||||
const certInfo = getElement<HTMLDivElement>('cert-info');
|
||||
if (certInfo) {
|
||||
certInfo.classList.add('hidden');
|
||||
}
|
||||
|
||||
const certPasswordSection = getElement<HTMLDivElement>('cert-password-section');
|
||||
if (certPasswordSection) {
|
||||
certPasswordSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCertUpload(e: Event): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
handleCertFile(input.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCertFile(file: File): Promise<void> {
|
||||
const validExtensions = ['.pfx', '.p12', '.pem'];
|
||||
const hasValidExtension = validExtensions.some(ext =>
|
||||
file.name.toLowerCase().endsWith(ext)
|
||||
);
|
||||
|
||||
if (!hasValidExtension) {
|
||||
showAlert('Invalid Certificate', 'Please select a .pfx, .p12, or .pem certificate file.');
|
||||
return;
|
||||
}
|
||||
|
||||
state.certFile = file;
|
||||
state.certData = null;
|
||||
|
||||
updateCertDisplay();
|
||||
|
||||
const isPemFile = file.name.toLowerCase().endsWith('.pem');
|
||||
|
||||
if (isPemFile) {
|
||||
try {
|
||||
const pemContent = await file.text();
|
||||
const isEncrypted = pemContent.includes('ENCRYPTED');
|
||||
|
||||
if (isEncrypted) {
|
||||
showPasswordSection();
|
||||
updatePasswordLabel('Private Key Password');
|
||||
} else {
|
||||
state.certData = parseCombinedPem(pemContent);
|
||||
updateCertInfo();
|
||||
showSignatureOptions();
|
||||
|
||||
const certStatus = getElement<HTMLDivElement>('cert-status');
|
||||
if (certStatus) {
|
||||
certStatus.innerHTML = 'Certificate loaded <i data-lucide="check" class="inline w-4 h-4"></i>';
|
||||
createIcons({ icons });
|
||||
certStatus.className = 'text-xs text-green-400';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const certStatus = getElement<HTMLDivElement>('cert-status');
|
||||
if (certStatus) {
|
||||
certStatus.textContent = 'Failed to parse PEM file';
|
||||
certStatus.className = 'text-xs text-red-400';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showPasswordSection();
|
||||
updatePasswordLabel('Certificate Password');
|
||||
}
|
||||
|
||||
hideSignatureOptions();
|
||||
updateProcessButton();
|
||||
}
|
||||
|
||||
function updateCertDisplay(): void {
|
||||
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
|
||||
|
||||
if (!certDisplayArea || !state.certFile) return;
|
||||
|
||||
certDisplayArea.innerHTML = '';
|
||||
|
||||
const certDiv = document.createElement('div');
|
||||
certDiv.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.certFile.name;
|
||||
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.id = 'cert-status';
|
||||
metaSpan.textContent = 'Enter password to unlock';
|
||||
|
||||
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.certFile = null;
|
||||
state.certData = null;
|
||||
certDisplayArea.innerHTML = '';
|
||||
hidePasswordSection();
|
||||
hideCertInfo();
|
||||
hideSignatureOptions();
|
||||
updateProcessButton();
|
||||
};
|
||||
|
||||
certDiv.append(infoContainer, removeBtn);
|
||||
certDisplayArea.appendChild(certDiv);
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
function showPasswordSection(): void {
|
||||
const certPasswordSection = getElement<HTMLDivElement>('cert-password-section');
|
||||
if (certPasswordSection) {
|
||||
certPasswordSection.classList.remove('hidden');
|
||||
}
|
||||
|
||||
const certPassword = getElement<HTMLInputElement>('cert-password');
|
||||
if (certPassword) {
|
||||
certPassword.value = '';
|
||||
certPassword.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function updatePasswordLabel(labelText: string): void {
|
||||
const label = document.querySelector('label[for="cert-password"]');
|
||||
if (label) {
|
||||
label.textContent = labelText;
|
||||
}
|
||||
}
|
||||
|
||||
function hidePasswordSection(): void {
|
||||
const certPasswordSection = getElement<HTMLDivElement>('cert-password-section');
|
||||
if (certPasswordSection) {
|
||||
certPasswordSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function showSignatureOptions(): void {
|
||||
const signatureOptions = getElement<HTMLDivElement>('signature-options');
|
||||
if (signatureOptions) {
|
||||
signatureOptions.classList.remove('hidden');
|
||||
}
|
||||
const visibleSigSection = getElement<HTMLDivElement>('visible-signature-section');
|
||||
if (visibleSigSection) {
|
||||
visibleSigSection.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function hideSignatureOptions(): void {
|
||||
const signatureOptions = getElement<HTMLDivElement>('signature-options');
|
||||
if (signatureOptions) {
|
||||
signatureOptions.classList.add('hidden');
|
||||
}
|
||||
const visibleSigSection = getElement<HTMLDivElement>('visible-signature-section');
|
||||
if (visibleSigSection) {
|
||||
visibleSigSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function hideCertInfo(): void {
|
||||
const certInfo = getElement<HTMLDivElement>('cert-info');
|
||||
if (certInfo) {
|
||||
certInfo.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordInput(): Promise<void> {
|
||||
const certPassword = getElement<HTMLInputElement>('cert-password');
|
||||
const password = certPassword?.value ?? '';
|
||||
|
||||
if (!state.certFile || !password) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isPemFile = state.certFile.name.toLowerCase().endsWith('.pem');
|
||||
|
||||
if (isPemFile) {
|
||||
const pemContent = await state.certFile.text();
|
||||
state.certData = parseCombinedPem(pemContent, password);
|
||||
} else {
|
||||
const certBytes = await readFileAsArrayBuffer(state.certFile) as ArrayBuffer;
|
||||
state.certData = parsePfxFile(certBytes, password);
|
||||
}
|
||||
|
||||
updateCertInfo();
|
||||
showSignatureOptions();
|
||||
updateProcessButton();
|
||||
|
||||
const certStatus = getElement<HTMLDivElement>('cert-status');
|
||||
if (certStatus) {
|
||||
certStatus.innerHTML = 'Certificate unlocked <i data-lucide="check-circle" class="inline w-4 h-4"></i>';
|
||||
createIcons({ icons });
|
||||
certStatus.className = 'text-xs text-green-400';
|
||||
}
|
||||
} catch (error) {
|
||||
state.certData = null;
|
||||
hideSignatureOptions();
|
||||
updateProcessButton();
|
||||
|
||||
const certStatus = getElement<HTMLDivElement>('cert-status');
|
||||
if (certStatus) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Invalid password or certificate';
|
||||
certStatus.textContent = errorMessage.includes('password')
|
||||
? 'Incorrect password'
|
||||
: 'Failed to parse certificate';
|
||||
certStatus.className = 'text-xs text-red-400';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateCertInfo(): void {
|
||||
if (!state.certData) return;
|
||||
|
||||
const certInfo = getElement<HTMLDivElement>('cert-info');
|
||||
const certSubject = getElement<HTMLSpanElement>('cert-subject');
|
||||
const certIssuer = getElement<HTMLSpanElement>('cert-issuer');
|
||||
const certValidity = getElement<HTMLSpanElement>('cert-validity');
|
||||
|
||||
if (!certInfo) return;
|
||||
|
||||
const info = getCertificateInfo(state.certData.certificate);
|
||||
|
||||
if (certSubject) {
|
||||
certSubject.textContent = info.subject;
|
||||
}
|
||||
if (certIssuer) {
|
||||
certIssuer.textContent = info.issuer;
|
||||
}
|
||||
if (certValidity) {
|
||||
const formatDate = (date: Date) => date.toLocaleDateString();
|
||||
certValidity.textContent = `${formatDate(info.validFrom)} - ${formatDate(info.validTo)}`;
|
||||
}
|
||||
|
||||
certInfo.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function updateProcessButton(): void {
|
||||
const processBtn = getElement<HTMLButtonElement>('process-btn');
|
||||
if (!processBtn) return;
|
||||
|
||||
const canProcess = state.pdfBytes !== null && state.certData !== null;
|
||||
|
||||
if (canProcess) {
|
||||
processBtn.style.display = '';
|
||||
} else {
|
||||
processBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function processSignature(): Promise<void> {
|
||||
if (!state.pdfBytes || !state.certData) {
|
||||
showAlert('Missing Data', 'Please upload both a PDF and a valid certificate.');
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = getElement<HTMLInputElement>('sign-reason')?.value ?? '';
|
||||
const location = getElement<HTMLInputElement>('sign-location')?.value ?? '';
|
||||
const contactInfo = getElement<HTMLInputElement>('sign-contact')?.value ?? '';
|
||||
|
||||
const signatureInfo: SignatureInfo = {};
|
||||
if (reason) signatureInfo.reason = reason;
|
||||
if (location) signatureInfo.location = location;
|
||||
if (contactInfo) signatureInfo.contactInfo = contactInfo;
|
||||
|
||||
let visibleSignature: VisibleSignatureOptions | undefined;
|
||||
|
||||
const enableVisibleSig = getElement<HTMLInputElement>('enable-visible-sig');
|
||||
if (enableVisibleSig?.checked) {
|
||||
const sigX = parseInt(getElement<HTMLInputElement>('sig-x')?.value ?? '25', 10);
|
||||
const sigY = parseInt(getElement<HTMLInputElement>('sig-y')?.value ?? '700', 10);
|
||||
const sigWidth = parseInt(getElement<HTMLInputElement>('sig-width')?.value ?? '150', 10);
|
||||
const sigHeight = parseInt(getElement<HTMLInputElement>('sig-height')?.value ?? '50', 10);
|
||||
|
||||
const sigPageSelect = getElement<HTMLSelectElement>('sig-page');
|
||||
let sigPage: number | string = 0;
|
||||
if (sigPageSelect) {
|
||||
if (sigPageSelect.value === 'last') {
|
||||
sigPage = 'last';
|
||||
} else if (sigPageSelect.value === 'all') {
|
||||
sigPage = 'all';
|
||||
} else if (sigPageSelect.value === 'custom') {
|
||||
sigPage = parseInt(getElement<HTMLInputElement>('sig-custom-page')?.value ?? '1', 10) - 1;
|
||||
} else {
|
||||
sigPage = parseInt(sigPageSelect.value, 10);
|
||||
}
|
||||
}
|
||||
|
||||
const enableSigText = getElement<HTMLInputElement>('enable-sig-text');
|
||||
let sigText = enableSigText?.checked ? getElement<HTMLInputElement>('sig-text')?.value : undefined;
|
||||
const sigTextColor = getElement<HTMLInputElement>('sig-text-color')?.value ?? '#000000';
|
||||
const sigTextSize = parseInt(getElement<HTMLInputElement>('sig-text-size')?.value ?? '12', 10);
|
||||
|
||||
if (!state.sigImageData && !sigText && state.certData) {
|
||||
const certInfo = getCertificateInfo(state.certData.certificate);
|
||||
const date = new Date().toLocaleDateString();
|
||||
sigText = `Digitally signed by ${certInfo.subject}\n${date}`;
|
||||
}
|
||||
|
||||
visibleSignature = {
|
||||
enabled: true,
|
||||
x: sigX,
|
||||
y: sigY,
|
||||
width: sigWidth,
|
||||
height: sigHeight,
|
||||
page: sigPage,
|
||||
imageData: state.sigImageData ?? undefined,
|
||||
imageType: state.sigImageType ?? undefined,
|
||||
text: sigText,
|
||||
textColor: sigTextColor,
|
||||
textSize: sigTextSize,
|
||||
};
|
||||
}
|
||||
|
||||
showLoader('Applying digital signature...');
|
||||
|
||||
try {
|
||||
const signedPdfBytes = await signPdf(state.pdfBytes, state.certData, {
|
||||
signatureInfo,
|
||||
visibleSignature,
|
||||
});
|
||||
|
||||
const blob = new Blob([signedPdfBytes.slice().buffer], { type: 'application/pdf' });
|
||||
const originalName = state.pdfFile?.name ?? 'document.pdf';
|
||||
const signedName = originalName.replace(/\.pdf$/i, '_signed.pdf');
|
||||
|
||||
downloadFile(blob, signedName);
|
||||
|
||||
hideLoader();
|
||||
showAlert('Success', 'PDF signed successfully! The signature can be verified in any PDF reader.', 'success', () => { resetState(); });
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
console.error('Signing error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
showAlert('Signing Failed', `Failed to sign PDF: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
} else {
|
||||
initializePage();
|
||||
}
|
||||
194
src/js/logic/digital-sign-pdf.ts
Normal file
194
src/js/logic/digital-sign-pdf.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { PdfSigner, type SignOption } from 'zgapdfsigner';
|
||||
import forge from 'node-forge';
|
||||
|
||||
export interface SignatureInfo {
|
||||
reason?: string;
|
||||
location?: string;
|
||||
contactInfo?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface CertificateData {
|
||||
p12Buffer: ArrayBuffer;
|
||||
password: string;
|
||||
certificate: forge.pki.Certificate;
|
||||
}
|
||||
|
||||
export interface SignPdfOptions {
|
||||
signatureInfo?: SignatureInfo;
|
||||
visibleSignature?: VisibleSignatureOptions;
|
||||
}
|
||||
|
||||
export interface VisibleSignatureOptions {
|
||||
enabled: boolean;
|
||||
imageData?: ArrayBuffer;
|
||||
imageType?: 'png' | 'jpeg' | 'webp';
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
page: number | string;
|
||||
text?: string;
|
||||
textColor?: string;
|
||||
textSize?: number;
|
||||
}
|
||||
|
||||
export function parsePfxFile(pfxBytes: ArrayBuffer, password: string): CertificateData {
|
||||
const pfxAsn1 = forge.asn1.fromDer(forge.util.createBuffer(new Uint8Array(pfxBytes)));
|
||||
const pfx = forge.pkcs12.pkcs12FromAsn1(pfxAsn1, password);
|
||||
|
||||
const certBags = pfx.getBags({ bagType: forge.pki.oids.certBag });
|
||||
const keyBags = pfx.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
|
||||
|
||||
const certBagArray = certBags[forge.pki.oids.certBag];
|
||||
const keyBagArray = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag];
|
||||
|
||||
if (!certBagArray || certBagArray.length === 0) {
|
||||
throw new Error('No certificate found in PFX file');
|
||||
}
|
||||
|
||||
if (!keyBagArray || keyBagArray.length === 0) {
|
||||
throw new Error('No private key found in PFX file');
|
||||
}
|
||||
|
||||
const certificate = certBagArray[0].cert;
|
||||
|
||||
if (!certificate) {
|
||||
throw new Error('Failed to extract certificate from PFX file');
|
||||
}
|
||||
|
||||
return { p12Buffer: pfxBytes, password, certificate };
|
||||
}
|
||||
|
||||
export function parsePemFiles(
|
||||
certPem: string,
|
||||
keyPem: string,
|
||||
keyPassword?: string
|
||||
): CertificateData {
|
||||
const certificate = forge.pki.certificateFromPem(certPem);
|
||||
|
||||
let privateKey: forge.pki.PrivateKey;
|
||||
if (keyPem.includes('ENCRYPTED')) {
|
||||
if (!keyPassword) {
|
||||
throw new Error('Password required for encrypted private key');
|
||||
}
|
||||
privateKey = forge.pki.decryptRsaPrivateKey(keyPem, keyPassword);
|
||||
if (!privateKey) {
|
||||
throw new Error('Failed to decrypt private key');
|
||||
}
|
||||
} else {
|
||||
privateKey = forge.pki.privateKeyFromPem(keyPem);
|
||||
}
|
||||
|
||||
const p12Password = keyPassword || 'temp-password';
|
||||
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
|
||||
privateKey,
|
||||
[certificate],
|
||||
p12Password,
|
||||
{ algorithm: '3des' }
|
||||
);
|
||||
const p12Der = forge.asn1.toDer(p12Asn1).getBytes();
|
||||
const p12Buffer = new Uint8Array(p12Der.length);
|
||||
for (let i = 0; i < p12Der.length; i++) {
|
||||
p12Buffer[i] = p12Der.charCodeAt(i);
|
||||
}
|
||||
|
||||
return { p12Buffer: p12Buffer.buffer, password: p12Password, certificate };
|
||||
}
|
||||
|
||||
export function parseCombinedPem(pemContent: string, password?: string): CertificateData {
|
||||
const certMatch = pemContent.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/);
|
||||
const keyMatch = pemContent.match(/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/);
|
||||
|
||||
if (!certMatch) {
|
||||
throw new Error('No certificate found in PEM file');
|
||||
}
|
||||
|
||||
if (!keyMatch) {
|
||||
throw new Error('No private key found in PEM file');
|
||||
}
|
||||
|
||||
return parsePemFiles(certMatch[0], keyMatch[0], password);
|
||||
}
|
||||
|
||||
export async function signPdf(
|
||||
pdfBytes: Uint8Array,
|
||||
certificateData: CertificateData,
|
||||
options: SignPdfOptions = {}
|
||||
): Promise<Uint8Array> {
|
||||
const signatureInfo = options.signatureInfo ?? {};
|
||||
|
||||
const signOptions: SignOption = {
|
||||
p12cert: certificateData.p12Buffer,
|
||||
pwd: certificateData.password,
|
||||
};
|
||||
|
||||
if (signatureInfo.reason) {
|
||||
signOptions.reason = signatureInfo.reason;
|
||||
}
|
||||
|
||||
if (signatureInfo.location) {
|
||||
signOptions.location = signatureInfo.location;
|
||||
}
|
||||
|
||||
if (signatureInfo.contactInfo) {
|
||||
signOptions.contact = signatureInfo.contactInfo;
|
||||
}
|
||||
|
||||
if (options.visibleSignature?.enabled) {
|
||||
const vs = options.visibleSignature;
|
||||
|
||||
const drawinf = {
|
||||
area: {
|
||||
x: vs.x,
|
||||
y: vs.y,
|
||||
w: vs.width,
|
||||
h: vs.height,
|
||||
},
|
||||
pageidx: vs.page,
|
||||
imgInfo: undefined as { imgData: ArrayBuffer; imgType: string } | undefined,
|
||||
textInfo: undefined as { text: string; size: number; color: string } | undefined,
|
||||
};
|
||||
|
||||
if (vs.imageData && vs.imageType) {
|
||||
drawinf.imgInfo = {
|
||||
imgData: vs.imageData,
|
||||
imgType: vs.imageType,
|
||||
};
|
||||
}
|
||||
|
||||
if (vs.text) {
|
||||
drawinf.textInfo = {
|
||||
text: vs.text,
|
||||
size: vs.textSize ?? 12,
|
||||
color: vs.textColor ?? '#000000',
|
||||
};
|
||||
}
|
||||
|
||||
signOptions.drawinf = drawinf as SignOption['drawinf'];
|
||||
}
|
||||
|
||||
const signer = new PdfSigner(signOptions);
|
||||
const signedPdfBytes = await signer.sign(pdfBytes);
|
||||
|
||||
return new Uint8Array(signedPdfBytes);
|
||||
}
|
||||
|
||||
export function getCertificateInfo(certificate: forge.pki.Certificate): {
|
||||
subject: string;
|
||||
issuer: string;
|
||||
validFrom: Date;
|
||||
validTo: Date;
|
||||
serialNumber: string;
|
||||
} {
|
||||
const subjectCN = certificate.subject.getField('CN');
|
||||
const issuerCN = certificate.issuer.getField('CN');
|
||||
|
||||
return {
|
||||
subject: subjectCN?.value as string ?? 'Unknown',
|
||||
issuer: issuerCN?.value as string ?? 'Unknown',
|
||||
validFrom: certificate.validity.notBefore,
|
||||
validTo: certificate.validity.notAfter,
|
||||
serialNumber: certificate.serialNumber,
|
||||
};
|
||||
}
|
||||
@@ -85,7 +85,7 @@ function resetState() {
|
||||
}
|
||||
|
||||
const toolUploader = document.getElementById('tool-uploader');
|
||||
const isFullWidth = localStorage.getItem('fullWidthMode') === 'true';
|
||||
const isFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
|
||||
if (toolUploader && !isFullWidth) {
|
||||
toolUploader.classList.remove('max-w-6xl');
|
||||
toolUploader.classList.add('max-w-2xl');
|
||||
@@ -139,7 +139,8 @@ async function setupFormViewer() {
|
||||
}
|
||||
|
||||
const toolUploader = document.getElementById('tool-uploader');
|
||||
const isFullWidth = localStorage.getItem('fullWidthMode') === 'true';
|
||||
// Default to true if not set
|
||||
const isFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
|
||||
if (toolUploader && !isFullWidth) {
|
||||
toolUploader.classList.remove('max-w-2xl');
|
||||
toolUploader.classList.add('max-w-6xl');
|
||||
|
||||
@@ -321,30 +321,63 @@ const init = async () => {
|
||||
const searchBar = document.getElementById('search-bar');
|
||||
const categoryGroups = dom.toolGrid.querySelectorAll('.category-group');
|
||||
|
||||
const searchResultsContainer = document.createElement('div');
|
||||
searchResultsContainer.id = 'search-results';
|
||||
searchResultsContainer.className = 'hidden grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6 col-span-full';
|
||||
dom.toolGrid.insertBefore(searchResultsContainer, dom.toolGrid.firstChild);
|
||||
|
||||
searchBar.addEventListener('input', () => {
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
|
||||
const searchTerm = searchBar.value.toLowerCase().trim();
|
||||
|
||||
if (!searchTerm) {
|
||||
searchResultsContainer.classList.add('hidden');
|
||||
searchResultsContainer.innerHTML = '';
|
||||
categoryGroups.forEach((group) => {
|
||||
(group as HTMLElement).style.display = '';
|
||||
const toolCards = group.querySelectorAll('.tool-card');
|
||||
toolCards.forEach((card) => {
|
||||
(card as HTMLElement).style.display = '';
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
categoryGroups.forEach((group) => {
|
||||
(group as HTMLElement).style.display = 'none';
|
||||
});
|
||||
|
||||
searchResultsContainer.innerHTML = '';
|
||||
searchResultsContainer.classList.remove('hidden');
|
||||
|
||||
const seenToolIds = new Set<string>();
|
||||
const allTools: HTMLElement[] = [];
|
||||
|
||||
categoryGroups.forEach((group) => {
|
||||
const toolCards = Array.from(group.querySelectorAll('.tool-card'));
|
||||
|
||||
let visibleToolsInCategory = 0;
|
||||
|
||||
toolCards.forEach((card) => {
|
||||
const toolName = (card.querySelector('h3')?.textContent || '').toLowerCase();
|
||||
const toolSubtitle = (card.querySelector('p')?.textContent || '').toLowerCase();
|
||||
const toolHref = (card as HTMLAnchorElement).href || (card as HTMLElement).dataset.toolId || '';
|
||||
|
||||
const isMatch = !searchTerm || toolName.includes(searchTerm) || toolSubtitle.includes(searchTerm);
|
||||
const toolId = toolHref.split('/').pop()?.replace('.html', '') || toolName;
|
||||
|
||||
(card as HTMLElement).style.display = isMatch ? '' : 'none';
|
||||
const isMatch = toolName.includes(searchTerm) || toolSubtitle.includes(searchTerm);
|
||||
const isDuplicate = seenToolIds.has(toolId);
|
||||
|
||||
if (isMatch) {
|
||||
visibleToolsInCategory++;
|
||||
if (isMatch && !isDuplicate) {
|
||||
seenToolIds.add(toolId);
|
||||
allTools.push(card.cloneNode(true) as HTMLElement);
|
||||
}
|
||||
});
|
||||
|
||||
(group as HTMLElement).style.display = visibleToolsInCategory === 0 ? 'none' : '';
|
||||
});
|
||||
|
||||
allTools.forEach((tool) => {
|
||||
searchResultsContainer.appendChild(tool);
|
||||
});
|
||||
|
||||
createIcons({ icons });
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', function (e) {
|
||||
@@ -465,8 +498,7 @@ const init = async () => {
|
||||
const fullWidthToggle = document.getElementById('full-width-toggle') as HTMLInputElement;
|
||||
const toolInterface = document.getElementById('tool-interface');
|
||||
|
||||
// Load saved preference
|
||||
const savedFullWidth = localStorage.getItem('fullWidthMode') === 'true';
|
||||
const savedFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
|
||||
if (fullWidthToggle) {
|
||||
fullWidthToggle.checked = savedFullWidth;
|
||||
applyFullWidthMode(savedFullWidth);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// This script applies the full-width preference from localStorage to page uploaders
|
||||
|
||||
export function initFullWidthMode() {
|
||||
const savedFullWidth = localStorage.getItem('fullWidthMode') === 'true';
|
||||
const savedFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
|
||||
|
||||
if (savedFullWidth) {
|
||||
applyFullWidthMode(true);
|
||||
|
||||
Reference in New Issue
Block a user