mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 20:43:17 +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>
161 lines
4.6 KiB
TypeScript
161 lines
4.6 KiB
TypeScript
import type { PlatformDb } from "./platform_db.ts";
|
|
import { config } from "./config.ts";
|
|
|
|
export type SmtpSettings = {
|
|
host: string;
|
|
port: number;
|
|
username: string;
|
|
password: string;
|
|
from_address: string;
|
|
enabled: boolean;
|
|
updated_at?: string;
|
|
};
|
|
|
|
export type SmtpPublic = {
|
|
host: string;
|
|
port: number;
|
|
username: string;
|
|
from_address: string;
|
|
enabled: boolean;
|
|
configured: boolean;
|
|
has_password: boolean;
|
|
updated_at: string | null;
|
|
source: "db" | "env" | "none";
|
|
};
|
|
|
|
type SmtpRow = {
|
|
host: string;
|
|
port: number;
|
|
username: string;
|
|
password: string;
|
|
from_address: string;
|
|
enabled: boolean;
|
|
updated_at: string;
|
|
};
|
|
|
|
function trim(v: unknown): string {
|
|
return (v ?? "").toString().trim();
|
|
}
|
|
|
|
function fromEnv(): SmtpSettings {
|
|
return {
|
|
host: config.smtpHost,
|
|
port: config.smtpPort || 587,
|
|
username: config.smtpUser,
|
|
password: config.smtpPass,
|
|
from_address: config.smtpFrom,
|
|
enabled: Boolean(config.smtpHost && config.smtpFrom),
|
|
};
|
|
}
|
|
|
|
async function readRow(platformDb: PlatformDb): Promise<SmtpRow | undefined> {
|
|
try {
|
|
return await platformDb.prepare(
|
|
`SELECT host, port, username, password, from_address, enabled, updated_at
|
|
FROM smtp_settings WHERE id = 1`,
|
|
).get() as SmtpRow | undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Config efectiva: fila en panels_platform si hay host; si no, variables de entorno. */
|
|
export async function resolveSmtp(
|
|
platformDb: PlatformDb,
|
|
): Promise<SmtpSettings & { source: "db" | "env" | "none" }> {
|
|
const row = await readRow(platformDb);
|
|
if (row && trim(row.host)) {
|
|
return {
|
|
host: trim(row.host),
|
|
port: Number(row.port) || 587,
|
|
username: trim(row.username),
|
|
password: row.password ?? "",
|
|
from_address: trim(row.from_address) || config.smtpFrom,
|
|
enabled: Boolean(row.enabled),
|
|
updated_at: row.updated_at,
|
|
source: "db",
|
|
};
|
|
}
|
|
const env = fromEnv();
|
|
if (env.host) return { ...env, source: "env" };
|
|
return { ...env, enabled: false, source: "none" };
|
|
}
|
|
|
|
export async function smtpConfigured(platformDb: PlatformDb): Promise<boolean> {
|
|
const s = await resolveSmtp(platformDb);
|
|
return s.enabled && Boolean(s.host && s.from_address);
|
|
}
|
|
|
|
export async function smtpPublicView(platformDb: PlatformDb): Promise<SmtpPublic> {
|
|
const s = await resolveSmtp(platformDb);
|
|
return {
|
|
host: s.host,
|
|
port: s.port,
|
|
username: s.username,
|
|
from_address: s.from_address,
|
|
enabled: s.enabled,
|
|
configured: await smtpConfigured(platformDb),
|
|
has_password: Boolean(s.password),
|
|
updated_at: s.updated_at ?? null,
|
|
source: s.source,
|
|
};
|
|
}
|
|
|
|
export type SaveSmtpInput = {
|
|
host?: string;
|
|
port?: number | string;
|
|
username?: string;
|
|
password?: string;
|
|
from_address?: string;
|
|
enabled?: boolean;
|
|
/** Si true y password vacío, conserva la contraseña actual. */
|
|
keep_password?: boolean;
|
|
};
|
|
|
|
function looksLikeEmailFrom(value: string): boolean {
|
|
// Acepta "user@dom.com" o "Nombre <user@dom.com>"
|
|
return /(?:^|<)[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+(?:>|$)/.test(value);
|
|
}
|
|
|
|
export async function saveSmtpSettings(
|
|
platformDb: PlatformDb,
|
|
input: SaveSmtpInput,
|
|
): Promise<{ error?: string; settings?: SmtpPublic }> {
|
|
const host = trim(input.host);
|
|
const fromAddress = trim(input.from_address);
|
|
const username = trim(input.username);
|
|
const port = Number(input.port ?? 587);
|
|
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
return { error: "Puerto SMTP inválido" };
|
|
}
|
|
|
|
const enabled = input.enabled !== false;
|
|
if (enabled && !host) return { error: "Host SMTP obligatorio si está habilitado" };
|
|
if (enabled && !fromAddress) return { error: "Remitente (From) obligatorio si está habilitado" };
|
|
if (enabled && fromAddress && !looksLikeEmailFrom(fromAddress)) {
|
|
return {
|
|
error: 'Remitente inválido. Use un correo, ej. PANELS <noreply@tudominio.com>',
|
|
};
|
|
}
|
|
|
|
const current = await readRow(platformDb);
|
|
let password = input.password ?? "";
|
|
if ((input.keep_password || password === "") && current?.password) {
|
|
password = current.password;
|
|
}
|
|
|
|
await platformDb.prepare(
|
|
`INSERT INTO smtp_settings (id, host, port, username, password, from_address, enabled, updated_at)
|
|
VALUES (1, ?, ?, ?, ?, ?, ?, now())
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
host = excluded.host,
|
|
port = excluded.port,
|
|
username = excluded.username,
|
|
password = excluded.password,
|
|
from_address = excluded.from_address,
|
|
enabled = excluded.enabled,
|
|
updated_at = now()`,
|
|
).run(host, port, username, password, fromAddress, enabled);
|
|
|
|
return { settings: await smtpPublicView(platformDb) };
|
|
}
|