mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 11:23:18 +00:00
Fase 2 (driver): - api/pg.ts: adaptador delgado sobre postgres.js (prepare/get/all/run, placeholders ? -> $n, withTenant con set_config para RLS), con parsers de tipo custom (numeric/date/timestamp(tz)/bigint) para que el resto del codigo heredado de SQLite (fechas/montos como string, ids como number) siga funcionando sin reescribir cada call-site a mano. - api/platform_db.ts, api/iam_db.ts (nuevo), api/db.ts: pools separados por base/esquema (panels_platform, panels_product.iam, panels_product.core), owner pool para bootstrap/scripts/lookups administrativos que cruzan tenant a proposito. - api/redis.ts: clientes iam/core separados (ACL panels_iam_redis / panels_core_redis). - api/sessions.ts + auth.ts: sesiones ahora en Redis (cookie = id opaco, no HMAC autocontenido); revocacion real (logout, cambio de password). - api/storage.ts (Fase 4c): documentos/PDFs via Contabo Object Storage (S3), con fallback a disco local si no hay credenciales S3 (dev). - api/scope.ts: middleware withCoreScope/requireCoreAuth que abre la transaccion con app.tenant_id fijado (RLS) para cada request. - api/cache.ts (Fase 4e): cache Redis con tenant_id obligatorio en la llave; aplicado a /v1/catalogs. Fase 3 (reescritura SQL, ~80 endpoints en main.ts/companies.ts/budget.ts/ payroll.ts/payroll_http.ts/excel.ts/saas.ts/smtp.ts): - Todo async/await, sintaxis Postgres (COALESCE, ~ regex, ON CONFLICT, now()/current_date, booleanos reales, RETURNING via lastInsertId()). - IDOR cross-tenant cerrado: GET/PATCH /v1/projects/:id, /v1/workers/:id ya no dependen de que el handler recuerde el WHERE tenant_id -- Row Level Security lo hace estructuralmente (verificado con un segundo tenant real: 404 en vez de fuga de datos). - API key ya no ve todos los tenants: ahora exige X-Tenant-Id explicito. Fase 3b (tests): api/test_helpers.ts corre cada test en una transaccion que siempre se revierte, contra el mismo baseline de Liquibase que produccion (ya no un esquema SQLite escrito a mano). payroll_test.ts reescrito con fixtures reales; 11/11 pasan contra Postgres. Fase 4 (IAM/RBAC): iam.roles/permissions/role_permissions formalizados (ver db/iam ya en fase 1); uploaded_by/created_by ahora son snapshot desnormalizado (uploaded_by_id/name); seed() en runtime eliminado, reemplazado por scripts/bootstrap-admin.ts (one-shot). Fase 4d (zona horaria): nuevo endpoint /v1/configuracion (GET/PUT), PAYROLL_TZ hardcodeado reemplazado por tenant_settings.timezone, document_validity.ts ya no usa new Date() crudo. Verificado end-to-end contra Postgres+Redis reales: login, sesiones, catalogos con cache, alta de trabajador, subida/descarga de documento cifrado, y el fix de IDOR probado con un segundo tenant real (403/404 en vez de fuga de datos). Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
298 lines
13 KiB
TypeScript
298 lines
13 KiB
TypeScript
import { join } from "node:path";
|
|
import type { PlatformDb } from "./platform_db.ts";
|
|
import { config, ROOT } from "./config.ts";
|
|
import { resolveSmtp, smtpConfigured as smtpConfiguredFromDb } from "./smtp.ts";
|
|
|
|
export type MailAttachment = {
|
|
filename: string;
|
|
content: Uint8Array;
|
|
cid: string;
|
|
contentType?: string;
|
|
};
|
|
|
|
export type MailPayload = {
|
|
to: string;
|
|
subject: string;
|
|
text: string;
|
|
html?: string;
|
|
attachments?: MailAttachment[];
|
|
};
|
|
|
|
export async function smtpConfigured(platformDb: PlatformDb): Promise<boolean> {
|
|
return await smtpConfiguredFromDb(platformDb);
|
|
}
|
|
|
|
/** Envía correo por SMTP. Si no hay SMTP, no falla: sent=false. */
|
|
export async function sendMail(
|
|
platformDb: PlatformDb,
|
|
payload: MailPayload,
|
|
): Promise<{ sent: boolean; error?: string; message_id?: string }> {
|
|
if (!await smtpConfigured(platformDb)) {
|
|
return { sent: false, error: "SMTP no configurado o deshabilitado" };
|
|
}
|
|
|
|
const s = await resolveSmtp(platformDb);
|
|
const from = (s.from_address ?? "").trim();
|
|
if (!from || !/(?:^|<)[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+(?:>|$)/.test(from)) {
|
|
return {
|
|
sent: false,
|
|
error:
|
|
"Remitente (From) inválido o vacío. Debe ser un correo, ej. PANELS <noreply@tudominio.com>",
|
|
};
|
|
}
|
|
try {
|
|
const nodemailer = await import("npm:nodemailer@6.9.16");
|
|
const transporter = nodemailer.createTransport({
|
|
host: s.host,
|
|
port: s.port,
|
|
secure: s.port === 465,
|
|
requireTLS: s.port === 587,
|
|
auth: s.username ? { user: s.username, pass: s.password } : undefined,
|
|
connectionTimeout: 20000,
|
|
greetingTimeout: 20000,
|
|
socketTimeout: 30000,
|
|
tls: {
|
|
rejectUnauthorized: false,
|
|
servername: s.host,
|
|
},
|
|
});
|
|
const info = await transporter.sendMail({
|
|
from,
|
|
to: payload.to,
|
|
subject: payload.subject,
|
|
text: payload.text,
|
|
html: payload.html,
|
|
attachments: payload.attachments?.map((a) => ({
|
|
filename: a.filename,
|
|
content: a.content,
|
|
cid: a.cid,
|
|
contentType: a.contentType ?? "image/png",
|
|
contentDisposition: "inline" as const,
|
|
})),
|
|
});
|
|
const messageId = typeof info?.messageId === "string" ? info.messageId : undefined;
|
|
console.log(`[smtp] sent to=${payload.to} id=${messageId ?? "—"} host=${s.host}:${s.port}`);
|
|
return { sent: true, message_id: messageId };
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : "Error al enviar correo";
|
|
console.error(`[smtp] fail to=${payload.to}:`, msg);
|
|
return { sent: false, error: msg };
|
|
}
|
|
}
|
|
|
|
/** Prueba de conexión / envío a un destinatario. */
|
|
export async function testSmtp(
|
|
platformDb: PlatformDb,
|
|
to: string,
|
|
): Promise<{ sent: boolean; error?: string; message_id?: string }> {
|
|
const dest = (to ?? "").trim();
|
|
if (!dest || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(dest)) {
|
|
return { sent: false, error: "Indique un correo de prueba válido" };
|
|
}
|
|
return sendMail(platformDb, {
|
|
to: dest,
|
|
subject: "PANELS — prueba SMTP",
|
|
text: `Este es un correo de prueba de la configuración SMTP de PANELS.
|
|
|
|
Si recibió este mensaje, el SMTP está funcionando.
|
|
|
|
Enviado: ${new Date().toISOString()}
|
|
Destinatario: ${dest}
|
|
`,
|
|
});
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
export type AccessEmailOpts = {
|
|
companyName: string;
|
|
username: string;
|
|
password: string;
|
|
contactName?: string;
|
|
};
|
|
|
|
const LOGO_CID = "panels-logo@panels";
|
|
|
|
export function accessEmailAttachments(): MailAttachment[] {
|
|
const path = join(ROOT, "api", "assets", "email", "logo.png");
|
|
try {
|
|
const content = Deno.readFileSync(path);
|
|
return [{
|
|
filename: "logo.png",
|
|
content,
|
|
cid: LOGO_CID,
|
|
contentType: "image/png",
|
|
}];
|
|
} catch {
|
|
console.warn("[mail] logo.png no encontrado en", path);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function accessEmailBody(opts: AccessEmailOpts): string {
|
|
const hi = opts.contactName ? `Hola, ${opts.contactName}` : "Hola";
|
|
return `${hi}
|
|
|
|
Tu acceso a PANELS está listo.
|
|
|
|
La empresa ${opts.companyName} ya tiene acceso a PANELS. Utiliza los siguientes datos para iniciar sesión:
|
|
|
|
Usuario administrador: ${opts.username}
|
|
Contraseña temporal: ${opts.password}
|
|
|
|
Ingresar: ${config.panelLoginUrl}
|
|
|
|
Por seguridad, al ingresar por primera vez deberás crear una nueva contraseña.
|
|
|
|
— Equipo PANELS
|
|
Todas tus operaciones. Un solo sistema.
|
|
`;
|
|
}
|
|
|
|
/** HTML del correo de acceso (diseño original + logo real por CID). */
|
|
export function accessEmailHtml(opts: AccessEmailOpts): string {
|
|
const name = (opts.contactName ?? "").trim();
|
|
const hi = name ? `Hola, ${escapeHtml(name)}` : "Hola";
|
|
const company = escapeHtml(opts.companyName);
|
|
const username = escapeHtml(opts.username);
|
|
const password = escapeHtml(opts.password);
|
|
const loginUrl = escapeHtml(config.panelLoginUrl);
|
|
|
|
const mark =
|
|
`<img src="cid:${LOGO_CID}" width="40" height="40" alt="PANELS" style="display:block;border:0;outline:none;" />`;
|
|
|
|
const watermark = `
|
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="120" height="120" style="border-collapse:collapse;opacity:0.22;">
|
|
<tr>
|
|
<td width="60" height="60" style="padding:6px;"><div style="width:48px;height:48px;background:#9DBCF5;border-radius:12px;line-height:48px;font-size:0;"> </div></td>
|
|
<td width="60" height="60" style="padding:6px;"><div style="width:48px;height:48px;background:#9DBCF5;border-radius:12px;line-height:48px;font-size:0;"> </div></td>
|
|
</tr>
|
|
<tr>
|
|
<td width="60" height="60" style="padding:6px;"><div style="width:48px;height:48px;background:#9DBCF5;border-radius:12px;line-height:48px;font-size:0;"> </div></td>
|
|
<td width="60" height="60" style="padding:6px;"><div style="width:48px;height:48px;background:#9DBCF5;border-radius:12px;line-height:48px;font-size:0;"> </div></td>
|
|
</tr>
|
|
</table>`;
|
|
|
|
const footerMark = `
|
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="40" height="40" style="border-collapse:separate;">
|
|
<tr>
|
|
<td width="20" height="20" style="padding:2px;"><div style="width:14px;height:14px;background:#5B8DEF;border-radius:3px;line-height:14px;font-size:0;"> </div></td>
|
|
<td width="20" height="20" style="padding:2px;"><div style="width:14px;height:14px;background:#5B8DEF;border-radius:3px;line-height:14px;font-size:0;"> </div></td>
|
|
</tr>
|
|
<tr>
|
|
<td width="20" height="20" style="padding:2px;"><div style="width:14px;height:14px;background:#5B8DEF;border-radius:3px;line-height:14px;font-size:0;"> </div></td>
|
|
<td width="20" height="20" style="padding:2px;"><div style="width:14px;height:14px;background:#5B8DEF;border-radius:3px;line-height:14px;font-size:0;"> </div></td>
|
|
</tr>
|
|
</table>`;
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Tu acceso a PANELS está listo</title>
|
|
</head>
|
|
<body style="margin:0;padding:0;background-color:#f4f6f9;font-family:Arial,Helvetica,sans-serif;-webkit-text-size-adjust:100%;">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f4f6f9;padding:24px 12px;">
|
|
<tr>
|
|
<td align="center">
|
|
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:560px;background-color:#ffffff;border-radius:12px;overflow:hidden;">
|
|
<tr>
|
|
<td style="padding:28px 32px 8px 32px;">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
<tr>
|
|
<td valign="middle" width="48">${mark}</td>
|
|
<td valign="middle" style="padding-left:12px;font-size:22px;font-weight:700;color:#0B1F4D;letter-spacing:0.5px;">PANELS</td>
|
|
<td align="right" valign="top" width="100" style="overflow:hidden;">${watermark}</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
|
|
<tr>
|
|
<td style="padding:8px 32px 8px 32px;">
|
|
<p style="margin:0 0 16px 0;font-size:16px;line-height:1.5;color:#4B5563;">${hi}</p>
|
|
<h1 style="margin:0 0 12px 0;font-size:26px;line-height:1.25;font-weight:700;color:#0B1F4D;">Tu acceso a PANELS está listo</h1>
|
|
<p style="margin:0 0 24px 0;font-size:15px;line-height:1.55;color:#4B5563;">La empresa <strong style="color:#0B1F4D;">${company}</strong> ya tiene acceso a PANELS. Utiliza los siguientes datos para iniciar sesión:</p>
|
|
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#EEF4FC;border-radius:12px;margin:0 0 24px 0;">
|
|
<tr>
|
|
<td style="padding:16px 18px;">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
<tr>
|
|
<td width="28" valign="middle" style="font-size:18px;color:#2F6FED;">👤</td>
|
|
<td width="160" valign="middle" style="width:160px;font-size:14px;color:#4B5563;padding-left:8px;">Usuario administrador</td>
|
|
<td width="16" align="center" valign="middle" style="color:#CBD5E1;">|</td>
|
|
<td valign="middle" style="font-size:15px;font-weight:700;color:#0B1F4D;font-family:Consolas,Monaco,monospace;">${username}</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:0 18px;">
|
|
<div style="height:1px;background-color:#D6E4F5;line-height:1px;font-size:0;"> </div>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:16px 18px;">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
<tr>
|
|
<td width="28" valign="middle" style="font-size:18px;color:#2F6FED;">🔒</td>
|
|
<td width="160" valign="middle" style="width:160px;font-size:14px;color:#4B5563;padding-left:8px;">Contraseña temporal</td>
|
|
<td width="16" align="center" valign="middle" style="color:#CBD5E1;">|</td>
|
|
<td valign="middle" style="font-size:15px;font-weight:700;color:#0B1F4D;font-family:Consolas,Monaco,monospace;">${password}</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 12px 0;">
|
|
<tr>
|
|
<td align="center" style="background-color:#2F6FED;border-radius:10px;">
|
|
<a href="${loginUrl}" style="display:block;padding:14px 24px;font-size:16px;font-weight:700;color:#ffffff;text-decoration:none;">Ingresar a PANELS</a>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
<p style="margin:0 0 28px 0;text-align:center;">
|
|
<a href="${loginUrl}" style="font-size:13px;color:#2F6FED;text-decoration:underline;word-break:break-all;">${loginUrl}</a>
|
|
</p>
|
|
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 28px 0;">
|
|
<tr>
|
|
<td width="28" valign="top" style="font-size:16px;color:#2F6FED;padding-top:1px;">🛡</td>
|
|
<td valign="top" style="font-size:13px;line-height:1.5;color:#4B5563;padding-left:8px;">Por seguridad, al ingresar por primera vez deberás crear una nueva contraseña.</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
|
|
<tr>
|
|
<td style="background-color:#0B1F4D;padding:22px 32px;">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
<tr>
|
|
<td valign="middle" style="color:#ffffff;">
|
|
<div style="font-size:14px;font-weight:700;margin-bottom:4px;">— Equipo PANELS</div>
|
|
<div style="font-size:12px;opacity:0.85;">Todas tus operaciones. Un solo sistema.</div>
|
|
</td>
|
|
<td width="56" align="right" valign="middle">${footerMark}</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<p style="margin:16px 0 0 0;font-size:11px;line-height:1.4;color:#9CA3AF;text-align:center;">Este correo fue enviado automáticamente. No respondas a este mensaje.</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>`;
|
|
}
|