feat: auditoría de seguridad en APIs, deuda Next 16 y pnpm como gestor

Cierra autenticación en /api/deployer, POST /api/modules, /api/stats y /verify (assertIT + validación de slugs/nombres). GET /api/modules público sanitizado (solo visibles, sin repoUrl ni logs). Corrige estado unhealthy, XSS en plantillas, open redirect en login. Sesión HMAC en tiempo constante, secret obligatorio, 503 si el backend cae, cookie secure solo en prod. Migra middleware a proxy, rate-limit en login, vitest + typecheck, Tailwind/CDN fuera, Boxicons local, polling pausado y código muerto eliminado. Estandariza pnpm (lock, Dockerfile, packageManager).
This commit is contained in:
Nurfog committed 2026-10-08 10:29:13 -03:00
1 parent ba71c4daa6
commit 8f30b1c1ec
27 files changed
+5702 -7322

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
node_modules
.next
.git
.gitignore
.env*
*.tsbuildinfo
coverage
Dockerfile
.dockerignore
README.md
.vscode
.orca
package-lock.json
yarn.lock
+4 -3
View File
@@ -2,14 +2,15 @@ FROM node:22-alpine AS base
FROM base AS deps FROM base AS deps
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ RUN corepack enable
RUN npm ci COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npm run build RUN pnpm run build
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
+12
View File
@@ -2,6 +2,18 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "X-Frame-Options", value: "DENY" },
],
},
];
},
}; };
export default nextConfig; export default nextConfig;
-7153
View File
File diff suppressed because it is too large. Load diff
+7 -5
View File
@@ -6,7 +6,9 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
@@ -16,13 +18,13 @@
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.4", "eslint-config-next": "16.3.4",
"tailwindcss": "^4", "typescript": "^5",
"typescript": "^5" "vitest": "^3"
} },
"packageManager": "pnpm@12.10.1"
} }
+5132
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
esbuild: true
unrs-resolver: true
+1 -3
View File
@@ -1,7 +1,5 @@
const config = { const config = {
plugins: { plugins: {},
"@tailwindcss/postcss": {},
},
}; };
export default config; export default config;
+65 -7
View File
@@ -4,17 +4,54 @@ import { COOKIE, isAllowed, signSession, SESSION_MAX_AGE } from "@/lib/auth";
const API_URL = const API_URL =
process.env.NEXT_PUBLIC_API_URL || "https://api.devops.norteamericano.cl"; process.env.NEXT_PUBLIC_API_URL || "https://api.devops.norteamericano.cl";
// Rate-limit en memoria: 10 intentos por IP cada 60s.
// Suficiente para una sola instancia (standalone); con más
// réplicas usar un store compartido (Redis/Upstash).
const WINDOW_MS = 60_000;
const MAX_ATTEMPTS = 10;
const attempts = new Map<string, { count: number; reset: number }>();
function clientIp(req: NextRequest): string {
const fwd = req.headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0].trim();
return req.headers.get("x-real-ip") || "unknown";
}
function rateLimited(ip: string): boolean {
const now = Date.now();
const cur = attempts.get(ip);
if (!cur || cur.reset <= now) {
attempts.set(ip, { count: 1, reset: now + WINDOW_MS });
return false;
}
cur.count += 1;
// Limpieza oportunista para no crecer sin cota.
if (attempts.size > 5000) {
for (const [k, v] of attempts) {
if (v.reset <= now) attempts.delete(k);
}
}
return cur.count > MAX_ATTEMPTS;
}
// Login IT: USUARIO = idUsuario. Valida en el backend // Login IT: USUARIO = idUsuario. Valida en el backend
// (sige_sam_v3.usuario, plano o SHA256) y exige que el email // (sige_sam_v3.usuario, plano o SHA256) y exige que el email
// retornado esté en IT_USERS (allowlist). // retornado esté en IT_USERS (allowlist).
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
if (rateLimited(clientIp(req))) {
return NextResponse.json(
{ error: "Demasiados intentos, espera un minuto" },
{ status: 429 }
);
}
const { idUsuario, email, password } = await req.json(); const { idUsuario, email, password } = await req.json();
const id = String(idUsuario ?? email ?? "").trim(); const id = String(idUsuario ?? email ?? "").trim();
if (!id || !password) { if (!id || !password) {
return NextResponse.json({ error: "Faltan credenciales" }, { status: 400 }); return NextResponse.json({ error: "Faltan credenciales" }, { status: 400 });
} }
let mail = ""; let mail = "";
let backendDown = false;
try { try {
const res = await fetch(`${API_URL}/api/Auth/it-login`, { const res = await fetch(`${API_URL}/api/Auth/it-login`, {
method: "POST", method: "POST",
@@ -22,20 +59,40 @@ export async function POST(req: NextRequest) {
body: JSON.stringify({ idUsuario: id, email: id, password: String(password) }), body: JSON.stringify({ idUsuario: id, email: id, password: String(password) }),
cache: "no-store", cache: "no-store",
}); });
if (!res.ok) throw new Error("backend"); if (res.status >= 500) {
const j = await res.json(); backendDown = true;
mail = String(j?.user?.email || "").trim().toLowerCase(); } else if (!res.ok) {
} catch {
mail = ""; mail = "";
} else {
const j = await res.json().catch(() => ({}));
mail = String(j?.user?.email || "").trim().toLowerCase();
}
} catch (e) {
console.error("[api/auth] backend it-login no disponible:", e);
backendDown = true;
}
if (backendDown) {
return NextResponse.json(
{ error: "Servicio de autenticación no disponible, reintenta más tarde" },
{ status: 503 }
);
} }
if (!mail || !isAllowed(mail)) { if (!mail || !isAllowed(mail)) {
await new Promise((r) => setTimeout(r, 600)); await new Promise((r) => setTimeout(r, 600));
return NextResponse.json({ error: "Credenciales inválidas" }, { status: 401 }); return NextResponse.json({ error: "Credenciales inválidas" }, { status: 401 });
} }
let session: string;
try {
session = await signSession(mail);
} catch (e) {
console.error("[api/auth] IT_SESSION_SECRET inválido:", e);
return NextResponse.json({ error: "Error de configuración del servidor" }, { status: 500 });
}
const secure = process.env.NODE_ENV === "production";
const res = NextResponse.json({ ok: true, email: mail }); const res = NextResponse.json({ ok: true, email: mail });
res.cookies.set(COOKIE, await signSession(mail), { res.cookies.set(COOKIE, session, {
httpOnly: true, httpOnly: true,
secure: true, secure,
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
maxAge: SESSION_MAX_AGE, maxAge: SESSION_MAX_AGE,
@@ -47,7 +104,8 @@ export async function POST(req: NextRequest) {
} }
export async function DELETE() { export async function DELETE() {
const secure = process.env.NODE_ENV === "production";
const res = NextResponse.json({ ok: true }); const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE, "", { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 0 }); res.cookies.set(COOKIE, "", { httpOnly: true, secure, sameSite: "lax", path: "/", maxAge: 0 });
return res; return res;
} }
+29 -4
View File
@@ -1,12 +1,36 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { assertIT } from "@/lib/correo-server";
const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010"; const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || ""; const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
// Solo segmentos seguros: evita path traversal (.., /, %, etc.)
const SEG_RE = /^[a-z0-9][a-z0-9_-]*$/i;
function badRequest(msg: string) {
return NextResponse.json({ error: msg }, { status: 400 });
}
// Proxy genérico al deployer: /api/deployer/<path>?<query> // Proxy genérico al deployer: /api/deployer/<path>?<query>
// Métodos: GET, POST, PUT, PATCH, DELETE. Permite exponer // Métodos: GET, POST, PUT, PATCH, DELETE. Requiere sesión IT.
// futuras acciones sin agregar rutas (deploy, webhook, backend...).
async function proxy(req: NextRequest, path: string[]) { async function proxy(req: NextRequest, path: string[]) {
try {
await assertIT(req);
} catch (e) {
const status =
typeof e === "object" && e !== null && "status" in e
? Number((e as { status: unknown }).status) || 401
: 401;
return NextResponse.json({ error: "Requiere sesión IT" }, { status });
}
if (!Array.isArray(path) || path.length === 0 || path.length > 8) {
return badRequest("Ruta inválida");
}
for (const seg of path) {
if (!seg || seg.length > 64 || seg === "." || seg === ".." || !SEG_RE.test(seg)) {
return badRequest("Ruta inválida");
}
}
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
}; };
@@ -35,9 +59,10 @@ async function proxy(req: NextRequest, path: string[]) {
} catch { } catch {
return new NextResponse(text, { status: res.status }); return new NextResponse(text, { status: res.status });
} }
} catch (e) { } catch {
console.error("[deployer-proxy] error al conectar con deployer");
return NextResponse.json( return NextResponse.json(
{ error: e instanceof Error ? e.message : "Error al conectar con deployer" }, { error: "Error al conectar con deployer" },
{ status: 502 } { status: 502 }
); );
} }
+17 -4
View File
@@ -1,9 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { assertIT } from "@/lib/correo-server";
import type { VerifyCheck, VerifyResult } from "@/lib/verify"; import type { VerifyCheck, VerifyResult } from "@/lib/verify";
const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010"; const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || ""; const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
const NAME_RE = /^[a-z0-9][a-z0-9-]{1,62}$/i;
function deployerHeaders(): Record<string, string> { function deployerHeaders(): Record<string, string> {
const h: Record<string, string> = { "Content-Type": "application/json" }; const h: Record<string, string> = { "Content-Type": "application/json" };
if (DEPLOYER_TOKEN) h["X-API-Token"] = DEPLOYER_TOKEN; if (DEPLOYER_TOKEN) h["X-API-Token"] = DEPLOYER_TOKEN;
@@ -35,10 +38,19 @@ async function fetchWithTimeout(
// 3. Siempre suma el chequeo de URL pública (Caddy + DNS + TLS) para avisar // 3. Siempre suma el chequeo de URL pública (Caddy + DNS + TLS) para avisar
// a quien importa con el paso exacto que falló. // a quien importa con el paso exacto que falló.
export async function GET( export async function GET(
_req: Request, req: Request,
{ params }: { params: Promise<{ name: string }> } { params }: { params: Promise<{ name: string }> }
) { ) {
const name = (await params).name; try {
await assertIT(req);
} catch {
return NextResponse.json({ error: "Requiere sesión IT" }, { status: 401 });
}
const rawName = (await params).name;
if (!rawName || rawName.length > 64 || !NAME_RE.test(rawName)) {
return NextResponse.json({ error: "Nombre de módulo inválido" }, { status: 400 });
}
const name = rawName.toLowerCase();
let base: VerifyResult | null = null; let base: VerifyResult | null = null;
// --- 1. Deployer nuevo: GET /api/modules/:name/verify --- // --- 1. Deployer nuevo: GET /api/modules/:name/verify ---
@@ -143,10 +155,11 @@ export async function GET(
} }
// --- 3. Chequeo de URL pública (siempre, portal) --- // --- 3. Chequeo de URL pública (siempre, portal) ---
const subdomain = const rawSubdomain =
mod?.subdomain || mod?.subdomain ||
(base.checks.find((c) => c.id === "caddy")?.detail.match(/https:\/\/([a-z0-9-]+)\./)?.[1] as string) || (base.checks.find((c) => c.id === "caddy")?.detail.match(/https:\/\/([a-z0-9-]+)\./)?.[1] as string) ||
name.toLowerCase(); name;
const subdomain = NAME_RE.test(rawSubdomain) ? rawSubdomain.toLowerCase() : name;
const publicUrl = `https://${subdomain}.devops.norteamericano.cl/`; const publicUrl = `https://${subdomain}.devops.norteamericano.cl/`;
let publicStatus: number | null = null; let publicStatus: number | null = null;
const caddyCheck = base.checks.find((c) => c.id === "caddy"); const caddyCheck = base.checks.find((c) => c.id === "caddy");
+101 -6
View File
@@ -1,33 +1,127 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { assertIT } from "@/lib/correo-server";
import type { ModuleState } from "@/lib/sam";
const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010"; const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || ""; const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
const NAME_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
const SUBDOMAIN_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
function headers() { function headers() {
const h: Record<string, string> = { "Content-Type": "application/json" }; const h: Record<string, string> = { "Content-Type": "application/json" };
if (DEPLOYER_TOKEN) h["X-API-Token"] = DEPLOYER_TOKEN; if (DEPLOYER_TOKEN) h["X-API-Token"] = DEPLOYER_TOKEN;
return h; return h;
} }
export async function GET() { function isIT(req: NextRequest): Promise<boolean> {
return assertIT(req).then(
() => true,
() => false
);
}
// Vista pública: solo módulos visibles y sin datos sensibles
// (repoUrl, logs de deploy). La vista completa queda para sesión IT.
function sanitizeForPublic(modules: ModuleState[]): ModuleState[] {
return modules
.filter((m) => m.visible !== false)
.map((m) => ({
name: m.name,
repoUrl: "",
branch: m.branch,
subdomain: m.subdomain,
port: m.port,
active: m.active,
category: m.category,
kind: m.kind,
visible: m.visible,
targetPort: m.targetPort,
color: m.color,
lastDeploy: m.lastDeploy
? { sha: m.lastDeploy.sha, time: m.lastDeploy.time, ok: m.lastDeploy.ok, log: "" }
: undefined,
container: m.container,
}));
}
function validRepoUrl(v: unknown): boolean {
if (typeof v !== "string" || v.length === 0 || v.length > 500) return false;
let u: URL;
try {
u = new URL(v);
} catch {
return false;
}
if (u.protocol !== "https:") return false;
if (u.username || u.password) return false;
const host = u.hostname.toLowerCase();
if (
host === "localhost" ||
host.startsWith("127.") ||
host.startsWith("10.") ||
host.startsWith("192.168.") ||
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
host.endsWith(".local") ||
host.endsWith(".internal")
)
return false;
return true;
}
export async function GET(req: NextRequest) {
try { try {
const res = await fetch(`${DEPLOYER_URL}/api/modules`, { const res = await fetch(`${DEPLOYER_URL}/api/modules`, {
headers: headers(), headers: headers(),
cache: "no-store", cache: "no-store",
}); });
const data = await res.json(); const data = await res.json();
return NextResponse.json(data, { status: res.status }); if (!res.ok) return NextResponse.json(data, { status: res.status });
} catch (e) { if (await isIT(req)) return NextResponse.json(data, { status: res.status });
const modules: ModuleState[] = Array.isArray(data.modules) ? data.modules : [];
return NextResponse.json( return NextResponse.json(
{ error: e instanceof Error ? e.message : "Error al conectar con deployer" }, { ...data, modules: sanitizeForPublic(modules) },
{ status: res.status }
);
} catch {
console.error("[api/modules] error al conectar con deployer");
return NextResponse.json(
{ error: "Error al conectar con deployer" },
{ status: 502 } { status: 502 }
); );
} }
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try {
await assertIT(req);
} catch {
return NextResponse.json({ error: "Requiere sesión IT" }, { status: 401 });
}
try { try {
const body = await req.json(); const body = await req.json();
const name = String(body?.name ?? "").trim().toLowerCase();
const repoUrl = String(body?.repoUrl ?? "").trim();
const subdomain =
body?.subdomain === undefined ? undefined : String(body.subdomain).trim().toLowerCase();
if (!NAME_RE.test(name)) {
return NextResponse.json(
{ error: "Nombre inválido ([a-z0-9-], 2-63, debe empezar con alfanumérico)" },
{ status: 400 }
);
}
if (!validRepoUrl(repoUrl)) {
return NextResponse.json(
{ error: "repoUrl inválida (https pública requerida)" },
{ status: 400 }
);
}
if (subdomain !== undefined && subdomain !== "" && !SUBDOMAIN_RE.test(subdomain)) {
return NextResponse.json({ error: "Subdominio inválido" }, { status: 400 });
}
if (body?.port !== undefined && (typeof body.port !== "number" || body.port < 1 || body.port > 65535)) {
return NextResponse.json({ error: "Puerto inválido" }, { status: 400 });
}
const res = await fetch(`${DEPLOYER_URL}/api/modules`, { const res = await fetch(`${DEPLOYER_URL}/api/modules`, {
method: "POST", method: "POST",
headers: headers(), headers: headers(),
@@ -35,9 +129,10 @@ export async function POST(req: NextRequest) {
}); });
const data = await res.json(); const data = await res.json();
return NextResponse.json(data, { status: res.status }); return NextResponse.json(data, { status: res.status });
} catch (e) { } catch {
console.error("[api/modules] error al registrar módulo");
return NextResponse.json( return NextResponse.json(
{ error: e instanceof Error ? e.message : "Error al registrar módulo" }, { error: "Error al registrar módulo" },
{ status: 502 } { status: 502 }
); );
} }
+11 -4
View File
@@ -1,9 +1,15 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { assertIT } from "@/lib/correo-server";
const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010"; const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || ""; const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
export async function GET() { export async function GET(req: NextRequest) {
try {
await assertIT(req);
} catch {
return NextResponse.json({ error: "Requiere sesión IT" }, { status: 401 });
}
try { try {
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -15,9 +21,10 @@ export async function GET() {
}); });
const data = await res.json(); const data = await res.json();
return NextResponse.json(data, { status: res.status }); return NextResponse.json(data, { status: res.status });
} catch (e) { } catch {
console.error("[api/stats] error al obtener stats");
return NextResponse.json( return NextResponse.json(
{ error: e instanceof Error ? e.message : "Error al obtener stats" }, { error: "Error al obtener stats" },
{ status: 502 } { status: 502 }
); );
} }
+72 -1
View File
@@ -15,6 +15,77 @@ import { itMenu } from "@/lib/sam";
// Lista (GET), crea (POST key/name/subject/bodyHtml/module) y // Lista (GET), crea (POST key/name/subject/bodyHtml/module) y
// edita (PUT subject/bodyHtml/isActive). Las variables son // edita (PUT subject/bodyHtml/isActive). Las variables son
// {{Clave}} (case-sensitive) y las pone cada módulo al enviar. // {{Clave}} (case-sensitive) y las pone cada módulo al enviar.
// Sanitiza HTML de plantilla antes de previsualizar: evita stored XSS
// (p. ej. <img onerror=> o javascript:). Allowlist pequeña, sin deps.
const ALLOWED_TAGS = new Set([
"p", "br", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
"ul", "ol", "li", "a", "strong", "b", "em", "i", "u", "s",
"table", "thead", "tbody", "tfoot", "tr", "td", "th",
"img", "hr", "blockquote", "pre", "code",
]);
function sanitizeHtml(dirty: string): string {
try {
const doc = new DOMParser().parseFromString(dirty, "text/html");
doc.querySelectorAll(
"script,iframe,object,embed,form,link,style,meta,base,video,audio,button,input,textarea,select"
).forEach((n) => n.remove());
const walk = (root: ParentNode) => {
const els = Array.from(root.querySelectorAll("*"));
for (const el of els) {
const tag = el.tagName.toLowerCase();
if (!ALLOWED_TAGS.has(tag)) {
const parent = el.parentNode;
if (parent) {
while (el.firstChild) parent.insertBefore(el.firstChild, el);
parent.removeChild(el);
}
continue;
}
for (const attr of Array.from(el.attributes)) {
const an = attr.name.toLowerCase();
const av = attr.value;
if (an.startsWith("on")) {
el.removeAttribute(attr.name);
continue;
}
if (an === "style") {
if (/expression|javascript:|vbscript:/i.test(av)) {
el.removeAttribute(attr.name);
}
continue;
}
if ((tag === "a" && an === "href") || (tag === "img" && an === "src")) {
const v = av.trim().toLowerCase();
const ok =
v.startsWith("http://") ||
v.startsWith("https://") ||
v.startsWith("mailto:") ||
v.startsWith("#") ||
(tag === "img" && v.startsWith("data:image/png;")) ||
(tag === "img" && v.startsWith("data:image/jpeg;")) ||
(tag === "img" && v.startsWith("data:image/gif;")) ||
(tag === "img" && v.startsWith("data:image/webp;"));
if (!ok) el.removeAttribute(attr.name);
continue;
}
if (!["href", "src", "alt", "title", "style", "colspan", "rowspan"].includes(an)) {
el.removeAttribute(attr.name);
}
}
if (tag === "a") {
el.setAttribute("rel", "noopener noreferrer");
el.setAttribute("target", "_blank");
}
}
};
walk(doc.body);
return doc.body.innerHTML;
} catch {
return "";
}
}
export default function Plantillas() { export default function Plantillas() {
const { data, error } = useModules(); const { data, error } = useModules();
const me = useMe(); const me = useMe();
@@ -369,7 +440,7 @@ export default function Plantillas() {
<div <div
className="small" className="small"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: eBody || "<span class='text-muted'>—</span>", __html: sanitizeHtml(eBody) || "<span class='text-muted'>—</span>",
}} }}
/> />
</div> </div>
+1 -6
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Fira_Sans, Work_Sans } from "next/font/google"; import { Fira_Sans, Work_Sans } from "next/font/google";
import "bootstrap/dist/css/bootstrap.min.css"; import "bootstrap/dist/css/bootstrap.min.css";
import "boxicons/css/boxicons.min.css";
import "./globals.css"; import "./globals.css";
const firaSans = Fira_Sans({ const firaSans = Fira_Sans({
@@ -30,12 +31,6 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="es" className={`${firaSans.variable} ${workSans.variable}`}> <html lang="es" className={`${firaSans.variable} ${workSans.variable}`}>
<head>
<link
href="https://unpkg.com/boxicons@2.1.4/css/boxicons.min.css"
rel="stylesheet"
/>
</head>
<body style={{ fontFamily: "var(--font-fira-sans), sans-serif" }}> <body style={{ fontFamily: "var(--font-fira-sans), sans-serif" }}>
{children} {children}
</body> </body>
+9 -2
View File
@@ -4,9 +4,16 @@ import { useState, Suspense } from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
function safeNext(v: string | null): string {
if (!v || !v.startsWith("/") || v.startsWith("//") || v.startsWith("/\\")) {
return "/tablero";
}
return v;
}
function LoginForm() { function LoginForm() {
const router = useRouter(); const router = useRouter();
const next = useSearchParams().get("next") || "/tablero"; const next = safeNext(useSearchParams().get("next"));
const [rut, setRut] = useState(""); const [rut, setRut] = useState("");
const [clave, setClave] = useState(""); const [clave, setClave] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -24,7 +31,7 @@ function LoginForm() {
}); });
const j = await res.json(); const j = await res.json();
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
router.push(next.startsWith("/") ? next : "/tablero"); router.push(safeNext(next));
router.refresh(); router.refresh();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Error al ingresar"); setError(err instanceof Error ? err.message : "Error al ingresar");
+1 -1
View File
@@ -36,7 +36,7 @@ export default function Home() {
const bySection: Record<string, ModuleState[]> = {}; const bySection: Record<string, ModuleState[]> = {};
modules.forEach((m) => { modules.forEach((m) => {
const s = sectionOf(m.name, m.category); const s = sectionOf(m.category);
if (!bySection[s]) bySection[s] = []; if (!bySection[s]) bySection[s] = [];
bySection[s].push(m); bySection[s].push(m);
}); });
+3 -3
View File
@@ -205,7 +205,7 @@ export default function UploadPage() {
<div className="app-card p-4"> <div className="app-card p-4">
<p className="text-muted mb-4"> <p className="text-muted mb-4">
Registra un proyecto <strong>Next.js 15 + Bootstrap</strong> desde un repositorio Registra un proyecto <strong>Next.js 16 + Bootstrap</strong> desde un repositorio
Gitea. Se clonará, desplegará automáticamente y quedará disponible en{" "} Gitea. Se clonará, desplegará automáticamente y quedará disponible en{" "}
<code>https://{"{subdominio}"}.devops.norteamericano.cl</code>. Cada push futuro <code>https://{"{subdominio}"}.devops.norteamericano.cl</code>. Cada push futuro
actualiza el módulo vía webhook. actualiza el módulo vía webhook.
@@ -566,7 +566,7 @@ export default function UploadPage() {
<div className="col-12 mt-2"> <div className="col-12 mt-2">
<div className="alert alert-info py-2 small mb-0"> <div className="alert alert-info py-2 small mb-0">
<i className="bx bx-info-circle me-1" /> <i className="bx bx-info-circle me-1" />
El repositorio debe ser un proyecto <strong>Next.js 15</strong> (con{" "} El repositorio debe ser un proyecto <strong>Next.js 16</strong> (con{" "}
<code>package.json</code> en raíz o en <code>frontend/</code>) y usar{" "} <code>package.json</code> en raíz o en <code>frontend/</code>) y usar{" "}
<strong>Bootstrap</strong>. Si no trae <code>Dockerfile</code>, se genera uno automáticamente. <strong>Bootstrap</strong>. Si no trae <code>Dockerfile</code>, se genera uno automáticamente.
Tras registrar, se verifica que el contenedor corra y la app responda antes de darlo por listo. Tras registrar, se verifica que el contenedor corra y la app responda antes de darlo por listo.
@@ -600,7 +600,7 @@ export default function UploadPage() {
</h6> </h6>
<ul className="small text-muted mb-0"> <ul className="small text-muted mb-0">
<li> <li>
Proyecto Next.js 15 con <code>next build</code> / <code>next start</code> Proyecto Next.js 16 con <code>next build</code> / <code>next start</code>
</li> </li>
<li>Bootstrap 5 (via CDN o npm) + Boxicons opcional</li> <li>Bootstrap 5 (via CDN o npm) + Boxicons opcional</li>
<li> <li>
+17 -8
View File
@@ -51,13 +51,19 @@ export default function SamShell({
<Image src="/favicon.png" alt="SAM" width={28} height={28} /> <Image src="/favicon.png" alt="SAM" width={28} height={28} />
<span className="brand-text">SAM</span> <span className="brand-text">SAM</span>
</span> </span>
<i <button
className={`bx ${open ? "bx-menu-alt-right" : "bx-menu"}`} type="button"
className="border-0 bg-transparent p-0 text-reset"
id="btn" id="btn"
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
role="button"
aria-label="Alternar menú" aria-label="Alternar menú"
aria-expanded={open}
>
<i
className={`bx ${open ? "bx-menu-alt-right" : "bx-menu"}`}
aria-hidden="true"
/> />
</button>
</div> </div>
{backHref && ( {backHref && (
<div className="px-3 pt-2"> <div className="px-3 pt-2">
@@ -92,13 +98,16 @@ export default function SamShell({
<div className="job">{topSede || subtitle || "SAM"}</div> <div className="job">{topSede || subtitle || "SAM"}</div>
</div> </div>
{allowLogout && ( {allowLogout && (
<i <button
className="bx bx-log-out ms-auto" type="button"
role="button" className="border-0 bg-transparent p-0 ms-auto text-reset"
title="Cerrar sesión" title="Cerrar sesión"
aria-label="Cerrar sesión"
onClick={logout} onClick={logout}
style={{ fontSize: "1.4rem", cursor: "pointer" }} style={{ fontSize: "1.4rem", cursor: "pointer", lineHeight: 1 }}
/> >
<i className="bx bx-log-out" aria-hidden="true" />
</button>
)} )}
</div> </div>
</div> </div>
+1 -1
View File
@@ -358,7 +358,7 @@ export default function TableroView() {
{SECTIONS.map((section) => { {SECTIONS.map((section) => {
const list = modules.filter( const list = modules.filter(
(m) => sectionOf(m.name, m.category) === section.id (m) => sectionOf(m.category) === section.id
); );
if (list.length === 0) return null; if (list.length === 0) return null;
return ( return (
+29 -28
View File
@@ -1,20 +1,31 @@
// ============================================================ // ============================================================
// SAM IT - Auth: allowlist de correos + sesión HMAC-SHA256. // SAM IT - Auth: allowlist de correos + sesión HMAC-SHA256.
// Sin dependencias: scrypt (node) para claves, WebCrypto para // Compatible Edge middleware (solo WebCrypto + atob/btoa).
// firmar/verificar (compatible Edge middleware). // Env: IT_USERS="email,email..." IT_SESSION_SECRET="..."
// Env: IT_USERS="email:hash,email:hash" IT_SESSION_SECRET="..."
// Hash: node scripts/hash-password.mjs <clave>
// ============================================================ // ============================================================
export const COOKIE = "it_session"; export const COOKIE = "it_session";
const MAX_AGE = 12 * 3600; const MAX_AGE = 12 * 3600;
function b64url(s: string): string { function b64url(s: string): string {
return Buffer.from(s, "utf8").toString("base64url"); const bytes = new TextEncoder().encode(s);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
} }
function unb64url(s: string): string { function unb64url(s: string): string {
return Buffer.from(s, "base64url").toString("utf8"); const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new TextDecoder().decode(bytes);
}
function toHex(buf: ArrayBuffer): string {
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
} }
async function hmac(secret: string, msg: string): Promise<string> { async function hmac(secret: string, msg: string): Promise<string> {
@@ -26,7 +37,7 @@ async function hmac(secret: string, msg: string): Promise<string> {
["sign"] ["sign"]
); );
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(msg)); const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(msg));
return Buffer.from(sig).toString("hex"); return toHex(sig);
} }
export function parseUsers(): Map<string, string> { export function parseUsers(): Map<string, string> {
@@ -48,26 +59,18 @@ export function isAllowed(email: string): boolean {
return parseUsers().has(email.trim().toLowerCase()); return parseUsers().has(email.trim().toLowerCase());
} }
// scrypt$N$r$p$saltHex$hashHex (node:crypto, timing-safe) function requireSecret(): string {
export async function verifyPassword(password: string, stored: string): Promise<boolean> { const secret = process.env.IT_SESSION_SECRET || "";
try { if (!secret || secret.length < 32) {
const [tag, N, r, p, salt, hash] = stored.split("$"); throw new Error(
if (tag !== "scrypt" || !salt || !hash) return false; "IT_SESSION_SECRET no configurado o muy corto (mínimo 32 caracteres)"
const { scrypt, timingSafeEqual } = await import("node:crypto");
const derived: Buffer = await new Promise((resolve, reject) => {
scrypt(password, Buffer.from(salt, "hex"), 64, { N: +N, r: +r, p: +p }, (e, k) =>
e ? reject(e) : resolve(k as Buffer)
); );
});
const expected = Buffer.from(hash, "hex");
return derived.length === expected.length && timingSafeEqual(derived, expected);
} catch {
return false;
} }
return secret;
} }
export async function signSession(email: string): Promise<string> { export async function signSession(email: string): Promise<string> {
const secret = process.env.IT_SESSION_SECRET || ""; const secret = requireSecret();
const exp = Math.floor(Date.now() / 1000) + MAX_AGE; const exp = Math.floor(Date.now() / 1000) + MAX_AGE;
const payload = b64url(JSON.stringify({ email: email.toLowerCase(), exp })); const payload = b64url(JSON.stringify({ email: email.toLowerCase(), exp }));
return `${payload}.${await hmac(secret, payload)}`; return `${payload}.${await hmac(secret, payload)}`;
@@ -84,14 +87,12 @@ export async function readSession(
if (!payload || !sig) return null; if (!payload || !sig) return null;
const good = await hmac(secret, payload); const good = await hmac(secret, payload);
if (sig.length !== good.length) return null; if (sig.length !== good.length) return null;
let ok = true; // Comparación en tiempo constante (sin break temprano).
let diff = 0;
for (let i = 0; i < sig.length; i++) { for (let i = 0; i < sig.length; i++) {
if (sig[i] !== good[i]) { diff |= sig.charCodeAt(i) ^ good.charCodeAt(i);
ok = false;
break;
} }
} if (diff !== 0) return null;
if (!ok) return null;
const data = JSON.parse(unb64url(payload)); const data = JSON.parse(unb64url(payload));
if (!data.email || !data.exp || data.exp < Date.now() / 1000) return null; if (!data.email || !data.exp || data.exp < Date.now() / 1000) return null;
if (!isAllowed(data.email)) return null; if (!isAllowed(data.email)) return null;
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import {
envOf,
getStatusInfo,
sectionOf,
visibleModules,
type ModuleState,
} from "./sam";
function mod(over: Partial<ModuleState> = {}): ModuleState {
return {
name: "test",
repoUrl: "",
branch: "main",
subdomain: "test",
port: 3000,
active: true,
...over,
};
}
describe("getStatusInfo", () => {
it("detecta (unhealthy) como no saludable aunque contenga 'healthy'", () => {
const s = getStatusInfo(mod({ container: "Up 2 hours (unhealthy)" }));
expect(s.label).toBe("No saludable");
expect(s.cls).toBe("status-unhealthy");
});
it("detecta (healthy) como activo", () => {
const s = getStatusInfo(mod({ container: "Up 2 hours (healthy)" }));
expect(s.label).toBe("Activo");
expect(s.cls).toBe("status-healthy");
});
it("detecta starting como iniciando", () => {
const s = getStatusInfo(mod({ container: "Up 5 seconds (health: starting)" }));
expect(s.label).toBe("Iniciando");
expect(s.cls).toBe("status-starting");
});
it("contenedor Up sin health-check cuenta como activo", () => {
const s = getStatusInfo(mod({ container: "Up 10 minutes" }));
expect(s.cls).toBe("status-healthy");
});
it("sin contenedor es detenido", () => {
expect(getStatusInfo(mod({ container: null })).cls).toBe("status-unhealthy");
expect(getStatusInfo(mod({ container: "" })).label).toBe("Detenido");
});
it("contenedor Exited es detenido", () => {
expect(getStatusInfo(mod({ container: "Exited (1) 2 hours ago" })).cls).toBe(
"status-unhealthy"
);
});
});
describe("visibleModules", () => {
it("filtra visible:false y conserva el resto", () => {
const list = [
mod({ name: "a" }),
mod({ name: "b", visible: false }),
mod({ name: "c", visible: true }),
];
expect(visibleModules(list).map((m) => m.name)).toEqual(["a", "c"]);
});
});
describe("sectionOf", () => {
it("usa la categoría cuando es una sección válida", () => {
expect(sectionOf("sam")).toBe("sam");
});
it("cae en otras-apps con categoría desconocida o vacía", () => {
expect(sectionOf("inventada")).toBe("otras-apps");
expect(sectionOf()).toBe("otras-apps");
});
});
describe("envOf", () => {
it("main/master/prod son Prod, resto Dev", () => {
expect(envOf("main").label).toBe("Prod");
expect(envOf("master").label).toBe("Prod");
expect(envOf("develop").label).toBe("Dev");
});
});
+9 -60
View File
@@ -36,10 +36,6 @@ export function visibleModules(modules: ModuleState[]): ModuleState[] {
return modules.filter((m) => m.visible !== false); return modules.filter((m) => m.visible !== false);
} }
export function isBackend(m: ModuleState): boolean {
return (m.kind || "frontend") === "backend";
}
export interface ApiResponse { export interface ApiResponse {
backend: { backend: {
repoUrl: string; repoUrl: string;
@@ -228,58 +224,6 @@ export function isPagosModule(name: string): boolean {
return moduleUsageKind(name) === "pagos"; return moduleUsageKind(name) === "pagos";
} }
// ============================================================
// Apps de referencia (lanzador sam.norteamericano.cl).
// Mientras no exista el módulo integrado, la sección SAM
// muestra el acceso legacy; al integrarse, la tarjeta del
// módulo reemplaza al acceso.
// ============================================================
export interface RefApp {
name: string;
title: string;
icon: string;
bg: string;
url: string;
}
export const REFERENCE_APPS: RefApp[] = [
{
name: "ventas",
title: "Ventas",
icon: "bx-money",
bg: "bg-success",
url: "https://sam.norteamericano.cl/ventas/login.aspx",
},
{
name: "comercial",
title: "Comercial",
icon: "bx-money",
bg: "bg-success",
url: "https://sam.norteamericano.cl/comercial/",
},
{
name: "contabilidad",
title: "Finanzas",
icon: "bx-detail",
bg: "bg-danger",
url: "https://sam.norteamericano.cl/contabilidad/",
},
{
name: "academico",
title: "Académico",
icon: "bx-user",
bg: "bg-primary",
url: "https://sam.norteamericano.cl/academico/",
},
{
name: "profesores",
title: "Profesor",
icon: "bx-pencil",
bg: "bg-warning text-dark",
url: "https://sam.norteamericano.cl/profesor/",
},
];
// (Reservado: accesos legacy, hoy sin uso en el índice.) // (Reservado: accesos legacy, hoy sin uso en el índice.)
const iconMap: Record<string, { icon: string; bg: string }> = { const iconMap: Record<string, { icon: string; bg: string }> = {
sam: { icon: "bx-layer", bg: "bg-primary" }, sam: { icon: "bx-layer", bg: "bg-primary" },
@@ -305,11 +249,17 @@ export function getModuleMeta(name: string, storedColor?: string | null) {
export function getStatusInfo(m: ModuleState) { export function getStatusInfo(m: ModuleState) {
if (!m.container) return { label: "Detenido", cls: "status-unhealthy" }; if (!m.container) return { label: "Detenido", cls: "status-unhealthy" };
if (m.container.includes("healthy")) const c = m.container.toLowerCase();
// "(unhealthy)" contiene "healthy" como substring: chequear primero.
if (c.includes("(unhealthy)") || c.includes("unhealthy"))
return { label: "No saludable", cls: "status-unhealthy" };
if (c.includes("(healthy)"))
return { label: "Activo", cls: "status-healthy" }; return { label: "Activo", cls: "status-healthy" };
if (m.container.includes("starting")) if (c.includes("starting") || c.includes("health: starting"))
return { label: "Iniciando", cls: "status-starting" }; return { label: "Iniciando", cls: "status-starting" };
if (/^up\b/.test(c.trim()))
return { label: "Activo", cls: "status-healthy" }; return { label: "Activo", cls: "status-healthy" };
return { label: "Detenido", cls: "status-unhealthy" };
} }
export function displayName(name: string) { export function displayName(name: string) {
@@ -361,10 +311,9 @@ export const SECTIONS: Section[] = [
// Categoría que pregunta la integración (default del deployer: otras-apps). // Categoría que pregunta la integración (default del deployer: otras-apps).
// "Otras Aplicaciones" concentra la revisión prod/dev: módulos // "Otras Aplicaciones" concentra la revisión prod/dev: módulos
// integrados + backend + gestión. // integrados + backend + gestión.
export function sectionOf(name: string, category?: string): string { export function sectionOf(category?: string): string {
const c = (category || "").trim().toLowerCase(); const c = (category || "").trim().toLowerCase();
if (c && SECTIONS.some((s) => s.id === c)) return c; if (c && SECTIONS.some((s) => s.id === c)) return c;
void name;
return "otras-apps"; return "otras-apps";
} }
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { healthOf, pct, shortUptime } from "./stats";
function mod(container?: string | null) {
return {
name: "test",
repoUrl: "",
branch: "main",
subdomain: "test",
port: 3000,
active: true,
container: container ?? null,
};
}
describe("healthOf", () => {
it("unhealthy es down aunque contenga 'healthy'", () => {
expect(healthOf(mod("Up 2 hours (unhealthy)"))).toBe("down");
});
it("healthy es ok", () => {
expect(healthOf(mod("Up 2 hours (healthy)"))).toBe("ok");
});
it("starting es starting", () => {
expect(healthOf(mod("Up 5 seconds (health: starting)"))).toBe("starting");
});
it("sin contenedor es down", () => {
expect(healthOf(mod(null))).toBe("down");
});
it("Exited es down", () => {
expect(healthOf(mod("Exited (1) 2 hours ago"))).toBe("down");
});
});
describe("pct", () => {
it("parsea porcentajes y acota 0-100", () => {
expect(pct("12.5%")).toBeCloseTo(12.5);
expect(pct("200%")).toBe(100);
expect(pct("abc")).toBe(0);
});
});
describe("shortUptime", () => {
it("acorta 'Up X (...)'", () => {
expect(shortUptime("Up 2 hours (healthy)")).toBe("2 hours");
});
});
+5 -2
View File
@@ -49,6 +49,9 @@ export type Health = "ok" | "starting" | "down";
export function healthOf(m: ModuleState): Health { export function healthOf(m: ModuleState): Health {
if (!m.container) return "down"; if (!m.container) return "down";
if (m.container.includes("starting")) return "starting"; const c = m.container.toLowerCase();
return "ok"; if (c.includes("(unhealthy)") || (c.includes("unhealthy") && !c.includes("(healthy)"))) return "down";
if (c.includes("starting")) return "starting";
if (c.includes("(healthy)") || /^up\b/.test(c.trim())) return "ok";
return "down";
} }
+27 -20
View File
@@ -37,17 +37,41 @@ function createStore<T>(
}; };
const subscribe = (cb: () => void) => { const subscribe = (cb: () => void) => {
const start = () => {
// No pollear con la pestaña oculta (ahorra requests al deployer).
if (typeof document !== "undefined" && document.hidden) return;
load();
if (!timer) timer = setInterval(load, ms);
};
const onVisible = () => {
if (document.hidden) {
if (timer) {
clearInterval(timer);
timer = null;
}
} else {
load();
if (!timer) timer = setInterval(load, ms);
}
};
if (!started) { if (!started) {
started = true; started = true;
load(); start();
timer = setInterval(load, ms); if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisible);
}
} }
listeners.add(cb); listeners.add(cb);
return () => { return () => {
listeners.delete(cb); listeners.delete(cb);
if (listeners.size === 0 && timer) { if (listeners.size === 0) {
if (timer) {
clearInterval(timer); clearInterval(timer);
timer = null; timer = null;
}
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisible);
}
started = false; started = false;
} }
}; };
@@ -123,20 +147,3 @@ const meStore = createStore(fetchMe, null as { email: string } | null, 60000);
export function useMe() { export function useMe() {
return useSyncExternalStore(meStore.subscribe, meStore.snapshot, () => null); return useSyncExternalStore(meStore.subscribe, meStore.snapshot, () => null);
} }
// Sección del sitio según hostname: it.sam.devops... -> "it".
// useSyncExternalStore evita mismatch de hidratación SSR/cliente.
function siteSnapshot(): string {
const h = window.location.hostname.toLowerCase();
const first = h.split(".")[0];
if (first === "it" && h.endsWith("sam.devops.norteamericano.cl")) return "it";
return "";
}
function subscribeNoop() {
return () => {};
}
export function useSiteSection(): string {
return useSyncExternalStore(subscribeNoop, siteSnapshot, () => "");
}
+1 -1
View File
@@ -12,7 +12,7 @@ function isProtected(path: string): boolean {
return PROTECTED.some((p) => path === p || path.startsWith(p + "/")); return PROTECTED.some((p) => path === p || path.startsWith(p + "/"));
} }
export async function middleware(req: NextRequest) { export async function proxy(req: NextRequest) {
const host = (req.headers.get("host") || "").toLowerCase(); const host = (req.headers.get("host") || "").toLowerCase();
if (req.nextUrl.pathname === "/m/portal" || req.nextUrl.pathname.startsWith("/m/portal/")) { if (req.nextUrl.pathname === "/m/portal" || req.nextUrl.pathname.startsWith("/m/portal/")) {
return NextResponse.redirect(IT_BOARD); return NextResponse.redirect(IT_BOARD);