diff --git a/DEPLOY_ANDROID.md b/DEPLOY_ANDROID.md index f1df046..88a7a81 100644 --- a/DEPLOY_ANDROID.md +++ b/DEPLOY_ANDROID.md @@ -43,3 +43,8 @@ - En producción no debe quedar `android/` fuente; solo `/opt/apps/linkedsign/android/apk/LinkedSign-release.apk`. - En cada paso a producción Android se publica únicamente el `APK release`; no se sube el código fuente Android. - El `APK release` que se publique debe corresponder a una versión nueva y anotada en `VERSION` y `CHANGELOG.md`. +- El paso Android no debe tocar ni reemplazar: + - `/opt/apps/linkedsign/backend/.env` + - `/opt/apps/linkedsign/storage/` + - `/opt/apps/linkedsign/logs/` + - configuración runtime hardened ya existente en producción diff --git a/DEPLOY_WEB.md b/DEPLOY_WEB.md index 2686463..ff604fa 100644 --- a/DEPLOY_WEB.md +++ b/DEPLOY_WEB.md @@ -27,31 +27,30 @@ - `git fetch origin main` - `git reset --hard origin/main` - `rm -rf /opt/apps/linkedsign/source/android /opt/apps/linkedsign/source/android_react /opt/apps/linkedsign/source/android_firma_electronica.html` -8. Recompila frontend: - - `cd /opt/apps/linkedsign/source/frontend` - - `npm ci` - - `npm run build` -9. Publica frontend: - - `rm -rf /opt/apps/linkedsign/frontend/*` - - `cp -r dist /opt/apps/linkedsign/frontend/` - - `cp server.js /opt/apps/linkedsign/frontend/` -10. Publica backend: - - `rm -rf /opt/apps/linkedsign/backend/*` - - `cp -r /opt/apps/linkedsign/source/backend/* /opt/apps/linkedsign/backend/` - - `cd /opt/apps/linkedsign/backend` - - `npm ci --omit=dev` -11. Reinicia servicios: +8. En producción usa el script seguro: + - `cd /opt/apps/linkedsign/source` + - `bash scripts/deploy_prod_web.sh` +9. Reinicia servicios: - `sudo systemctl restart linkedsign-frontend` - `sudo systemctl restart linkedsign-backend` -12. Limpia archivos `.md` en producción: +10. Limpia archivos `.md` en producción: - `find /opt/apps/linkedsign -type f -iname '*.md' -delete` -13. Verifica: +11. Verifica: - `curl -I http://127.0.0.1:3000` - `curl http://127.0.0.1:4000/health` ## Notas -- Si cambias variables de entorno, actualiza `/opt/apps/linkedsign/backend/.env`. +- Nunca copies `.env` desde `dev` a `prod`. +- El archivo runtime de producción es: + - `/opt/apps/linkedsign/backend/.env` +- Si cambias variables de entorno, actualiza solo ese archivo runtime. - Si cambias rutas del APK o storage, revisa también `PRODUCCION.md`. - En producción no debe quedar código fuente Android; solo el APK release en `/opt/apps/linkedsign/android/apk/`. - Si en el despliegue hay cambios Android, recuerda que a producción solo pasa el `APK release` versionado; no el proyecto Android completo. +- El deploy web no debe tocar ni reemplazar: + - `/opt/apps/linkedsign/backend/.env` + - `/opt/apps/linkedsign/storage/` + - `/opt/apps/linkedsign/logs/` + - `/opt/apps/linkedsign/android/apk/` + - permisos hardened de producción diff --git a/backend/src/modules/certificates/certificates.service.js b/backend/src/modules/certificates/certificates.service.js index 2f8cd2b..c9f77f0 100644 --- a/backend/src/modules/certificates/certificates.service.js +++ b/backend/src/modules/certificates/certificates.service.js @@ -167,4 +167,12 @@ const loadForSigning = async (certId, userId, password) => { return { privateKey, certificate }; }; -module.exports = { upload, list, getOne, remove, loadForSigning, getLatestActiveForUser }; +module.exports = { + upload, + list, + getOne, + remove, + loadForSigning, + getLatestActiveForUser, + parseCertificate, +}; diff --git a/backend/src/modules/invitations/invitations.controller.js b/backend/src/modules/invitations/invitations.controller.js new file mode 100644 index 0000000..e7f1574 --- /dev/null +++ b/backend/src/modules/invitations/invitations.controller.js @@ -0,0 +1,105 @@ +const invitationsService = require('./invitations.service'); +const { sendSuccess } = require('../../utils/apiResponse'); + +const getCtx = (req) => ({ + ip: req.ip?.replace('::ffff:', ''), + userAgent: req.headers['user-agent'], + userEmail: req.user?.email || null, +}); + +const createFlow = async (req, res, next) => { + try { + const result = await invitationsService.createFlow(req.body, req.user.id, getCtx(req)); + return sendSuccess(res, result, 201); + } catch (error) { + return next(error); + } +}; + +const getFlowForOwner = async (req, res, next) => { + try { + const result = await invitationsService.getFlowForOwner(Number(req.params.documentId), req.user.id); + return sendSuccess(res, result, 200); + } catch (error) { + return next(error); + } +}; + +const addInviteesToFlow = async (req, res, next) => { + try { + const result = await invitationsService.addInviteesToFlow(Number(req.params.documentId), req.body, req.user.id, getCtx(req)); + return sendSuccess(res, result, 200); + } catch (error) { + return next(error); + } +}; + +const removeInvitation = async (req, res, next) => { + try { + const result = await invitationsService.removeInvitation(Number(req.params.invitationId), req.user.id, getCtx(req)); + return sendSuccess(res, result, 200); + } catch (error) { + return next(error); + } +}; + +const getPublicInvitation = async (req, res, next) => { + try { + const result = await invitationsService.getPublicInvitation(req.params.token, getCtx(req)); + return sendSuccess(res, result, 200); + } catch (error) { + return next(error); + } +}; + +const downloadPublicDocument = async (req, res, next) => { + try { + const { filePath, filename } = await invitationsService.getPublicDocumentFile(req.params.token, getCtx(req)); + res.sendFile(filePath, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `inline; filename="${filename}"`, + }, + }); + } catch (error) { + return next(error); + } +}; + +const acceptTerms = async (req, res, next) => { + try { + const result = await invitationsService.acceptTerms(req.params.token, getCtx(req)); + return sendSuccess(res, result, 200); + } catch (error) { + return next(error); + } +}; + +const signInvitation = async (req, res, next) => { + try { + const rect = typeof req.body.rect === 'string' ? JSON.parse(req.body.rect) : req.body.rect; + const result = await invitationsService.signInvitation({ + token: req.params.token, + certificateFile: req.file, + certPassword: req.body.certPassword, + page: Number(req.body.page) || 0, + rect, + reason: req.body.reason, + location: req.body.location, + }, getCtx(req)); + return sendSuccess(res, result, 200); + } catch (error) { + return next(error); + } +}; + +module.exports = { + createFlow, + getFlowForOwner, + addInviteesToFlow, + removeInvitation, + getPublicInvitation, + downloadPublicDocument, + acceptTerms, + signInvitation, +}; diff --git a/backend/src/modules/invitations/invitations.repository.js b/backend/src/modules/invitations/invitations.repository.js new file mode 100644 index 0000000..9feebae --- /dev/null +++ b/backend/src/modules/invitations/invitations.repository.js @@ -0,0 +1,333 @@ +const db = require('../../config/db'); + +const ensureSchema = async () => { + await db.execute(` + CREATE TABLE IF NOT EXISTS invitation_flows ( + id INT AUTO_INCREMENT PRIMARY KEY, + document_id INT NOT NULL, + owner_user_id INT NOT NULL, + base_signature_id INT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + current_signed_filename VARCHAR(255) NOT NULL, + current_signed_path VARCHAR(500) NOT NULL, + signature_style VARCHAR(32) NOT NULL DEFAULT 'qr', + qr_position VARCHAR(16) NULL, + qr_background VARCHAR(16) NULL, + qr_border VARCHAR(16) NULL, + signature_image_base64 MEDIUMTEXT NULL, + total_invites INT NOT NULL DEFAULT 0, + completed_invites INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_invitation_flows_document_owner (document_id, owner_user_id), + INDEX idx_invitation_flows_status (status) + ) + `); + + await db.execute(` + CREATE TABLE IF NOT EXISTS invitation_signers ( + id INT AUTO_INCREMENT PRIMARY KEY, + flow_id INT NOT NULL, + owner_user_id INT NOT NULL, + document_id INT NOT NULL, + order_index INT NOT NULL, + invitee_name VARCHAR(160) NOT NULL, + invitee_email VARCHAR(255) NOT NULL, + token VARCHAR(255) NOT NULL, + token_expires DATETIME NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'queued', + legal_accepted_at DATETIME NULL, + viewed_at DATETIME NULL, + sent_at DATETIME NULL, + signed_at DATETIME NULL, + signed_filename VARCHAR(255) NULL, + signed_path VARCHAR(500) NULL, + certificate_subject_cn VARCHAR(255) NULL, + certificate_issuer_cn VARCHAR(255) NULL, + verification_code VARCHAR(100) NULL, + verification_url VARCHAR(500) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_invitation_signers_token (token), + UNIQUE KEY uq_invitation_signers_flow_order (flow_id, order_index), + INDEX idx_invitation_signers_document_owner (document_id, owner_user_id), + INDEX idx_invitation_signers_status (status) + ) + `); +}; + +const createFlow = async ({ + documentId, + ownerUserId, + baseSignatureId, + currentSignedFilename, + currentSignedPath, + signatureStyle, + qrPosition, + qrBackground, + qrBorder, + signatureImageBase64, + totalInvites, +}) => { + const [result] = await db.execute( + `INSERT INTO invitation_flows + (document_id, owner_user_id, base_signature_id, current_signed_filename, current_signed_path, + signature_style, qr_position, qr_background, qr_border, signature_image_base64, total_invites) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + documentId, + ownerUserId, + baseSignatureId, + currentSignedFilename, + currentSignedPath, + signatureStyle, + qrPosition || null, + qrBackground || null, + qrBorder || null, + signatureImageBase64 || null, + totalInvites, + ] + ); + return result.insertId; +}; + +const createInvitation = async ({ + flowId, + ownerUserId, + documentId, + orderIndex, + inviteeName, + inviteeEmail, + token, + tokenExpires, + status, +}) => { + const [result] = await db.execute( + `INSERT INTO invitation_signers + (flow_id, owner_user_id, document_id, order_index, invitee_name, invitee_email, token, token_expires, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [flowId, ownerUserId, documentId, orderIndex, inviteeName, inviteeEmail, token, tokenExpires, status] + ); + return result.insertId; +}; + +const findFlowByDocumentOwner = async (documentId, ownerUserId) => { + const [rows] = await db.execute( + 'SELECT * FROM invitation_flows WHERE document_id = ? AND owner_user_id = ? ORDER BY id DESC LIMIT 1', + [documentId, ownerUserId] + ); + return rows[0] || null; +}; + +const findFlowById = async (id) => { + const [rows] = await db.execute('SELECT * FROM invitation_flows WHERE id = ?', [id]); + return rows[0] || null; +}; + +const listInvitationsByFlow = async (flowId) => { + const [rows] = await db.execute( + 'SELECT * FROM invitation_signers WHERE flow_id = ? ORDER BY order_index ASC', + [flowId] + ); + return rows; +}; + +const getMaxOrderIndex = async (flowId) => { + const [rows] = await db.execute( + `SELECT COALESCE(MAX(order_index), 0) AS maxOrder + FROM invitation_signers + WHERE flow_id = ?`, + [flowId] + ); + return Number(rows[0]?.maxOrder || 0); +}; + +const findInvitationByIdForOwner = async (id, ownerUserId) => { + const [rows] = await db.execute( + `SELECT i.*, f.status as flow_status, f.current_signed_filename, f.current_signed_path, + f.signature_style, f.qr_position, f.qr_background, f.qr_border, f.signature_image_base64, + f.base_signature_id, f.total_invites, f.completed_invites, + d.title as document_title, d.filename as document_filename, + u.name as owner_name, u.email as owner_email + FROM invitation_signers i + JOIN invitation_flows f ON f.id = i.flow_id + JOIN documents d ON d.id = i.document_id + JOIN users u ON u.id = i.owner_user_id + WHERE i.id = ? AND i.owner_user_id = ? + LIMIT 1`, + [id, ownerUserId] + ); + return rows[0] || null; +}; + +const findInvitationByToken = async (token) => { + const [rows] = await db.execute( + `SELECT i.*, f.status as flow_status, f.current_signed_filename, f.current_signed_path, + f.signature_style, f.qr_position, f.qr_background, f.qr_border, f.signature_image_base64, + f.base_signature_id, f.total_invites, f.completed_invites, + d.title as document_title, d.filename as document_filename, + u.name as owner_name, u.email as owner_email + FROM invitation_signers i + JOIN invitation_flows f ON f.id = i.flow_id + JOIN documents d ON d.id = i.document_id + JOIN users u ON u.id = i.owner_user_id + WHERE i.token = ? + LIMIT 1`, + [token] + ); + return rows[0] || null; +}; + +const markInvitationSent = async (id) => { + await db.execute( + `UPDATE invitation_signers + SET status = 'sent', + sent_at = COALESCE(sent_at, NOW()) + WHERE id = ?`, + [id] + ); +}; + +const markInvitationViewed = async (id) => { + await db.execute( + `UPDATE invitation_signers + SET status = CASE WHEN status = 'sent' THEN 'opened' ELSE status END, + viewed_at = COALESCE(viewed_at, NOW()) + WHERE id = ?`, + [id] + ); +}; + +const markInvitationTermsAccepted = async (id) => { + await db.execute( + `UPDATE invitation_signers + SET legal_accepted_at = COALESCE(legal_accepted_at, NOW()) + WHERE id = ?`, + [id] + ); +}; + +const markInvitationSigned = async ({ + id, + signedFilename, + signedPath, + certificateSubjectCn, + certificateIssuerCn, + verificationCode, + verificationUrl, +}) => { + await db.execute( + `UPDATE invitation_signers + SET status = 'signed', + signed_at = NOW(), + signed_filename = ?, + signed_path = ?, + certificate_subject_cn = ?, + certificate_issuer_cn = ?, + verification_code = ?, + verification_url = ? + WHERE id = ?`, + [signedFilename, signedPath, certificateSubjectCn || null, certificateIssuerCn || null, verificationCode || null, verificationUrl || null, id] + ); +}; + +const invalidateInvitationToken = async (id, replacementToken) => { + await db.execute( + `UPDATE invitation_signers + SET token = ?, token_expires = NOW() + WHERE id = ?`, + [replacementToken, id] + ); +}; + +const activateInvitation = async (id) => { + await db.execute( + `UPDATE invitation_signers + SET status = 'sent', + sent_at = COALESCE(sent_at, NOW()) + WHERE id = ?`, + [id] + ); +}; + +const updateFlowProgress = async ({ flowId, currentSignedFilename, currentSignedPath, completedInvites, status }) => { + await db.execute( + `UPDATE invitation_flows + SET current_signed_filename = ?, + current_signed_path = ?, + completed_invites = ?, + status = ? + WHERE id = ?`, + [currentSignedFilename, currentSignedPath, completedInvites, status, flowId] + ); +}; + +const findNextInvitation = async (flowId) => { + const [rows] = await db.execute( + `SELECT * FROM invitation_signers + WHERE flow_id = ? AND status = 'queued' + ORDER BY order_index ASC + LIMIT 1`, + [flowId] + ); + return rows[0] || null; +}; + +const countSignedInvitations = async (flowId) => { + const [rows] = await db.execute( + `SELECT COUNT(*) AS total + FROM invitation_signers + WHERE flow_id = ? AND status = 'signed'`, + [flowId] + ); + return Number(rows[0]?.total || 0); +}; + +const countInvitations = async (flowId) => { + const [rows] = await db.execute( + `SELECT COUNT(*) AS total + FROM invitation_signers + WHERE flow_id = ?`, + [flowId] + ); + return Number(rows[0]?.total || 0); +}; + +const updateFlowCounts = async ({ flowId, totalInvites, completedInvites, status }) => { + await db.execute( + `UPDATE invitation_flows + SET total_invites = ?, + completed_invites = ?, + status = ? + WHERE id = ?`, + [totalInvites, completedInvites, status, flowId] + ); +}; + +const removeInvitation = async (id) => { + await db.execute('DELETE FROM invitation_signers WHERE id = ?', [id]); +}; + +module.exports = { + ensureSchema, + createFlow, + createInvitation, + findFlowByDocumentOwner, + findFlowById, + listInvitationsByFlow, + getMaxOrderIndex, + findInvitationByIdForOwner, + findInvitationByToken, + markInvitationSent, + markInvitationViewed, + markInvitationTermsAccepted, + markInvitationSigned, + invalidateInvitationToken, + activateInvitation, + updateFlowProgress, + findNextInvitation, + countSignedInvitations, + countInvitations, + updateFlowCounts, + removeInvitation, +}; diff --git a/backend/src/modules/invitations/invitations.routes.js b/backend/src/modules/invitations/invitations.routes.js new file mode 100644 index 0000000..3f0835a --- /dev/null +++ b/backend/src/modules/invitations/invitations.routes.js @@ -0,0 +1,20 @@ +const express = require('express'); +const authenticate = require('../../middlewares/authenticate'); +const { uploadTempCertificate } = require('../../config/multer'); +const invitationsController = require('./invitations.controller'); +const { validate, createInvitationsSchema, addInviteesSchema, validateGuestMultipart } = require('./invitations.validator'); + +const router = express.Router(); + +router.get('/public/:token', invitationsController.getPublicInvitation); +router.get('/public/:token/document', invitationsController.downloadPublicDocument); +router.post('/public/:token/accept-terms', invitationsController.acceptTerms); +router.post('/public/:token/sign', uploadTempCertificate.single('certificateFile'), validateGuestMultipart, invitationsController.signInvitation); + +router.use(authenticate); +router.post('/', validate(createInvitationsSchema), invitationsController.createFlow); +router.get('/document/:documentId', invitationsController.getFlowForOwner); +router.post('/document/:documentId/invitees', validate(addInviteesSchema), invitationsController.addInviteesToFlow); +router.delete('/:invitationId', invitationsController.removeInvitation); + +module.exports = router; diff --git a/backend/src/modules/invitations/invitations.service.js b/backend/src/modules/invitations/invitations.service.js new file mode 100644 index 0000000..df862d6 --- /dev/null +++ b/backend/src/modules/invitations/invitations.service.js @@ -0,0 +1,574 @@ +const fs = require('fs/promises'); +const path = require('path'); +const crypto = require('crypto'); +const { v4: uuidv4 } = require('uuid'); +const invitationsRepository = require('./invitations.repository'); +const documentsRepository = require('../documents/documents.repository'); +const signaturesRepository = require('../signatures/signatures.repository'); +const certificatesService = require('../certificates/certificates.service'); +const usersRepository = require('../users/users.repository'); +const { signPdf } = require('../signing/signing.service'); +const auditService = require('../audit/audit.service'); +const mailService = require('../mail/mail.service'); +const env = require('../../config/env'); + +const INVITATION_EXPIRY_DAYS = 7; + +const buildGuestUrl = (token) => `${env.frontendUrl.replace(/\/$/, '')}/guest-sign/${token}`; + +const sanitizeBaseName = (value) => String(value || 'documento') + .replace(/\.pdf$/i, '') + .replace(/[^a-zA-Z0-9._-]/g, '_'); + +const createInvitationToken = () => crypto.randomBytes(24).toString('hex'); +const createConsumedToken = () => `used_${crypto.randomBytes(24).toString('hex')}`; + +const createExpiryDate = () => { + const date = new Date(); + date.setDate(date.getDate() + INVITATION_EXPIRY_DAYS); + return date; +}; + +const ensureOwnerCanInvite = async (documentId, ownerUserId) => { + const document = await documentsRepository.findById(documentId); + if (!document) { + const error = new Error('Document not found.'); + error.statusCode = 404; + throw error; + } + if (document.user_id !== ownerUserId) { + const error = new Error('Access denied.'); + error.statusCode = 403; + throw error; + } + if (document.status !== 'signed') { + const error = new Error('El documento debe estar firmado por el usuario antes de invitar terceros.'); + error.statusCode = 409; + throw error; + } + + const baseSignature = await signaturesRepository.findLatestByDocumentAndUser(documentId, ownerUserId); + if (!baseSignature) { + const error = new Error('No se encontró la firma base del usuario principal.'); + error.statusCode = 409; + throw error; + } + + return { document, baseSignature }; +}; + +const createFlow = async ({ documentId, invitees }, ownerUserId, context = {}) => { + if (invitees.length > 5) { + const error = new Error('Solo puedes invitar hasta 5 firmantes externos.'); + error.statusCode = 400; + throw error; + } + + const existingFlow = await invitationsRepository.findFlowByDocumentOwner(documentId, ownerUserId); + if (existingFlow && existingFlow.status !== 'completed') { + const error = new Error('Este documento ya tiene una cadena de firmas externas en curso.'); + error.statusCode = 409; + throw error; + } + + const { document, baseSignature } = await ensureOwnerCanInvite(documentId, ownerUserId); + const ownerPreferences = await usersRepository.getSignaturePreferences(ownerUserId); + + const flowId = await invitationsRepository.createFlow({ + documentId, + ownerUserId, + baseSignatureId: baseSignature.id, + currentSignedFilename: baseSignature.signed_filename, + currentSignedPath: baseSignature.storage_path, + signatureStyle: ownerPreferences.signatureStyle, + qrPosition: ownerPreferences.qrPosition, + qrBackground: ownerPreferences.qrBackground, + qrBorder: ownerPreferences.qrBorder, + signatureImageBase64: ownerPreferences.signatureImageBase64, + totalInvites: invitees.length, + }); + + const flow = await invitationsRepository.findFlowById(flowId); + const created = []; + + for (let index = 0; index < invitees.length; index += 1) { + const invitee = invitees[index]; + const token = createInvitationToken(); + const invitationId = await invitationsRepository.createInvitation({ + flowId, + ownerUserId, + documentId, + orderIndex: index + 1, + inviteeName: invitee.name.trim(), + inviteeEmail: invitee.email.trim().toLowerCase(), + token, + tokenExpires: createExpiryDate(), + status: index === 0 ? 'sent' : 'queued', + }); + + if (index === 0) { + await invitationsRepository.markInvitationSent(invitationId); + await mailService.sendExternalSigningInvitationEmail({ + ownerName: document.owner_name || document.owner_email, + inviteeName: invitee.name.trim(), + inviteeEmail: invitee.email.trim().toLowerCase(), + documentTitle: document.title, + inviteUrl: buildGuestUrl(token), + orderIndex: 1, + totalInvites: invitees.length, + }); + } + + created.push({ + id: invitationId, + inviteeName: invitee.name.trim(), + inviteeEmail: invitee.email.trim().toLowerCase(), + orderIndex: index + 1, + status: index === 0 ? 'sent' : 'queued', + inviteUrl: index === 0 ? buildGuestUrl(token) : null, + }); + } + + await auditService.logEvent({ + type: 'EXTERNAL_SIGNING_FLOW_CREATED', + userId: ownerUserId, + email: context.userEmail || null, + ip: context.ip, + userAgent: context.userAgent, + metadata: { + documentId, + flowId, + totalInvites: invitees.length, + onlyP12Allowed: true, + }, + }); + + return { + flowId, + documentId, + documentTitle: document.title, + status: flow.status, + totalInvites: invitees.length, + completedInvites: 0, + signatureStyle: ownerPreferences.signatureStyle, + invitees: created, + warnings: [ + 'Solo se debe invitar a personas que cuenten con firma electrónica P12/PFX.', + 'Las firmas se solicitarán en el orden configurado.', + ], + }; +}; + +const getFlowForOwner = async (documentId, ownerUserId) => { + const flow = await invitationsRepository.findFlowByDocumentOwner(documentId, ownerUserId); + if (!flow) return null; + const invitees = await invitationsRepository.listInvitationsByFlow(flow.id); + return { + ...flow, + invitees, + }; +}; + +const addInviteesToFlow = async (documentId, { invitees }, ownerUserId, context = {}) => { + if (invitees.length > 5) { + const error = new Error('Solo puedes invitar hasta 5 firmantes externos.'); + error.statusCode = 400; + throw error; + } + + const flow = await invitationsRepository.findFlowByDocumentOwner(documentId, ownerUserId); + if (!flow) { + const error = new Error('No existe una cadena de firmas externas para este documento.'); + error.statusCode = 404; + throw error; + } + + if (flow.status === 'completed') { + const error = new Error('La cadena ya fue completada. Crea una nueva si necesitas más firmas.'); + error.statusCode = 409; + throw error; + } + + const { document } = await ensureOwnerCanInvite(documentId, ownerUserId); + const existingInvitees = await invitationsRepository.listInvitationsByFlow(flow.id); + const currentCount = existingInvitees.length; + if (currentCount + invitees.length > 5) { + const error = new Error('Solo puedes tener hasta 5 firmantes externos por documento.'); + error.statusCode = 400; + throw error; + } + + const existingEmails = new Set(existingInvitees.map((item) => String(item.invitee_email).toLowerCase())); + const normalizedInvitees = invitees.map((invitee) => ({ + name: invitee.name.trim(), + email: invitee.email.trim().toLowerCase(), + })); + + for (const invitee of normalizedInvitees) { + if (existingEmails.has(invitee.email)) { + const error = new Error(`El correo ${invitee.email} ya está en la cadena de firmas.`); + error.statusCode = 409; + throw error; + } + } + + const hasActiveInvitation = existingInvitees.some((item) => ['sent', 'opened'].includes(item.status)); + let shouldActivateFirstNew = !hasActiveInvitation; + let nextOrderIndex = await invitationsRepository.getMaxOrderIndex(flow.id); + + for (const invitee of normalizedInvitees) { + nextOrderIndex += 1; + const token = createInvitationToken(); + const status = shouldActivateFirstNew ? 'sent' : 'queued'; + const invitationId = await invitationsRepository.createInvitation({ + flowId: flow.id, + ownerUserId, + documentId, + orderIndex: nextOrderIndex, + inviteeName: invitee.name, + inviteeEmail: invitee.email, + token, + tokenExpires: createExpiryDate(), + status, + }); + + if (shouldActivateFirstNew) { + await invitationsRepository.markInvitationSent(invitationId); + await mailService.sendExternalSigningInvitationEmail({ + ownerName: document.owner_name || document.owner_email, + inviteeName: invitee.name, + inviteeEmail: invitee.email, + documentTitle: document.title, + inviteUrl: buildGuestUrl(token), + orderIndex: nextOrderIndex, + totalInvites: currentCount + normalizedInvitees.length, + }); + shouldActivateFirstNew = false; + } + } + + await invitationsRepository.updateFlowCounts({ + flowId: flow.id, + totalInvites: currentCount + normalizedInvitees.length, + completedInvites: await invitationsRepository.countSignedInvitations(flow.id), + status: 'active', + }); + + await auditService.logEvent({ + type: 'EXTERNAL_SIGNING_INVITEES_ADDED', + userId: ownerUserId, + email: context.userEmail || null, + ip: context.ip, + userAgent: context.userAgent, + metadata: { + flowId: flow.id, + documentId, + addedCount: normalizedInvitees.length, + totalInvites: currentCount + normalizedInvitees.length, + }, + }); + + return getFlowForOwner(documentId, ownerUserId); +}; + +const removeInvitation = async (invitationId, ownerUserId, context = {}) => { + const invitation = await invitationsRepository.findInvitationByIdForOwner(invitationId, ownerUserId); + if (!invitation) { + const error = new Error('Invitación no encontrada.'); + error.statusCode = 404; + throw error; + } + + if (invitation.status === 'signed') { + const error = new Error('No puedes eliminar una invitación que ya fue firmada.'); + error.statusCode = 409; + throw error; + } + + const wasActive = ['sent', 'opened'].includes(invitation.status); + + await invitationsRepository.removeInvitation(invitationId); + + const completedInvites = await invitationsRepository.countSignedInvitations(invitation.flow_id); + const totalInvites = await invitationsRepository.countInvitations(invitation.flow_id); + + let nextInvitation = null; + if (wasActive) { + nextInvitation = await invitationsRepository.findNextInvitation(invitation.flow_id); + if (nextInvitation) { + await invitationsRepository.activateInvitation(nextInvitation.id); + await mailService.sendExternalSigningInvitationEmail({ + ownerName: invitation.owner_name, + inviteeName: nextInvitation.invitee_name, + inviteeEmail: nextInvitation.invitee_email, + documentTitle: invitation.document_title, + inviteUrl: buildGuestUrl(nextInvitation.token), + orderIndex: nextInvitation.order_index, + totalInvites, + }); + } + } + + const flowStatus = totalInvites === 0 + ? 'cancelled' + : completedInvites >= totalInvites + ? 'completed' + : 'active'; + + await invitationsRepository.updateFlowCounts({ + flowId: invitation.flow_id, + totalInvites, + completedInvites, + status: flowStatus, + }); + + await auditService.logEvent({ + type: 'EXTERNAL_SIGNING_INVITATION_REMOVED', + userId: ownerUserId, + email: context.userEmail || null, + ip: context.ip, + userAgent: context.userAgent, + metadata: { + invitationId, + flowId: invitation.flow_id, + documentId: invitation.document_id, + inviteeEmail: invitation.invitee_email, + nextInvitationId: nextInvitation?.id || null, + }, + }); + + return getFlowForOwner(invitation.document_id, ownerUserId); +}; + +const getPublicInvitation = async (token, context = {}) => { + const invitation = await invitationsRepository.findInvitationByToken(token); + if (!invitation) { + const error = new Error('Invitación no encontrada o expirada.'); + error.statusCode = 404; + throw error; + } + if (new Date(invitation.token_expires) < new Date()) { + const error = new Error('La invitación ha expirado.'); + error.statusCode = 410; + throw error; + } + + await invitationsRepository.markInvitationViewed(invitation.id); + await auditService.logEvent({ + type: 'EXTERNAL_SIGNING_LINK_OPENED', + email: invitation.invitee_email, + ip: context.ip, + userAgent: context.userAgent, + metadata: { + invitationId: invitation.id, + documentId: invitation.document_id, + orderIndex: invitation.order_index, + }, + }); + + return { + invitationId: invitation.id, + inviteeName: invitation.invitee_name, + inviteeEmail: invitation.invitee_email, + orderIndex: invitation.order_index, + totalInvites: invitation.total_invites, + status: invitation.status, + tokenExpires: invitation.token_expires, + legalAcceptedAt: invitation.legal_accepted_at, + ownerName: invitation.owner_name, + documentTitle: invitation.document_title, + documentFilename: invitation.current_signed_filename, + currentSignedFilename: invitation.current_signed_filename, + signatureStyle: invitation.signature_style, + qrPosition: invitation.qr_position, + qrBackground: invitation.qr_background, + qrBorder: invitation.qr_border, + warnings: [ + 'Debes usar tu propio certificado P12/PFX para firmar.', + 'Los datos de tu certificado y su contraseña no serán guardados por el sistema.', + ], + }; +}; + +const acceptTerms = async (token, context = {}) => { + const invitation = await invitationsRepository.findInvitationByToken(token); + if (!invitation) { + const error = new Error('Invitación no encontrada o expirada.'); + error.statusCode = 404; + throw error; + } + + await invitationsRepository.markInvitationTermsAccepted(invitation.id); + await auditService.logEvent({ + type: 'EXTERNAL_SIGNING_TERMS_ACCEPTED', + email: invitation.invitee_email, + ip: context.ip, + userAgent: context.userAgent, + metadata: { + invitationId: invitation.id, + documentId: invitation.document_id, + }, + }); + + return { message: 'Condiciones aceptadas.' }; +}; + +const getPublicDocumentFile = async (token, context = {}) => { + const invitation = await getPublicInvitation(token, context); + const rawInvitation = await invitationsRepository.findInvitationByToken(token); + return { + filePath: rawInvitation.current_signed_path, + filename: invitation.currentSignedFilename, + }; +}; + +const signInvitation = async ({ token, certificateFile, certPassword, page, rect, reason, location }, context = {}) => { + const invitation = await invitationsRepository.findInvitationByToken(token); + if (!invitation) { + const error = new Error('Invitación no encontrada o expirada.'); + error.statusCode = 404; + throw error; + } + if (new Date(invitation.token_expires) < new Date()) { + const error = new Error('La invitación ha expirado.'); + error.statusCode = 410; + throw error; + } + if (!invitation.legal_accepted_at) { + const error = new Error('Debes aceptar las condiciones antes de firmar.'); + error.statusCode = 409; + throw error; + } + if (invitation.status === 'signed') { + const error = new Error('Esta invitación ya fue utilizada.'); + error.statusCode = 409; + throw error; + } + if (!['sent', 'opened'].includes(invitation.status)) { + const error = new Error('Esta invitación aún no está activa.'); + error.statusCode = 409; + throw error; + } + + const outputDir = path.join(env.storagePath, 'signed', 'invited', String(invitation.flow_id)); + await fs.mkdir(outputDir, { recursive: true }); + + let parsedCertificate; + let signedResult; + + try { + const p12Buffer = await fs.readFile(certificateFile.path); + parsedCertificate = certificatesService.parseCertificate + ? certificatesService.parseCertificate(p12Buffer, certPassword) + : null; + const signerInfo = parsedCertificate || {}; + const outputBaseName = sanitizeBaseName( + `${invitation.document_title}_flow_${invitation.flow_id}_invite_${invitation.order_index}` + ); + + signedResult = await signPdf({ + pdfPath: invitation.current_signed_path, + p12Buffer, + p12Password: certPassword, + signerName: signerInfo.subjectCn || invitation.invitee_name, + reason: reason || 'Firmado por invitación segura', + location: location || '', + page, + rect, + signatureStyle: invitation.signature_style || 'qr', + qrPosition: invitation.qr_position || 'right', + qrBackground: invitation.qr_background || 'transparent', + qrBorder: invitation.qr_border || 'none', + signatureImageBase64: invitation.signature_image_base64 || null, + verificationUrl: null, + outputDir, + outputBaseName, + }); + + await invitationsRepository.markInvitationSigned({ + id: invitation.id, + signedFilename: signedResult.signedFilename, + signedPath: signedResult.signedPath, + certificateSubjectCn: signerInfo.subjectCn || null, + certificateIssuerCn: signerInfo.issuerCn || null, + verificationCode: null, + verificationUrl: null, + }); + await invitationsRepository.invalidateInvitationToken(invitation.id, createConsumedToken()); + + const completedInvites = await invitationsRepository.countSignedInvitations(invitation.flow_id); + const nextInvitation = await invitationsRepository.findNextInvitation(invitation.flow_id); + const flowStatus = nextInvitation ? 'active' : 'completed'; + + await invitationsRepository.updateFlowProgress({ + flowId: invitation.flow_id, + currentSignedFilename: signedResult.signedFilename, + currentSignedPath: signedResult.signedPath, + completedInvites, + status: flowStatus, + }); + + await signaturesRepository.updateSignedArtifact(invitation.base_signature_id, signedResult.signedFilename, signedResult.signedPath); + + if (nextInvitation) { + await invitationsRepository.activateInvitation(nextInvitation.id); + await mailService.sendExternalSigningInvitationEmail({ + ownerName: invitation.owner_name, + inviteeName: nextInvitation.invitee_name, + inviteeEmail: nextInvitation.invitee_email, + documentTitle: invitation.document_title, + inviteUrl: buildGuestUrl(nextInvitation.token), + orderIndex: nextInvitation.order_index, + totalInvites: invitation.total_invites, + }); + } + + await mailService.sendExternalSigningOwnerNotificationEmail({ + ownerName: invitation.owner_name, + ownerEmail: invitation.owner_email, + inviteeName: invitation.invitee_name, + documentTitle: invitation.document_title, + completedInvites, + totalInvites: invitation.total_invites, + nextInviteeName: nextInvitation?.invitee_name || null, + isCompleted: !nextInvitation, + }); + + await auditService.logEvent({ + type: 'EXTERNAL_SIGNING_COMPLETED', + userId: invitation.owner_user_id, + email: invitation.invitee_email, + ip: context.ip, + userAgent: context.userAgent, + metadata: { + invitationId: invitation.id, + flowId: invitation.flow_id, + documentId: invitation.document_id, + orderIndex: invitation.order_index, + nextInvitationId: nextInvitation?.id || null, + }, + }); + + return { + message: 'Documento firmado correctamente.', + signedFilename: signedResult.signedFilename, + orderIndex: invitation.order_index, + totalInvites: invitation.total_invites, + nextInviteeName: nextInvitation?.invitee_name || null, + completed: !nextInvitation, + }; + } finally { + try { await fs.unlink(certificateFile.path); } catch {} + } +}; + +module.exports = { + createFlow, + getFlowForOwner, + addInviteesToFlow, + removeInvitation, + getPublicInvitation, + getPublicDocumentFile, + acceptTerms, + signInvitation, +}; diff --git a/backend/src/modules/invitations/invitations.validator.js b/backend/src/modules/invitations/invitations.validator.js new file mode 100644 index 0000000..6d8d7b5 --- /dev/null +++ b/backend/src/modules/invitations/invitations.validator.js @@ -0,0 +1,86 @@ +const Joi = require('joi'); + +const createInvitationsSchema = Joi.object({ + documentId: Joi.number().integer().positive().required(), + invitees: Joi.array() + .items( + Joi.object({ + name: Joi.string().trim().min(2).max(160).required(), + email: Joi.string().trim().email().required(), + }) + ) + .min(1) + .max(5) + .required(), +}); + +const addInviteesSchema = Joi.object({ + invitees: Joi.array() + .items( + Joi.object({ + name: Joi.string().trim().min(2).max(160).required(), + email: Joi.string().trim().email().required(), + }) + ) + .min(1) + .max(5) + .required(), +}); + +const validateGuestMultipart = (req, res, next) => { + const issues = []; + const { certPassword, page, rect, acceptedTerms } = req.body; + + if (!req.file) { + issues.push('Debes cargar un certificado P12 o PFX'); + } + + if (!certPassword) { + issues.push('La contraseña del certificado es obligatoria'); + } + + if (!['true', true, '1', 1].includes(acceptedTerms)) { + issues.push('Debes aceptar las condiciones para firmar'); + } + + if (page === undefined || Number.isNaN(Number(page))) { + issues.push('La página de firma es obligatoria'); + } + + if (!rect) { + issues.push('La posición de firma es obligatoria'); + } else { + try { + const parsed = typeof rect === 'string' ? JSON.parse(rect) : rect; + const valid = ['x', 'y', 'width', 'height'].every((key) => Number.isFinite(Number(parsed?.[key]))); + if (!valid) issues.push('La posición de firma es inválida'); + } catch { + issues.push('La posición de firma es inválida'); + } + } + + 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) { + return res.status(400).json({ + success: false, + data: null, + error: error.details.map((item) => item.message).join('; '), + }); + } + return next(); +}; + +module.exports = { + validate, + createInvitationsSchema, + addInviteesSchema, + validateGuestMultipart, +}; diff --git a/backend/src/modules/mail/mail.repository.js b/backend/src/modules/mail/mail.repository.js index 58d220a..6d6dfbb 100644 --- a/backend/src/modules/mail/mail.repository.js +++ b/backend/src/modules/mail/mail.repository.js @@ -33,8 +33,12 @@ const getTransporter = () => { const sendMail = async ({ to, subject, html, text, attachments }) => { const activeTransporter = getTransporter(); + const from = env.mail.from.includes('<') + ? env.mail.from + : `LinkedSign <${env.mail.from}>`; const info = await activeTransporter.sendMail({ - from: env.mail.from, + from, + replyTo: env.mail.from, to, subject, html, diff --git a/backend/src/modules/mail/mail.service.js b/backend/src/modules/mail/mail.service.js index 045acfa..f410846 100644 --- a/backend/src/modules/mail/mail.service.js +++ b/backend/src/modules/mail/mail.service.js @@ -1,35 +1,75 @@ const env = require('../../config/env'); const mailRepository = require('./mail.repository'); +const escapeHtml = (value) => String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const buildBaseUrl = () => env.frontendUrl.replace(/\/$/, ''); + +const supportEmail = () => env.mail.from; + +const renderEmailLayout = ({ preheader, title, intro, bodyHtml, ctaLabel, ctaUrl, footerNote }) => ` +
+Hola ${name},
-Para activar tu cuenta, confirma tu correo electrónico haciendo clic en el siguiente botón:
- -Si el botón no funciona, copia y pega este enlace en tu navegador:
- - ${attachments.length > 0 ? 'Adjuntamos una copia de los documentos legales firmados durante tu registro inicial.
' : ''} -Si no solicitaste esta cuenta, puedes ignorar este mensaje.
-Adjuntamos una copia de los documentos legales firmados durante tu registro inicial.
' + : '', + ctaLabel: 'Verificar cuenta', + ctaUrl: verificationUrl, + footerNote: 'Si no solicitaste esta cuenta, puedes ignorar este mensaje.', + }); const info = await mailRepository.sendMail({ to: email, @@ -62,12 +102,19 @@ const sendPasswordChangedEmail = async ({ name, email, changedAt }) => { 'Si no realizaste este cambio, contacta soporte inmediatamente.', ].join('\n'); const html = ` -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.
+Hola ${name},
-Recibimos una solicitud para cambiar la contraseña de tu cuenta de LinkedSign.
-Para aplicar el cambio, confirma la solicitud usando este botón:
-- - Confirmar cambio de contraseña - -
-Si el botón no funciona, copia y pega este enlace en tu navegador:
- -Si no realizaste esta solicitud, ignora este mensaje.
-Para aplicar el cambio, confirma esta solicitud.
', + ctaLabel: 'Confirmar cambio', + ctaUrl: confirmationUrl, + footerNote: 'Si no realizaste esta solicitud, puedes ignorar este mensaje.', + }); const info = await mailRepository.sendMail({ to: email, @@ -126,7 +166,7 @@ const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationTo }; const sendAccountLockedEmail = async ({ name, email, unlockToken }) => { - const unlockUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/unlock-account/${unlockToken}`; + const unlockUrl = `${buildBaseUrl()}/auth/unlock-account/${unlockToken}`; const subject = 'Tu cuenta de LinkedSign fue bloqueada temporalmente'; const text = [ `Hola ${name},`, @@ -138,23 +178,15 @@ const sendAccountLockedEmail = async ({ name, email, unlockToken }) => { 'El sistema te pedirá la contraseña de tu certificado de firma y luego solicitará una nueva contraseña de acceso.', 'Si no reconoces esta actividad, contacta soporte de inmediato.', ].join('\n'); - const html = ` -Hola ${name},
-Detectamos 3 intentos fallidos de acceso y tu cuenta fue bloqueada temporalmente por seguridad.
-Para iniciar el desbloqueo, usa este botón:
- -Si el botón no funciona, copia y pega este enlace en tu navegador:
- -El sistema te pedirá la contraseña de tu certificado de firma registrado y luego solicitará una nueva contraseña de acceso.
-Si no reconoces esta actividad, contacta soporte de inmediato.
-Para iniciar el desbloqueo, el sistema te pedirá la contraseña de tu certificado registrado y luego solicitará una nueva contraseña de acceso.
', + ctaLabel: 'Iniciar desbloqueo', + ctaUrl: unlockUrl, + footerNote: 'Si no reconoces esta actividad, responde a este correo para revisión.', + }); const info = await mailRepository.sendMail({ to: email, @@ -169,9 +201,117 @@ const sendAccountLockedEmail = async ({ name, email, unlockToken }) => { }; }; +const sendExternalSigningInvitationEmail = async ({ + ownerName, + inviteeName, + inviteeEmail, + documentTitle, + inviteUrl, + orderIndex, + totalInvites, +}) => { + const subject = `Invitación de firma para ${documentTitle}`; + const text = [ + `Hola ${inviteeName},`, + '', + `${ownerName} te ha invitado a firmar el documento "${documentTitle}".`, + `Orden de firma: ${orderIndex} de ${totalInvites}.`, + '', + 'Importante:', + '- Debes usar tu propio certificado electrónico P12/PFX.', + '- Los datos de tu certificado y su contraseña no serán guardados por LinkedSign.', + '', + 'Abre este enlace seguro para revisar el documento y firmarlo:', + inviteUrl, + ].join('\n'); + + const html = renderEmailLayout({ + preheader: `Tienes una invitación de firma para ${documentTitle}.`, + title: 'Invitación de firma', + intro: `Hola ${escapeHtml(inviteeName)}. ${escapeHtml(ownerName)} te ha invitado a firmar el documento ${escapeHtml(documentTitle)}.`, + bodyHtml: ` +Orden de firma: ${orderIndex} de ${totalInvites}
+Para completar la firma:
+El enlace no está activo o ya expiró.
++ LinkedSign procesará esta firma de forma puntual para completar la cadena del documento. + Tu certificado P12/PFX y la contraseña que ingreses no serán guardados por el sistema. +
++ Esta invitación requiere obligatoriamente firma electrónica con tu propio certificado P12/PFX. +
+{invitation.documentTitle}
+La firma se registró correctamente y el usuario principal ya fue notificado.
+