Fix passenger startup and clean tracked generated files

This commit is contained in:
abiandev
2026-03-31 15:14:12 +02:00
parent c1b9fe666b
commit 5385841a62
5908 changed files with 15305 additions and 872091 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
const mysql = require('mysql2');
const dotenv = require('dotenv');
const path = require('path');
dotenv.config();
dotenv.config({ path: path.resolve(__dirname, '..', '..', '.env'), override: true });
const pool = mysql.createPool({
host: process.env.DB_HOST,
+2 -1
View File
@@ -1,7 +1,8 @@
const admin = require('firebase-admin');
const dotenv = require('dotenv');
const path = require('path');
dotenv.config();
dotenv.config({ path: path.resolve(__dirname, '..', '..', '.env'), override: true });
const initFirebaseAdmin = () => {
if (admin.apps.length) {
+185 -67
View File
@@ -1,71 +1,189 @@
const db = require('../config/db');
const md5 = require('md5');
const jwt = require('jsonwebtoken');
const db = require('../config/db');
const md5 = require('md5');
const jwt = require('jsonwebtoken');
const fs = require('fs/promises');
const { buildProfileImageUrl, normalizeProfilePhotoPath } = require('../utils/profilePhoto');
const DEFAULT_JWT_EXPIRES_IN = '24h';
const isLegacyLoginModeEnabled = () => process.env.LOGIN_LEGACY_MODE === '1';
const LOGIN_FILE_LOG_PATH = '/var/log/login.log';
const logLoginAttempt = async (usuario, acceso) => {
const fecha = new Date().toISOString().slice(0, 19).replace('T', ' ');
try {
await db.query(
"INSERT INTO c_log_app (usuario, fecha, acceso) VALUES (?, ?, ?)",
[usuario, fecha, acceso]
);
} catch (logError) {
console.error('Login audit insert failed:', {
message: logError.message,
usuario,
acceso
});
}
};
const logLoginAttemptToFile = async (usuario, status, reason) => {
const timestamp = new Date().toISOString();
const safeUsuario = String(usuario || '').replace(/\s+/g, ' ').trim() || 'unknown';
const safeReason = String(reason || '').replace(/\s+/g, ' ').trim() || 'unspecified';
const line = `[${timestamp}] usuario=${safeUsuario} status=${status} reason=${safeReason}\n`;
try {
await fs.appendFile(LOGIN_FILE_LOG_PATH, line, 'utf8');
} catch (fileLogError) {
console.error('Login file log write failed:', {
message: fileLogError.message,
usuario: safeUsuario,
status,
reason: safeReason,
file: LOGIN_FILE_LOG_PATH
});
}
};
const legacyInvalidCredentialsResponse = (res) => res.status(200).json("0");
const legacyInternalErrorResponse = (res) => res.status(500).json("0");
const login = async (req, res) => {
try {
const usuario = String(req.body?.usuario || '').trim();
const contrasena = String(req.body?.contrasena || '');
const legacyMode = isLegacyLoginModeEnabled();
if (!usuario || !contrasena) {
await logLoginAttemptToFile(usuario, 'FAIL', 'missing_credentials');
if (legacyMode) {
return legacyInvalidCredentialsResponse(res);
}
return res.status(401).json({
success: false,
error: 'Invalid credentials'
});
}
const hashedPassword = md5(contrasena);
const login = async (req, res) => {
try {
const { usuario, contrasena } = req.body;
if (!usuario || !contrasena) {
return res.json("0"); // Maintain legacy error response
}
const hashedPassword = md5(contrasena);
// Parametrized query to prevent SQL Injection
const [rows] = await db.query(
"SELECT id_transportista, nombre, id_proveedor, dni FROM m_proveedores_trasportistas WHERE desactivado = 0 AND dni = ? AND password = ?",
[usuario, hashedPassword]
);
const fecha = new Date().toISOString().slice(0, 19).replace('T', ' '); // Format: YYYY-MM-DD HH:mm:ss
if (rows.length > 0) {
const user = rows[0];
// Log successful attempt
await db.query(
"INSERT INTO c_log_app (usuario, fecha, acceso) VALUES (?, ?, ?)",
[usuario, fecha, 1]
);
// Legacy format response: array with [dni, id_proveedor]
// const response = [user.dni, user.id_proveedor];
// Modern JWT response
const token = jwt.sign(
{
id: user.id_transportista,
dni: user.dni,
id_proveedor: user.id_proveedor
},
process.env.JWT_SECRET,
{ expiresIn: '365d' } // Long expiration for app usability
);
return res.json({
success: true,
token: token,
user: {
dni: user.dni,
id_proveedor: user.id_proveedor,
nombre: user.nombre
}
});
} else {
// Log failed attempt
await db.query(
"INSERT INTO c_log_app (usuario, fecha, acceso) VALUES (?, ?, ?)",
[usuario, fecha, 0]
);
return res.json("0");
}
} catch (error) {
console.error("Login error:", error);
return res.status(500).json("0"); // Fallback to "0" on error to not crash client
}
};
// Parametrized query to prevent SQL Injection
const [rows] = await db.query(
`SELECT
t.id_transportista,
t.nombre,
t.id_proveedor,
t.dni,
t.foto_perfil,
cr.email_operaciones
FROM m_proveedores_trasportistas t
LEFT JOIN m_proveedores p ON p.id_proveedor = t.id_proveedor
LEFT JOIN m_cr cr ON cr.id_cr = p.id_cr
WHERE t.desactivado = 0 AND t.dni = ? AND t.password = ?`,
[usuario, hashedPassword]
);
if (rows.length > 0) {
const user = rows[0];
const emailOperaciones = String(user.email_operaciones || '').trim();
const normalizedProfilePath = normalizeProfilePhotoPath(user.foto_perfil);
if (!emailOperaciones) {
await logLoginAttempt(usuario, 0);
await logLoginAttemptToFile(usuario, 'FAIL', 'missing_email_operaciones');
console.warn('Login blocked: missing m_cr.email_operaciones', {
usuario,
id_proveedor: user.id_proveedor
});
if (legacyMode) {
return legacyInvalidCredentialsResponse(res);
}
return res.status(403).json({
success: false,
error: 'Forbidden'
});
}
await logLoginAttempt(usuario, 1);
await logLoginAttemptToFile(usuario, 'SUCCESS', 'authenticated');
if (!process.env.JWT_SECRET) {
await logLoginAttemptToFile(usuario, 'FAIL', 'missing_jwt_secret');
console.error('Missing JWT_SECRET environment variable');
if (legacyMode) {
return legacyInternalErrorResponse(res);
}
return res.status(500).json({
success: false,
error: 'Internal server error'
});
}
const jwtPayload = {
id: user.id_transportista,
dni: user.dni,
id_proveedor: user.id_proveedor,
usuario
};
const token = jwt.sign(
jwtPayload,
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || DEFAULT_JWT_EXPIRES_IN }
);
if (legacyMode) {
return res.status(200).json([user.dni, user.id_proveedor]);
}
return res.status(200).json({
success: true,
token,
user: {
dni: user.dni,
id_proveedor: user.id_proveedor,
usuario,
nombre: user.nombre,
email_operaciones: emailOperaciones,
foto_perfil: normalizedProfilePath,
profileImage: buildProfileImageUrl(req, normalizedProfilePath)
}
});
} else {
await logLoginAttempt(usuario, 0);
await logLoginAttemptToFile(usuario, 'FAIL', 'invalid_credentials');
if (legacyMode) {
return legacyInvalidCredentialsResponse(res);
}
return res.status(401).json({
success: false,
error: 'Invalid credentials'
});
}
} catch (error) {
const usuario = String(req.body?.usuario || '').trim();
await logLoginAttemptToFile(usuario, 'FAIL', 'exception');
console.error("Login error:", error);
if (isLegacyLoginModeEnabled()) {
return legacyInternalErrorResponse(res);
}
return res.status(500).json({
success: false,
error: 'Internal server error'
});
}
};
module.exports = {
login
+822
View File
@@ -0,0 +1,822 @@
const crypto = require('crypto');
const db = require('../config/db');
const { FRONT_FILE_FIELD, BACK_FILE_FIELD } = require('../middleware/driverLicenseUpload');
const { encryptBuffer } = require('../services/driverLicenseCrypto');
const {
persistEncryptedBuffer,
readDecryptedBuffer,
removeStoredFile
} = require('../services/driverLicenseStorage');
const DRIVER_LICENSE_DOCUMENT_TYPE = 'driver_license';
const DEFAULT_RETENTION_DAYS = 365;
const ADMIN_ROLES = new Set(['admin', 'superadmin', 'compliance', 'backoffice']);
const DOCUMENT_SIDES = new Set(['front', 'back']);
const MIME_EXTENSIONS = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp'
};
const SIDE_TO_FILE_FIELD = {
front: FRONT_FILE_FIELD,
back: BACK_FILE_FIELD
};
const UUID_V4_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const normalizeDni = (rawDni) => String(rawDni || '').trim().toUpperCase();
const parseProviderId = (rawProviderId) => {
const parsed = Number.parseInt(String(rawProviderId || '').trim(), 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
return null;
}
return parsed;
};
const parseDocumentSide = (rawSide) => {
const side = String(rawSide || '').trim().toLowerCase();
return DOCUMENT_SIDES.has(side) ? side : null;
};
const parseRetentionDays = () => {
const parsed = Number.parseInt(String(process.env.DRIVER_LICENSE_RETENTION_DAYS || ''), 10);
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 3650) {
return DEFAULT_RETENTION_DAYS;
}
return parsed;
};
const getDriverLicenseKeyVersion = () => {
const keyVersion = String(
process.env.DRIVER_LICENSE_KEY_VERSION ||
process.env.DRIVER_LICENSE_ENCRYPTION_KEY_VERSION ||
'v1'
)
.trim()
.slice(0, 32);
return keyVersion || 'v1';
};
const isPrivilegedUser = (user) => {
const role = String(user?.role || '').trim().toLowerCase();
const isAdminFlag = user?.is_admin === true || user?.is_admin === 1 || user?.is_admin === '1';
return isAdminFlag || ADMIN_ROLES.has(role);
};
const isAuthorizedForTarget = (user, dni, idProveedor) => {
if (!user) {
return false;
}
if (isPrivilegedUser(user)) {
return true;
}
const userDni = normalizeDni(user.dni);
const userProviderId = parseProviderId(user.id_proveedor);
return userDni === dni && userProviderId === idProveedor;
};
const getClientIp = (req) => String(req.ip || '').slice(0, 45);
const getUserAgent = (req) => String(req.headers['user-agent'] || '').slice(0, 255);
const parseUploaderId = (user) => {
const parsed = Number.parseInt(String(user?.id || ''), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const detectMimeTypeFromSignature = (buffer) => {
if (!Buffer.isBuffer(buffer) || buffer.length < 12) {
return null;
}
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return 'image/jpeg';
}
const isPng =
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47 &&
buffer[4] === 0x0d &&
buffer[5] === 0x0a &&
buffer[6] === 0x1a &&
buffer[7] === 0x0a;
if (isPng) {
return 'image/png';
}
const riff = buffer.subarray(0, 4).toString('ascii');
const webp = buffer.subarray(8, 12).toString('ascii');
if (riff === 'RIFF' && webp === 'WEBP') {
return 'image/webp';
}
return null;
};
const buildPublicReference = (side, publicId) => `secure/driver-license/${side}/${publicId}`;
const buildApiResponse = (side, publicId) => {
const reference = buildPublicReference(side, publicId);
const baseResponse = {
success: true,
document_side: side,
data: {
document_side: side
}
};
if (side === 'front') {
baseResponse.carnet_conducir_frontal = reference;
baseResponse.driverLicenseFrontImage = reference;
baseResponse.carnet_conducir = reference;
baseResponse.data.carnet_conducir_frontal = reference;
} else {
baseResponse.carnet_conducir_trasera = reference;
baseResponse.driverLicenseBackImage = reference;
baseResponse.carnet_conducir = reference;
baseResponse.data.carnet_conducir_trasera = reference;
}
return baseResponse;
};
const getUploadedFileForSide = (req, side) => {
const frontFiles = Array.isArray(req.files?.[FRONT_FILE_FIELD]) ? req.files[FRONT_FILE_FIELD] : [];
const backFiles = Array.isArray(req.files?.[BACK_FILE_FIELD]) ? req.files[BACK_FILE_FIELD] : [];
const totalFiles = frontFiles.length + backFiles.length;
if (totalFiles === 0) {
return {
ok: false,
error: `Archivo requerido. Para ${side} usa el campo ${SIDE_TO_FILE_FIELD[side]}.`
};
}
if (totalFiles > 1) {
return {
ok: false,
error: 'Solo se permite 1 archivo por request.'
};
}
if (side === 'front' && frontFiles.length === 1) {
return { ok: true, file: frontFiles[0] };
}
if (side === 'back' && backFiles.length === 1) {
return { ok: true, file: backFiles[0] };
}
return {
ok: false,
error: `Para document_side=${side} debe enviarse ${SIDE_TO_FILE_FIELD[side]}.`
};
};
const recordAuditEvent = async ({
fileId,
action,
actorUserId,
dni,
idProveedor,
side,
ip,
userAgent
}) => {
try {
await db.query(
`INSERT INTO driver_license_access_audit
(driver_license_file_id, action, actor_user_id, dni_target, id_proveedor_target, side_target, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
fileId || null,
action,
actorUserId || null,
dni || null,
idProveedor || null,
side || null,
ip || null,
userAgent || null
]
);
} catch (auditError) {
console.error('Driver license audit insert failed:', {
message: auditError.message,
action,
fileId: fileId || null,
actorUserId: actorUserId || null,
dni: dni || null,
idProveedor: idProveedor || null,
side: side || null
});
}
};
const ensureTargetExists = async (dni, idProveedor) => {
const [rows] = await db.query(
`SELECT id_transportista
FROM m_proveedores_trasportistas
WHERE dni = ?
AND id_proveedor = ?
AND desactivado = 0
LIMIT 1`,
[dni, idProveedor]
);
return rows.length > 0;
};
const findDriverLicenseFileByPublicId = async (publicId) => {
const [rows] = await db.query(
`SELECT
id,
public_id,
dni,
id_proveedor,
side,
storage_key,
mime_type,
encryption_alg,
encryption_iv,
encryption_tag,
expires_at,
deleted_at
FROM driver_license_files
WHERE public_id = ?
LIMIT 1`,
[publicId]
);
return rows[0] || null;
};
const findActiveDriverLicenseByTargetAndSide = async (dni, idProveedor, side) => {
const [rows] = await db.query(
`SELECT
id,
public_id,
dni,
id_proveedor,
side,
storage_key,
mime_type,
encryption_alg,
encryption_iv,
encryption_tag,
expires_at,
deleted_at
FROM driver_license_files
WHERE dni = ?
AND id_proveedor = ?
AND side = ?
AND document_type = ?
AND deleted_at IS NULL
ORDER BY id DESC
LIMIT 1`,
[dni, idProveedor, side, DRIVER_LICENSE_DOCUMENT_TYPE]
);
return rows[0] || null;
};
const hasExpired = (expiresAt) => {
if (!expiresAt) {
return false;
}
return new Date(expiresAt).getTime() <= Date.now();
};
const markAsDeleted = async (fileId) => {
await db.query(
`UPDATE driver_license_files
SET deleted_at = COALESCE(deleted_at, NOW())
WHERE id = ?`,
[fileId]
);
};
const validateUploadPayload = (req) => {
const dni = normalizeDni(req.body?.dni);
const idProveedor = parseProviderId(req.body?.id_proveedor);
const documentType = String(req.body?.document_type || '').trim();
const side = parseDocumentSide(req.body?.document_side);
if (!dni || !idProveedor || documentType !== DRIVER_LICENSE_DOCUMENT_TYPE) {
return {
ok: false,
statusCode: 400,
body: {
success: false,
error: 'dni, id_proveedor y document_type=driver_license son obligatorios.'
}
};
}
if (!side) {
return {
ok: false,
statusCode: 400,
body: {
success: false,
error: 'document_side invalido. Valores permitidos: front, back.'
}
};
}
const fileSelection = getUploadedFileForSide(req, side);
if (!fileSelection.ok) {
return {
ok: false,
statusCode: 400,
body: {
success: false,
error: fileSelection.error
}
};
}
const detectedMime = detectMimeTypeFromSignature(fileSelection.file.buffer);
if (!detectedMime || detectedMime !== fileSelection.file.mimetype) {
return {
ok: false,
statusCode: 400,
body: {
success: false,
error: 'Archivo invalido: el contenido no coincide con el tipo permitido.'
}
};
}
return {
ok: true,
dni,
idProveedor,
side,
file: fileSelection.file,
detectedMime
};
};
const updateDriverLicense = async (req, res) => {
const uploadValidation = validateUploadPayload(req);
if (!uploadValidation.ok) {
return res.status(uploadValidation.statusCode).json(uploadValidation.body);
}
const { dni, idProveedor, side, file, detectedMime } = uploadValidation;
const uploaderId = parseUploaderId(req.user);
if (!isAuthorizedForTarget(req.user, dni, idProveedor)) {
await recordAuditEvent({
fileId: null,
action: 'upload_forbidden',
actorUserId: uploaderId,
dni,
idProveedor,
side,
ip: getClientIp(req),
userAgent: getUserAgent(req)
});
return res.status(403).json({
success: false,
error: 'Forbidden'
});
}
let storageKey = null;
let connection;
let insertedFileId = null;
try {
const targetExists = await ensureTargetExists(dni, idProveedor);
if (!targetExists) {
await recordAuditEvent({
fileId: null,
action: 'upload_forbidden',
actorUserId: uploaderId,
dni,
idProveedor,
side,
ip: getClientIp(req),
userAgent: getUserAgent(req)
});
return res.status(403).json({
success: false,
error: 'Forbidden'
});
}
const publicId = crypto.randomUUID();
const checksumSha256 = crypto
.createHash('sha256')
.update(file.buffer)
.digest('hex');
const retentionDays = parseRetentionDays();
const expiresAt = new Date(Date.now() + retentionDays * 24 * 60 * 60 * 1000);
const encrypted = encryptBuffer(file.buffer);
const persisted = await persistEncryptedBuffer(encrypted.ciphertext);
storageKey = persisted.storageKey;
connection = await db.getConnection();
await connection.beginTransaction();
await connection.query(
`UPDATE driver_license_files
SET deleted_at = NOW()
WHERE dni = ?
AND id_proveedor = ?
AND side = ?
AND document_type = ?
AND deleted_at IS NULL`,
[dni, idProveedor, side, DRIVER_LICENSE_DOCUMENT_TYPE]
);
const [insertResult] = await connection.query(
`INSERT INTO driver_license_files
(public_id, dni, id_proveedor, side, document_type, storage_key, mime_type, size_bytes, checksum_sha256, encryption_alg, encryption_iv, encryption_tag, key_version, uploaded_by, expires_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
[
publicId,
dni,
idProveedor,
side,
DRIVER_LICENSE_DOCUMENT_TYPE,
storageKey,
detectedMime,
file.size,
checksumSha256,
encrypted.algorithm,
encrypted.ivHex,
encrypted.authTagHex,
getDriverLicenseKeyVersion(),
uploaderId,
expiresAt
]
);
insertedFileId = insertResult.insertId;
await connection.commit();
await recordAuditEvent({
fileId: insertedFileId,
action: 'upload_success',
actorUserId: uploaderId,
dni,
idProveedor,
side,
ip: getClientIp(req),
userAgent: getUserAgent(req)
});
return res.status(200).json(buildApiResponse(side, publicId));
} catch (error) {
if (connection) {
try {
await connection.rollback();
} catch (rollbackError) {
console.error('Rollback failed after driver license upload error:', {
message: rollbackError.message,
dni,
idProveedor,
side
});
}
}
if (storageKey) {
try {
await removeStoredFile(storageKey);
} catch (deleteError) {
console.error('Failed to cleanup encrypted driver license after error:', {
message: deleteError.message,
dni,
idProveedor,
side
});
}
}
console.error('Error uploading driver license:', {
message: error.message,
dni,
idProveedor,
side,
userId: uploaderId
});
return res.status(500).json({
success: false,
error: 'Internal server error'
});
} finally {
if (connection) {
connection.release();
}
}
};
const sendFileRecord = async (res, fileRecord) => {
const decrypted = await readDecryptedBuffer(fileRecord.storage_key, {
algorithm: fileRecord.encryption_alg,
ivHex: fileRecord.encryption_iv,
authTagHex: fileRecord.encryption_tag
});
const extension = MIME_EXTENSIONS[fileRecord.mime_type] || '.bin';
res.setHeader('Content-Type', fileRecord.mime_type || 'application/octet-stream');
res.setHeader('Content-Length', String(decrypted.length));
res.setHeader('Cache-Control', 'private, no-store, max-age=0');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader(
'Content-Disposition',
`inline; filename="driver_license_${fileRecord.side}_${fileRecord.public_id}${extension}"`
);
return res.status(200).send(decrypted);
};
const sendDriverLicenseBySide = async (req, res) => {
const side = parseDocumentSide(req.params?.side);
const dni = normalizeDni(req.query?.dni || req.user?.dni);
const idProveedor = parseProviderId(req.query?.id_proveedor || req.user?.id_proveedor);
const actorUserId = parseUploaderId(req.user);
if (!side) {
return res.status(400).json({
success: false,
error: 'document_side invalido. Valores permitidos: front, back.'
});
}
if (!dni || !idProveedor) {
return res.status(400).json({
success: false,
error: 'dni e id_proveedor son obligatorios.'
});
}
if (!isAuthorizedForTarget(req.user, dni, idProveedor)) {
await recordAuditEvent({
fileId: null,
action: 'download_forbidden',
actorUserId,
dni,
idProveedor,
side,
ip: getClientIp(req),
userAgent: getUserAgent(req)
});
return res.status(403).json({
success: false,
error: 'Forbidden'
});
}
try {
const fileRecord = await findActiveDriverLicenseByTargetAndSide(dni, idProveedor, side);
if (!fileRecord || fileRecord.deleted_at) {
return res.status(404).json({
success: false,
error: 'Not found'
});
}
if (hasExpired(fileRecord.expires_at)) {
await markAsDeleted(fileRecord.id);
await recordAuditEvent({
fileId: fileRecord.id,
action: 'download_expired',
actorUserId,
dni,
idProveedor,
side,
ip: getClientIp(req),
userAgent: getUserAgent(req)
});
return res.status(404).json({
success: false,
error: 'Not found'
});
}
await recordAuditEvent({
fileId: fileRecord.id,
action: 'download_success',
actorUserId,
dni,
idProveedor,
side,
ip: getClientIp(req),
userAgent: getUserAgent(req)
});
return sendFileRecord(res, fileRecord);
} catch (error) {
console.error('Error retrieving driver license by side:', {
message: error.message,
dni,
idProveedor,
side,
userId: actorUserId
});
return res.status(500).json({
success: false,
error: 'Internal server error'
});
}
};
const sendDriverLicense = async (req, res) => {
const publicId = String(req.params?.publicId || '').trim();
const actorUserId = parseUploaderId(req.user);
if (!UUID_V4_REGEX.test(publicId)) {
return res.status(400).json({
success: false,
error: 'Invalid id'
});
}
try {
const fileRecord = await findDriverLicenseFileByPublicId(publicId);
if (!fileRecord || fileRecord.deleted_at) {
return res.status(404).json({
success: false,
error: 'Not found'
});
}
if (hasExpired(fileRecord.expires_at)) {
await markAsDeleted(fileRecord.id);
await recordAuditEvent({
fileId: fileRecord.id,
action: 'download_expired',
actorUserId,
dni: fileRecord.dni,
idProveedor: fileRecord.id_proveedor,
side: fileRecord.side,
ip: getClientIp(req),
userAgent: getUserAgent(req)
});
return res.status(404).json({
success: false,
error: 'Not found'
});
}
if (!isAuthorizedForTarget(req.user, normalizeDni(fileRecord.dni), parseProviderId(fileRecord.id_proveedor))) {
await recordAuditEvent({
fileId: fileRecord.id,
action: 'download_forbidden',
actorUserId,
dni: fileRecord.dni,
idProveedor: fileRecord.id_proveedor,
side: fileRecord.side,
ip: getClientIp(req),
userAgent: getUserAgent(req)
});
return res.status(403).json({
success: false,
error: 'Forbidden'
});
}
await recordAuditEvent({
fileId: fileRecord.id,
action: 'download_success',
actorUserId,
dni: fileRecord.dni,
idProveedor: fileRecord.id_proveedor,
side: fileRecord.side,
ip: getClientIp(req),
userAgent: getUserAgent(req)
});
return sendFileRecord(res, fileRecord);
} catch (error) {
console.error('Error retrieving driver license by id:', {
message: error.message,
publicId,
userId: actorUserId
});
return res.status(500).json({
success: false,
error: 'Internal server error'
});
}
};
const deleteDriverLicense = async (req, res) => {
const publicId = String(req.params?.publicId || '').trim();
const actorUserId = parseUploaderId(req.user);
if (!UUID_V4_REGEX.test(publicId)) {
return res.status(400).json({
success: false,
error: 'Invalid id'
});
}
try {
const fileRecord = await findDriverLicenseFileByPublicId(publicId);
if (!fileRecord || fileRecord.deleted_at) {
return res.status(404).json({
success: false,
error: 'Not found'
});
}
if (!isAuthorizedForTarget(req.user, normalizeDni(fileRecord.dni), parseProviderId(fileRecord.id_proveedor))) {
await recordAuditEvent({
fileId: fileRecord.id,
action: 'delete_forbidden',
actorUserId,
dni: fileRecord.dni,
idProveedor: fileRecord.id_proveedor,
side: fileRecord.side,
ip: getClientIp(req),
userAgent: getUserAgent(req)
});
return res.status(403).json({
success: false,
error: 'Forbidden'
});
}
await markAsDeleted(fileRecord.id);
await recordAuditEvent({
fileId: fileRecord.id,
action: 'delete_success',
actorUserId,
dni: fileRecord.dni,
idProveedor: fileRecord.id_proveedor,
side: fileRecord.side,
ip: getClientIp(req),
userAgent: getUserAgent(req)
});
return res.status(200).json({
success: true
});
} catch (error) {
console.error('Error deleting driver license:', {
message: error.message,
publicId,
userId: actorUserId
});
return res.status(500).json({
success: false,
error: 'Internal server error'
});
}
};
module.exports = {
updateDriverLicense,
sendDriverLicense,
sendDriverLicenseBySide,
deleteDriverLicense,
DRIVER_LICENSE_DOCUMENT_TYPE,
buildPublicReference
};
+116
View File
@@ -0,0 +1,116 @@
SELECT
CAST(vp.id_punto AS SIGNED) AS id_punto,
CAST(COALESCE(m.id_punto, vp.ref_punto_id) AS SIGNED) AS id_punto_ref,
vp.posicion,
COALESCE(m.nombre, '') AS nombre,
COALESCE(m.contacto, '') AS contacto,
COALESCE(m.telefono, '') AS telefono,
COALESCE(m.direccion, '') AS direccion,
CASE
WHEN REPLACE(TRIM(COALESCE(m.latitud, '')), ',', '.') REGEXP '^(-|)[0-9]+(\\.[0-9]+|)$'
THEN CAST(REPLACE(TRIM(m.latitud), ',', '.') AS DOUBLE)
ELSE NULL
END AS latitud,
CASE
WHEN REPLACE(TRIM(COALESCE(m.longitud, '')), ',', '.') REGEXP '^(-|)[0-9]+(\\.[0-9]+|)$'
THEN CAST(REPLACE(TRIM(m.longitud), ',', '.') AS DOUBLE)
ELSE NULL
END AS longitud,
COALESCE(
vp.obs,
COALESCE(m.observaciones, ''),
''
) AS obs,
CASE
WHEN vp.actualizado_automaticamente = 1 THEN NULL
ELSE vp.id_estado_intermedio
END AS id_estado_intermedio,
CASE
WHEN vp.actualizado_automaticamente = 1 THEN ''
ELSE COALESCE(te.estado, '')
END AS estado_intermedio,
CASE
WHEN vp.actualizado_automaticamente = 1 THEN ''
ELSE COALESCE(te.estado_en, '')
END AS estado_intermedio_en,
CASE
WHEN vp.actualizado_automaticamente = 1 THEN NULL
ELSE vp.fecha_y_hora
END AS fecha_y_hora,
CASE
WHEN vp.actualizado_automaticamente = 1 THEN NULL
ELSE vp.ind_fallido
END AS ind_fallido,
CASE
WHEN vp.actualizado_automaticamente = 1 THEN NULL
ELSE vp.latitud_estado
END AS latitud_estado,
CASE
WHEN vp.actualizado_automaticamente = 1 THEN NULL
ELSE vp.longitud_estado
END AS longitud_estado,
CASE
WHEN vp.actualizado_automaticamente = 1 THEN NULL
ELSE vp.fecha_hora
END AS fecha_hora,
CASE
WHEN vp.actualizado_automaticamente = 1 THEN NULL
ELSE vp.fecha_hora
END AS datetime,
NULLIF(TRIM(v.observaciones_mercancia), '') AS observaciones_mercancia,
NULLIF(TRIM(v.observaciones_cliente), '') AS observaciones_cliente
FROM (
SELECT
id_punto,
id_viaje,
posicion,
id_estado_intermedio,
DATE_FORMAT(fecha_y_hora, '%Y-%m-%d %H:%i:%s') AS fecha_y_hora,
COALESCE(ind_fallido, 0) AS ind_fallido,
latitud AS latitud_estado,
longitud AS longitud_estado,
fecha_foto,
COALESCE(actualizado_automaticamente, 0) AS actualizado_automaticamente,
NULLIF(TRIM(valor), '') AS valor_plain,
CASE
WHEN NULLIF(TRIM(SUBSTRING_INDEX(valor, ':|:', 1)), '') REGEXP '^[0-9]+$'
THEN CAST(NULLIF(TRIM(SUBSTRING_INDEX(valor, ':|:', 1)), '') AS UNSIGNED)
ELSE NULL
END AS ref_punto_id,
CASE
WHEN NULLIF(TRIM(SUBSTRING_INDEX(valor, ':|:', 1)), '') REGEXP '^[0-9]+$'
THEN NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 2), ':|:', -1)), '')
ELSE NULLIF(TRIM(valor), '')
END AS obs,
CASE
WHEN NULLIF(TRIM(SUBSTRING_INDEX(valor, ':|:', 1)), '') REGEXP '^[0-9]+$'
AND NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 3), ':|:', -1)), '') REGEXP '^([0-9]{4})-([0-9]{2})-([0-9]{2})$'
AND NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 4), ':|:', -1)), '') REGEXP '^([0-2][0-9]):([0-5][0-9])$'
THEN CONCAT(
NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 3), ':|:', -1)), ''),
' ',
NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 4), ':|:', -1)), ''),
':00'
)
WHEN NULLIF(TRIM(SUBSTRING_INDEX(valor, ':|:', 1)), '') REGEXP '^[0-9]+$'
AND NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 3), ':|:', -1)), '') REGEXP '^([0-9]{4})-([0-9]{2})-([0-9]{2})$'
AND NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 4), ':|:', -1)), '') REGEXP '^([0-2][0-9]):([0-5][0-9]):([0-5][0-9])$'
THEN CONCAT(
NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 3), ':|:', -1)), ''),
' ',
NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 4), ':|:', -1)), '')
)
WHEN fecha_foto IS NOT NULL
THEN DATE_FORMAT(fecha_foto, '%Y-%m-%d %H:%i:%s')
ELSE NULL
END AS fecha_hora
FROM c_viajes_puntos
WHERE id_viaje = ?
) vp
LEFT JOIN m_puntos_envio_recogida m
ON m.id_punto = vp.ref_punto_id
LEFT JOIN t_viaje_estados te
ON te.id_estado = vp.id_estado_intermedio
LEFT JOIN c_viajes v
ON v.id_viaje = vp.id_viaje
ORDER BY vp.posicion ASC, vp.id_punto ASC;`
+146 -114
View File
@@ -1,117 +1,149 @@
const fs = require('fs');
const path = require('path');
const saveLocation = async (req, res) => {
try {
const data = req.body;
console.log("🔍 [DEBUG SERVER] Full Request Body:", JSON.stringify(data, null, 2)); // Debug payload structure
const logFile = path.join(__dirname, '../logs/coordenadas.txt');
// Ensure logs directory exists
const logDir = path.dirname(logFile);
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
// Helper function to process a single location object
const processSingleLocation = (locationData) => {
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown';
// Intentar obtener alias desde "extras.alias" o "alias"
let alias = 'Desconocido';
if (locationData.extras && locationData.extras.alias) {
alias = locationData.extras.alias;
} else if (locationData.alias) {
alias = locationData.alias;
}
let lat, lng;
if (locationData.location && locationData.location.coords) {
lat = locationData.location.coords.latitude;
lng = locationData.location.coords.longitude;
} else if (locationData.coords) {
lat = locationData.coords.latitude;
lng = locationData.coords.longitude;
} else if (locationData.latitude && locationData.longitude) {
lat = locationData.latitude;
lng = locationData.longitude;
}
if (lat !== undefined && lng !== undefined) {
console.log(`📍 [Server] Received: Alias=${alias} | Lat=${lat} | Lng=${lng}`);
const linea = `[${timestamp}] Alias: ${alias} | IP: ${ip} | Lat: ${lat} | Lng: ${lng}\n`;
fs.appendFileSync(logFile, linea);
return true;
} else {
const errorLine = `[${timestamp}] Alias: ${alias} | IP: ${ip} | ⚠️ Formato inválido o sin coordenadas\n`;
try {
fs.appendFileSync(logFile, errorLine);
} catch (e) {
console.error("Error writing error log:", e);
}
return false;
}
};
// Determine if it's a Batch (Array) or Single Location
let locations = [];
let globalAlias = null;
// 1. Priority: JWT User (Secure & Reliable)
if (req.user) {
console.log("🔐 [Server] req.user payload:", JSON.stringify(req.user)); // Debug token content
if (req.user.dni) {
globalAlias = req.user.dni;
console.log(`🔒 [Server] Authenticated User (GlobalAlias): ${globalAlias}`);
}
}
// Case 1: Root Array
if (Array.isArray(data)) {
locations = data;
}
// Case 2: Wrapped in "location" property (common with params)
else if (data.location && Array.isArray(data.location)) {
locations = data.location;
// Check for alias in root (Fallback if no token)
if (!globalAlias) {
if (data.alias) globalAlias = data.alias;
else if (data.params && data.params.alias) globalAlias = data.params.alias;
}
}
// Case 3: Single Object
else {
locations = [data];
}
console.log(`📦 [Server] Processing ${locations.length} locations`);
let savedCount = 0;
locations.forEach(loc => {
// FORCE Inject global alias (Security: Always trust Token over Client Body)
if (globalAlias) {
if (!loc.extras) loc.extras = {};
loc.extras.alias = globalAlias;
// Also purge root alias to avoid confusion in processSingleLocation logic
if (loc.alias) delete loc.alias;
}
// Fallback for processSingleLocation helper logic...
if (processSingleLocation(loc)) savedCount++;
});
if (savedCount > 0) {
return res.json({ success: true, count: savedCount, message: "Locations saved" });
} else {
return res.json({ success: false, error: 'no_coords', message: "No valid coordinates found in batch/object" });
}
} catch (error) {
console.error("Error saving location:", error);
return res.status(500).json({ success: false, error: error.message });
}
};
const db = require('../config/db');
const getDniFromLocation = (locationData) => {
if (locationData?.extras?.alias) {
return String(locationData.extras.alias);
}
if (locationData?.alias) {
return String(locationData.alias);
}
return null;
};
const getCoordinatesFromLocation = (locationData) => {
if (locationData?.location?.coords) {
return {
lat: locationData.location.coords.latitude,
lng: locationData.location.coords.longitude
};
}
if (locationData?.coords) {
return {
lat: locationData.coords.latitude,
lng: locationData.coords.longitude
};
}
if (
Object.prototype.hasOwnProperty.call(locationData || {}, 'latitude') &&
Object.prototype.hasOwnProperty.call(locationData || {}, 'longitude')
) {
return {
lat: locationData.latitude,
lng: locationData.longitude
};
}
return null;
};
const normalizeTripId = (rawValue) => {
if (rawValue === undefined || rawValue === null || String(rawValue).trim() === '') {
return null;
}
const parsed = Number.parseInt(String(rawValue).trim(), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const getTripIdFromLocation = (locationData) => {
const candidates = [
locationData?.id_viaje,
locationData?.tripId,
locationData?.idViaje,
locationData?.params?.id_viaje,
locationData?.params?.tripId,
locationData?.extras?.id_viaje,
locationData?.extras?.tripId
];
for (const value of candidates) {
const normalized = normalizeTripId(value);
if (normalized !== null) {
return normalized;
}
}
return null;
};
const saveLocation = async (req, res) => {
try {
const data = req.body;
console.log('Location payload received:', JSON.stringify(data, null, 2));
let locations = [];
let globalDni = null;
let globalTripId = null;
if (req.user?.dni) {
globalDni = String(req.user.dni);
}
if (Array.isArray(data)) {
locations = data;
} else if (data?.location && Array.isArray(data.location)) {
locations = data.location;
if (!globalDni) {
if (data.alias) {
globalDni = String(data.alias);
} else if (data.params?.alias) {
globalDni = String(data.params.alias);
}
}
globalTripId = getTripIdFromLocation(data);
} else {
locations = [data];
globalTripId = getTripIdFromLocation(data);
}
const now = new Date();
const rowsToInsert = [];
for (const loc of locations) {
const coords = getCoordinatesFromLocation(loc);
const dni = globalDni || getDniFromLocation(loc);
const tripId = globalTripId !== null ? globalTripId : getTripIdFromLocation(loc);
if (coords && coords.lat !== undefined && coords.lat !== null && coords.lng !== undefined && coords.lng !== null) {
rowsToInsert.push([
String(coords.lat),
String(coords.lng),
dni || null,
now,
tripId
]);
}
}
if (rowsToInsert.length === 0) {
return res.json({
success: false,
error: 'no_coords',
message: 'No valid coordinates found in batch/object'
});
}
await db.query(
`INSERT INTO c_trazabilidad_transportista
(latitud, longitud, id_usuario, fecha, id_viaje)
VALUES ?`,
[rowsToInsert]
);
return res.json({
success: true,
count: rowsToInsert.length,
message: 'Locations saved'
});
} catch (error) {
console.error('Error saving location:', error);
return res.status(500).json({ success: false, error: error.message });
}
};
module.exports = {
saveLocation
+231 -12
View File
@@ -1,10 +1,100 @@
const db = require('../config/db');
const md5 = require('md5');
const db = require('../config/db');
const md5 = require('md5');
const fs = require('fs');
const path = require('path');
const { buildProfileImageUrl, normalizeProfilePhotoPath } = require('../utils/profilePhoto');
const TRANSPORTISTA_DNI = "09852109J";
const TRANSPORTISTA_ID_PROVEEDOR = 3333;
const DRIVER_LICENSE_DOCUMENT_TYPE = 'driver_license';
const DRIVER_LICENSE_SIGNAL_KEYS = new Set([
'document_type',
'document_side',
'carnet_conducir',
'carnet_conducir_frontal',
'carnet_conducir_trasera',
'driver_license'
]);
const DRIVER_LICENSE_FILENAME_PATTERN = /(carnet|driver[_-]?license|licencia)/i;
const normalizeDni = (rawDni) => String(rawDni || '').trim().toUpperCase();
const parseProviderId = (rawProviderId) => {
const parsed = Number.parseInt(String(rawProviderId || '').trim(), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const isAuthorizedProfileUpdateTarget = (user, dni, idProveedor) => {
if (!user) {
return false;
}
const tokenDni = normalizeDni(user.dni);
const tokenProviderId = parseProviderId(user.id_proveedor);
return tokenDni === dni && tokenProviderId === idProveedor;
};
const looksLikeDriverLicensePayload = (req) => {
const body = req.body || {};
const documentType = String(body.document_type || '').trim().toLowerCase();
const documentSide = String(body.document_side || '').trim().toLowerCase();
const fileOriginalName = String(req.file?.originalname || '').trim();
if (documentType === DRIVER_LICENSE_DOCUMENT_TYPE) {
return true;
}
if (documentSide === 'front' || documentSide === 'back') {
return true;
}
for (const key of Object.keys(body)) {
const normalizedKey = String(key || '').trim().toLowerCase();
if (!normalizedKey) {
continue;
}
if (DRIVER_LICENSE_SIGNAL_KEYS.has(normalizedKey)) {
return true;
}
}
return DRIVER_LICENSE_FILENAME_PATTERN.test(fileOriginalName);
};
const getProfile = async (req, res) => {
const logFcmError = (error, context) => {
try {
// Handle potential mismatch from legacy code (id_prov vs id_proveedor)
const id_proveedor = req.body.id_proveedor || req.body.id_prov;
const logPath = path.resolve(__dirname, '..', '..', 'tmp', 'fcm_error.log');
const logEntry = [
`[${new Date().toISOString()}] saveFcmToken error`,
error?.stack || String(error),
`context: ${JSON.stringify(context)}`,
''
].join('\n');
fs.appendFileSync(logPath, `${logEntry}\n`);
} catch (logError) {
console.error('Failed to write fcm_error.log', logError);
}
};
const deleteFileIfExists = (filePath) => {
if (!filePath) {
return;
}
fs.unlink(filePath, (unlinkError) => {
if (unlinkError && unlinkError.code !== 'ENOENT') {
console.error('Failed to remove uploaded file:', unlinkError);
}
});
};
const getProfile = async (req, res) => {
try {
// Handle potential mismatch from legacy code (id_prov vs id_proveedor)
const id_proveedor = req.body.id_proveedor || req.body.id_prov;
if (!id_proveedor) {
return res.status(400).json({ error: "Missing id_proveedor" });
@@ -15,13 +105,106 @@ const getProfile = async (req, res) => {
[id_proveedor]
);
const transportistaDni = req.user?.dni || TRANSPORTISTA_DNI;
const transportistaIdProveedor = req.user?.id_proveedor || TRANSPORTISTA_ID_PROVEEDOR;
const [transportistaRows] = await db.query(
"SELECT dni, id_proveedor, foto_perfil FROM m_proveedores_trasportistas WHERE dni = ? AND id_proveedor = ?",
[transportistaDni, transportistaIdProveedor]
);
if (transportistaRows.length !== 1) {
console.error(
"Transportista record missing or not unique for configured dni/id_proveedor",
{ dni: transportistaDni, id_proveedor: transportistaIdProveedor, count: transportistaRows.length }
);
return res.status(500).json([]);
}
const transportista = transportistaRows[0];
const normalizedProfilePath = normalizeProfilePhotoPath(transportista.foto_perfil);
const responseRows = rows.map((row) => ({
...row,
dni_transportista: transportista.dni,
id_proveedor_transportista: transportista.id_proveedor,
foto_perfil: normalizedProfilePath,
profileImage: buildProfileImageUrl(req, normalizedProfilePath)
}));
// PHP returned an array of objects
return res.json(rows);
return res.json(responseRows);
} catch (error) {
console.error("Error getting profile:", error);
return res.status(500).json([]);
}
};
return res.status(500).json([]);
}
};
const updateProfilePhoto = async (req, res) => {
const uploadedFilePath = req.file?.path;
try {
const dni = normalizeDni(req.body?.dni);
const id_proveedor = parseProviderId(req.body?.id_proveedor);
if (!dni || !id_proveedor) {
deleteFileIfExists(uploadedFilePath);
return res.status(400).json({ error: 'dni and id_proveedor are required' });
}
if (!req.file) {
return res.status(400).json({ error: 'foto_perfil file is required' });
}
if (!isAuthorizedProfileUpdateTarget(req.user, dni, id_proveedor)) {
deleteFileIfExists(uploadedFilePath);
return res.status(403).json({ error: 'Forbidden' });
}
if (looksLikeDriverLicensePayload(req)) {
deleteFileIfExists(uploadedFilePath);
return res.status(400).json({
error: 'Para carnet de conducir usa /api/update_driver_license (document_type=driver_license).'
});
}
const safeFileName = path.basename(req.file.filename);
const relativePhotoPath = path.posix.join('uploads', 'profile', safeFileName);
const [transportistaRows] = await db.query(
"SELECT dni, id_proveedor FROM m_proveedores_trasportistas WHERE dni = ? AND id_proveedor = ?",
[dni, id_proveedor]
);
if (transportistaRows.length === 0) {
deleteFileIfExists(uploadedFilePath);
return res.status(404).json({ error: 'Transportista no encontrado' });
}
const [result] = await db.query(
"UPDATE m_proveedores_trasportistas SET foto_perfil = ? WHERE dni = ? AND id_proveedor = ?",
[relativePhotoPath, dni, id_proveedor]
);
if (result.affectedRows === 0) {
deleteFileIfExists(uploadedFilePath);
return res.status(500).json({ error: 'No se pudo actualizar la foto de perfil' });
}
return res.status(200).json({
success: true,
foto_perfil: relativePhotoPath,
profileImage: buildProfileImageUrl(req, relativePhotoPath)
});
} catch (error) {
deleteFileIfExists(uploadedFilePath);
console.error('Error updating profile photo:', {
message: error.message,
dni: req.body?.dni,
id_proveedor: req.body?.id_proveedor
});
return res.status(500).json({ error: 'Internal server error' });
}
};
const changePassword = async (req, res) => {
try {
@@ -55,7 +238,43 @@ const changePassword = async (req, res) => {
}
};
module.exports = {
getProfile,
changePassword
const saveFcmToken = async (req, res) => {
try {
const { dni, id_proveedor, fcm_token } = req.body;
if (!dni || !id_proveedor || !fcm_token) {
return res.status(400).json({ error: "Missing dni, id_proveedor, or fcm_token" });
}
const [transportistaRows] = await db.query(
"SELECT dni, id_proveedor FROM m_proveedores_trasportistas WHERE dni = ? AND id_proveedor = ?",
[dni, id_proveedor]
);
if (transportistaRows.length !== 1) {
return res.status(404).json({ error: "Transportista not found or not unique" });
}
const [result] = await db.query(
"INSERT INTO m_transportista_fcm (id_proveedor, dni, fcm) VALUES (?, ?, ?)",
[id_proveedor, dni, fcm_token]
);
if (result.affectedRows === 0) {
return res.status(500).json({ error: "Failed to save fcm token" });
}
return res.json({ ok: true });
} catch (error) {
console.error("Error saving FCM token:", error);
logFcmError(error, { dni: req.body?.dni, id_proveedor: req.body?.id_proveedor });
return res.status(500).json({ error: "Internal server error" });
}
};
module.exports = {
getProfile,
changePassword,
saveFcmToken,
updateProfilePhoto
};
@@ -0,0 +1,301 @@
-- Query modificada: Si no hay viajes con estado 2-6, muestra 1 viaje con estado 1 más próximo a la fecha/hora actual
-- Primero intenta obtener viajes con estado 2-6
(
SELECT
p.id_viaje,
v.cod_viaje,
p.n_proveedor,
v.id_estado,
e.estado,
e.estado_en,
p1.nombre AS nombrea,
p2.nombre AS nombreb,
p1.direccion AS direcciona,
p2.direccion AS direccionb,
p1.latitud AS latitud_origen,
p1.longitud AS longitud_origen,
p2.latitud AS latitud_destino,
p2.longitud AS longitud_destino,
CASE
WHEN COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9]$'
THEN CONCAT(
DATE_FORMAT(p.fecha_salida, '%Y-%m-%d'),
' ',
COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
),
':00'
)
WHEN COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9]:[0-5][0-9]$'
THEN CONCAT(
DATE_FORMAT(p.fecha_salida, '%Y-%m-%d'),
' ',
COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
)
)
ELSE NULL
END AS fecha_salida,
CASE
WHEN COALESCE(
NULLIF(TRIM(p.destino_h_fin), ''),
NULLIF(TRIM(v.destino_h_fin), ''),
NULLIF(TRIM(p.destino_h_inicio), ''),
NULLIF(TRIM(v.destino_h_inicio), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9]$'
THEN CONCAT(
DATE_FORMAT(p.fecha_llegada, '%Y-%m-%d'),
' ',
COALESCE(
NULLIF(TRIM(p.destino_h_fin), ''),
NULLIF(TRIM(v.destino_h_fin), ''),
NULLIF(TRIM(p.destino_h_inicio), ''),
NULLIF(TRIM(v.destino_h_inicio), '')
),
':00'
)
WHEN COALESCE(
NULLIF(TRIM(p.destino_h_fin), ''),
NULLIF(TRIM(v.destino_h_fin), ''),
NULLIF(TRIM(p.destino_h_inicio), ''),
NULLIF(TRIM(v.destino_h_inicio), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9]:[0-5][0-9]$'
THEN CONCAT(
DATE_FORMAT(p.fecha_llegada, '%Y-%m-%d'),
' ',
COALESCE(
NULLIF(TRIM(p.destino_h_fin), ''),
NULLIF(TRIM(v.destino_h_fin), ''),
NULLIF(TRIM(p.destino_h_inicio), ''),
NULLIF(TRIM(v.destino_h_inicio), '')
)
)
ELSE NULL
END AS fecha_llegada,
CONCAT(COALESCE(p1.cp, ''), '/', COALESCE(p2.cp, '')) AS inicio_fin,
COALESCE(p.id_tipovehiculo, '') AS matricula,
NULLIF(TRIM(v.observaciones_mercancia), '') AS observaciones_mercancia,
NULLIF(TRIM(v.observaciones_cliente), '') AS observaciones_cliente
FROM c_viajes_proveedor p
INNER JOIN c_viajes v
ON v.id_viaje = p.id_viaje
INNER JOIN t_viaje_estados e
ON e.id_estado = v.id_estado
INNER JOIN (
SELECT dni, MAX(id_transportista) as id_transportista
FROM m_proveedores_trasportistas
WHERE desactivado = 0
GROUP BY dni
) t_filter
ON t_filter.dni = p.dni
INNER JOIN m_proveedores_trasportistas t
ON t.dni = t_filter.dni
AND t.id_transportista = t_filter.id_transportista
AND t.desactivado = 0
LEFT JOIN m_puntos_envio_recogida p1
ON p1.id_punto = p.id_punto_recogida
LEFT JOIN m_puntos_envio_recogida p2
ON p2.id_punto = p.id_punto_entrega
WHERE p.dni = ?
AND v.id_estado BETWEEN ? AND ?
AND COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9](:[0-5][0-9]){0,1}$'
AND COALESCE(
NULLIF(TRIM(p.destino_h_fin), ''),
NULLIF(TRIM(v.destino_h_fin), ''),
NULLIF(TRIM(p.destino_h_inicio), ''),
NULLIF(TRIM(v.destino_h_inicio), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9](:[0-5][0-9]){0,1}$'
ORDER BY p.fecha_salida DESC, p.id_viaje DESC
LIMIT 1
)
UNION ALL
(
-- Si no hay viajes con estado 2-6, busca 1 viaje con estado 1 más próximo a la fecha/hora actual
SELECT
p.id_viaje,
v.cod_viaje,
p.n_proveedor,
v.id_estado,
e.estado,
e.estado_en,
p1.nombre AS nombrea,
p2.nombre AS nombreb,
p1.direccion AS direcciona,
p2.direccion AS direccionb,
p1.latitud AS latitud_origen,
p1.longitud AS longitud_origen,
p2.latitud AS latitud_destino,
p2.longitud AS longitud_destino,
CASE
WHEN COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9]$'
THEN CONCAT(
DATE_FORMAT(p.fecha_salida, '%Y-%m-%d'),
' ',
COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
),
':00'
)
WHEN COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9]:[0-5][0-9]$'
THEN CONCAT(
DATE_FORMAT(p.fecha_salida, '%Y-%m-%d'),
' ',
COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
)
)
ELSE NULL
END AS fecha_salida,
CASE
WHEN COALESCE(
NULLIF(TRIM(p.destino_h_fin), ''),
NULLIF(TRIM(v.destino_h_fin), ''),
NULLIF(TRIM(p.destino_h_inicio), ''),
NULLIF(TRIM(v.destino_h_inicio), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9]$'
THEN CONCAT(
DATE_FORMAT(p.fecha_llegada, '%Y-%m-%d'),
' ',
COALESCE(
NULLIF(TRIM(p.destino_h_fin), ''),
NULLIF(TRIM(v.destino_h_fin), ''),
NULLIF(TRIM(p.destino_h_inicio), ''),
NULLIF(TRIM(v.destino_h_inicio), '')
),
':00'
)
WHEN COALESCE(
NULLIF(TRIM(p.destino_h_fin), ''),
NULLIF(TRIM(v.destino_h_fin), ''),
NULLIF(TRIM(p.destino_h_inicio), ''),
NULLIF(TRIM(v.destino_h_inicio), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9]:[0-5][0-9]$'
THEN CONCAT(
DATE_FORMAT(p.fecha_llegada, '%Y-%m-%d'),
' ',
COALESCE(
NULLIF(TRIM(p.destino_h_fin), ''),
NULLIF(TRIM(v.destino_h_fin), ''),
NULLIF(TRIM(p.destino_h_inicio), ''),
NULLIF(TRIM(v.destino_h_inicio), '')
)
)
ELSE NULL
END AS fecha_llegada,
CONCAT(COALESCE(p1.cp, ''), '/', COALESCE(p2.cp, '')) AS inicio_fin,
COALESCE(p.id_tipovehiculo, '') AS matricula,
NULLIF(TRIM(v.observaciones_mercancia), '') AS observaciones_mercancia,
NULLIF(TRIM(v.observaciones_cliente), '') AS observaciones_cliente
FROM c_viajes_proveedor p
INNER JOIN c_viajes v
ON v.id_viaje = p.id_viaje
INNER JOIN t_viaje_estados e
ON e.id_estado = v.id_estado
INNER JOIN (
SELECT dni, MAX(id_transportista) as id_transportista
FROM m_proveedores_trasportistas
WHERE desactivado = 0
GROUP BY dni
) t_filter
ON t_filter.dni = p.dni
INNER JOIN m_proveedores_trasportistas t
ON t.dni = t_filter.dni
AND t.id_transportista = t_filter.id_transportista
AND t.desactivado = 0
LEFT JOIN m_puntos_envio_recogida p1
ON p1.id_punto = p.id_punto_recogida
LEFT JOIN m_puntos_envio_recogida p2
ON p2.id_punto = p.id_punto_entrega
WHERE p.dni = ?
AND v.id_estado = ?
AND COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9](:[0-5][0-9]){0,1}$'
AND COALESCE(
NULLIF(TRIM(p.destino_h_fin), ''),
NULLIF(TRIM(v.destino_h_fin), ''),
NULLIF(TRIM(p.destino_h_inicio), ''),
NULLIF(TRIM(v.destino_h_inicio), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9](:[0-5][0-9]){0,1}$'
ORDER BY ABS(TIMESTAMPDIFF(SECOND,
CASE
WHEN COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9]$'
THEN STR_TO_DATE(CONCAT(
DATE_FORMAT(p.fecha_salida, '%Y-%m-%d'),
' ',
COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
),
':00'
), '%Y-%m-%d %H:%i:%s')
WHEN COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9]:[0-5][0-9]$'
THEN STR_TO_DATE(CONCAT(
DATE_FORMAT(p.fecha_salida, '%Y-%m-%d'),
' ',
COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
)
), '%Y-%m-%d %H:%i:%s')
ELSE NULL
END,
NOW()
))
LIMIT 1
)
LIMIT 1
@@ -0,0 +1,119 @@
SELECT
p.id_viaje,
v.cod_viaje,
p.n_proveedor,
v.id_estado,
e.estado,
e.estado_en,
p1.nombre AS nombrea,
p2.nombre AS nombreb,
p1.direccion AS direcciona,
p2.direccion AS direccionb,
p1.latitud AS latitud_origen,
p1.longitud AS longitud_origen,
p2.latitud AS latitud_destino,
p2.longitud AS longitud_destino,
CASE
WHEN COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9]$'
THEN CONCAT(
DATE_FORMAT(p.fecha_salida, '%Y-%m-%d'),
' ',
COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
),
':00'
)
WHEN COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9]:[0-5][0-9]$'
THEN CONCAT(
DATE_FORMAT(p.fecha_salida, '%Y-%m-%d'),
' ',
COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
)
)
ELSE NULL
END AS fecha_salida,
CASE
WHEN COALESCE(
NULLIF(TRIM(p.destino_h_fin), ''),
NULLIF(TRIM(v.destino_h_fin), ''),
NULLIF(TRIM(p.destino_h_inicio), ''),
NULLIF(TRIM(v.destino_h_inicio), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9]$'
THEN CONCAT(
DATE_FORMAT(p.fecha_llegada, '%Y-%m-%d'),
' ',
COALESCE(
NULLIF(TRIM(p.destino_h_fin), ''),
NULLIF(TRIM(v.destino_h_fin), ''),
NULLIF(TRIM(p.destino_h_inicio), ''),
NULLIF(TRIM(v.destino_h_inicio), '')
),
':00'
)
WHEN COALESCE(
NULLIF(TRIM(p.destino_h_fin), ''),
NULLIF(TRIM(v.destino_h_fin), ''),
NULLIF(TRIM(p.destino_h_inicio), ''),
NULLIF(TRIM(v.destino_h_inicio), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9]:[0-5][0-9]$'
THEN CONCAT(
DATE_FORMAT(p.fecha_llegada, '%Y-%m-%d'),
' ',
COALESCE(
NULLIF(TRIM(p.destino_h_fin), ''),
NULLIF(TRIM(v.destino_h_fin), ''),
NULLIF(TRIM(p.destino_h_inicio), ''),
NULLIF(TRIM(v.destino_h_inicio), '')
)
)
ELSE NULL
END AS fecha_llegada,
CONCAT(COALESCE(p1.cp, ''), '/', COALESCE(p2.cp, '')) AS inicio_fin,
COALESCE(p.id_tipovehiculo, '') AS matricula,
NULLIF(TRIM(v.observaciones_mercancia), '') AS observaciones_mercancia,
NULLIF(TRIM(v.observaciones_cliente), '') AS observaciones_cliente
FROM c_viajes_proveedor p
INNER JOIN c_viajes v
ON v.id_viaje = p.id_viaje
INNER JOIN t_viaje_estados e
ON e.id_estado = v.id_estado
INNER JOIN m_proveedores_trasportistas t
ON t.dni = p.dni
AND t.desactivado = 0
LEFT JOIN m_puntos_envio_recogida p1
ON p1.id_punto = p.id_punto_recogida
LEFT JOIN m_puntos_envio_recogida p2
ON p2.id_punto = p.id_punto_entrega
WHERE p.dni = ?
AND v.id_estado BETWEEN ? AND ?
AND COALESCE(
NULLIF(TRIM(p.origen_h_inicio), ''),
NULLIF(TRIM(v.origen_h_inicio), ''),
NULLIF(TRIM(p.origen_h_fin), ''),
NULLIF(TRIM(v.origen_h_fin), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9](:[0-5][0-9]){0,1}$'
AND COALESCE(
NULLIF(TRIM(p.destino_h_fin), ''),
NULLIF(TRIM(v.destino_h_fin), ''),
NULLIF(TRIM(p.destino_h_inicio), ''),
NULLIF(TRIM(v.destino_h_inicio), '')
) REGEXP '^[0-2][0-9]:[0-5][0-9](:[0-5][0-9]){0,1}$'
ORDER BY p.fecha_salida DESC, p.id_viaje DESC
LIMIT 1
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20 -8
View File
@@ -1,6 +1,17 @@
const jwt = require('jsonwebtoken');
const authenticateDevice = (req, res, next) => {
const jwt = require('jsonwebtoken');
const cetDateTimeFormatter = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Madrid',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
const authenticateDevice = (req, res, next) => {
// 1. Obtener Token del Header 'Authorization: Bearer <token>'
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1]; // Ignorar 'Bearer '
@@ -18,10 +29,11 @@ const authenticateDevice = (req, res, next) => {
req.user = decoded;
next();
} catch (err) {
console.warn(`[Auth] Token inválido desde IP: ${req.ip}`);
return res.status(403).json({ success: false, error: 'INVALID_TOKEN', message: 'Token inválido o expirado' });
}
};
} catch (err) {
const cetDateTime = cetDateTimeFormatter.format(new Date());
console.warn(`[Auth] [${cetDateTime} CET] Token inválido desde IP: ${req.ip}`);
return res.status(403).json({ success: false, error: 'INVALID_TOKEN', message: 'Token inválido o expirado' });
}
};
module.exports = authenticateDevice;
+97
View File
@@ -0,0 +1,97 @@
const multer = require('multer');
const path = require('path');
const MAX_DRIVER_LICENSE_SIZE_BYTES = 5 * 1024 * 1024;
const FRONT_FILE_FIELD = 'carnet_conducir_frontal';
const BACK_FILE_FIELD = 'carnet_conducir_trasera';
const MIME_TO_ALLOWED_EXTENSIONS = {
'image/jpeg': new Set(['.jpg', '.jpeg']),
'image/png': new Set(['.png']),
'image/webp': new Set(['.webp'])
};
const ALLOWED_MIME_TYPES = new Set(Object.keys(MIME_TO_ALLOWED_EXTENSIONS));
const hasAllowedExtensionForMime = (file) => {
const extension = path.extname(String(file?.originalname || '')).toLowerCase();
const allowedExtensions = MIME_TO_ALLOWED_EXTENSIONS[file.mimetype];
if (!allowedExtensions) {
return false;
}
return allowedExtensions.has(extension);
};
const internalUpload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: MAX_DRIVER_LICENSE_SIZE_BYTES,
files: 1
},
fileFilter: (req, file, cb) => {
if (!ALLOWED_MIME_TYPES.has(file.mimetype) || !hasAllowedExtensionForMime(file)) {
return cb(new Error('INVALID_FILE_TYPE'));
}
cb(null, true);
}
});
const uploadDriverLicense = (req, res, next) => {
internalUpload.fields([
{ name: FRONT_FILE_FIELD, maxCount: 1 },
{ name: BACK_FILE_FIELD, maxCount: 1 }
])(req, res, (error) => {
if (!error) {
return next();
}
if (error instanceof multer.MulterError) {
if (error.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({
success: false,
error: 'Archivo demasiado grande. Maximo 5MB.'
});
}
if (error.code === 'LIMIT_FILE_COUNT' || error.code === 'LIMIT_UNEXPECTED_FILE') {
return res.status(400).json({
success: false,
error: 'Solo se permite 1 archivo por request.'
});
}
return res.status(400).json({
success: false,
error: 'Payload de archivo invalido.'
});
}
if (error.message === 'INVALID_FILE_TYPE') {
return res.status(400).json({
success: false,
error: 'Tipo de archivo invalido. Solo image/jpeg, image/png o image/webp.'
});
}
console.error('Driver license upload middleware error:', {
message: error.message,
code: error.code
});
return res.status(500).json({
success: false,
error: 'Internal server error'
});
});
};
module.exports = {
FRONT_FILE_FIELD,
BACK_FILE_FIELD,
uploadDriverLicense,
MAX_DRIVER_LICENSE_SIZE_BYTES,
ALLOWED_MIME_TYPES
};
+84
View File
@@ -0,0 +1,84 @@
const crypto = require('crypto');
const fs = require('fs');
const multer = require('multer');
const path = require('path');
const MAX_PROFILE_PHOTO_SIZE_BYTES = 5 * 1024 * 1024;
const PROFILE_UPLOADS_DIR = path.resolve(__dirname, '..', '..', 'uploads', 'profile');
const ALLOWED_MIME_TYPES = new Set(['image/jpeg', 'image/png']);
const ensureProfileUploadsDir = () => {
fs.mkdirSync(PROFILE_UPLOADS_DIR, { recursive: true });
};
const sanitizeFileBaseName = (originalName) => {
const baseName = path.basename(originalName || 'profile_photo', path.extname(originalName || ''));
const sanitized = baseName
.replace(/[^a-zA-Z0-9_-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 40);
return sanitized || 'profile_photo';
};
const getExtensionFromMimeType = (mimeType) => (mimeType === 'image/png' ? '.png' : '.jpg');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
try {
ensureProfileUploadsDir();
cb(null, PROFILE_UPLOADS_DIR);
} catch (error) {
cb(error);
}
},
filename: (req, file, cb) => {
const safeBaseName = sanitizeFileBaseName(file.originalname);
const uniqueSuffix = `${Date.now()}_${crypto.randomBytes(6).toString('hex')}`;
const extension = getExtensionFromMimeType(file.mimetype);
cb(null, `${safeBaseName}_${uniqueSuffix}${extension}`);
}
});
const internalUpload = multer({
storage,
limits: {
fileSize: MAX_PROFILE_PHOTO_SIZE_BYTES,
files: 1
},
fileFilter: (req, file, cb) => {
if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
return cb(new Error('INVALID_FILE_TYPE'));
}
cb(null, true);
}
});
const uploadProfilePhoto = (req, res, next) => {
internalUpload.single('foto_perfil')(req, res, (error) => {
if (!error) {
return next();
}
if (error instanceof multer.MulterError) {
if (error.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'Archivo demasiado grande. Maximo 5MB.' });
}
return res.status(400).json({ error: 'Archivo invalido.' });
}
if (error.message === 'INVALID_FILE_TYPE') {
return res.status(400).json({ error: 'Tipo de archivo invalido. Solo se permite image/jpeg o image/png.' });
}
console.error('Error uploading profile photo:', error);
return res.status(500).json({ error: 'Internal server error' });
});
};
module.exports = {
uploadProfilePhoto
};
+19
View File
@@ -0,0 +1,19 @@
const jwt = require('jsonwebtoken');
const requireBearerAuth = (req, res, next) => {
const authHeader = req.headers.authorization || '';
const [scheme, token] = authHeader.split(' ');
if (scheme !== 'Bearer' || !token) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
return next();
} catch (error) {
return res.status(401).json({ error: 'Unauthorized' });
}
};
module.exports = requireBearerAuth;
+153
View File
@@ -0,0 +1,153 @@
const crypto = require('crypto');
const fs = require('fs');
const multer = require('multer');
const path = require('path');
const {
getTripStatusUploadsDir,
replicateUploadedFilesToRemote,
removeUploadedTripStatusFiles
} = require('../services/tripStatusPhotoStorage');
const { appendPostLog } = require('../utils/postLog');
const MAX_TRIP_STATUS_PHOTO_SIZE_BYTES = 15 * 1024 * 1024;
const MAX_TRIP_STATUS_FILES = 5;
const ALLOWED_MIME_TYPES = new Set([
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
'image/heic',
'image/heif'
]);
const getTripDirectorySegment = (req) => {
const tripId = Number.parseInt(req.params?.id, 10);
return Number.isInteger(tripId) && tripId > 0 ? String(tripId) : 'unknown';
};
const getTripStatusUploadsTripDir = (req) =>
path.join(getTripStatusUploadsDir(), getTripDirectorySegment(req));
const ensureTripStatusUploadsDir = (req) => {
fs.mkdirSync(getTripStatusUploadsTripDir(req), { recursive: true });
};
const getExtensionFromMimeType = (mimeType) => {
if (mimeType === 'image/png') {
return '.png';
}
if (mimeType === 'image/webp') {
return '.webp';
}
if (mimeType === 'image/heic') {
return '.heic';
}
if (mimeType === 'image/heif') {
return '.heif';
}
return '.jpg';
};
const storage = multer.diskStorage({
destination: (req, file, cb) => {
try {
ensureTripStatusUploadsDir(req);
cb(null, getTripStatusUploadsTripDir(req));
} catch (error) {
cb(error);
}
},
filename: (req, file, cb) => {
const token = crypto.randomBytes(3).toString('hex');
const extension = getExtensionFromMimeType(file.mimetype);
cb(null, `${token}${extension}`);
}
});
const internalUpload = multer({
storage,
limits: {
fileSize: MAX_TRIP_STATUS_PHOTO_SIZE_BYTES,
files: MAX_TRIP_STATUS_FILES
},
fileFilter: (req, file, cb) => {
if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
return cb(new Error('INVALID_FILE_TYPE'));
}
cb(null, true);
}
});
const uploadTripStatusPhotos = (req, res, next) => {
internalUpload.fields([
{ name: 'fotos', maxCount: MAX_TRIP_STATUS_FILES },
{ name: 'fotos[]', maxCount: MAX_TRIP_STATUS_FILES }
])(req, res, async (error) => {
if (!error) {
const uploadedFiles = collectUploadedTripStatusFiles(req);
const authorizationHeader = req.get('authorization');
appendPostLog({
request_id: req.requestId || null,
method: req.method,
path: req.originalUrl || req.url,
ip: req.ip || null,
content_type: req.get('content-type') || null,
has_authorization_header: Boolean(authorizationHeader),
authorization: authorizationHeader ? '[REDACTED]' : null,
query: req.query || {},
body: req.body || {},
raw_body: null,
files: uploadedFiles.map((file) => ({
field_name: file.fieldname || null,
filename: file.filename || null,
originalname: file.originalname || null,
mimetype: file.mimetype || null,
size: Number.isFinite(file.size) ? file.size : null
}))
});
await replicateUploadedFilesToRemote({
tripId: req.params?.id,
files: uploadedFiles
});
return next();
}
if (error instanceof multer.MulterError) {
return res.status(400).json({
success: false,
error: 'Invalid payload'
});
}
if (error.message === 'INVALID_FILE_TYPE') {
return res.status(400).json({
success: false,
error: 'Invalid payload'
});
}
console.error('Error uploading trip status photos:', error);
return res.status(500).json({
success: false,
error: 'Internal server error'
});
});
};
const collectUploadedTripStatusFiles = (req) => [
...(Array.isArray(req.files?.fotos) ? req.files.fotos : []),
...(Array.isArray(req.files?.['fotos[]']) ? req.files['fotos[]'] : [])
];
module.exports = {
uploadTripStatusPhotos,
collectUploadedTripStatusFiles,
removeUploadedTripStatusFiles,
MAX_TRIP_STATUS_FILES
};
+25
View File
@@ -0,0 +1,25 @@
const express = require('express');
const requireBearerAuth = require('../middleware/requireBearerAuth');
const { uploadDriverLicense } = require('../middleware/driverLicenseUpload');
const {
updateDriverLicense,
sendDriverLicenseBySide,
sendDriverLicense,
deleteDriverLicense
} = require('../controllers/driverLicenseController');
const router = express.Router();
router.post('/update_driver_license', requireBearerAuth, uploadDriverLicense, updateDriverLicense);
router.post('/upload_driver_license', requireBearerAuth, uploadDriverLicense, updateDriverLicense);
router.get('/secure/driver-license/side/:side', requireBearerAuth, sendDriverLicenseBySide);
router.get('/driver-license/side/:side', requireBearerAuth, sendDriverLicenseBySide);
router.get('/secure/driver-license/:publicId', requireBearerAuth, sendDriverLicense);
router.get('/secure/driver-license/:publicId/view', requireBearerAuth, sendDriverLicense);
router.get('/driver-license/:publicId', requireBearerAuth, sendDriverLicense);
router.get('/driver-license/:publicId/view', requireBearerAuth, sendDriverLicense);
router.delete('/secure/driver-license/:publicId', requireBearerAuth, deleteDriverLicense);
router.delete('/driver-license/:publicId', requireBearerAuth, deleteDriverLicense);
module.exports = router;
+5
View File
@@ -1,6 +1,7 @@
const express = require('express');
const router = express.Router();
const locationController = require('../controllers/locationController');
const profileController = require('../controllers/profileController');
const authenticateDevice = require('../middleware/auth');
@@ -8,4 +9,8 @@ const authenticateDevice = require('../middleware/auth');
// The plugin might POST to /api/locations
router.post('/api/locations', authenticateDevice, locationController.saveLocation);
// FCM token endpoints following the same /api prefix
router.post('/api/save_fcm', authenticateDevice, profileController.saveFcmToken);
router.post('/api/save_fcm_test', profileController.saveFcmToken);
module.exports = router;
+12 -6
View File
@@ -1,10 +1,16 @@
const express = require('express');
const router = express.Router();
const profileController = require('../controllers/profileController');
const verifyToken = require('../middleware/authMiddleware');
const express = require('express');
const router = express.Router();
const profileController = require('../controllers/profileController');
const verifyToken = require('../middleware/authMiddleware');
const { uploadProfilePhoto } = require('../middleware/profilePhotoUpload');
// Modern routes (PROTECTED)
router.post('/get_profile', verifyToken, profileController.getProfile);
router.post('/change_password', verifyToken, profileController.changePassword);
router.post('/get_profile', verifyToken, profileController.getProfile);
router.post('/change_password', verifyToken, profileController.changePassword);
router.post('/save_fcm', verifyToken, profileController.saveFcmToken);
router.post('/update_profile_photo', verifyToken, uploadProfilePhoto, profileController.updateProfilePhoto);
// Temporary public route for quick testing (remove when done)
router.post('/save_fcm_test', profileController.saveFcmToken);
module.exports = router;
+20
View File
@@ -0,0 +1,20 @@
const express = require('express');
const tripsController = require('../controllers/tripsController');
const requireBearerAuth = require('../middleware/requireBearerAuth');
const { uploadTripStatusPhotos } = require('../middleware/tripStatusUpload');
const router = express.Router();
router.get('/trips/states', requireBearerAuth, tripsController.getTripStates);
router.get('/trips/active', requireBearerAuth, tripsController.getActiveTrip);
router.get('/trips', requireBearerAuth, tripsController.getTrips);
router.get('/trips/:id/intermediate-points', requireBearerAuth, tripsController.getIntermediatePoints);
router.post('/trips/:id/intermediate-points/:pointId/status', requireBearerAuth, tripsController.updateIntermediatePointStatus);
router.get('/trips/:id/status-history', requireBearerAuth, tripsController.getTripStatusHistory);
router.post('/trips/:tripId/start', requireBearerAuth, tripsController.startTrip);
router.post('/trips/:tripId/incidencias', requireBearerAuth, tripsController.createTripIncidence);
router.post('/trips/:id/auto-status', requireBearerAuth, tripsController.updateTripStatusAutomatically);
router.post('/trips/:id/status', requireBearerAuth, uploadTripStatusPhotos, tripsController.updateTripStatus);
router.delete('/trips/:id/status', requireBearerAuth, tripsController.clearTripStatus);
module.exports = router;
+110
View File
@@ -0,0 +1,110 @@
const path = require('path');
const dotenv = require('dotenv');
const db = require('../config/db');
const { removeStoredFile } = require('../services/driverLicenseStorage');
dotenv.config({ path: path.resolve(__dirname, '..', '..', '.env'), override: true });
const DEFAULT_BATCH_SIZE = 100;
const parseBatchSize = () => {
const parsed = Number.parseInt(String(process.env.DRIVER_LICENSE_PURGE_BATCH_SIZE || ''), 10);
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 1000) {
return DEFAULT_BATCH_SIZE;
}
return parsed;
};
const logAudit = async ({ fileId, action, dni, idProveedor, side }) => {
try {
await db.query(
`INSERT INTO driver_license_access_audit
(driver_license_file_id, action, actor_user_id, dni_target, id_proveedor_target, side_target, ip_address, user_agent)
VALUES (?, ?, NULL, ?, ?, ?, NULL, 'purge-job')`,
[fileId, action, dni || null, idProveedor || null, side || null]
);
} catch (error) {
console.error('Could not write purge audit row:', {
message: error.message,
fileId,
action
});
}
};
const run = async () => {
const batchSize = parseBatchSize();
const [rows] = await db.query(
`SELECT id, storage_key, dni, id_proveedor, side
FROM driver_license_files
WHERE storage_key IS NOT NULL
AND (
deleted_at IS NOT NULL
OR expires_at <= NOW()
)
ORDER BY id ASC
LIMIT ?`,
[batchSize]
);
let purgedCount = 0;
let failedCount = 0;
for (const row of rows) {
try {
await removeStoredFile(row.storage_key);
await db.query(
`UPDATE driver_license_files
SET storage_key = NULL,
deleted_at = COALESCE(deleted_at, NOW())
WHERE id = ?`,
[row.id]
);
await logAudit({
fileId: row.id,
action: 'purge_success',
dni: row.dni,
idProveedor: row.id_proveedor,
side: row.side
});
purgedCount += 1;
} catch (error) {
failedCount += 1;
await logAudit({
fileId: row.id,
action: 'purge_failed',
dni: row.dni,
idProveedor: row.id_proveedor,
side: row.side
});
console.error('Failed to purge driver license file:', {
message: error.message,
fileId: row.id
});
}
}
console.log('[driver-license-purge] completed', {
scanned: rows.length,
purged: purgedCount,
failed: failedCount
});
};
run()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error('[driver-license-purge] fatal error', {
message: error.message
});
process.exit(1);
});
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="${APP_DIR:-/var/www/node.gestion.abianservice.com}"
APP_ENTRY="${APP_ENTRY:-app.js}"
APP_USER="${APP_USER:-apache}"
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-3001}"
LOG_FILE="${LOG_FILE:-/tmp/node-gestion-api.log}"
HEALTH_PATH="${HEALTH_PATH:-/}"
AUTH_CHECK_PATH="${AUTH_CHECK_PATH:-/api/trips/active}"
AUTH_EXPECTED_STATUS="${AUTH_EXPECTED_STATUS:-401}"
STARTUP_TIMEOUT_SEC="${STARTUP_TIMEOUT_SEC:-30}"
CHECK_INTERVAL_SEC="${CHECK_INTERVAL_SEC:-1}"
if [[ -z "${NODE_BIN}" ]]; then
echo "[restart:api] FAIL: node binary not found in PATH"
exit 1
fi
if [[ ! -d "${APP_DIR}" ]]; then
echo "[restart:api] FAIL: APP_DIR not found: ${APP_DIR}"
exit 1
fi
if [[ ! -f "${APP_DIR}/${APP_ENTRY}" ]]; then
echo "[restart:api] FAIL: app entry not found: ${APP_DIR}/${APP_ENTRY}"
exit 1
fi
echo "[restart:api] APP_DIR=${APP_DIR}"
echo "[restart:api] APP_ENTRY=${APP_ENTRY}"
echo "[restart:api] APP_USER=${APP_USER}"
echo "[restart:api] HOST=${HOST}"
echo "[restart:api] PORT=${PORT}"
old_pids="$(pgrep -u "${APP_USER}" -f "node ${APP_ENTRY}" || true)"
if [[ -n "${old_pids}" ]]; then
echo "[restart:api] stopping old pid(s): ${old_pids}"
kill -TERM ${old_pids} || true
else
echo "[restart:api] no previous process found"
fi
deadline=$((SECONDS + STARTUP_TIMEOUT_SEC))
while ((SECONDS < deadline)); do
still_running="$(pgrep -u "${APP_USER}" -f "node ${APP_ENTRY}" || true)"
if [[ -z "${still_running}" ]]; then
break
fi
sleep "${CHECK_INTERVAL_SEC}"
done
still_running="$(pgrep -u "${APP_USER}" -f "node ${APP_ENTRY}" || true)"
if [[ -n "${still_running}" ]]; then
echo "[restart:api] forcing stop pid(s): ${still_running}"
kill -KILL ${still_running} || true
fi
start_cmd="cd '${APP_DIR}' && nohup '${NODE_BIN}' '${APP_ENTRY}' >> '${LOG_FILE}' 2>&1 < /dev/null &"
if [[ "$(id -un)" == "${APP_USER}" ]]; then
bash -lc "${start_cmd}"
else
runuser -u "${APP_USER}" -- bash -lc "${start_cmd}"
fi
deadline=$((SECONDS + STARTUP_TIMEOUT_SEC))
new_pid=""
while ((SECONDS < deadline)); do
new_pid="$(pgrep -u "${APP_USER}" -f "node ${APP_ENTRY}" | head -n 1 || true)"
if [[ -n "${new_pid}" ]]; then
break
fi
sleep "${CHECK_INTERVAL_SEC}"
done
if [[ -z "${new_pid}" ]]; then
echo "[restart:api] FAIL: process did not start"
echo "[restart:api] last log lines:"
tail -n 40 "${LOG_FILE}" || true
exit 1
fi
echo "[restart:api] started pid=${new_pid}"
deadline=$((SECONDS + STARTUP_TIMEOUT_SEC))
listening="0"
while ((SECONDS < deadline)); do
if ss -lnt "( sport = :${PORT} )" | grep -q "${HOST}:${PORT}"; then
listening="1"
break
fi
sleep "${CHECK_INTERVAL_SEC}"
done
if [[ "${listening}" != "1" ]]; then
echo "[restart:api] FAIL: process started but port ${HOST}:${PORT} is not listening"
echo "[restart:api] last log lines:"
tail -n 40 "${LOG_FILE}" || true
exit 1
fi
root_status="$(curl -sS -o /dev/null -w "%{http_code}" "http://${HOST}:${PORT}${HEALTH_PATH}" || true)"
if [[ "${root_status}" != "200" ]]; then
echo "[restart:api] FAIL: health check ${HEALTH_PATH} returned ${root_status} (expected 200)"
exit 1
fi
auth_status="$(curl -sS -o /dev/null -w "%{http_code}" "http://${HOST}:${PORT}${AUTH_CHECK_PATH}" || true)"
if [[ "${auth_status}" != "${AUTH_EXPECTED_STATUS}" ]]; then
echo "[restart:api] FAIL: auth check ${AUTH_CHECK_PATH} returned ${auth_status} (expected ${AUTH_EXPECTED_STATUS})"
exit 1
fi
echo "[restart:api] PASS: API restarted and checks passed"
echo "[restart:api] health ${HEALTH_PATH}=200"
echo "[restart:api] auth ${AUTH_CHECK_PATH}=${AUTH_EXPECTED_STATUS}"
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env node
'use strict';
const DEFAULT_BASE_URL = 'https://node.desarrollogestion.abianservice.com';
const ALLOWED_STATES = new Set([7, 8, 9]);
const baseUrl = (process.env.BASE_URL || process.argv[2] || DEFAULT_BASE_URL).replace(/\/+$/, '');
const jwt = process.env.JWT || process.env.SMOKE_JWT || process.argv[3] || '';
const toStateNumber = (value) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : null;
};
const countByState = (trips) =>
trips.reduce(
(acc, trip) => {
const state = toStateNumber(trip?.id_estado ?? trip?.statusId);
if (state === null) {
acc.unparseable += 1;
return acc;
}
acc.total += 1;
if (state === 7) acc.byState['7'] += 1;
if (state === 8) acc.byState['8'] += 1;
if (state === 9) acc.byState['9'] += 1;
if (!ALLOWED_STATES.has(state)) {
acc.outsideAllowed += 1;
}
return acc;
},
{
total: 0,
unparseable: 0,
outsideAllowed: 0,
byState: { '7': 0, '8': 0, '9': 0 }
}
);
const requestTrips = async (token) => {
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const response = await fetch(`${baseUrl}/api/trips`, {
method: 'GET',
headers
});
const raw = await response.text();
let body = null;
try {
body = raw ? JSON.parse(raw) : null;
} catch (_error) {
body = raw;
}
return {
status: response.status,
body
};
};
const main = async () => {
console.log(`[smoke:trips] BASE_URL=${baseUrl}`);
const noToken = await requestTrips('');
console.log(`[smoke:trips] no-token status=${noToken.status}`);
if (noToken.status !== 401) {
console.error('[smoke:trips] FAIL: expected 401 for request without token');
console.error('[smoke:trips] response:', JSON.stringify(noToken.body, null, 2));
process.exit(1);
}
if (!jwt) {
console.log('[smoke:trips] PASS (partial): no JWT provided, skipped authenticated checks');
return;
}
const withToken = await requestTrips(jwt);
console.log(`[smoke:trips] with-token status=${withToken.status}`);
if (withToken.status !== 200) {
console.error('[smoke:trips] FAIL: expected 200 for request with token');
console.error('[smoke:trips] response:', JSON.stringify(withToken.body, null, 2));
process.exit(1);
}
const trips = Array.isArray(withToken.body?.trips) ? withToken.body.trips : [];
const counters = countByState(trips);
const sample = trips.slice(0, 3);
console.log('[smoke:trips] counts:', JSON.stringify({
total_rows: trips.length,
parseable_status_rows: counters.total,
unparseable_status_rows: counters.unparseable,
by_state: counters.byState,
rows_outside_7_8_9: counters.outsideAllowed
}));
console.log('[smoke:trips] sample:', JSON.stringify(sample, null, 2));
if (counters.outsideAllowed > 0) {
console.error('[smoke:trips] FAIL: endpoint returned rows outside states 7/8/9');
process.exit(1);
}
console.log('[smoke:trips] PASS');
};
main().catch((error) => {
console.error('[smoke:trips] FAIL:', error.message);
process.exit(1);
});
+83
View File
@@ -0,0 +1,83 @@
const crypto = require('crypto');
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH_BYTES = 12;
const AUTH_TAG_LENGTH_BYTES = 16;
const decodeEncryptionKey = () => {
const rawKey = String(process.env.DRIVER_LICENSE_ENCRYPTION_KEY || '').trim();
if (!rawKey) {
throw new Error('Missing DRIVER_LICENSE_ENCRYPTION_KEY');
}
if (/^[a-fA-F0-9]{64}$/.test(rawKey)) {
return Buffer.from(rawKey, 'hex');
}
const normalizedBase64 = rawKey.startsWith('base64:') ? rawKey.slice(7) : rawKey;
return Buffer.from(normalizedBase64, 'base64');
};
const getEncryptionKey = () => {
const key = decodeEncryptionKey();
if (key.length !== 32) {
throw new Error('DRIVER_LICENSE_ENCRYPTION_KEY must be 32 bytes (hex64 or base64)');
}
return key;
};
const encryptBuffer = (plaintextBuffer) => {
if (!Buffer.isBuffer(plaintextBuffer)) {
throw new Error('plaintextBuffer must be a Buffer');
}
const iv = crypto.randomBytes(IV_LENGTH_BYTES);
const cipher = crypto.createCipheriv(ALGORITHM, getEncryptionKey(), iv);
const ciphertext = Buffer.concat([cipher.update(plaintextBuffer), cipher.final()]);
return {
algorithm: ALGORITHM,
ivHex: iv.toString('hex'),
authTagHex: cipher.getAuthTag().toString('hex'),
ciphertext
};
};
const decryptBuffer = ({ ciphertext, ivHex, authTagHex, algorithm }) => {
const selectedAlgorithm = String(algorithm || ALGORITHM).trim().toLowerCase();
if (selectedAlgorithm !== ALGORITHM) {
throw new Error('Unsupported encryption algorithm');
}
if (!Buffer.isBuffer(ciphertext)) {
throw new Error('ciphertext must be a Buffer');
}
if (!/^[a-fA-F0-9]{24}$/.test(String(ivHex || ''))) {
throw new Error('Invalid encryption iv');
}
if (!/^[a-fA-F0-9]{32}$/.test(String(authTagHex || ''))) {
throw new Error('Invalid encryption auth tag');
}
const decipher = crypto.createDecipheriv(ALGORITHM, getEncryptionKey(), Buffer.from(ivHex, 'hex'));
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
return Buffer.concat([
decipher.update(ciphertext),
decipher.final()
]);
};
module.exports = {
ALGORITHM,
AUTH_TAG_LENGTH_BYTES,
IV_LENGTH_BYTES,
encryptBuffer,
decryptBuffer
};
+105
View File
@@ -0,0 +1,105 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { decryptBuffer } = require('./driverLicenseCrypto');
const DEFAULT_STORAGE_DIR = path.resolve(__dirname, '..', '..', 'secure_storage', 'driver-license');
const getStorageDir = () =>
process.env.DRIVER_LICENSE_STORAGE_DIR || DEFAULT_STORAGE_DIR;
const assertSafeStorageKey = (storageKey) => {
const normalized = String(storageKey || '').trim();
if (!normalized || normalized.includes('..') || normalized.startsWith('/')) {
throw new Error('Invalid storage key');
}
return normalized;
};
const resolveStoragePath = (storageKey) => {
const safeStorageKey = assertSafeStorageKey(storageKey);
const baseDir = path.resolve(getStorageDir());
const candidatePath = path.resolve(baseDir, ...safeStorageKey.split('/'));
if (candidatePath !== baseDir && !candidatePath.startsWith(`${baseDir}${path.sep}`)) {
throw new Error('Invalid storage key path');
}
return candidatePath;
};
const ensureStorageDirExists = async (absolutePath) => {
await fs.promises.mkdir(path.dirname(absolutePath), {
recursive: true,
mode: 0o700
});
};
const generateStorageKey = () => {
const now = new Date();
const year = String(now.getUTCFullYear());
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
const uuid = crypto.randomUUID();
const hash = crypto
.createHash('sha256')
.update(`${uuid}:${Date.now()}:${crypto.randomBytes(32).toString('hex')}`)
.digest('hex')
.slice(0, 24);
return path.posix.join(year, month, `${uuid}_${hash}.bin`);
};
const persistEncryptedBuffer = async (encryptedBuffer) => {
if (!Buffer.isBuffer(encryptedBuffer)) {
throw new Error('encryptedBuffer must be a Buffer');
}
const storageKey = generateStorageKey();
const absolutePath = resolveStoragePath(storageKey);
await ensureStorageDirExists(absolutePath);
await fs.promises.writeFile(absolutePath, encryptedBuffer, {
mode: 0o600,
flag: 'wx'
});
return {
storageKey
};
};
const readEncryptedBuffer = async (storageKey) => {
const absolutePath = resolveStoragePath(storageKey);
return fs.promises.readFile(absolutePath);
};
const readDecryptedBuffer = async (storageKey, encryptionMetadata) => {
const ciphertext = await readEncryptedBuffer(storageKey);
return decryptBuffer({
ciphertext,
ivHex: encryptionMetadata?.ivHex,
authTagHex: encryptionMetadata?.authTagHex,
algorithm: encryptionMetadata?.algorithm
});
};
const removeStoredFile = async (storageKey) => {
try {
await fs.promises.unlink(resolveStoragePath(storageKey));
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
};
module.exports = {
getStorageDir,
persistEncryptedBuffer,
readEncryptedBuffer,
readDecryptedBuffer,
removeStoredFile
};
+167
View File
@@ -0,0 +1,167 @@
const db = require('../config/db');
const INCIDENCE_EMAIL_SUBJECT = 'Se ha creado una incidencia';
const sanitizeEmailValue = (rawValue) => String(rawValue || '').trim();
const splitEmails = (rawValue) =>
sanitizeEmailValue(rawValue)
.split(/[;,]/)
.map((item) => item.trim())
.filter(Boolean);
const isValidEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const dedupeEmails = (emails) => {
const uniqueEmails = [];
const seenEmails = new Set();
for (const email of emails) {
const normalizedEmail = String(email || '').trim().toLowerCase();
if (!normalizedEmail || seenEmails.has(normalizedEmail) || !isValidEmail(normalizedEmail)) {
continue;
}
seenEmails.add(normalizedEmail);
uniqueEmails.push(normalizedEmail);
}
return uniqueEmails;
};
const parseSmtpPort = (rawPort) => {
const port = Number.parseInt(String(rawPort || '').trim(), 10);
return Number.isInteger(port) && port > 0 ? port : null;
};
const buildEmailText = ({ tripId, tripCode, incidencia, userId }) => {
const lines = [
'Se ha creado una incidencia.',
'',
`Viaje: ${tripCode || tripId}`,
`ID viaje: ${tripId}`,
`Usuario: ${userId || 'N/A'}`,
'',
'Incidencia:',
incidencia
];
return lines.join('\n');
};
const resolveIncidenceMailData = async (tripId) => {
const [baseRows] = await db.query(
`SELECT
v.id_viaje,
v.cod_viaje,
v.id_cliente,
TRIM(mc.email_oper) AS email_cliente_oper,
TRIM(cr.email_operaciones) AS email_cr_operaciones,
TRIM(cr.email_email) AS smtp_from_email,
TRIM(cr.host_smtp) AS smtp_host,
TRIM(cr.puerto_smtp) AS smtp_port,
TRIM(cr.user_smtp) AS smtp_user,
TRIM(cr.pass_smtp) AS smtp_pass
FROM c_viajes v
LEFT JOIN m_clientes mc ON mc.id_cliente = v.id_cliente
LEFT JOIN m_cr cr ON cr.id_cr = v.id_cr
WHERE v.id_viaje = ?
LIMIT 1`,
[tripId]
);
if (baseRows.length === 0) {
return null;
}
const row = baseRows[0];
const [contactRows] = await db.query(
`SELECT TRIM(email) AS email
FROM m_clientes_contactos
WHERE id_cliente = ?
AND ind_envio = 1
AND email IS NOT NULL`,
[row.id_cliente || 0]
);
const clientContactEmails = contactRows
.map((contactRow) => contactRow.email)
.filter(Boolean);
const recipients = dedupeEmails([
...splitEmails(row.email_cliente_oper),
...clientContactEmails,
...splitEmails(row.email_cr_operaciones)
]);
return {
tripCode: row.cod_viaje || null,
recipients,
smtp: {
from: sanitizeEmailValue(row.smtp_from_email) || sanitizeEmailValue(row.smtp_user),
host: sanitizeEmailValue(row.smtp_host),
port: parseSmtpPort(row.smtp_port),
user: sanitizeEmailValue(row.smtp_user),
pass: sanitizeEmailValue(row.smtp_pass)
}
};
};
const hasValidSmtpConfig = (smtp) =>
Boolean(
smtp &&
smtp.host &&
smtp.port &&
smtp.user &&
smtp.pass &&
smtp.from &&
isValidEmail(smtp.from)
);
const sendTripIncidenceEmail = async ({ tripId, incidencia, userId }) => {
const mailData = await resolveIncidenceMailData(tripId);
if (!mailData) {
return { status: 'skipped_trip_not_found', recipientsCount: 0 };
}
if (mailData.recipients.length === 0) {
return { status: 'skipped_no_recipients', recipientsCount: 0 };
}
if (!hasValidSmtpConfig(mailData.smtp)) {
return { status: 'skipped_invalid_smtp', recipientsCount: mailData.recipients.length };
}
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: mailData.smtp.host,
port: mailData.smtp.port,
secure: mailData.smtp.port === 465,
auth: {
user: mailData.smtp.user,
pass: mailData.smtp.pass
}
});
await transporter.sendMail({
from: mailData.smtp.from,
to: mailData.recipients.join(','),
subject: INCIDENCE_EMAIL_SUBJECT,
text: buildEmailText({
tripId,
tripCode: mailData.tripCode,
incidencia,
userId
})
});
return {
status: 'sent',
recipientsCount: mailData.recipients.length
};
};
module.exports = {
sendTripIncidenceEmail
};
+360
View File
@@ -0,0 +1,360 @@
const fs = require('fs');
const path = require('path');
const REMOTE_STORAGE_MODES = new Set(['dual', 'sftp']);
const DEFAULT_SFTP_PORT = 22;
let sftpClientFactoryOverride = null;
const getTripStatusUploadsDir = () =>
process.env.TRIP_STATUS_UPLOAD_DIR ||
path.resolve(__dirname, '..', '..', 'uploads', 'trips', 'status');
const getTripStatusPhotoStorageMode = () =>
String(process.env.TRIP_STATUS_PHOTO_STORAGE_MODE || 'local')
.trim()
.toLowerCase();
const shouldUseRemoteStorage = () =>
REMOTE_STORAGE_MODES.has(getTripStatusPhotoStorageMode());
const getTripDirectorySegment = (tripId) => {
const parsedTripId = Number.parseInt(tripId, 10);
return Number.isInteger(parsedTripId) && parsedTripId > 0 ? String(parsedTripId) : 'unknown';
};
const normalizeRemoteBaseDir = (rawValue) =>
String(rawValue || '')
.trim()
.replace(/\/+$/g, '');
const getSftpConfig = () => {
const host = String(process.env.TRIP_STATUS_SFTP_HOST || '').trim();
const username = String(process.env.TRIP_STATUS_SFTP_USERNAME || '').trim();
const password = String(process.env.TRIP_STATUS_SFTP_PASSWORD || '').trim();
const remoteBaseDir = normalizeRemoteBaseDir(process.env.TRIP_STATUS_SFTP_REMOTE_BASE_DIR);
const parsedPort = Number.parseInt(process.env.TRIP_STATUS_SFTP_PORT, 10);
const port = Number.isInteger(parsedPort) && parsedPort > 0 ? parsedPort : DEFAULT_SFTP_PORT;
const missing = [];
if (!host) {
missing.push('TRIP_STATUS_SFTP_HOST');
}
if (!username) {
missing.push('TRIP_STATUS_SFTP_USERNAME');
}
if (!password) {
missing.push('TRIP_STATUS_SFTP_PASSWORD');
}
if (!remoteBaseDir) {
missing.push('TRIP_STATUS_SFTP_REMOTE_BASE_DIR');
}
return {
host,
username,
password,
port,
remoteBaseDir,
isValid: missing.length === 0,
missing
};
};
const getSftpClientFactory = () => {
if (typeof sftpClientFactoryOverride === 'function') {
return sftpClientFactoryOverride;
}
try {
const SftpClient = require('ssh2-sftp-client');
return () => new SftpClient();
} catch (error) {
console.error('SFTP client dependency is unavailable for trip status photos:', {
message: error.message
});
return null;
}
};
const isRemoteFileNotFoundError = (error) => {
const normalizedMessage = String(error?.message || '').toLowerCase();
return (
error?.code === 'ENOENT' ||
error?.code === 2 ||
normalizedMessage.includes('no such file') ||
normalizedMessage.includes('not exist')
);
};
const buildRemoteTripDir = (remoteBaseDir, tripId) =>
path.posix.join(remoteBaseDir, getTripDirectorySegment(tripId));
const buildRemoteFilePath = (remoteBaseDir, tripId, fileName) =>
path.posix.join(buildRemoteTripDir(remoteBaseDir, tripId), fileName);
const ensureRemoteTripDirectory = async (client, { remoteBaseDir, tripDirectorySegment }) => {
const remoteTripDir = buildRemoteTripDir(remoteBaseDir, tripDirectorySegment);
const baseExists = await client.exists(remoteBaseDir);
if (!baseExists) {
await client.mkdir(remoteBaseDir, true);
}
const tripDirExists = await client.exists(remoteTripDir);
if (!tripDirExists) {
await client.mkdir(remoteTripDir, false);
}
return remoteTripDir;
};
const withSftpClient = async (handler, { logContext }) => {
if (!shouldUseRemoteStorage()) {
return false;
}
const sftpConfig = getSftpConfig();
if (!sftpConfig.isValid) {
console.error('Trip status photo remote storage skipped due to missing SFTP config:', {
context: logContext,
missing: sftpConfig.missing
});
return false;
}
const createClient = getSftpClientFactory();
if (!createClient) {
return false;
}
const client = createClient();
try {
await client.connect({
host: sftpConfig.host,
port: sftpConfig.port,
username: sftpConfig.username,
password: sftpConfig.password
});
await handler(client, sftpConfig);
return true;
} catch (error) {
console.error('Trip status photo remote storage operation failed:', {
context: logContext,
message: error.message
});
return false;
} finally {
try {
await client.end();
} catch (closeError) {
console.error('Failed to close SFTP connection for trip status photos:', {
context: logContext,
message: closeError.message
});
}
}
};
const replicateUploadedFilesToRemote = async ({ tripId, files }) => {
const normalizedFiles = (files || []).filter((file) => file?.path && file?.filename);
if (normalizedFiles.length === 0) {
return;
}
const tripDirectorySegment = getTripDirectorySegment(tripId);
for (const file of normalizedFiles) {
file.tripStatusTripId = tripDirectorySegment;
}
await withSftpClient(
async (client, sftpConfig) => {
await ensureRemoteTripDirectory(client, {
remoteBaseDir: sftpConfig.remoteBaseDir,
tripDirectorySegment
});
for (const file of normalizedFiles) {
const remoteFilePath = buildRemoteFilePath(
sftpConfig.remoteBaseDir,
tripDirectorySegment,
file.filename
);
try {
await client.put(file.path, remoteFilePath);
file.tripStatusRemoteUploaded = true;
file.tripStatusRemotePath = remoteFilePath;
} catch (error) {
file.tripStatusRemoteUploaded = false;
file.tripStatusRemotePath = null;
console.error('Failed to replicate trip status photo to SFTP. Local fallback kept:', {
tripId: tripDirectorySegment,
fileName: file.filename,
message: error.message
});
}
}
},
{
logContext: 'replicate_upload'
}
);
};
const removeRemoteFiles = async (remoteFilePaths, { logContext }) => {
const uniqueRemotePaths = Array.from(new Set((remoteFilePaths || []).filter(Boolean)));
if (uniqueRemotePaths.length === 0) {
return;
}
await withSftpClient(
async (client) => {
for (const remoteFilePath of uniqueRemotePaths) {
try {
await client.delete(remoteFilePath);
} catch (error) {
if (isRemoteFileNotFoundError(error)) {
continue;
}
console.error('Failed to remove remote trip status photo:', {
context: logContext,
remoteFilePath,
message: error.message
});
}
}
},
{ logContext }
);
};
const removeLocalFileIfExists = async (filePath, { logContext, fileName, tripId }) => {
try {
await fs.promises.unlink(filePath);
return true;
} catch (error) {
if (error.code === 'ENOENT') {
return false;
}
console.error('Failed to remove local trip status photo:', {
context: logContext,
tripId,
file: fileName,
path: filePath,
message: error.message
});
return false;
}
};
const cleanupUploadedTripStatusFiles = async (files) => {
const normalizedFiles = (files || []).filter((file) => file?.path || file?.filename);
if (normalizedFiles.length === 0) {
return;
}
const sftpConfig = getSftpConfig();
const remoteFilePaths = [];
for (const file of normalizedFiles) {
if (file?.tripStatusRemotePath) {
remoteFilePaths.push(file.tripStatusRemotePath);
continue;
}
const tripId = file?.tripStatusTripId;
if (shouldUseRemoteStorage() && sftpConfig.isValid && tripId && file?.filename) {
remoteFilePaths.push(
buildRemoteFilePath(sftpConfig.remoteBaseDir, tripId, file.filename)
);
}
}
await removeRemoteFiles(remoteFilePaths, { logContext: 'cleanup_uploaded_files' });
for (const file of normalizedFiles) {
if (!file?.path) {
continue;
}
await removeLocalFileIfExists(file.path, {
logContext: 'cleanup_uploaded_files',
fileName: file.filename || null,
tripId: file?.tripStatusTripId || null
});
}
};
const parsePhotoNames = (rawPhotoReferences) =>
String(rawPhotoReferences || '')
.split(';')
.map((item) => item.trim())
.filter(Boolean)
.filter((item) => !item.includes('/') && !item.includes('\\'));
const removeStatusPhotosByName = async ({ tripId, rawPhotoReferences }) => {
const photoNames = parsePhotoNames(rawPhotoReferences);
if (photoNames.length === 0) {
return;
}
const uploadsDir = getTripStatusUploadsDir();
const tripUploadsDir = path.join(uploadsDir, getTripDirectorySegment(tripId));
const sftpConfig = getSftpConfig();
const remoteFilePaths =
shouldUseRemoteStorage() && sftpConfig.isValid
? photoNames.map((photoName) =>
buildRemoteFilePath(sftpConfig.remoteBaseDir, tripId, photoName)
)
: [];
await removeRemoteFiles(remoteFilePaths, { logContext: 'remove_status_photos_by_name' });
for (const photoName of photoNames) {
const candidatePaths = [
path.join(tripUploadsDir, photoName),
path.join(uploadsDir, photoName)
];
for (const candidatePath of candidatePaths) {
const removed = await removeLocalFileIfExists(candidatePath, {
logContext: 'remove_status_photos_by_name',
fileName: photoName,
tripId
});
if (removed) {
break;
}
}
}
};
const removeUploadedTripStatusFiles = (files) => {
cleanupUploadedTripStatusFiles(files).catch((error) => {
console.error('Failed to cleanup uploaded trip status files:', {
message: error.message
});
});
};
const __setSftpClientFactoryForTests = (factory) => {
sftpClientFactoryOverride = typeof factory === 'function' ? factory : null;
};
const __resetSftpClientFactoryForTests = () => {
sftpClientFactoryOverride = null;
};
module.exports = {
getTripStatusUploadsDir,
replicateUploadedFilesToRemote,
removeUploadedTripStatusFiles,
removeStatusPhotosByName,
__setSftpClientFactoryForTests,
__resetSftpClientFactoryForTests
};
@@ -0,0 +1,39 @@
CREATE TABLE IF NOT EXISTS driver_license_files (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
public_id CHAR(36) NOT NULL,
dni VARCHAR(20) NOT NULL,
id_proveedor INT UNSIGNED NOT NULL,
document_type VARCHAR(40) NOT NULL DEFAULT 'driver_license',
storage_key VARCHAR(255) NULL,
mime_type VARCHAR(100) NOT NULL,
size_bytes INT UNSIGNED NOT NULL,
checksum_sha256 CHAR(64) NOT NULL,
uploaded_by BIGINT UNSIGNED NULL,
key_version VARCHAR(32) NOT NULL DEFAULT 'v1',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_driver_license_public_id (public_id),
KEY idx_driver_license_target_active (dni, id_proveedor, document_type, deleted_at),
KEY idx_driver_license_retention (expires_at, deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS driver_license_access_audit (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
driver_license_file_id BIGINT UNSIGNED NULL,
action VARCHAR(40) NOT NULL,
actor_user_id BIGINT UNSIGNED NULL,
dni_target VARCHAR(20) NULL,
id_proveedor_target INT UNSIGNED NULL,
ip_address VARCHAR(45) NULL,
user_agent VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_driver_license_audit_created (created_at),
KEY idx_driver_license_audit_target (dni_target, id_proveedor_target),
CONSTRAINT fk_driver_license_access_file
FOREIGN KEY (driver_license_file_id)
REFERENCES driver_license_files (id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,115 @@
CREATE TABLE IF NOT EXISTS driver_license_files (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
public_id CHAR(36) NOT NULL,
dni VARCHAR(20) NOT NULL,
id_proveedor INT UNSIGNED NOT NULL,
side ENUM('front', 'back') NOT NULL,
document_type VARCHAR(40) NOT NULL DEFAULT 'driver_license',
storage_key VARCHAR(255) NULL,
mime_type VARCHAR(100) NOT NULL,
size_bytes INT UNSIGNED NOT NULL,
checksum_sha256 CHAR(64) NOT NULL,
encryption_alg VARCHAR(32) NOT NULL DEFAULT 'aes-256-gcm',
encryption_iv CHAR(24) NOT NULL,
encryption_tag CHAR(32) NOT NULL,
key_version VARCHAR(32) NOT NULL DEFAULT 'v1',
uploaded_by BIGINT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_driver_license_public_id (public_id),
KEY idx_driver_license_target_side_active (dni, id_proveedor, side, deleted_at),
KEY idx_driver_license_retention (expires_at, deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS driver_license_access_audit (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
driver_license_file_id BIGINT UNSIGNED NULL,
action VARCHAR(40) NOT NULL,
actor_user_id BIGINT UNSIGNED NULL,
dni_target VARCHAR(20) NULL,
id_proveedor_target INT UNSIGNED NULL,
side_target ENUM('front', 'back') NULL,
ip_address VARCHAR(45) NULL,
user_agent VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_driver_license_audit_created (created_at),
KEY idx_driver_license_audit_target (dni_target, id_proveedor_target, side_target),
CONSTRAINT fk_driver_license_access_file
FOREIGN KEY (driver_license_file_id)
REFERENCES driver_license_files (id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ALTER TABLE driver_license_files
ADD COLUMN IF NOT EXISTS public_id CHAR(36) NULL AFTER id,
ADD COLUMN IF NOT EXISTS side ENUM('front', 'back') NOT NULL DEFAULT 'front' AFTER id_proveedor,
ADD COLUMN IF NOT EXISTS document_type VARCHAR(40) NOT NULL DEFAULT 'driver_license' AFTER side,
ADD COLUMN IF NOT EXISTS encryption_alg VARCHAR(32) NOT NULL DEFAULT 'aes-256-gcm' AFTER checksum_sha256,
ADD COLUMN IF NOT EXISTS encryption_iv CHAR(24) NOT NULL DEFAULT '' AFTER encryption_alg,
ADD COLUMN IF NOT EXISTS encryption_tag CHAR(32) NOT NULL DEFAULT '' AFTER encryption_iv,
ADD COLUMN IF NOT EXISTS key_version VARCHAR(32) NOT NULL DEFAULT 'v1' AFTER encryption_tag;
UPDATE driver_license_files
SET public_id = UUID()
WHERE public_id IS NULL OR public_id = '';
ALTER TABLE driver_license_files
MODIFY COLUMN public_id CHAR(36) NOT NULL;
SET @sql := IF(
(SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'driver_license_files'
AND index_name = 'uq_driver_license_public_id') = 0,
'ALTER TABLE driver_license_files ADD UNIQUE KEY uq_driver_license_public_id (public_id)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql := IF(
(SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'driver_license_files'
AND index_name = 'idx_driver_license_target_side_active') = 0,
'ALTER TABLE driver_license_files ADD KEY idx_driver_license_target_side_active (dni, id_proveedor, side, deleted_at)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql := IF(
(SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'driver_license_files'
AND index_name = 'idx_driver_license_retention') = 0,
'ALTER TABLE driver_license_files ADD KEY idx_driver_license_retention (expires_at, deleted_at)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
ALTER TABLE driver_license_access_audit
ADD COLUMN IF NOT EXISTS side_target ENUM('front', 'back') NULL AFTER id_proveedor_target;
SET @sql := IF(
(SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'driver_license_access_audit'
AND index_name = 'idx_driver_license_audit_target') = 0,
'ALTER TABLE driver_license_access_audit ADD KEY idx_driver_license_audit_target (dni_target, id_proveedor_target, side_target)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
+42
View File
@@ -0,0 +1,42 @@
const fs = require('fs');
const path = require('path');
const DEFAULT_POSTS_LOG_PATH = '/var/log/posts.log';
const formatLogDateTime = (dateValue) => {
const year = dateValue.getFullYear();
const month = String(dateValue.getMonth() + 1).padStart(2, '0');
const day = String(dateValue.getDate()).padStart(2, '0');
const hour = String(dateValue.getHours()).padStart(2, '0');
const minute = String(dateValue.getMinutes()).padStart(2, '0');
const second = String(dateValue.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
};
const getPostsLogPath = () => process.env.POSTS_LOG_PATH || DEFAULT_POSTS_LOG_PATH;
const appendPostLog = (payload) => {
const now = new Date();
const logPath = getPostsLogPath();
const entry = {
timestamp: now.toISOString(),
date_time: formatLogDateTime(now),
...payload
};
fs.promises
.mkdir(path.dirname(logPath), { recursive: true })
.then(() => fs.promises.appendFile(logPath, `${JSON.stringify(entry)}\n`))
.catch((error) => {
console.error('Failed to append POST debug log:', {
message: error.message,
file: logPath
});
});
};
module.exports = {
appendPostLog,
getPostsLogPath
};
+31
View File
@@ -0,0 +1,31 @@
const normalizeProfilePhotoPath = (photoPath) => {
if (!photoPath || typeof photoPath !== 'string') {
return null;
}
const normalizedPath = photoPath.replace(/\\/g, '/').replace(/^\/+/, '');
return normalizedPath || null;
};
const buildProfileImageUrl = (req, photoPath) => {
const normalizedPath = normalizeProfilePhotoPath(photoPath);
if (!normalizedPath) {
return null;
}
const forwardedProto = (req.headers['x-forwarded-proto'] || '').split(',')[0].trim();
const host = req.get('host');
const isLocalHost = host && /^(localhost|127\.0\.0\.1|::1)(:\d+)?$/i.test(host);
const protocol = forwardedProto || (!isLocalHost ? 'https' : (req.protocol || 'http'));
if (!host) {
return normalizedPath;
}
return `${protocol}://${host}/${normalizedPath}`;
};
module.exports = {
normalizeProfilePhotoPath,
buildProfileImageUrl
};