Initial LinkedSign platform

This commit is contained in:
2026-04-06 20:33:59 -05:00
commit 1074c7c9d2
159 changed files with 19072 additions and 0 deletions
@@ -0,0 +1,313 @@
const fs = require('fs/promises');
const path = require('path');
const QRCode = require('qrcode');
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
const { pdflibAddPlaceholder } = require('@signpdf/placeholder-pdf-lib');
const { SignPdf } = require('@signpdf/signpdf');
const { P12Signer } = require('@signpdf/signer-p12');
const { getSignedDocumentPath } = require('../../utils/storagePath');
// ── Color helpers ──────────────────────────────────────────────────────────────
const C = {
primary: rgb(0.31, 0.27, 0.90),
dark: rgb(0.10, 0.10, 0.30),
gray: rgb(0.35, 0.35, 0.45),
lightGray: rgb(0.60, 0.60, 0.65),
bgBlue: rgb(0.95, 0.97, 1.00),
bgDark: rgb(0.12, 0.14, 0.25),
bgGray: rgb(0.96, 0.96, 0.97),
white: rgb(1.00, 1.00, 1.00),
accent: rgb(0.20, 0.60, 0.85),
green: rgb(0.10, 0.55, 0.30),
bgGreen: rgb(0.93, 0.98, 0.94),
};
// ── Shared text truncation ─────────────────────────────────────────────────────
function truncate(text, font, size, maxWidth) {
if (!text) return '';
const ellipsis = '...';
if (font.widthOfTextAtSize(text, size) <= maxWidth) return text;
let t = text;
while (t.length > 0 && font.widthOfTextAtSize(t + ellipsis, size) > maxWidth) t = t.slice(0, -1);
return t + ellipsis;
}
function getSignatureWidth(style, signerName = '') {
if (style !== 'qr') return 200;
const normalizedName = String(signerName).trim();
const calculatedWidth = 78 + (normalizedName.length * 4.9);
return Math.max(170, Math.min(270, Math.round(calculatedWidth)));
}
// ── Style: CLASSIC (blue bar + DS badge) ──────────────────────────────────────
async function drawStyleClassic(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location }) {
const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const f2 = await pdfDoc.embedFont(StandardFonts.Helvetica);
page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: C.bgBlue, borderColor: C.primary, borderWidth: 1 });
page.drawRectangle({ x: sx, y: sy, width: 4, height: sh, color: C.primary });
const nameSize = signerName.length > 24 ? 7 : 8.5;
page.drawText('DS', { x: sx + 7, y: sy + sh - 18, size: 9, font: f1, color: C.primary });
page.drawText(truncate(signerName, f1, nameSize, sw - 30), { x: sx + 22, y: sy + sh - 17, size: nameSize, font: f1, color: C.dark });
if (reason) page.drawText(`Razon: ${truncate(reason, f2, 7, sw - 60)}`, { x: sx + 8, y: sy + sh - 30, size: 7, font: f2, color: C.gray });
page.drawText(`Fecha: ${dateStr} ${timeStr}`, { x: sx + 8, y: sy + sh - 41, size: 7, font: f2, color: C.gray });
if (location) page.drawText(`Lugar: ${truncate(location, f2, 7, sw - 60)}`, { x: sx + 8, y: sy + sh - 52, size: 7, font: f2, color: C.gray });
page.drawText('Firma digital', { x: sx + sw - 54, y: sy + 4, size: 6, font: f2, color: C.lightGray });
}
// ── Style: QR CODE ─────────────────────────────────────────────────────────────
async function drawStyleQR(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location, verificationUrl, qrPosition, qrBackground, qrBorder }) {
const f1 = await pdfDoc.embedFont(StandardFonts.Helvetica);
const f2 = await pdfDoc.embedFont(StandardFonts.Helvetica);
const useSoftBackground = qrBackground === 'soft';
const useBorder = qrBorder === 'line';
const qrContent = verificationUrl || [
'FIRMA DIGITAL VERIFICADA',
`Firmante: ${signerName}`,
reason ? `Razon: ${reason}` : '',
`Fecha: ${dateStr} ${timeStr}`,
location ? `Lugar: ${location}` : '',
].filter(Boolean).join('\n');
// Generate QR as PNG buffer
const qrPng = await QRCode.toBuffer(qrContent, {
type: 'png',
width: 100,
margin: 1,
color: { dark: '#1f1b6e', light: useSoftBackground ? '#f2f4ff' : '#0000' },
});
const qrImage = await pdfDoc.embedPng(qrPng);
const qrSize = Math.min(sh - 10, 44);
const qrOnRight = qrPosition === 'right';
const textPadding = 6;
const textX = qrOnRight ? sx + textPadding : sx + qrSize + 6;
const textW = sw - qrSize - 10;
// Background
if (useSoftBackground) {
page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: C.bgBlue });
}
if (useBorder) {
page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, borderColor: C.primary, borderWidth: 1 });
}
const qrX = qrOnRight ? sx + sw - qrSize - 2 : sx + 2;
if (useSoftBackground) {
page.drawRectangle({ x: qrOnRight ? sx + sw - qrSize - 4 : sx, y: sy, width: qrSize + 4, height: sh, color: C.bgBlue });
}
page.drawImage(qrImage, { x: qrX, y: sy + 4, width: qrSize, height: qrSize });
const dividerX = qrOnRight ? sx + sw - qrSize - 4 : textX - 2;
page.drawLine({ start: { x: dividerX, y: sy + 4 }, end: { x: dividerX, y: sy + sh - 4 }, thickness: 0.5, color: C.primary, opacity: 0.4 });
// Text on right
const nameSize = signerName.length > 26 ? 6.8 : 7.6;
page.drawText(truncate(signerName, f1, nameSize, textW), { x: textX, y: sy + sh - 13, size: nameSize, font: f1, color: C.dark });
if (reason) page.drawText(truncate(reason, f2, 6.2, textW), { x: textX, y: sy + sh - 23, size: 6.2, font: f2, color: C.gray });
page.drawText(`${dateStr}`, { x: textX, y: sy + sh - 33, size: 6.2, font: f2, color: C.gray });
page.drawText(`${timeStr}`, { x: textX, y: sy + sh - 43, size: 6.2, font: f2, color: C.gray });
if (location) page.drawText(truncate(location, f2, 5.6, textW), { x: textX, y: sy + sh - 52, size: 5.6, font: f2, color: C.lightGray });
page.drawText('Escanee el QR para verificar', { x: sx + 2, y: sy + 2, size: 5, font: f2, color: C.lightGray });
}
// ── Style: PROFESSIONAL (dark header + fields) ────────────────────────────────
async function drawStyleProfessional(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location }) {
const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const f2 = await pdfDoc.embedFont(StandardFonts.Helvetica);
const headerH = 16;
// Background
page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: C.bgGray, borderColor: rgb(0.70, 0.72, 0.78), borderWidth: 0.75 });
// Dark header bar
page.drawRectangle({ x: sx, y: sy + sh - headerH, width: sw, height: headerH, color: C.bgDark });
page.drawText('FIRMA DIGITAL', { x: sx + 6, y: sy + sh - headerH + 4, size: 7.5, font: f1, color: C.white });
page.drawText('Firmado electronicamente', { x: sx + sw - 88, y: sy + sh - headerH + 4, size: 5.5, font: f2, color: rgb(0.7, 0.7, 0.85) });
// Separator line below header
page.drawLine({ start: { x: sx, y: sy + sh - headerH - 1 }, end: { x: sx + sw, y: sy + sh - headerH - 1 }, thickness: 0.5, color: C.primary, opacity: 0.3 });
// Signer name (prominent)
const nameSize = signerName.length > 28 ? 7.5 : 9;
page.drawText(truncate(signerName, f1, nameSize, sw - 10), { x: sx + 6, y: sy + sh - headerH - 14, size: nameSize, font: f1, color: C.dark });
// Separator line
const lineY = sy + sh - headerH - 20;
page.drawLine({ start: { x: sx + 4, y: lineY }, end: { x: sx + sw - 4, y: lineY }, thickness: 0.4, color: C.lightGray, opacity: 0.6 });
// Fields in two columns
const col1X = sx + 6;
const col2X = sx + sw / 2 + 2;
const fieldW = sw / 2 - 10;
page.drawText('Razon:', { x: col1X, y: lineY - 11, size: 5.5, font: f1, color: C.gray });
page.drawText(truncate(reason || 'Firmado digitalmente', f2, 6, fieldW), { x: col1X, y: lineY - 20, size: 6, font: f2, color: C.dark });
page.drawText('Fecha:', { x: col2X, y: lineY - 11, size: 5.5, font: f1, color: C.gray });
page.drawText(`${dateStr}`, { x: col2X, y: lineY - 20, size: 6, font: f2, color: C.dark });
page.drawText(`${timeStr}`, { x: col2X, y: lineY - 28, size: 6, font: f2, color: C.dark });
if (location) {
page.drawText('Lugar:', { x: col1X, y: lineY - 30, size: 5.5, font: f1, color: C.gray });
page.drawText(truncate(location, f2, 6, fieldW), { x: col1X, y: lineY - 39, size: 6, font: f2, color: C.dark });
}
}
// ── Style: IMAGE (custom handwritten image + info) ────────────────────────────
async function drawStyleImage(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location, signatureImageBase64 }) {
const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const f2 = await pdfDoc.embedFont(StandardFonts.Helvetica);
// Background
page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: C.white, borderColor: rgb(0.75, 0.75, 0.80), borderWidth: 0.75 });
page.drawRectangle({ x: sx, y: sy, width: 4, height: sh, color: C.green });
const imgAreaW = sw * 0.45;
const imgAreaH = sh - 10;
if (signatureImageBase64) {
try {
// Strip data URL prefix if present
const base64Data = signatureImageBase64.replace(/^data:image\/\w+;base64,/, '');
const imgBuf = Buffer.from(base64Data, 'base64');
let embeddedImg;
try {
embeddedImg = await pdfDoc.embedPng(imgBuf);
} catch {
embeddedImg = await pdfDoc.embedJpg(imgBuf);
}
const scale = Math.min(imgAreaW / embeddedImg.width, imgAreaH / embeddedImg.height);
const drawW = embeddedImg.width * scale;
const drawH = embeddedImg.height * scale;
page.drawImage(embeddedImg, {
x: sx + 6 + (imgAreaW - drawW) / 2,
y: sy + (sh - drawH) / 2,
width: drawW, height: drawH,
});
} catch { /* ignore embed errors */ }
} else {
// Placeholder if no image provided
page.drawRectangle({ x: sx + 6, y: sy + 8, width: imgAreaW, height: imgAreaH, color: C.bgGray, borderColor: rgb(0.8, 0.8, 0.85), borderWidth: 0.5 });
page.drawText('Firma', { x: sx + 6 + imgAreaW / 2 - 10, y: sy + sh / 2 - 4, size: 7, font: f2, color: C.lightGray });
}
// Vertical divider
const divX = sx + imgAreaW + 8;
page.drawLine({ start: { x: divX, y: sy + 4 }, end: { x: divX, y: sy + sh - 4 }, thickness: 0.5, color: rgb(0.8, 0.8, 0.85) });
// Text right side
const textX = divX + 5;
const textW = sw - imgAreaW - 16;
const nameSize = signerName.length > 20 ? 7 : 8.5;
page.drawText(truncate(signerName, f1, nameSize, textW), { x: textX, y: sy + sh - 14, size: nameSize, font: f1, color: C.dark });
page.drawLine({ start: { x: textX, y: sy + sh - 18 }, end: { x: textX + textW, y: sy + sh - 18 }, thickness: 0.4, color: C.green, opacity: 0.5 });
if (reason) page.drawText(truncate(reason, f2, 6.5, textW), { x: textX, y: sy + sh - 29, size: 6.5, font: f2, color: C.gray });
page.drawText(`${dateStr}`, { x: textX, y: sy + sh - 39, size: 6.5, font: f2, color: C.gray });
page.drawText(`${timeStr}`, { x: textX, y: sy + sh - 49, size: 6.5, font: f2, color: C.gray });
if (location) page.drawText(truncate(location, f2, 6, textW), { x: textX, y: sy + sh - 59, size: 6, font: f2, color: C.lightGray });
}
// ── Main sign function ─────────────────────────────────────────────────────────
const signPdf = async ({
pdfPath, p12Buffer, p12Password,
signerName, reason = '', location = '',
page = 0, rect = null,
signatureStyle = 'classic', signatureImageBase64 = null,
qrPosition = 'left',
qrBackground = 'transparent',
qrBorder = 'none',
verificationUrl = null,
}) => {
const pdfBytes = await fs.readFile(pdfPath);
const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
const pages = pdfDoc.getPages();
const tp = pages[Math.min(page, pages.length - 1)];
const { width: pw, height: ph } = tp.getSize();
const cropBox = typeof tp.getCropBox === 'function'
? tp.getCropBox()
: { x: 0, y: 0, width: pw, height: ph };
const defaultWidth = getSignatureWidth(signatureStyle, signerName);
const requestedRect = rect || {
x: cropBox.width - (defaultWidth + 22),
y: 18,
width: defaultWidth,
height: 65,
};
const resolvedWidth = requestedRect.width || defaultWidth;
const resolvedX = Math.min(
Math.max(requestedRect.x, 0),
Math.max(0, cropBox.width - resolvedWidth - 4)
);
const resolvedY = Math.min(
Math.max(requestedRect.y, 0),
Math.max(0, cropBox.height - (requestedRect.height || 65) - 4)
);
const sigRect = {
...requestedRect,
x: resolvedX + cropBox.x,
y: resolvedY + cropBox.y,
width: resolvedWidth,
};
const { x: sx, y: sy, width: sw, height: sh } = sigRect;
const now = new Date();
const dateStr = now.toLocaleDateString('es-EC', { day: '2-digit', month: '2-digit', year: 'numeric' });
const timeStr = now.toLocaleTimeString('es-EC', { hour: '2-digit', minute: '2-digit', hour12: true });
const info = { signerName, reason, dateStr, timeStr, location, signatureImageBase64, verificationUrl, qrPosition, qrBackground, qrBorder };
// ── Draw visual stamp on page content stream ─────────────────────
const style = signatureStyle || 'classic';
if (style === 'qr') await drawStyleQR(pdfDoc, tp, sx, sy, sw, sh, info);
else if (style === 'professional') await drawStyleProfessional(pdfDoc, tp, sx, sy, sw, sh, info);
else if (style === 'image') await drawStyleImage(pdfDoc, tp, sx, sy, sw, sh, info);
else await drawStyleClassic(pdfDoc, tp, sx, sy, sw, sh, info);
// ── Save page with visual stamp ───────────────────────────────────
const pdfWithStamp = Buffer.from(await pdfDoc.save({ useObjectStreams: false }));
// ── Add cryptographic signature placeholder ───────────────────────
const pdfDocForSign = await PDFDocument.load(pdfWithStamp);
pdflibAddPlaceholder({
pdfDoc: pdfDocForSign,
reason: reason || 'Firma digital',
contactInfo: signerName,
name: signerName,
location: location || '',
signatureLength: 32768,
rect: [sx, sy, sx + sw, sy + sh],
pageIndex: Math.min(page, pdfDocForSign.getPageCount() - 1),
});
const pdfWithPlaceholder = Buffer.from(await pdfDocForSign.save({ useObjectStreams: false }));
// ── Sign ──────────────────────────────────────────────────────────
const signer = new P12Signer(p12Buffer, { passphrase: p12Password || '' });
const signInstance = new SignPdf();
const signedBuf = await signInstance.sign(pdfWithPlaceholder, signer);
// ── Save ──────────────────────────────────────────────────────────
const destDir = await getSignedDocumentPath();
const ext = path.extname(pdfPath);
const base = path.basename(pdfPath, ext).replace(/^\d+-/, '');
const nowForFilename = new Date();
const year = String(nowForFilename.getFullYear());
const month = String(nowForFilename.getMonth() + 1).padStart(2, '0');
const day = String(nowForFilename.getDate()).padStart(2, '0');
const hours = String(nowForFilename.getHours()).padStart(2, '0');
const minutes = String(nowForFilename.getMinutes()).padStart(2, '0');
const dateSuffix = `${year}${month}${day}${hours}${minutes}`;
const signedFilename = `${base}_${dateSuffix}${ext}`;
const signedPath = path.join(destDir, signedFilename);
await fs.writeFile(signedPath, signedBuf);
return { signedFilename, signedPath };
};
module.exports = { signPdf };