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
+11
View File
@@ -0,0 +1,11 @@
const auditModel = {
id: 'number',
type: 'string',
userId: 'number|null',
email: 'string|null',
ip: 'string',
userAgent: 'string',
timestamp: 'Date',
};
module.exports = auditModel;
@@ -0,0 +1,44 @@
const db = require('../../config/db');
const fs = require('fs/promises');
const path = require('path');
const saveEvent = async (event) => {
try {
await db.execute(
'INSERT INTO audit_logs (type, user_id, email, ip, user_agent, metadata) VALUES (?, ?, ?, ?, ?, ?)',
[
event.type,
event.userId || null,
event.email || null,
event.ip || null,
event.userAgent || null,
event.metadata ? JSON.stringify(event.metadata) : null,
]
);
} catch (err) {
// Fallback to file log if DB fails
try {
const logsDir = path.resolve(process.cwd(), 'logs');
await fs.mkdir(logsDir, { recursive: true });
await fs.appendFile(
path.resolve(logsDir, 'audit.log'),
`${JSON.stringify({ ...event, _fallback: true })}\n`
);
} catch {
// Silent
}
}
};
const getEvents = async ({ type, userId, limit = 100, offset = 0 } = {}) => {
let sql = 'SELECT * FROM audit_logs WHERE 1=1';
const params = [];
if (type) { sql += ' AND type = ?'; params.push(type); }
if (userId) { sql += ' AND user_id = ?'; params.push(userId); }
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const [rows] = await db.execute(sql, params);
return rows;
};
module.exports = { saveEvent, getEvents };
@@ -0,0 +1,11 @@
const auditRepository = require('./audit.repository');
const logEvent = async ({ type, userId = null, email = null, ip = null, userAgent = null, metadata = null } = {}) => {
await auditRepository.saveEvent({ type, userId, email, ip, userAgent, metadata });
};
const getEvents = async (filters) => {
return auditRepository.getEvents(filters);
};
module.exports = { logEvent, getEvents };
@@ -0,0 +1,90 @@
const authService = require('./auth.service');
const usersService = require('../users/users.service');
const { sendSuccess } = require('../../utils/apiResponse');
const getContext = (req) => ({
ip: req.ip?.replace('::ffff:', '') || null,
userAgent: req.headers['user-agent'] || null,
});
const register = async (req, res, next) => {
try {
const result = await authService.register(req.body);
return sendSuccess(res, {
message: 'Registration successful. Please check your email to verify your account.',
userId: result.userId,
}, 201);
} catch (err) {
return next(err);
}
};
const login = async (req, res, next) => {
try {
const result = await authService.login(req.body, getContext(req));
return sendSuccess(res, result, 200);
} catch (err) {
return next(err);
}
};
const refresh = async (req, res, next) => {
try {
const result = await authService.refresh(req.body.refreshToken);
return sendSuccess(res, result, 200);
} catch (err) {
return next(err);
}
};
const logout = async (req, res, next) => {
try {
await authService.logout(req.body.refreshToken);
return sendSuccess(res, { message: 'Logged out.' }, 200);
} catch (err) {
return next(err);
}
};
const changePassword = async (req, res, next) => {
try {
await authService.changePassword(req.user.id, req.body, getContext(req));
return sendSuccess(res, { message: 'Password change requested. Please confirm the change from the email sent to your account.' }, 200);
} catch (err) {
return next(err);
}
};
const confirmPasswordChange = async (req, res, next) => {
try {
await authService.confirmPasswordChange(req.params.token);
return sendSuccess(res, { message: 'Password changed successfully.' }, 200);
} catch (err) {
return next(err);
}
};
const verifyEmail = async (req, res, next) => {
try {
await authService.verifyEmail(req.params.token);
return sendSuccess(res, { message: 'Email verified successfully.' }, 200);
} catch (err) {
return next(err);
}
};
const me = async (req, res, next) => {
try {
const signaturePreferences = await usersService.getSignaturePreferences(req.user.id);
return sendSuccess(res, {
user: {
...req.user,
signaturePreferences,
},
}, 200);
} catch (err) {
return next(err);
}
};
module.exports = { register, login, refresh, logout, verifyEmail, me, changePassword, confirmPasswordChange };
+115
View File
@@ -0,0 +1,115 @@
const db = require('../../config/db');
const findByEmail = async (email) => {
const [rows] = await db.execute(
'SELECT id, name, email, password_hash, role, is_verified, is_active FROM users WHERE email = ?',
[email]
);
return rows[0] || null;
};
const findById = async (id) => {
const [rows] = await db.execute(
'SELECT id, name, email, role, is_verified, is_active, created_at FROM users WHERE id = ?',
[id]
);
return rows[0] || null;
};
const findAuthUserById = async (id) => {
const [rows] = await db.execute(
'SELECT id, name, email, password_hash, role, is_verified, is_active FROM users WHERE id = ?',
[id]
);
return rows[0] || null;
};
const createUser = async ({ name, email, passwordHash, verificationToken, verificationTokenExpires }) => {
const [result] = await db.execute(
`INSERT INTO users (name, email, password_hash, verification_token, verification_token_expires)
VALUES (?, ?, ?, ?, ?)`,
[name, email, passwordHash, verificationToken, verificationTokenExpires]
);
return result.insertId;
};
const verifyEmail = async (token) => {
const [rows] = await db.execute(
'SELECT id FROM users WHERE verification_token = ? AND verification_token_expires > NOW() AND is_verified = 0',
[token]
);
if (!rows[0]) return null;
await db.execute(
'UPDATE users SET is_verified = 1, verification_token = NULL, verification_token_expires = NULL WHERE id = ?',
[rows[0].id]
);
return rows[0].id;
};
const updateVerificationToken = async (userId, token, expiresAt) => {
await db.execute(
'UPDATE users SET verification_token = ?, verification_token_expires = ? WHERE id = ?',
[token, expiresAt, userId]
);
};
const saveRefreshToken = async (userId, token, expiresAt) => {
await db.execute(
'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)',
[userId, token, expiresAt]
);
};
const findRefreshToken = async (token) => {
const [rows] = await db.execute(
'SELECT rt.*, u.id as uid, u.email, u.name, u.role, u.is_verified, u.is_active FROM refresh_tokens rt JOIN users u ON rt.user_id = u.id WHERE rt.token = ? AND rt.expires_at > NOW()',
[token]
);
return rows[0] || null;
};
const deleteRefreshToken = async (token) => {
await db.execute('DELETE FROM refresh_tokens WHERE token = ?', [token]);
};
const deleteUserRefreshTokens = async (userId) => {
await db.execute('DELETE FROM refresh_tokens WHERE user_id = ?', [userId]);
};
const setResetToken = async (userId, token, expiresAt) => {
await db.execute(
'UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?',
[token, expiresAt, userId]
);
};
const findByResetToken = async (token) => {
const [rows] = await db.execute(
'SELECT id FROM users WHERE reset_token = ? AND reset_token_expires > NOW()',
[token]
);
return rows[0] || null;
};
const updatePassword = async (userId, passwordHash) => {
await db.execute(
'UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?',
[passwordHash, userId]
);
};
module.exports = {
findByEmail,
findById,
findAuthUserById,
createUser,
verifyEmail,
updateVerificationToken,
saveRefreshToken,
findRefreshToken,
deleteRefreshToken,
deleteUserRefreshTokens,
setResetToken,
findByResetToken,
updatePassword,
};
+17
View File
@@ -0,0 +1,17 @@
const express = require('express');
const authController = require('./auth.controller');
const { validate, registerSchema, loginSchema, refreshSchema, changePasswordSchema } = require('./auth.validator');
const authenticate = require('../../middlewares/authenticate');
const router = express.Router();
router.post('/register', validate(registerSchema), authController.register);
router.post('/login', validate(loginSchema), authController.login);
router.post('/refresh', validate(refreshSchema), authController.refresh);
router.post('/logout', authController.logout);
router.get('/verify/:token', authController.verifyEmail);
router.get('/confirm-password-change/:token', authController.confirmPasswordChange);
router.get('/me', authenticate, authController.me);
router.patch('/change-password', authenticate, validate(changePasswordSchema), authController.changePassword);
module.exports = router;
+286
View File
@@ -0,0 +1,286 @@
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');
const authRepository = require('./auth.repository');
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 { signAccess, signRefresh } = require('../../utils/jwt');
const SALT_ROUNDS = 12;
const buildDisplayNameFromEmail = (email) => {
const localPart = String(email).split('@')[0] || 'Usuario';
const normalized = localPart
.replace(/[._-]+/g, ' ')
.trim()
.slice(0, 100);
return normalized || 'Usuario';
};
const issueVerificationEmail = async (user) => {
const verificationToken = uuidv4();
const verificationTokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000);
await authRepository.updateVerificationToken(user.id, verificationToken, verificationTokenExpires);
const mailResult = await mailService.sendVerificationEmail({
name: user.name || buildDisplayNameFromEmail(user.email),
email: user.email,
verificationToken,
});
return {
verificationToken,
verificationUrl: mailResult.verificationUrl,
};
};
const register = async ({ email, password }) => {
const existing = await authRepository.findByEmail(email);
if (existing) {
const err = new Error('Email already registered.');
err.statusCode = 409;
throw err;
}
const name = buildDisplayNameFromEmail(email);
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
const verificationToken = uuidv4();
const verificationTokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000);
const userId = await authRepository.createUser({
name,
email,
passwordHash,
verificationToken,
verificationTokenExpires,
});
let mailResult;
try {
mailResult = await mailService.sendVerificationEmail({
name,
email,
verificationToken,
});
} 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';
throw err;
}
await auditService.logEvent({
type: 'USER_REGISTERED',
userId,
email,
metadata: {
verificationEmailSent: true,
},
});
return { userId, verificationToken, verificationUrl: mailResult.verificationUrl };
};
const login = async ({ email, password }, context = {}) => {
const user = await authRepository.findByEmail(email.trim().toLowerCase());
if (!user || !user.is_active) {
await auditService.logEvent({ type: 'LOGIN_FAILED', email, ip: context.ip, userAgent: context.userAgent });
const err = new Error('Invalid credentials.');
err.statusCode = 401;
throw err;
}
const passwordMatch = await bcrypt.compare(password, user.password_hash);
if (!passwordMatch) {
await auditService.logEvent({ type: 'LOGIN_FAILED', userId: user.id, email, ip: context.ip, userAgent: context.userAgent });
const err = new Error('Invalid credentials.');
err.statusCode = 401;
throw err;
}
if (!user.is_verified) {
let verificationUrl = null;
try {
({ verificationUrl } = await issueVerificationEmail(user));
} catch (error) {
const err = new Error('Your email is not verified and the verification email could not be resent. Please contact support.');
err.statusCode = 503;
err.code = 'MAIL_SEND_FAILED';
throw err;
}
await auditService.logEvent({
type: 'VERIFICATION_EMAIL_RESENT',
userId: user.id,
email: user.email,
ip: context.ip,
userAgent: context.userAgent,
metadata: { source: 'login' },
});
const err = new Error('Your email is not verified yet. A new verification link has been sent to your email.');
err.statusCode = 403;
err.code = 'EMAIL_NOT_VERIFIED';
err.data = { verificationUrl };
throw err;
}
const payload = { sub: user.id, email: user.email, role: user.role, name: user.name };
const accessToken = signAccess(payload);
const refreshToken = signRefresh(payload);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await authRepository.saveRefreshToken(user.id, refreshToken, expiresAt);
await auditService.logEvent({ type: 'LOGIN_SUCCESS', userId: user.id, email, ip: context.ip, userAgent: context.userAgent });
const signaturePreferences = await usersService.getSignaturePreferences(user.id);
return {
accessToken,
refreshToken,
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
signaturePreferences,
},
};
};
const refresh = async (token) => {
const record = await authRepository.findRefreshToken(token);
if (!record) {
const err = new Error('Invalid or expired refresh token.');
err.statusCode = 401;
throw err;
}
if (!record.is_active) {
await authRepository.deleteUserRefreshTokens(record.user_id);
const err = new Error('Session invalidated.');
err.statusCode = 401;
throw err;
}
await authRepository.deleteRefreshToken(token);
const payload = { sub: record.uid, email: record.email, role: record.role };
const accessToken = signAccess(payload);
const newRefreshToken = signRefresh(payload);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await authRepository.saveRefreshToken(record.uid, newRefreshToken, expiresAt);
return { accessToken, refreshToken: newRefreshToken };
};
const logout = async (token) => {
await authRepository.deleteRefreshToken(token);
};
const changePassword = async (userId, { currentPassword, newPassword }, context = {}) => {
const user = await authRepository.findAuthUserById(userId);
if (!user || !user.is_active) {
const err = new Error('User not found.');
err.statusCode = 404;
throw err;
}
const passwordMatch = await bcrypt.compare(currentPassword, user.password_hash);
if (!passwordMatch) {
const err = new Error('Current password is incorrect.');
err.statusCode = 401;
throw err;
}
const isSamePassword = await bcrypt.compare(newPassword, user.password_hash);
if (isSamePassword) {
const err = new Error('The new password must be different from the current password.');
err.statusCode = 400;
throw err;
}
const passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS);
const confirmationToken = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
await passwordChangeRepository.saveRequest(confirmationToken, {
userId,
email: user.email,
passwordHash,
expiresAt: expiresAt.toISOString(),
});
await mailService.sendPasswordChangeConfirmationEmail({
name: user.name || buildDisplayNameFromEmail(user.email),
email: user.email,
confirmationToken,
});
await auditService.logEvent({
type: 'PASSWORD_CHANGE_REQUESTED',
userId,
email: user.email,
ip: context.ip,
userAgent: context.userAgent,
});
return { confirmationToken };
};
const confirmPasswordChange = async (token) => {
const request = await passwordChangeRepository.findRequest(token);
if (!request) {
const err = new Error('Invalid or expired password change token.');
err.statusCode = 400;
throw err;
}
if (new Date(request.expiresAt) < new Date()) {
await passwordChangeRepository.deleteRequest(token);
const err = new Error('Invalid or expired password change token.');
err.statusCode = 400;
throw err;
}
const user = await authRepository.findAuthUserById(request.userId);
if (!user || !user.is_active) {
await passwordChangeRepository.deleteRequest(token);
const err = new Error('User not found.');
err.statusCode = 404;
throw err;
}
await authRepository.updatePassword(request.userId, request.passwordHash);
await passwordChangeRepository.deleteRequest(token);
await auditService.logEvent({
type: 'PASSWORD_CHANGED',
userId: user.id,
email: user.email,
});
try {
await mailService.sendPasswordChangedEmail({
name: user.name || buildDisplayNameFromEmail(user.email),
email: user.email,
changedAt: new Date(),
});
} catch (error) {
console.error('[PASSWORD_CHANGED_EMAIL_FAILED]', error.message);
}
};
const verifyEmail = async (token) => {
const userId = await authRepository.verifyEmail(token);
if (!userId) {
const err = new Error('Invalid or expired verification token.');
err.statusCode = 400;
throw err;
}
return userId;
};
module.exports = { register, login, refresh, logout, verifyEmail, changePassword, confirmPasswordChange };
@@ -0,0 +1,48 @@
const Joi = require('joi');
const isStrongPassword = (value, helpers) => {
const rules = [
{ test: value.length >= 8, message: 'at least 8 characters' },
{ test: /[A-Z]/.test(value), message: 'one uppercase letter' },
{ test: /[a-z]/.test(value), message: 'one lowercase letter' },
{ test: /\d/.test(value), message: 'one number' },
{ test: /[^A-Za-z0-9]/.test(value), message: 'one special character' },
];
const missing = rules.filter((rule) => !rule.test).map((rule) => rule.message);
if (missing.length > 0) {
return helpers.message(`Password must include ${missing.join(', ')}.`);
}
return value;
};
const registerSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().max(100).required().custom(isStrongPassword),
});
const loginSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().required(),
});
const refreshSchema = Joi.object({
refreshToken: Joi.string().required(),
});
const changePasswordSchema = Joi.object({
currentPassword: Joi.string().required(),
newPassword: Joi.string().max(100).required().custom(isStrongPassword),
});
const validate = (schema) => (req, res, next) => {
const { error } = schema.validate(req.body, { abortEarly: false });
if (error) {
const messages = error.details.map((d) => d.message);
return res.status(400).json({ success: false, data: null, error: messages.join('; ') });
}
next();
};
module.exports = { validate, registerSchema, loginSchema, refreshSchema, changePasswordSchema };
@@ -0,0 +1,43 @@
const fs = require('fs/promises');
const path = require('path');
const storageDir = path.resolve(process.cwd(), 'storage', 'auth');
const storageFile = path.join(storageDir, 'password-change-requests.json');
const readRequests = async () => {
try {
const raw = await fs.readFile(storageFile, 'utf8');
return JSON.parse(raw);
} catch (error) {
return {};
}
};
const writeRequests = async (data) => {
await fs.mkdir(storageDir, { recursive: true });
await fs.writeFile(storageFile, JSON.stringify(data, null, 2), 'utf8');
};
const saveRequest = async (token, data) => {
const current = await readRequests();
current[token] = data;
await writeRequests(current);
};
const findRequest = async (token) => {
const current = await readRequests();
return current[token] || null;
};
const deleteRequest = async (token) => {
const current = await readRequests();
if (!current[token]) return;
delete current[token];
await writeRequests(current);
};
module.exports = {
saveRequest,
findRequest,
deleteRequest,
};
@@ -0,0 +1,36 @@
const certificatesService = require('./certificates.service');
const { sendSuccess } = require('../../utils/apiResponse');
const getCtx = (req) => ({ ip: req.ip?.replace('::ffff:', ''), userAgent: req.headers['user-agent'] });
const upload = async (req, res, next) => {
try {
if (!req.file) { const e = new Error('No file uploaded.'); e.statusCode = 400; throw e; }
const { password, alias } = req.body;
const cert = await certificatesService.upload(req.file, password || '', req.user.id, alias, getCtx(req));
return sendSuccess(res, cert, 201);
} catch (err) { return next(err); }
};
const list = async (req, res, next) => {
try {
const certs = await certificatesService.list(req.user.id);
return sendSuccess(res, certs, 200);
} catch (err) { return next(err); }
};
const getOne = async (req, res, next) => {
try {
const cert = await certificatesService.getOne(Number(req.params.id), req.user.id);
return sendSuccess(res, cert, 200);
} catch (err) { return next(err); }
};
const remove = async (req, res, next) => {
try {
await certificatesService.remove(Number(req.params.id), req.user.id, getCtx(req));
return sendSuccess(res, { message: 'Certificate deleted.' }, 200);
} catch (err) { return next(err); }
};
module.exports = { upload, list, getOne, remove };
@@ -0,0 +1,78 @@
const db = require('../../config/db');
const fs = require('fs/promises');
const path = require('path');
const chainMetadataDir = path.resolve(process.cwd(), 'storage', 'certificates', 'metadata');
const chainMetadataFile = path.join(chainMetadataDir, 'chain-status.json');
const readChainMetadata = async () => {
try {
const raw = await fs.readFile(chainMetadataFile, 'utf8');
return JSON.parse(raw);
} catch {
return {};
}
};
const writeChainMetadata = async (data) => {
await fs.mkdir(chainMetadataDir, { recursive: true });
await fs.writeFile(chainMetadataFile, JSON.stringify(data, null, 2), 'utf8');
};
const create = async ({ userId, alias, filename, storagePath, subjectCn, issuerCn, validFrom, validTo, serialNumber }) => {
const [result] = await db.execute(
`INSERT INTO certificates (user_id, alias, filename, storage_path, subject_cn, issuer_cn, valid_from, valid_to, serial_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[userId, alias, filename, storagePath, subjectCn || null, issuerCn || null, validFrom || null, validTo || null, serialNumber || null]
);
return result.insertId;
};
const findByUser = async (userId) => {
const [rows] = await db.execute(
'SELECT id, user_id, alias, filename, subject_cn, issuer_cn, valid_from, valid_to, serial_number, is_active, created_at FROM certificates WHERE user_id = ? ORDER BY created_at DESC',
[userId]
);
return rows;
};
const findById = async (id) => {
const [rows] = await db.execute('SELECT * FROM certificates WHERE id = ?', [id]);
return rows[0] || null;
};
const deactivate = async (id) => {
await db.execute('UPDATE certificates SET is_active = 0 WHERE id = ?', [id]);
};
const remove = async (id) => {
await db.execute('DELETE FROM certificates WHERE id = ?', [id]);
};
const saveChainStatus = async (certId, metadata) => {
const current = await readChainMetadata();
current[String(certId)] = metadata;
await writeChainMetadata(current);
};
const getChainStatus = async (certId) => {
const current = await readChainMetadata();
return current[String(certId)] || null;
};
const removeChainStatus = async (certId) => {
const current = await readChainMetadata();
delete current[String(certId)];
await writeChainMetadata(current);
};
module.exports = {
create,
findByUser,
findById,
deactivate,
remove,
saveChainStatus,
getChainStatus,
removeChainStatus,
};
@@ -0,0 +1,15 @@
const express = require('express');
const { uploadCertificate } = require('../../config/multer');
const authenticate = require('../../middlewares/authenticate');
const certificatesController = require('./certificates.controller');
const router = express.Router();
router.use(authenticate);
router.post('/', uploadCertificate.single('file'), certificatesController.upload);
router.get('/', certificatesController.list);
router.get('/:id', certificatesController.getOne);
router.delete('/:id', certificatesController.remove);
module.exports = router;
@@ -0,0 +1,168 @@
const fs = require('fs/promises');
const forge = require('node-forge');
const certificatesRepository = require('./certificates.repository');
const auditService = require('../audit/audit.service');
const makeError = (msg, status, code) => {
const e = new Error(msg);
e.statusCode = status;
if (code) e.code = code;
return e;
};
const tryParsePkcs12 = (pfxBuffer, password) => {
const p12Der = forge.util.createBuffer(pfxBuffer.toString('binary'));
const p12Asn1 = forge.asn1.fromDer(p12Der);
return forge.pkcs12.pkcs12FromAsn1(p12Asn1, password || '');
};
const parseCertificate = (pfxBuffer, password) => {
let p12;
try {
p12 = tryParsePkcs12(pfxBuffer, password);
} catch (err) {
const msg = err.message || '';
if (msg.includes('MAC') || msg.includes('password') || msg.includes('integrity')) {
throw makeError('Contraseña incorrecta o requerida para este certificado.', 422, 'PASSWORD_REQUIRED');
}
throw makeError('No se pudo leer el archivo P12. Verifica que sea un certificado válido.', 400);
}
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
const certificates = certBags[forge.pki.oids.certBag] || [];
const cert = certificates[0]?.cert;
if (!cert) throw makeError('El archivo P12 no contiene un certificado válido.', 400);
const keyBags = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
const privateKey = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]?.[0]?.key;
if (!privateKey) throw makeError('El archivo P12 no contiene una clave privada.', 400);
const now = new Date();
const validTo = cert.validity.notAfter;
const validFrom = cert.validity.notBefore;
if (now < validFrom) {
throw makeError(`El certificado aún no es válido. Válido desde: ${validFrom.toLocaleDateString('es-EC')}.`, 400);
}
if (now > validTo) {
throw makeError(`El certificado está vencido desde el ${validTo.toLocaleDateString('es-EC')}.`, 400);
}
const getAttr = (subject, name) => subject.getField(name)?.value || null;
return {
subjectCn: getAttr(cert.subject, 'CN'),
issuerCn: getAttr(cert.issuer, 'CN'),
validFrom,
validTo,
serialNumber: cert.serialNumber,
certificateCount: certificates.length,
chainStatus: certificates.length > 1 ? 'complete' : 'leaf_only',
};
};
const upload = async (file, password, userId, alias, context = {}) => {
const buffer = await fs.readFile(file.path);
const info = parseCertificate(buffer, password); // throws with code if password needed
const certId = await certificatesRepository.create({
userId,
alias: alias || file.originalname,
filename: file.filename,
storagePath: file.path,
...info,
});
await certificatesRepository.saveChainStatus(certId, {
certificateCount: info.certificateCount,
chainStatus: info.chainStatus,
});
await auditService.logEvent({
type: 'CERTIFICATE_UPLOAD',
userId,
ip: context.ip,
metadata: {
certId,
alias,
subject: info.subjectCn,
certificateCount: info.certificateCount,
chainStatus: info.chainStatus,
},
});
const cert = await certificatesRepository.findById(certId);
const { storage_path, ...safeFields } = cert;
return {
...safeFields,
certificate_count: info.certificateCount,
chain_status: info.chainStatus,
};
};
const list = async (userId) => {
const certs = await certificatesRepository.findByUser(userId);
return Promise.all(
certs.map(async (cert) => {
const chain = await certificatesRepository.getChainStatus(cert.id);
return {
...cert,
certificate_count: chain?.certificateCount || null,
chain_status: chain?.chainStatus || null,
};
})
);
};
const getOne = async (id, userId) => {
const cert = await certificatesRepository.findById(id);
if (!cert) throw makeError('Certificate not found.', 404);
if (cert.user_id !== userId) throw makeError('Access denied.', 403);
const { storage_path, ...safeFields } = cert;
const chain = await certificatesRepository.getChainStatus(id);
return {
...safeFields,
certificate_count: chain?.certificateCount || null,
chain_status: chain?.chainStatus || null,
};
};
const remove = async (id, userId, context = {}) => {
const cert = await certificatesRepository.findById(id);
if (!cert) throw makeError('Certificate not found.', 404);
if (cert.user_id !== userId) throw makeError('Access denied.', 403);
try { await fs.unlink(cert.storage_path); } catch { /* already gone */ }
await certificatesRepository.remove(id);
await certificatesRepository.removeChainStatus(id);
await auditService.logEvent({ type: 'CERTIFICATE_DELETE', userId, ip: context.ip, metadata: { certId: id } });
};
const loadForSigning = async (certId, userId, password) => {
const cert = await certificatesRepository.findById(certId);
if (!cert) throw makeError('Certificate not found.', 404);
if (cert.user_id !== userId) throw makeError('Access denied.', 403);
if (cert.valid_to && new Date() > new Date(cert.valid_to)) {
throw makeError(`El certificado "${cert.alias}" está vencido desde el ${new Date(cert.valid_to).toLocaleDateString('es-EC')}.`, 400);
}
const buffer = await fs.readFile(cert.storage_path);
let p12;
try {
p12 = tryParsePkcs12(buffer, password);
} catch {
throw makeError('Contraseña del certificado incorrecta o requerida.', 422, 'CERT_PASSWORD_REQUIRED');
}
const keyBags = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
const privateKey = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]?.[0]?.key;
const certificate = certBags[forge.pki.oids.certBag]?.[0]?.cert;
if (!privateKey || !certificate) {
throw makeError('No se pudo extraer la clave del certificado. Verifica la contraseña.', 400, 'CERT_PASSWORD_REQUIRED');
}
return { privateKey, certificate };
};
module.exports = { upload, list, getOne, remove, loadForSigning };
@@ -0,0 +1,44 @@
const documentsService = require('./documents.service');
const { sendSuccess } = require('../../utils/apiResponse');
const getCtx = (req) => ({ ip: req.ip?.replace('::ffff:', ''), userAgent: req.headers['user-agent'] });
const upload = async (req, res, next) => {
try {
if (!req.file) {
const err = new Error('No file uploaded.'); err.statusCode = 400; throw err;
}
const doc = await documentsService.upload(req.file, req.user.id, getCtx(req));
return sendSuccess(res, doc, 201);
} catch (err) { return next(err); }
};
const list = async (req, res, next) => {
try {
const result = await documentsService.list(req.user.id, req.query);
return sendSuccess(res, result, 200);
} catch (err) { return next(err); }
};
const getOne = async (req, res, next) => {
try {
const doc = await documentsService.getOne(Number(req.params.id), req.user.id);
return sendSuccess(res, doc, 200);
} catch (err) { return next(err); }
};
const download = async (req, res, next) => {
try {
const doc = await documentsService.getOne(Number(req.params.id), req.user.id);
res.download(doc.storage_path, doc.filename);
} catch (err) { return next(err); }
};
const remove = async (req, res, next) => {
try {
await documentsService.remove(Number(req.params.id), req.user.id, getCtx(req));
return sendSuccess(res, { message: 'Document deleted.' }, 200);
} catch (err) { return next(err); }
};
module.exports = { upload, list, getOne, download, remove };
@@ -0,0 +1,40 @@
const db = require('../../config/db');
const create = async ({ userId, title, filename, storagePath, fileSize, hashSha256 }) => {
const [result] = await db.execute(
'INSERT INTO documents (user_id, title, filename, storage_path, file_size, hash_sha256) VALUES (?, ?, ?, ?, ?, ?)',
[userId, title, filename, storagePath, fileSize, hashSha256 || null]
);
return result.insertId;
};
const findById = async (id) => {
const [rows] = await db.execute(
'SELECT d.*, u.name as owner_name, u.email as owner_email FROM documents d JOIN users u ON d.user_id = u.id WHERE d.id = ?',
[id]
);
return rows[0] || null;
};
const findByUser = async (userId, { limit = 50, offset = 0 } = {}) => {
const [rows] = await db.execute(
'SELECT * FROM documents WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?',
[userId, limit, offset]
);
return rows;
};
const countByUser = async (userId) => {
const [rows] = await db.execute('SELECT COUNT(*) as total FROM documents WHERE user_id = ?', [userId]);
return rows[0].total;
};
const updateStatus = async (id, status) => {
await db.execute('UPDATE documents SET status = ? WHERE id = ?', [status, id]);
};
const remove = async (id) => {
await db.execute('DELETE FROM documents WHERE id = ?', [id]);
};
module.exports = { create, findById, findByUser, countByUser, updateStatus, remove };
@@ -0,0 +1,16 @@
const express = require('express');
const { uploadDocument } = require('../../config/multer');
const authenticate = require('../../middlewares/authenticate');
const documentsController = require('./documents.controller');
const router = express.Router();
router.use(authenticate);
router.post('/', uploadDocument.single('file'), documentsController.upload);
router.get('/', documentsController.list);
router.get('/:id', documentsController.getOne);
router.get('/:id/download', documentsController.download);
router.delete('/:id', documentsController.remove);
module.exports = router;
@@ -0,0 +1,59 @@
const fs = require('fs/promises');
const crypto = require('crypto');
const documentsRepository = require('./documents.repository');
const auditService = require('../audit/audit.service');
const upload = async (file, userId, context = {}) => {
const buffer = await fs.readFile(file.path);
const hash = crypto.createHash('sha256').update(buffer).digest('hex');
const title = file.originalname.replace(/\.pdf$/i, '');
const docId = await documentsRepository.create({
userId,
title,
filename: file.filename,
storagePath: file.path,
fileSize: file.size,
hashSha256: hash,
});
await auditService.logEvent({
type: 'DOCUMENT_UPLOAD',
userId,
ip: context.ip,
userAgent: context.userAgent,
metadata: { docId, filename: file.originalname, size: file.size },
});
return documentsRepository.findById(docId);
};
const list = async (userId, query = {}) => {
const limit = Math.min(Number(query.limit) || 20, 100);
const offset = Number(query.offset) || 0;
const [rows, total] = await Promise.all([
documentsRepository.findByUser(userId, { limit, offset }),
documentsRepository.countByUser(userId),
]);
return { rows, total, limit, offset };
};
const getOne = async (id, userId) => {
const doc = await documentsRepository.findById(id);
if (!doc) {
const err = new Error('Document not found.'); err.statusCode = 404; throw err;
}
if (doc.user_id !== userId) {
const err = new Error('Access denied.'); err.statusCode = 403; throw err;
}
return doc;
};
const remove = async (id, userId, context = {}) => {
const doc = await getOne(id, userId);
try { await fs.unlink(doc.storage_path); } catch { /* already deleted */ }
await documentsRepository.remove(id);
await auditService.logEvent({ type: 'DOCUMENT_DELETE', userId, ip: context.ip, metadata: { docId: id } });
};
module.exports = { upload, list, getOne, remove };
@@ -0,0 +1,15 @@
const healthService = require('./health.service');
const { sendSuccess } = require('../../utils/apiResponse');
const getHealth = async (req, res, next) => {
try {
const result = await healthService.getHealth();
return sendSuccess(res, result);
} catch (error) {
return next(error);
}
};
module.exports = {
getHealth,
};
@@ -0,0 +1,9 @@
const getStatus = async () => {
return {
status: 'ok',
};
};
module.exports = {
getStatus,
};
@@ -0,0 +1,8 @@
const express = require('express');
const healthController = require('./health.controller');
const router = express.Router();
router.get('/', healthController.getHealth);
module.exports = router;
@@ -0,0 +1,14 @@
const healthRepository = require('./health.repository');
const getHealth = async () => {
const baseStatus = await healthRepository.getStatus();
return {
...baseStatus,
timestamp: new Date().toISOString(),
};
};
module.exports = {
getHealth,
};
@@ -0,0 +1,49 @@
const nodemailer = require('nodemailer');
const env = require('../../config/env');
let transporter;
const getTransporter = () => {
if (transporter) return transporter;
if (env.mail.user && env.mail.pass) {
transporter = nodemailer.createTransport({
host: env.mail.host,
port: env.mail.port,
secure: env.mail.secure,
auth: {
user: env.mail.user,
pass: env.mail.pass,
},
tls: {
rejectUnauthorized: env.mail.rejectUnauthorized,
},
});
return transporter;
}
transporter = nodemailer.createTransport({
streamTransport: true,
newline: 'unix',
buffer: true,
});
return transporter;
};
const sendMail = async ({ to, subject, html, text }) => {
const activeTransporter = getTransporter();
const info = await activeTransporter.sendMail({
from: env.mail.from,
to,
subject,
html,
text,
});
return info;
};
module.exports = {
sendMail,
};
+128
View File
@@ -0,0 +1,128 @@
const env = require('../../config/env');
const mailRepository = require('./mail.repository');
const sendVerificationEmail = async ({ name, email, verificationToken }) => {
const verificationUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/verify-email/${verificationToken}`;
const subject = 'Confirma tu cuenta de LinkedSign';
const text = [
`Hola ${name},`,
'',
'Confirma tu cuenta de LinkedSign usando este enlace:',
verificationUrl,
'',
'Si no solicitaste esta cuenta, puedes ignorar este mensaje.',
].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>
<p>Hola ${name},</p>
<p>Para activar tu cuenta, confirma tu correo electrónico haciendo clic en el siguiente botón:</p>
<p style="margin: 24px 0;">
<a href="${verificationUrl}" style="background: #4f46e5; color: #ffffff; text-decoration: none; padding: 12px 18px; border-radius: 8px; display: inline-block; font-weight: 600;">
Confirmar cuenta
</a>
</p>
<p>Si el botón no funciona, copia y pega este enlace en tu navegador:</p>
<p><a href="${verificationUrl}">${verificationUrl}</a></p>
<p>Si no solicitaste esta cuenta, puedes ignorar este mensaje.</p>
</div>
`;
const info = await mailRepository.sendMail({
to: email,
subject,
html,
text,
});
if (!env.mail.user || !env.mail.pass) {
console.log('[mail] SMTP no configurado. Enlace de verificación:');
console.log(verificationUrl);
}
return {
verificationUrl,
messageId: info.messageId || null,
};
};
const sendPasswordChangedEmail = async ({ name, email, changedAt }) => {
const subject = 'Tu contraseña de LinkedSign fue actualizada';
const formattedDate = changedAt.toLocaleString('es-EC');
const text = [
`Hola ${name},`,
'',
'Te confirmamos que la contraseña de tu cuenta de LinkedSign fue actualizada.',
`Fecha: ${formattedDate}`,
'',
'Si no realizaste este cambio, contacta soporte inmediatamente.',
].join('\n');
const html = `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
<h2 style="margin-bottom: 12px;">Contraseña actualizada</h2>
<p>Hola ${name},</p>
<p>Te confirmamos que la contraseña de tu cuenta de LinkedSign fue actualizada.</p>
<p><strong>Fecha:</strong> ${formattedDate}</p>
<p>Si no realizaste este cambio, contacta soporte inmediatamente.</p>
</div>
`;
const info = await mailRepository.sendMail({
to: email,
subject,
html,
text,
});
return {
messageId: info.messageId || null,
};
};
const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationToken }) => {
const confirmationUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/confirm-password-change/${confirmationToken}`;
const subject = 'Confirma el cambio de contraseña de LinkedSign';
const text = [
`Hola ${name},`,
'',
'Recibimos una solicitud para cambiar la contraseña de tu cuenta.',
'Para aplicar el cambio, confirma la solicitud usando este enlace:',
confirmationUrl,
'',
'Si no realizaste esta solicitud, ignora este mensaje.',
].join('\n');
const html = `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
<h2 style="margin-bottom: 12px;">Confirma el cambio de contraseña</h2>
<p>Hola ${name},</p>
<p>Recibimos una solicitud para cambiar la contraseña de tu cuenta de LinkedSign.</p>
<p>Para aplicar el cambio, confirma la solicitud usando este botón:</p>
<p style="margin: 24px 0;">
<a href="${confirmationUrl}" style="background: #4f46e5; color: #ffffff; text-decoration: none; padding: 12px 18px; border-radius: 8px; display: inline-block; font-weight: 600;">
Confirmar cambio de contraseña
</a>
</p>
<p>Si el botón no funciona, copia y pega este enlace en tu navegador:</p>
<p><a href="${confirmationUrl}">${confirmationUrl}</a></p>
<p>Si no realizaste esta solicitud, ignora este mensaje.</p>
</div>
`;
const info = await mailRepository.sendMail({
to: email,
subject,
html,
text,
});
return {
confirmationUrl,
messageId: info.messageId || null,
};
};
module.exports = {
sendVerificationEmail,
sendPasswordChangedEmail,
sendPasswordChangeConfirmationEmail,
};
@@ -0,0 +1,67 @@
const signaturesService = require('./signatures.service');
const signaturesRepository = require('./signatures.repository');
const { sendSuccess } = require('../../utils/apiResponse');
const getCtx = (req) => ({
ip: req.ip?.replace('::ffff:', ''),
userAgent: req.headers['user-agent'],
userName: req.user?.name || req.user?.email,
userEmail: req.user?.email || null,
frontendUrl: req.headers.origin || null,
});
const sign = async (req, res, next) => {
try {
const { documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 } = req.body;
const docId = Number(documentId);
const certId = Number(certificateId);
if (!docId || !certId || isNaN(docId) || isNaN(certId)) {
const e = new Error('documentId and certificateId are required.'); e.statusCode = 400; throw e;
}
const result = await signaturesService.sign(
{ documentId: docId, certificateId: certId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 },
req.user.id,
getCtx(req)
);
return sendSuccess(res, result, 200);
} catch (err) { return next(err); }
};
const getSignatures = async (req, res, next) => {
try {
const sigs = await signaturesService.getSignatures(Number(req.params.documentId), req.user.id);
return sendSuccess(res, sigs, 200);
} catch (err) { return next(err); }
};
const listSignedDocuments = async (req, res, next) => {
try {
const rows = await signaturesService.listSignedDocuments(req.user.id);
return sendSuccess(res, rows, 200);
} catch (err) { return next(err); }
};
const verify = async (req, res, next) => {
try {
const result = await signaturesService.verifySignature(req.params.code);
return sendSuccess(res, result, 200);
} catch (err) { return next(err); }
};
const download = async (req, res, next) => {
try {
const sig = await signaturesRepository.findById(Number(req.params.id));
if (!sig) { const e = new Error('Signature not found.'); e.statusCode = 404; throw e; }
if (sig.user_id !== req.user.id) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; }
res.download(sig.storage_path, sig.signed_filename);
} catch (err) { return next(err); }
};
const remove = async (req, res, next) => {
try {
await signaturesService.removeSignedDocument(Number(req.params.id), req.user.id, getCtx(req));
return sendSuccess(res, { message: 'Signed document deleted.' }, 200);
} catch (err) { return next(err); }
};
module.exports = { sign, getSignatures, listSignedDocuments, verify, download, remove };
@@ -0,0 +1,114 @@
const db = require('../../config/db');
const fs = require('fs/promises');
const path = require('path');
const verificationDir = path.resolve(process.cwd(), 'storage', 'signatures');
const verificationFile = path.join(verificationDir, 'verification-codes.json');
const readVerificationMap = async () => {
try {
const raw = await fs.readFile(verificationFile, 'utf8');
return JSON.parse(raw);
} catch {
return {};
}
};
const writeVerificationMap = async (data) => {
await fs.mkdir(verificationDir, { recursive: true });
await fs.writeFile(verificationFile, JSON.stringify(data, null, 2), 'utf8');
};
const create = async ({ documentId, userId, certificateId, signedFilename, storagePath, reason, location }) => {
const [result] = await db.execute(
`INSERT INTO signatures (document_id, user_id, certificate_id, signed_filename, storage_path, reason, location)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[documentId, userId, certificateId, signedFilename, storagePath, reason || null, location || null]
);
return result.insertId;
};
const findByDocument = async (documentId) => {
const [rows] = await db.execute(
`SELECT s.*, u.name as signer_name, u.email as signer_email, c.alias as cert_alias
FROM signatures s
JOIN users u ON s.user_id = u.id
JOIN certificates c ON s.certificate_id = c.id
WHERE s.document_id = ?
ORDER BY s.signed_at ASC`,
[documentId]
);
return rows;
};
const findById = async (id) => {
const [rows] = await db.execute('SELECT * FROM signatures WHERE id = ?', [id]);
return rows[0] || null;
};
const findByUser = async (userId) => {
const [rows] = await db.execute(
`SELECT
s.id,
s.document_id,
s.user_id,
s.certificate_id,
s.signed_filename,
s.storage_path,
s.reason,
s.location,
s.signed_at,
d.title AS document_title,
d.filename AS original_filename,
c.alias AS certificate_alias
FROM signatures s
JOIN documents d ON s.document_id = d.id
JOIN certificates c ON s.certificate_id = c.id
WHERE s.user_id = ?
ORDER BY s.signed_at DESC, s.id DESC`,
[userId]
);
return rows;
};
const saveVerificationRecord = async (code, data) => {
const current = await readVerificationMap();
current[code] = data;
await writeVerificationMap(current);
};
const findVerificationRecord = async (code) => {
const current = await readVerificationMap();
return current[code] || null;
};
const deleteVerificationRecordBySignatureId = async (signatureId) => {
const current = await readVerificationMap();
let changed = false;
Object.keys(current).forEach((code) => {
if (current[code]?.signatureId === signatureId) {
delete current[code];
changed = true;
}
});
if (changed) {
await writeVerificationMap(current);
}
};
const remove = async (id) => {
await db.execute('DELETE FROM signatures WHERE id = ?', [id]);
};
module.exports = {
create,
findByDocument,
findById,
findByUser,
saveVerificationRecord,
findVerificationRecord,
deleteVerificationRecordBySignatureId,
remove,
};
@@ -0,0 +1,17 @@
const express = require('express');
const authenticate = require('../../middlewares/authenticate');
const signaturesController = require('./signatures.controller');
const router = express.Router();
router.get('/verify/:code', signaturesController.verify);
router.use(authenticate);
router.post('/sign', signaturesController.sign);
router.get('/mine', signaturesController.listSignedDocuments);
router.get('/document/:documentId', signaturesController.getSignatures);
router.get('/:id/download', signaturesController.download);
router.delete('/:id', signaturesController.remove);
module.exports = router;
@@ -0,0 +1,200 @@
const fs = require('fs/promises');
const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid');
const signaturesRepository = require('./signatures.repository');
const documentsRepository = require('../documents/documents.repository');
const certificatesRepository = require('../certificates/certificates.repository');
const certificatesService = require('../certificates/certificates.service');
const { signPdf } = require('../signing/signing.service');
const auditService = require('../audit/audit.service');
const env = require('../../config/env');
const sign = async ({ documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 }, userId, context = {}) => {
const doc = await documentsRepository.findById(documentId);
if (!doc) { const e = new Error('Document not found.'); e.statusCode = 404; throw e; }
if (doc.user_id !== userId) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; }
if (doc.status === 'signed') { const e = new Error('Document already signed.'); e.statusCode = 409; throw e; }
const cert = await certificatesRepository.findById(certificateId);
if (!cert) { const e = new Error('Certificate not found.'); e.statusCode = 404; throw e; }
if (cert.user_id !== userId) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; }
if (cert.valid_to && new Date() > new Date(cert.valid_to)) {
const e = new Error(`El certificado "${cert.alias}" está vencido.`); e.statusCode = 400; throw e;
}
// Load p12 buffer — throws CERT_PASSWORD_REQUIRED if wrong/missing password
// We need the raw buffer for signing; re-use loadForSigning just to validate password
await certificatesService.loadForSigning(certificateId, userId, certPassword);
const p12Buffer = await fs.readFile(cert.storage_path);
const signerName = cert.subject_cn || context.userName || 'Firmante';
const verificationCode = uuidv4();
const frontendBaseUrl = (context.frontendUrl || env.frontendUrl || '').replace(/\/$/, '');
const verificationUrl = `${frontendBaseUrl}/verify/${verificationCode}`;
let signedFilename, signedPath;
try {
({ signedFilename, signedPath } = await signPdf({
pdfPath: doc.storage_path,
p12Buffer,
p12Password: certPassword || '',
signerName,
reason: reason || 'Firmado digitalmente',
location: location || '',
page: page || 0,
rect: rect || null,
signatureStyle: signatureStyle || 'classic',
qrPosition: qrPosition || 'left',
qrBackground: qrBackground || 'transparent',
qrBorder: qrBorder || 'none',
signatureImageBase64: signatureImageBase64 || null,
verificationUrl,
}));
} catch (signErr) {
const msg = signErr.message || '';
// Re-map known signing errors
if (msg.includes('MAC') || msg.includes('password') || msg.includes('passphrase')) {
const e = new Error('Contraseña del certificado incorrecta.');
e.statusCode = 422; e.code = 'CERT_PASSWORD_REQUIRED'; throw e;
}
if (msg.includes('encrypt') || msg.includes('decrypt') || msg.includes('permission')) {
const e = new Error('El PDF está protegido y no puede firmarse.'); e.statusCode = 400; throw e;
}
// Unknown error — re-throw with detail
const e = new Error(`Error al firmar el documento: ${msg}`); e.statusCode = 500; throw e;
}
const sigId = await signaturesRepository.create({
documentId,
userId,
certificateId,
signedFilename,
storagePath: signedPath,
reason,
location,
});
const signedBuffer = await fs.readFile(signedPath);
const signedHashSha256 = crypto.createHash('sha256').update(signedBuffer).digest('hex');
await signaturesRepository.saveVerificationRecord(verificationCode, {
signatureId: sigId,
documentId,
certificateId,
userId,
verificationCode,
verificationUrl,
signerName,
signerEmail: context.userEmail || null,
certificateSubjectCn: cert.subject_cn || null,
certificateIssuerCn: cert.issuer_cn || null,
signedFilename,
signedPath,
signedHashSha256,
reason: reason || 'Firmado digitalmente',
location: location || '',
signedAt: new Date().toISOString(),
documentTitle: doc.title,
});
await documentsRepository.updateStatus(documentId, 'signed');
await auditService.logEvent({
type: 'DOCUMENT_SIGNED',
userId,
ip: context.ip,
metadata: { documentId, certificateId, sigId },
});
return { sigId, signedFilename, documentId, verificationCode, verificationUrl };
};
const getSignatures = async (documentId, userId) => {
const doc = await documentsRepository.findById(documentId);
if (!doc) { const e = new Error('Document not found.'); e.statusCode = 404; throw e; }
if (doc.user_id !== userId) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; }
return signaturesRepository.findByDocument(documentId);
};
const listSignedDocuments = async (userId) => {
return signaturesRepository.findByUser(userId);
};
const verifySignature = async (verificationCode) => {
const record = await signaturesRepository.findVerificationRecord(verificationCode);
if (!record) {
const error = new Error('Verification record not found.');
error.statusCode = 404;
throw error;
}
const signature = await signaturesRepository.findById(record.signatureId);
if (!signature) {
const error = new Error('Signature not found.');
error.statusCode = 404;
throw error;
}
const signedBuffer = await fs.readFile(record.signedPath);
const currentHashSha256 = crypto.createHash('sha256').update(signedBuffer).digest('hex');
const isValid = currentHashSha256 === record.signedHashSha256;
return {
isValid,
verificationCode: record.verificationCode,
verificationUrl: record.verificationUrl,
signerName: record.signerName,
signerEmail: record.signerEmail,
certificateSubjectCn: record.certificateSubjectCn,
certificateIssuerCn: record.certificateIssuerCn,
signedFilename: record.signedFilename,
documentTitle: record.documentTitle,
reason: record.reason,
location: record.location,
signedAt: record.signedAt,
integrity: {
expectedHashSha256: record.signedHashSha256,
currentHashSha256,
},
};
};
const removeSignedDocument = async (signatureId, userId, context = {}) => {
const signature = await signaturesRepository.findById(signatureId);
if (!signature) {
const error = new Error('Signature not found.');
error.statusCode = 404;
throw error;
}
if (signature.user_id !== userId) {
const error = new Error('Access denied.');
error.statusCode = 403;
throw error;
}
const document = await documentsRepository.findById(signature.document_id);
try { await fs.unlink(signature.storage_path); } catch {}
await signaturesRepository.deleteVerificationRecordBySignatureId(signatureId);
await signaturesRepository.remove(signatureId);
if (document && document.user_id === userId) {
try { await fs.unlink(document.storage_path); } catch {}
await documentsRepository.remove(document.id);
}
await auditService.logEvent({
type: 'SIGNED_DOCUMENT_DELETE',
userId,
ip: context.ip,
userAgent: context.userAgent,
metadata: {
signatureId,
documentId: signature.document_id,
},
});
};
module.exports = { sign, getSignatures, listSignedDocuments, verifySignature, removeSignedDocument };
@@ -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 };
@@ -0,0 +1,25 @@
const fs = require('fs/promises');
const path = require('path');
const { loadCertificate } = require('./signing.service');
const run = async () => {
try {
const certificatePath = process.argv[2];
const password = process.argv[3] || '';
if (!certificatePath) {
throw new Error('Certificate path is required.');
}
const filePath = path.resolve(certificatePath);
const pfxBuffer = await fs.readFile(filePath);
const result = await loadCertificate(pfxBuffer, password);
console.log('privateKey exists:', Boolean(result.privateKey));
console.log('certificate exists:', Boolean(result.certificate));
} catch (error) {
console.error('Error loading certificate:', error.message);
}
};
run();
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,15 @@
const usersService = require('./users.service');
const { sendSuccess } = require('../../utils/apiResponse');
const updateSignatureAppearancePreference = async (req, res, next) => {
try {
const signaturePreferences = await usersService.updateSignaturePreferences(req.user.id, req.body);
return sendSuccess(res, { signaturePreferences }, 200);
} catch (error) {
return next(error);
}
};
module.exports = {
updateSignatureAppearancePreference,
};
@@ -0,0 +1,66 @@
const fs = require('fs/promises');
const path = require('path');
const preferencesDir = path.resolve(process.cwd(), 'storage', 'preferences');
const preferencesFile = path.join(preferencesDir, 'signature-styles.json');
const normalizeSignaturePreferences = (value) => {
if (!value) {
return {
signatureStyle: 'qr',
qrPosition: 'left',
qrBackground: 'transparent',
qrBorder: 'none',
signatureImageBase64: null,
};
}
if (typeof value === 'string') {
return {
signatureStyle: value,
qrPosition: 'left',
qrBackground: 'transparent',
qrBorder: 'none',
signatureImageBase64: null,
};
}
return {
signatureStyle: value.signatureStyle || 'qr',
qrPosition: value.qrPosition || 'left',
qrBackground: value.qrBackground || 'transparent',
qrBorder: value.qrBorder || 'none',
signatureImageBase64: value.signatureImageBase64 || null,
};
};
const readPreferences = async () => {
try {
const raw = await fs.readFile(preferencesFile, 'utf8');
return JSON.parse(raw);
} catch (error) {
return {};
}
};
const writePreferences = async (data) => {
await fs.mkdir(preferencesDir, { recursive: true });
await fs.writeFile(preferencesFile, JSON.stringify(data, null, 2), 'utf8');
};
const getSignaturePreferences = async (userId) => {
const preferences = await readPreferences();
return normalizeSignaturePreferences(preferences[String(userId)]);
};
const setSignaturePreferences = async (userId, signaturePreferences) => {
const preferences = await readPreferences();
preferences[String(userId)] = normalizeSignaturePreferences(signaturePreferences);
await writePreferences(preferences);
return preferences[String(userId)];
};
module.exports = {
getSignaturePreferences,
setSignaturePreferences,
};
+11
View File
@@ -0,0 +1,11 @@
const express = require('express');
const authenticate = require('../../middlewares/authenticate');
const usersController = require('./users.controller');
const router = express.Router();
router.use(authenticate);
router.patch('/me/preferences/signature-appearance', usersController.updateSignatureAppearancePreference);
module.exports = router;
@@ -0,0 +1,49 @@
const usersRepository = require('./users.repository');
const ALLOWED_SIGNATURE_STYLES = ['classic', 'qr', 'professional', 'image'];
const ALLOWED_QR_POSITIONS = ['left', 'right'];
const ALLOWED_QR_BACKGROUNDS = ['transparent', 'soft'];
const ALLOWED_QR_BORDERS = ['none', 'line'];
const getSignaturePreferences = async (userId) => {
return usersRepository.getSignaturePreferences(userId);
};
const updateSignaturePreferences = async (userId, { signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 }) => {
if (!ALLOWED_SIGNATURE_STYLES.includes(signatureStyle)) {
const error = new Error('Invalid signature style.');
error.statusCode = 400;
throw error;
}
if (!ALLOWED_QR_POSITIONS.includes(qrPosition)) {
const error = new Error('Invalid QR position.');
error.statusCode = 400;
throw error;
}
if (!ALLOWED_QR_BACKGROUNDS.includes(qrBackground)) {
const error = new Error('Invalid QR background.');
error.statusCode = 400;
throw error;
}
if (!ALLOWED_QR_BORDERS.includes(qrBorder)) {
const error = new Error('Invalid QR border.');
error.statusCode = 400;
throw error;
}
return usersRepository.setSignaturePreferences(userId, {
signatureStyle,
qrPosition,
qrBackground,
qrBorder,
signatureImageBase64: signatureImageBase64 || null,
});
};
module.exports = {
getSignaturePreferences,
updateSignaturePreferences,
};