se agrega los requerimientos de la LODPD con firma de documentos
This commit is contained in:
@@ -17,5 +17,12 @@ MAIL_TLS_REJECT_UNAUTHORIZED=true
|
||||
MAIL_USER=
|
||||
MAIL_PASS=
|
||||
MAIL_FROM=noreply@linkedsign.local
|
||||
LEGAL_CONTROLLER_NAME=LinkedCorp
|
||||
LEGAL_PLATFORM_NAME=LinkedSign
|
||||
LEGAL_CONTACT_EMAIL=noreply@linkedsign.local
|
||||
LEGAL_CONTACT_PHONE=
|
||||
LEGAL_CONTACT_ADDRESS=
|
||||
LEGAL_WEBSITE=http://localhost:5173
|
||||
LEGAL_RIGHTS_EMAIL=noreply@linkedsign.local
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
ANDROID_APK_PATH=
|
||||
|
||||
@@ -39,6 +39,16 @@ const env = {
|
||||
from: process.env.MAIL_FROM || 'noreply@linkedsign.local',
|
||||
},
|
||||
|
||||
legal: {
|
||||
controllerName: process.env.LEGAL_CONTROLLER_NAME || 'LinkedCorp',
|
||||
platformName: process.env.LEGAL_PLATFORM_NAME || 'LinkedSign',
|
||||
contactEmail: process.env.LEGAL_CONTACT_EMAIL || process.env.MAIL_FROM || 'sign@linkedcorp.net',
|
||||
contactPhone: process.env.LEGAL_CONTACT_PHONE || 'No informado en este entorno',
|
||||
contactAddress: process.env.LEGAL_CONTACT_ADDRESS || 'No informado en este entorno',
|
||||
website: process.env.LEGAL_WEBSITE || process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
rightsEmail: process.env.LEGAL_RIGHTS_EMAIL || process.env.MAIL_FROM || 'sign@linkedcorp.net',
|
||||
},
|
||||
|
||||
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
androidApkPath:
|
||||
process.env.ANDROID_APK_PATH || path.resolve(process.cwd(), '..', 'android', 'apk', 'LinkedSign-debug.apk'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const { getDocumentPath, getCertificatePath } = require('../utils/storagePath');
|
||||
const { getDocumentPath, getCertificatePath, getTempUploadPath } = require('../utils/storagePath');
|
||||
|
||||
const documentStorage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
@@ -31,6 +31,20 @@ const certificateStorage = multer.diskStorage({
|
||||
},
|
||||
});
|
||||
|
||||
const tempCertificateStorage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
try {
|
||||
cb(null, await getTempUploadPath('certificates'));
|
||||
} catch (err) {
|
||||
cb(err);
|
||||
}
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const safe = path.basename(file.originalname || 'cert.p12').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
cb(null, `${Date.now()}-${safe}`);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadDocument = multer({
|
||||
storage: documentStorage,
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
@@ -54,4 +68,16 @@ const uploadCertificate = multer({
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = { uploadDocument, uploadCertificate };
|
||||
const uploadTempCertificate = multer({
|
||||
storage: tempCertificateStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!['.p12', '.pfx'].includes(ext)) {
|
||||
return cb(new Error('Only P12/PFX certificate files are allowed.'));
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = { uploadDocument, uploadCertificate, uploadTempCertificate };
|
||||
|
||||
@@ -9,7 +9,18 @@ const getContext = (req) => ({
|
||||
|
||||
const register = async (req, res, next) => {
|
||||
try {
|
||||
const result = await authService.register(req.body);
|
||||
if (!req.file) {
|
||||
const error = new Error('Debes cargar un certificado P12 o PFX.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
const result = await authService.register({
|
||||
email: req.body.email,
|
||||
password: req.body.password,
|
||||
certPassword: req.body.certPassword,
|
||||
certificateAlias: req.body.certificateAlias,
|
||||
certificateFile: req.file,
|
||||
}, getContext(req));
|
||||
return sendSuccess(res, {
|
||||
message: 'Registration successful. Please check your email to verify your account.',
|
||||
userId: result.userId,
|
||||
|
||||
@@ -98,6 +98,10 @@ const updatePassword = async (userId, passwordHash) => {
|
||||
);
|
||||
};
|
||||
|
||||
const deleteUser = async (userId) => {
|
||||
await db.execute('DELETE FROM users WHERE id = ?', [userId]);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
findByEmail,
|
||||
findById,
|
||||
@@ -112,4 +116,5 @@ module.exports = {
|
||||
setResetToken,
|
||||
findByResetToken,
|
||||
updatePassword,
|
||||
deleteUser,
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
const express = require('express');
|
||||
const authController = require('./auth.controller');
|
||||
const { validate, registerSchema, loginSchema, refreshSchema, changePasswordSchema } = require('./auth.validator');
|
||||
const { validate, validateRegisterMultipart, loginSchema, refreshSchema, changePasswordSchema } = require('./auth.validator');
|
||||
const authenticate = require('../../middlewares/authenticate');
|
||||
const { uploadTempCertificate } = require('../../config/multer');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/register', validate(registerSchema), authController.register);
|
||||
router.post('/register', uploadTempCertificate.single('certificateFile'), validateRegisterMultipart, authController.register);
|
||||
router.post('/login', validate(loginSchema), authController.login);
|
||||
router.post('/refresh', validate(refreshSchema), authController.refresh);
|
||||
router.post('/logout', authController.logout);
|
||||
|
||||
@@ -5,7 +5,12 @@ const passwordChangeRepository = require('./password-change.repository');
|
||||
const auditService = require('../audit/audit.service');
|
||||
const usersService = require('../users/users.service');
|
||||
const mailService = require('../mail/mail.service');
|
||||
const certificatesService = require('../certificates/certificates.service');
|
||||
const legalService = require('../legal/legal.service');
|
||||
const { getCertificatePath } = require('../../utils/storagePath');
|
||||
const { signAccess, signRefresh } = require('../../utils/jwt');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
|
||||
@@ -37,7 +42,7 @@ const issueVerificationEmail = async (user) => {
|
||||
};
|
||||
};
|
||||
|
||||
const register = async ({ email, password }) => {
|
||||
const register = async ({ email, password, certPassword, certificateFile, certificateAlias }, context = {}) => {
|
||||
const existing = await authRepository.findByEmail(email);
|
||||
if (existing) {
|
||||
const err = new Error('Email already registered.');
|
||||
@@ -57,17 +62,56 @@ const register = async ({ email, password }) => {
|
||||
verificationToken,
|
||||
verificationTokenExpires,
|
||||
});
|
||||
let mailResult;
|
||||
|
||||
let uploadedCertificate = null;
|
||||
let legalDocuments = [];
|
||||
|
||||
try {
|
||||
mailResult = await mailService.sendVerificationEmail({
|
||||
const certificateDir = await getCertificatePath(userId);
|
||||
const certificateFilename = path.basename(certificateFile.path);
|
||||
const finalCertificatePath = path.join(certificateDir, certificateFilename);
|
||||
|
||||
await fs.rename(certificateFile.path, finalCertificatePath);
|
||||
|
||||
uploadedCertificate = await certificatesService.upload({
|
||||
...certificateFile,
|
||||
path: finalCertificatePath,
|
||||
filename: certificateFilename,
|
||||
}, certPassword, userId, certificateAlias || certificateFile.originalname, context);
|
||||
|
||||
const p12Buffer = await fs.readFile(finalCertificatePath);
|
||||
legalDocuments = await legalService.issueInitialLegalPackage({
|
||||
userId,
|
||||
userName: name,
|
||||
userEmail: email,
|
||||
signerName: uploadedCertificate.subject_cn || name,
|
||||
p12Buffer,
|
||||
p12Password: certPassword,
|
||||
context,
|
||||
});
|
||||
|
||||
const attachments = legalDocuments.map((item) => ({
|
||||
filename: item.signedFilename,
|
||||
path: item.signedPath,
|
||||
}));
|
||||
|
||||
await mailService.sendVerificationEmail({
|
||||
name,
|
||||
email,
|
||||
verificationToken,
|
||||
attachments,
|
||||
});
|
||||
} catch (error) {
|
||||
const err = new Error('The account was created, but the verification email could not be sent. Please contact support or try again later.');
|
||||
err.statusCode = 503;
|
||||
err.code = 'MAIL_SEND_FAILED';
|
||||
await authRepository.deleteUser(userId);
|
||||
try {
|
||||
if (certificateFile?.path) {
|
||||
await fs.unlink(certificateFile.path);
|
||||
}
|
||||
} catch {}
|
||||
const err = new Error('No se pudo completar el registro legal inicial. Revisa el certificado, la contraseña del P12 y vuelve a intentarlo.');
|
||||
err.statusCode = error.statusCode || 503;
|
||||
err.code = error.code || 'REGISTER_LEGAL_FLOW_FAILED';
|
||||
err.message = error.message || err.message;
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -77,10 +121,16 @@ const register = async ({ email, password }) => {
|
||||
email,
|
||||
metadata: {
|
||||
verificationEmailSent: true,
|
||||
legalPackageCreated: true,
|
||||
legalVersion: '1.0.1',
|
||||
},
|
||||
});
|
||||
|
||||
return { userId, verificationToken, verificationUrl: mailResult.verificationUrl };
|
||||
return {
|
||||
userId,
|
||||
verificationToken,
|
||||
legalDocuments: legalDocuments.map(({ signedPath, originalPath, ...item }) => item),
|
||||
};
|
||||
};
|
||||
|
||||
const login = async ({ email, password }, context = {}) => {
|
||||
|
||||
@@ -17,10 +17,17 @@ const isStrongPassword = (value, helpers) => {
|
||||
return value;
|
||||
};
|
||||
|
||||
const registerSchema = Joi.object({
|
||||
email: Joi.string().email().required(),
|
||||
password: Joi.string().max(100).required().custom(isStrongPassword),
|
||||
});
|
||||
const passwordRuleCheckers = [
|
||||
{ test: (value) => value.length >= 8, label: 'al menos 8 caracteres' },
|
||||
{ test: (value) => /[A-Z]/.test(value), label: 'una mayúscula' },
|
||||
{ test: (value) => /[a-z]/.test(value), label: 'una minúscula' },
|
||||
{ test: (value) => /\d/.test(value), label: 'un número' },
|
||||
{ test: (value) => /[^A-Za-z0-9]/.test(value), label: 'un carácter especial' },
|
||||
];
|
||||
|
||||
const getPasswordIssues = (value = '') => passwordRuleCheckers
|
||||
.filter((rule) => !rule.test(value))
|
||||
.map((rule) => rule.label);
|
||||
|
||||
const loginSchema = Joi.object({
|
||||
email: Joi.string().email().required(),
|
||||
@@ -36,6 +43,42 @@ const changePasswordSchema = Joi.object({
|
||||
newPassword: Joi.string().max(100).required().custom(isStrongPassword),
|
||||
});
|
||||
|
||||
const validateRegisterMultipart = (req, res, next) => {
|
||||
const { email, password, certPassword, consentAccepted } = req.body;
|
||||
const issues = [];
|
||||
|
||||
if (!email || Joi.string().email().validate(email).error) {
|
||||
issues.push('Correo electrónico inválido');
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
issues.push('La contraseña es obligatoria');
|
||||
} else {
|
||||
const passwordIssues = getPasswordIssues(password);
|
||||
if (passwordIssues.length > 0) {
|
||||
issues.push(`La contraseña debe incluir ${passwordIssues.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!certPassword) {
|
||||
issues.push('La contraseña del certificado es obligatoria para firmar los documentos legales');
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
issues.push('Debes cargar un certificado P12 o PFX');
|
||||
}
|
||||
|
||||
if (!['true', true, 'on', '1', 1].includes(consentAccepted)) {
|
||||
issues.push('Debes aceptar los documentos legales para completar el registro');
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
return res.status(400).json({ success: false, data: null, error: issues.join('; ') });
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
const validate = (schema) => (req, res, next) => {
|
||||
const { error } = schema.validate(req.body, { abortEarly: false });
|
||||
if (error) {
|
||||
@@ -45,4 +88,4 @@ const validate = (schema) => (req, res, next) => {
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { validate, registerSchema, loginSchema, refreshSchema, changePasswordSchema };
|
||||
module.exports = { validate, validateRegisterMultipart, loginSchema, refreshSchema, changePasswordSchema };
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
const legalService = require('./legal.service');
|
||||
const { sendSuccess } = require('../../utils/apiResponse');
|
||||
|
||||
const list = async (req, res, next) => {
|
||||
try {
|
||||
const items = await legalService.listByUser(req.user.id);
|
||||
return sendSuccess(res, items, 200);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const getOne = async (req, res, next) => {
|
||||
try {
|
||||
const item = await legalService.getOne(req.params.id, req.user.id);
|
||||
const { originalPath, signedPath, ...safe } = item;
|
||||
return sendSuccess(res, safe, 200);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const download = async (req, res, next) => {
|
||||
try {
|
||||
const item = await legalService.getOne(req.params.id, req.user.id);
|
||||
return res.download(item.signedPath, item.signedFilename);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const templates = async (req, res, next) => {
|
||||
try {
|
||||
const items = await legalService.getTemplates();
|
||||
return sendSuccess(res, items, 200);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const templatePreview = async (req, res, next) => {
|
||||
try {
|
||||
const { filename, pdfBytes } = await legalService.getTemplatePreview(req.params.slug);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
return res.send(Buffer.from(pdfBytes));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
getOne,
|
||||
download,
|
||||
templates,
|
||||
templatePreview,
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const env = require('../../config/env');
|
||||
|
||||
const metadataDir = path.join(env.storagePath, 'legal', 'metadata');
|
||||
const metadataFile = path.join(metadataDir, 'legal-documents.json');
|
||||
|
||||
const readAll = async () => {
|
||||
try {
|
||||
const raw = await fs.readFile(metadataFile, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const writeAll = async (items) => {
|
||||
await fs.mkdir(metadataDir, { recursive: true });
|
||||
await fs.writeFile(metadataFile, JSON.stringify(items, null, 2), 'utf8');
|
||||
};
|
||||
|
||||
const createMany = async (items) => {
|
||||
const current = await readAll();
|
||||
current.push(...items);
|
||||
await writeAll(current);
|
||||
return items;
|
||||
};
|
||||
|
||||
const findByUser = async (userId) => {
|
||||
const current = await readAll();
|
||||
return current
|
||||
.filter((item) => item.userId === userId)
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
};
|
||||
|
||||
const findById = async (id) => {
|
||||
const current = await readAll();
|
||||
return current.find((item) => item.id === id) || null;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createMany,
|
||||
findByUser,
|
||||
findById,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
const express = require('express');
|
||||
const authenticate = require('../../middlewares/authenticate');
|
||||
const legalController = require('./legal.controller');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/templates', legalController.templates);
|
||||
router.get('/templates/:slug/preview', legalController.templatePreview);
|
||||
router.get('/', authenticate, legalController.list);
|
||||
router.get('/:id', authenticate, legalController.getOne);
|
||||
router.get('/:id/download', authenticate, legalController.download);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,225 @@
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { PDFDocument, StandardFonts, rgb } = require('pdf-lib');
|
||||
const legalRepository = require('./legal.repository');
|
||||
const { getLegalTemplates, LEGAL_VERSION, EFFECTIVE_DATE } = require('./legal.templates');
|
||||
const { getLegalDocumentPath } = require('../../utils/storagePath');
|
||||
const { signPdf } = require('../signing/signing.service');
|
||||
const auditService = require('../audit/audit.service');
|
||||
|
||||
const wrapText = (text, font, size, maxWidth) => {
|
||||
const words = String(text).split(/\s+/);
|
||||
const lines = [];
|
||||
let current = '';
|
||||
|
||||
words.forEach((word) => {
|
||||
const candidate = current ? `${current} ${word}` : word;
|
||||
if (font.widthOfTextAtSize(candidate, size) <= maxWidth) {
|
||||
current = candidate;
|
||||
} else {
|
||||
if (current) lines.push(current);
|
||||
current = word;
|
||||
}
|
||||
});
|
||||
|
||||
if (current) lines.push(current);
|
||||
return lines;
|
||||
};
|
||||
|
||||
const buildLegalPdfBytes = async ({ userName, userEmail, template }) => {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const page = pdfDoc.addPage([595.28, 841.89]);
|
||||
const bold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
const regular = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const margin = 46;
|
||||
const width = page.getWidth() - (margin * 2);
|
||||
let y = page.getHeight() - 56;
|
||||
|
||||
page.drawText('LinkedSign', { x: margin, y, size: 18, font: bold, color: rgb(0.09, 0.40, 0.75) });
|
||||
y -= 28;
|
||||
page.drawText(template.title, { x: margin, y, size: 15, font: bold, color: rgb(0.08, 0.10, 0.18) });
|
||||
y -= 18;
|
||||
page.drawText(`Versión ${template.version} · Vigente desde ${template.effectiveDate}`, { x: margin, y, size: 10, font: regular, color: rgb(0.38, 0.44, 0.60) });
|
||||
y -= 20;
|
||||
page.drawText(`Titular: ${userName}`, { x: margin, y, size: 10, font: regular, color: rgb(0.08, 0.10, 0.18) });
|
||||
y -= 14;
|
||||
page.drawText(`Correo: ${userEmail}`, { x: margin, y, size: 10, font: regular, color: rgb(0.08, 0.10, 0.18) });
|
||||
y -= 18;
|
||||
|
||||
const summaryLines = wrapText(template.summary, regular, 10.5, width);
|
||||
summaryLines.forEach((line) => {
|
||||
page.drawText(line, { x: margin, y, size: 10.5, font: regular, color: rgb(0.25, 0.29, 0.39) });
|
||||
y -= 13;
|
||||
});
|
||||
y -= 10;
|
||||
|
||||
template.sections.forEach((section) => {
|
||||
page.drawText(section.heading, { x: margin, y, size: 11.5, font: bold, color: rgb(0.08, 0.10, 0.18) });
|
||||
y -= 15;
|
||||
|
||||
section.body.forEach((paragraph) => {
|
||||
const lines = wrapText(paragraph, regular, 10, width - 12);
|
||||
lines.forEach((line, index) => {
|
||||
const text = index === 0 ? `• ${line}` : ` ${line}`;
|
||||
page.drawText(text, { x: margin, y, size: 10, font: regular, color: rgb(0.22, 0.25, 0.34) });
|
||||
y -= 12;
|
||||
});
|
||||
y -= 6;
|
||||
});
|
||||
y -= 4;
|
||||
});
|
||||
|
||||
return pdfDoc.save();
|
||||
};
|
||||
|
||||
const buildLegalPdf = async ({ userName, userEmail, template, outputPath }) => {
|
||||
const pdfBytes = await buildLegalPdfBytes({ userName, userEmail, template });
|
||||
await fs.writeFile(outputPath, pdfBytes);
|
||||
return pdfBytes;
|
||||
};
|
||||
|
||||
const sanitizeSlug = (value) => value.replace(/[^a-z0-9-]/gi, '_');
|
||||
|
||||
const issueInitialLegalPackage = async ({
|
||||
userId,
|
||||
userName,
|
||||
userEmail,
|
||||
signerName,
|
||||
p12Buffer,
|
||||
p12Password,
|
||||
context = {},
|
||||
}) => {
|
||||
const originalDir = await getLegalDocumentPath(userId, 'original');
|
||||
const signedDir = await getLegalDocumentPath(userId, 'signed');
|
||||
const timestamp = new Date();
|
||||
const stamp = [
|
||||
timestamp.getFullYear(),
|
||||
String(timestamp.getMonth() + 1).padStart(2, '0'),
|
||||
String(timestamp.getDate()).padStart(2, '0'),
|
||||
String(timestamp.getHours()).padStart(2, '0'),
|
||||
String(timestamp.getMinutes()).padStart(2, '0'),
|
||||
].join('');
|
||||
|
||||
const templates = getLegalTemplates();
|
||||
const created = [];
|
||||
|
||||
for (const template of templates) {
|
||||
const baseName = `${sanitizeSlug(template.slug)}_${stamp}`;
|
||||
const originalFilename = `${baseName}.pdf`;
|
||||
const originalPath = path.join(originalDir, originalFilename);
|
||||
|
||||
await buildLegalPdf({
|
||||
userName,
|
||||
userEmail,
|
||||
template,
|
||||
outputPath: originalPath,
|
||||
});
|
||||
|
||||
const { signedFilename, signedPath } = await signPdf({
|
||||
pdfPath: originalPath,
|
||||
p12Buffer,
|
||||
p12Password,
|
||||
signerName,
|
||||
reason: 'Aceptación legal de registro',
|
||||
location: 'LinkedSign',
|
||||
signatureStyle: 'qr',
|
||||
qrPosition: 'right',
|
||||
qrBackground: 'transparent',
|
||||
qrBorder: 'none',
|
||||
outputDir: signedDir,
|
||||
outputBaseName: `${baseName}_signed`,
|
||||
});
|
||||
|
||||
const signedBuffer = await fs.readFile(signedPath);
|
||||
const hashSha256 = crypto.createHash('sha256').update(signedBuffer).digest('hex');
|
||||
|
||||
created.push({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
slug: template.slug,
|
||||
title: template.title,
|
||||
version: template.version,
|
||||
effectiveDate: template.effectiveDate,
|
||||
createdAt: timestamp.toISOString(),
|
||||
originalFilename,
|
||||
originalPath,
|
||||
signedFilename,
|
||||
signedPath,
|
||||
hashSha256,
|
||||
});
|
||||
}
|
||||
|
||||
await legalRepository.createMany(created);
|
||||
|
||||
await auditService.logEvent({
|
||||
type: 'LEGAL_PACKAGE_SIGNED',
|
||||
userId,
|
||||
email: userEmail,
|
||||
ip: context.ip,
|
||||
userAgent: context.userAgent,
|
||||
metadata: {
|
||||
version: LEGAL_VERSION,
|
||||
documents: created.map((item) => ({ slug: item.slug, title: item.title })),
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
};
|
||||
|
||||
const listByUser = async (userId) => {
|
||||
const items = await legalRepository.findByUser(userId);
|
||||
return items.map(({ originalPath, signedPath, ...safe }) => safe);
|
||||
};
|
||||
|
||||
const getOne = async (id, userId) => {
|
||||
const item = await legalRepository.findById(id);
|
||||
if (!item) {
|
||||
const error = new Error('Legal document not found.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
if (item.userId !== userId) {
|
||||
const error = new Error('Access denied.');
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
const getTemplates = async () => getLegalTemplates().map((template) => ({
|
||||
slug: template.slug,
|
||||
title: template.title,
|
||||
version: template.version,
|
||||
effectiveDate: template.effectiveDate,
|
||||
summary: template.summary,
|
||||
sections: template.sections,
|
||||
}));
|
||||
|
||||
const getTemplatePreview = async (slug) => {
|
||||
const template = getLegalTemplates().find((item) => item.slug === slug);
|
||||
if (!template) {
|
||||
const error = new Error('Legal template not found.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const pdfBytes = await buildLegalPdfBytes({
|
||||
userName: 'Vista previa de registro',
|
||||
userEmail: 'preview@linkedsign.local',
|
||||
template,
|
||||
});
|
||||
|
||||
return {
|
||||
filename: `${sanitizeSlug(slug)}_preview.pdf`,
|
||||
pdfBytes,
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
issueInitialLegalPackage,
|
||||
listByUser,
|
||||
getOne,
|
||||
getTemplates,
|
||||
getTemplatePreview,
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
const env = require('../../config/env');
|
||||
|
||||
const LEGAL_VERSION = '1.1.0';
|
||||
const EFFECTIVE_DATE = '2026-04-07';
|
||||
|
||||
const buildControllerBlock = () => {
|
||||
const {
|
||||
controllerName,
|
||||
platformName,
|
||||
contactEmail,
|
||||
contactPhone,
|
||||
contactAddress,
|
||||
website,
|
||||
rightsEmail,
|
||||
} = env.legal;
|
||||
|
||||
return {
|
||||
controllerName,
|
||||
platformName,
|
||||
contactEmail,
|
||||
contactPhone,
|
||||
contactAddress,
|
||||
website,
|
||||
rightsEmail,
|
||||
};
|
||||
};
|
||||
|
||||
const getLegalTemplates = () => {
|
||||
const controller = buildControllerBlock();
|
||||
|
||||
return [
|
||||
{
|
||||
slug: 'terms-of-use',
|
||||
title: `Términos de Uso de ${controller.platformName}`,
|
||||
version: LEGAL_VERSION,
|
||||
effectiveDate: EFFECTIVE_DATE,
|
||||
summary: `Condiciones de acceso y uso de ${controller.platformName} como plataforma de gestión documental y firma electrónica.`,
|
||||
sections: [
|
||||
{
|
||||
heading: 'Identificación del prestador',
|
||||
body: [
|
||||
`${controller.controllerName} opera ${controller.platformName} como plataforma tecnológica para gestión documental, firma electrónica, administración de certificados y conservación de evidencia técnica asociada.`,
|
||||
`Canales de contacto del responsable: correo ${controller.contactEmail}, teléfono ${controller.contactPhone}, dirección ${controller.contactAddress} y sitio web ${controller.website}.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Objeto y alcance',
|
||||
body: [
|
||||
`Estos términos regulan el acceso y uso de ${controller.platformName}. La persona usuaria declara que utilizará el sistema de forma lícita, veraz, diligente y conforme a la normativa ecuatoriana aplicable.`,
|
||||
'El servicio está orientado a personas que cuentan con capacidad legal suficiente y, cuando corresponda, con un certificado digital válido y autorizado para su uso.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Responsabilidades de la persona usuaria',
|
||||
body: [
|
||||
'La persona usuaria es responsable del resguardo de sus credenciales, de la confidencialidad de su certificado digital y del contenido de los documentos que cargue o firme dentro del sistema.',
|
||||
'La persona usuaria declara que el archivo P12 o PFX cargado le pertenece o que cuenta con autorización suficiente para usarlo y que su utilización no vulnera derechos de terceros.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Uso de la firma electrónica y de la plataforma',
|
||||
body: [
|
||||
`${controller.platformName} procesa documentos PDF, certificados digitales, metadatos de firma y evidencia técnica para habilitar firma electrónica visible y criptográfica, verificación y trazabilidad de operaciones.`,
|
||||
'La contraseña del certificado proporcionada durante el registro inicial se utiliza exclusivamente para firmar los documentos legales de alta y no se conserva como credencial de acceso ni como dato persistente del sistema.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Disponibilidad, conservación y soporte',
|
||||
body: [
|
||||
`${controller.platformName} podrá conservar documentos firmados, evidencias técnicas, registros de auditoría, comprobantes de aceptación legal y demás soportes durante el tiempo necesario para prestar el servicio, atender reclamaciones y cumplir obligaciones legales aplicables.`,
|
||||
`Las consultas, incidentes y solicitudes relacionadas con la cuenta, el servicio o los documentos legales pueden remitirse a ${controller.contactEmail}.`,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'privacy-policy',
|
||||
title: 'Política de Privacidad y Aviso de Tratamiento de Datos',
|
||||
version: LEGAL_VERSION,
|
||||
effectiveDate: EFFECTIVE_DATE,
|
||||
summary: `Información sobre tratamiento de datos personales conforme a la LOPDP para personas usuarias de ${controller.platformName}.`,
|
||||
sections: [
|
||||
{
|
||||
heading: 'Responsable del tratamiento',
|
||||
body: [
|
||||
`El responsable del tratamiento es ${controller.controllerName}, operador de ${controller.platformName}.`,
|
||||
`Datos de contacto del responsable: correo ${controller.contactEmail}, teléfono ${controller.contactPhone}, dirección ${controller.contactAddress} y sitio web ${controller.website}.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Datos personales tratados',
|
||||
body: [
|
||||
'Se pueden tratar datos identificativos y de contacto, correo electrónico, credenciales de acceso cifradas, datos del certificado digital, dirección IP, agente de usuario, registros de auditoría, archivos cargados, documentos firmados, metadatos de firma y evidencia técnica vinculada al servicio.',
|
||||
'Durante el alta legal también se trata el archivo del certificado digital y la contraseña del certificado exclusivamente para validar el certificado y firmar los documentos legales iniciales.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Finalidades y base jurídica',
|
||||
body: [
|
||||
`Los datos se tratan para crear y administrar la cuenta, autenticar a la persona usuaria, habilitar firma electrónica, administrar certificados, generar y conservar evidencia de operaciones, atender solicitudes, enviar notificaciones y cumplir obligaciones legales, regulatorias o contractuales.`,
|
||||
'La base jurídica del tratamiento podrá ser la ejecución de la relación precontractual o contractual, el cumplimiento de obligaciones legales y, cuando corresponda, el consentimiento expreso, libre, específico, informado e inequívoco de la persona titular.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Conservación y consecuencias de la entrega de datos',
|
||||
body: [
|
||||
'Los datos se conservarán mientras la cuenta permanezca activa y, posteriormente, durante el tiempo necesario para cumplir obligaciones legales, atender reclamaciones, resguardar evidencias y cumplir políticas de conservación documental y de seguridad.',
|
||||
`Si la persona titular no proporciona los datos mínimos requeridos o no entrega un certificado digital válido para el alta legal inicial, ${controller.platformName} no podrá completar el registro ni habilitar las funciones de firma electrónica que dependan de dicho certificado.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Destinatarios, encargados y transferencias',
|
||||
body: [
|
||||
'Los datos podrán ser tratados por proveedores tecnológicos que actúen por cuenta del responsable para servicios de hosting, almacenamiento, correo transaccional, seguridad, soporte o respaldo, siempre bajo instrucciones documentadas y medidas de seguridad adecuadas.',
|
||||
'No se realizan decisiones basadas únicamente en tratamientos automatizados con efectos jurídicos para la persona titular ni perfiles comerciales automatizados en el marco de este flujo de registro. Si existieran transferencias nacionales o internacionales necesarias para la prestación del servicio o por obligación legal, se realizarán con las salvaguardas exigidas por la normativa aplicable.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Derechos de la persona titular y canal de atención',
|
||||
body: [
|
||||
'La persona titular puede ejercer los derechos previstos en la LOPDP, incluyendo acceso, rectificación, actualización, eliminación, oposición, portabilidad, suspensión del tratamiento y revocatoria del consentimiento cuando sea jurídicamente procedente.',
|
||||
`Las solicitudes para ejercicio de derechos, consultas o reclamos pueden enviarse a ${controller.rightsEmail}. ${controller.controllerName} atenderá dichas solicitudes conforme a los plazos y requisitos establecidos en la normativa aplicable.`,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'data-processing-consent',
|
||||
title: 'Consentimiento Expreso para Tratamiento de Datos y Uso de Firma Electrónica',
|
||||
version: LEGAL_VERSION,
|
||||
effectiveDate: EFFECTIVE_DATE,
|
||||
summary: 'Aceptación expresa del tratamiento de datos personales, del uso del certificado digital para el alta inicial y de la generación de evidencia legal del registro.',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Declaración de consentimiento',
|
||||
body: [
|
||||
`La persona usuaria declara de forma expresa, libre, específica, informada e inequívoca que ha leído y comprendido los Términos de Uso, la Política de Privacidad y este documento, y que acepta el tratamiento de sus datos personales por ${controller.controllerName} para operar dentro de ${controller.platformName}.`,
|
||||
'La persona usuaria autoriza el uso del certificado digital cargado exclusivamente para firmar los documentos legales del alta inicial, generar evidencia de aceptación y habilitar el flujo de registro dentro de la plataforma.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Uso del certificado y de la contraseña del certificado',
|
||||
body: [
|
||||
'La contraseña del archivo P12 o PFX se utilizará únicamente durante el proceso técnico de validación del certificado y firma de los documentos legales iniciales.',
|
||||
'La contraseña del certificado no será almacenada por la plataforma como credencial persistente ni será reutilizada automáticamente para otras operaciones posteriores.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Evidencia y entrega de copias',
|
||||
body: [
|
||||
'La aceptación queda respaldada por los documentos legales firmados electrónicamente, por el correo registrado, por la fecha y hora del registro, por la huella digital del archivo firmado y por los registros técnicos asociados a la operación.',
|
||||
`La persona usuaria recibirá una copia de los documentos legales firmados en el correo registrado y podrá consultarlos posteriormente dentro de ${controller.platformName} en la sección Legal, en modo de solo lectura.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Revocatoria y límites',
|
||||
body: [
|
||||
`La persona titular podrá solicitar información adicional o ejercer sus derechos mediante ${controller.rightsEmail}. La revocatoria del consentimiento, cuando resulte jurídicamente procedente, no afectará la licitud de los tratamientos realizados con anterioridad ni la conservación obligatoria de evidencias y soportes legales.`,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
LEGAL_VERSION,
|
||||
EFFECTIVE_DATE,
|
||||
getLegalTemplates,
|
||||
};
|
||||
@@ -31,7 +31,7 @@ const getTransporter = () => {
|
||||
return transporter;
|
||||
};
|
||||
|
||||
const sendMail = async ({ to, subject, html, text }) => {
|
||||
const sendMail = async ({ to, subject, html, text, attachments }) => {
|
||||
const activeTransporter = getTransporter();
|
||||
const info = await activeTransporter.sendMail({
|
||||
from: env.mail.from,
|
||||
@@ -39,6 +39,7 @@ const sendMail = async ({ to, subject, html, text }) => {
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
attachments,
|
||||
});
|
||||
|
||||
return info;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const env = require('../../config/env');
|
||||
const mailRepository = require('./mail.repository');
|
||||
|
||||
const sendVerificationEmail = async ({ name, email, verificationToken }) => {
|
||||
const sendVerificationEmail = async ({ name, email, verificationToken, attachments = [] }) => {
|
||||
const verificationUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/verify-email/${verificationToken}`;
|
||||
const subject = 'Confirma tu cuenta de LinkedSign';
|
||||
const text = [
|
||||
@@ -10,8 +10,10 @@ const sendVerificationEmail = async ({ name, email, verificationToken }) => {
|
||||
'Confirma tu cuenta de LinkedSign usando este enlace:',
|
||||
verificationUrl,
|
||||
'',
|
||||
attachments.length > 0 ? 'Adjuntamos una copia de los documentos legales firmados durante tu registro.' : '',
|
||||
'',
|
||||
'Si no solicitaste esta cuenta, puedes ignorar este mensaje.',
|
||||
].join('\n');
|
||||
].filter(Boolean).join('\n');
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
|
||||
<h2 style="margin-bottom: 12px;">Confirma tu cuenta de LinkedSign</h2>
|
||||
@@ -24,6 +26,7 @@ const sendVerificationEmail = async ({ name, email, verificationToken }) => {
|
||||
</p>
|
||||
<p>Si el botón no funciona, copia y pega este enlace en tu navegador:</p>
|
||||
<p><a href="${verificationUrl}">${verificationUrl}</a></p>
|
||||
${attachments.length > 0 ? '<p>Adjuntamos una copia de los documentos legales firmados durante tu registro inicial.</p>' : ''}
|
||||
<p>Si no solicitaste esta cuenta, puedes ignorar este mensaje.</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -33,6 +36,7 @@ const sendVerificationEmail = async ({ name, email, verificationToken }) => {
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
attachments,
|
||||
});
|
||||
|
||||
if (!env.mail.user || !env.mail.pass) {
|
||||
|
||||
@@ -222,6 +222,8 @@ const signPdf = async ({
|
||||
qrBackground = 'transparent',
|
||||
qrBorder = 'none',
|
||||
verificationUrl = null,
|
||||
outputDir = null,
|
||||
outputBaseName = null,
|
||||
}) => {
|
||||
const pdfBytes = await fs.readFile(pdfPath);
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
|
||||
@@ -293,9 +295,9 @@ const signPdf = async ({
|
||||
const signedBuf = await signInstance.sign(pdfWithPlaceholder, signer);
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────
|
||||
const destDir = await getSignedDocumentPath();
|
||||
const destDir = outputDir || await getSignedDocumentPath();
|
||||
const ext = path.extname(pdfPath);
|
||||
const base = path.basename(pdfPath, ext).replace(/^\d+-/, '');
|
||||
const base = outputBaseName || path.basename(pdfPath, ext).replace(/^\d+-/, '');
|
||||
const nowForFilename = new Date();
|
||||
const year = String(nowForFilename.getFullYear());
|
||||
const month = String(nowForFilename.getMonth() + 1).padStart(2, '0');
|
||||
@@ -303,7 +305,7 @@ const signPdf = async ({
|
||||
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 signedFilename = outputBaseName ? `${base}${ext}` : `${base}_${dateSuffix}${ext}`;
|
||||
const signedPath = path.join(destDir, signedFilename);
|
||||
await fs.writeFile(signedPath, signedBuf);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ const certificatesRoutes = require('../modules/certificates/certificates.routes'
|
||||
const downloadsRoutes = require('../modules/downloads/downloads.routes');
|
||||
const documentsRoutes = require('../modules/documents/documents.routes');
|
||||
const signaturesRoutes = require('../modules/signatures/signatures.routes');
|
||||
const legalRoutes = require('../modules/legal/legal.routes');
|
||||
const usersRoutes = require('../modules/users/users.routes');
|
||||
const healthRoutes = require('../modules/health/health.routes');
|
||||
|
||||
@@ -13,6 +14,7 @@ router.use('/auth', authRoutes);
|
||||
router.use('/certificates', certificatesRoutes);
|
||||
router.use('/downloads', downloadsRoutes);
|
||||
router.use('/documents', documentsRoutes);
|
||||
router.use('/legal', legalRoutes);
|
||||
router.use('/signatures', signaturesRoutes);
|
||||
router.use('/users', usersRoutes);
|
||||
router.use('/health', healthRoutes);
|
||||
|
||||
@@ -26,4 +26,22 @@ const getCertificatePath = async (userId) => {
|
||||
return fullPath;
|
||||
};
|
||||
|
||||
module.exports = { getDocumentPath, getSignedDocumentPath, getCertificatePath };
|
||||
const getTempUploadPath = async (bucket = 'misc') => {
|
||||
const fullPath = path.join(env.storagePath, 'tmp', bucket);
|
||||
await fs.mkdir(fullPath, { recursive: true });
|
||||
return fullPath;
|
||||
};
|
||||
|
||||
const getLegalDocumentPath = async (userId, variant = 'signed') => {
|
||||
const fullPath = path.join(env.storagePath, 'legal', 'users', String(userId), variant);
|
||||
await fs.mkdir(fullPath, { recursive: true });
|
||||
return fullPath;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getDocumentPath,
|
||||
getSignedDocumentPath,
|
||||
getCertificatePath,
|
||||
getTempUploadPath,
|
||||
getLegalDocumentPath,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user