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:
1 parent
ba71c4daa6
commit
8f30b1c1ec
27 files changed
+5708
-7328
No files matched your search
@@ -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
@@ -2,14 +2,15 @@ FROM node:22-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
RUN corepack enable
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN pnpm run build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -2,6 +2,18 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
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;
|
||||
Generated
-7153
File diff suppressed because it is too large.
Load diff
+7
-5
@@ -6,7 +6,9 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.3.8",
|
||||
@@ -16,13 +18,13 @@
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.4",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
"typescript": "^5",
|
||||
"vitest": "^3"
|
||||
},
|
||||
"packageManager": "pnpm@12.10.1"
|
||||
}
|
||||
Generated
+5132
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
unrs-resolver: true
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
plugins: {},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -4,17 +4,54 @@ import { COOKIE, isAllowed, signSession, SESSION_MAX_AGE } from "@/lib/auth";
|
||||
const API_URL =
|
||||
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
|
||||
// (sige_sam_v3.usuario, plano o SHA256) y exige que el email
|
||||
// retornado esté en IT_USERS (allowlist).
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
if (rateLimited(clientIp(req))) {
|
||||
return NextResponse.json(
|
||||
{ error: "Demasiados intentos, espera un minuto" },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
const { idUsuario, email, password } = await req.json();
|
||||
const id = String(idUsuario ?? email ?? "").trim();
|
||||
if (!id || !password) {
|
||||
return NextResponse.json({ error: "Faltan credenciales" }, { status: 400 });
|
||||
}
|
||||
let mail = "";
|
||||
let backendDown = false;
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/Auth/it-login`, {
|
||||
method: "POST",
|
||||
@@ -22,20 +59,40 @@ export async function POST(req: NextRequest) {
|
||||
body: JSON.stringify({ idUsuario: id, email: id, password: String(password) }),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error("backend");
|
||||
const j = await res.json();
|
||||
mail = String(j?.user?.email || "").trim().toLowerCase();
|
||||
} catch {
|
||||
mail = "";
|
||||
if (res.status >= 500) {
|
||||
backendDown = true;
|
||||
} else if (!res.ok) {
|
||||
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)) {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
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 });
|
||||
res.cookies.set(COOKIE, await signSession(mail), {
|
||||
res.cookies.set(COOKIE, session, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
secure,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: SESSION_MAX_AGE,
|
||||
@@ -47,7 +104,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const secure = process.env.NODE_ENV === "production";
|
||||
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;
|
||||
}
|
||||
@@ -1,12 +1,36 @@
|
||||
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_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>
|
||||
// Métodos: GET, POST, PUT, PATCH, DELETE. Permite exponer
|
||||
// futuras acciones sin agregar rutas (deploy, webhook, backend...).
|
||||
// Métodos: GET, POST, PUT, PATCH, DELETE. Requiere sesión IT.
|
||||
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> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
@@ -35,9 +59,10 @@ async function proxy(req: NextRequest, path: string[]) {
|
||||
} catch {
|
||||
return new NextResponse(text, { status: res.status });
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
console.error("[deployer-proxy] error al conectar con deployer");
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : "Error al conectar con deployer" },
|
||||
{ error: "Error al conectar con deployer" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { assertIT } from "@/lib/correo-server";
|
||||
import type { VerifyCheck, VerifyResult } from "@/lib/verify";
|
||||
|
||||
const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
|
||||
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
|
||||
|
||||
const NAME_RE = /^[a-z0-9][a-z0-9-]{1,62}$/i;
|
||||
|
||||
function deployerHeaders(): Record<string, string> {
|
||||
const h: Record<string, string> = { "Content-Type": "application/json" };
|
||||
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
|
||||
// a quien importa con el paso exacto que falló.
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
req: Request,
|
||||
{ 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;
|
||||
|
||||
// --- 1. Deployer nuevo: GET /api/modules/:name/verify ---
|
||||
@@ -143,10 +155,11 @@ export async function GET(
|
||||
}
|
||||
|
||||
// --- 3. Chequeo de URL pública (siempre, portal) ---
|
||||
const subdomain =
|
||||
const rawSubdomain =
|
||||
mod?.subdomain ||
|
||||
(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/`;
|
||||
let publicStatus: number | null = null;
|
||||
const caddyCheck = base.checks.find((c) => c.id === "caddy");
|
||||
|
||||
@@ -1,33 +1,127 @@
|
||||
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_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() {
|
||||
const h: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (DEPLOYER_TOKEN) h["X-API-Token"] = DEPLOYER_TOKEN;
|
||||
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 {
|
||||
const res = await fetch(`${DEPLOYER_URL}/api/modules`, {
|
||||
headers: headers(),
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (e) {
|
||||
if (!res.ok) return NextResponse.json(data, { status: res.status });
|
||||
if (await isIT(req)) return NextResponse.json(data, { status: res.status });
|
||||
const modules: ModuleState[] = Array.isArray(data.modules) ? data.modules : [];
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
await assertIT(req);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Requiere sesión IT" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
@@ -35,9 +129,10 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (e) {
|
||||
} catch {
|
||||
console.error("[api/modules] error al registrar módulo");
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : "Error al registrar módulo" },
|
||||
{ error: "Error al registrar módulo" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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_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 {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
@@ -15,9 +21,10 @@ export async function GET() {
|
||||
});
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
} catch (e) {
|
||||
} catch {
|
||||
console.error("[api/stats] error al obtener stats");
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : "Error al obtener stats" },
|
||||
{ error: "Error al obtener stats" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,77 @@ import { itMenu } from "@/lib/sam";
|
||||
// Lista (GET), crea (POST key/name/subject/bodyHtml/module) y
|
||||
// edita (PUT subject/bodyHtml/isActive). Las variables son
|
||||
// {{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() {
|
||||
const { data, error } = useModules();
|
||||
const me = useMe();
|
||||
@@ -369,7 +440,7 @@ export default function Plantillas() {
|
||||
<div
|
||||
className="small"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: eBody || "<span class='text-muted'>—</span>",
|
||||
__html: sanitizeHtml(eBody) || "<span class='text-muted'>—</span>",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1
-6
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Fira_Sans, Work_Sans } from "next/font/google";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "boxicons/css/boxicons.min.css";
|
||||
import "./globals.css";
|
||||
|
||||
const firaSans = Fira_Sans({
|
||||
@@ -30,12 +31,6 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<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" }}>
|
||||
{children}
|
||||
</body>
|
||||
|
||||
@@ -4,9 +4,16 @@ import { useState, Suspense } from "react";
|
||||
import Image from "next/image";
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const next = useSearchParams().get("next") || "/tablero";
|
||||
const next = safeNext(useSearchParams().get("next"));
|
||||
const [rut, setRut] = useState("");
|
||||
const [clave, setClave] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -24,7 +31,7 @@ function LoginForm() {
|
||||
});
|
||||
const j = await res.json();
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
router.push(next.startsWith("/") ? next : "/tablero");
|
||||
router.push(safeNext(next));
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Error al ingresar");
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ export default function Home() {
|
||||
|
||||
const bySection: Record<string, ModuleState[]> = {};
|
||||
modules.forEach((m) => {
|
||||
const s = sectionOf(m.name, m.category);
|
||||
const s = sectionOf(m.category);
|
||||
if (!bySection[s]) bySection[s] = [];
|
||||
bySection[s].push(m);
|
||||
});
|
||||
|
||||
@@ -205,7 +205,7 @@ export default function UploadPage() {
|
||||
|
||||
<div className="app-card p-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{" "}
|
||||
<code>https://{"{subdominio}"}.devops.norteamericano.cl</code>. Cada push futuro
|
||||
actualiza el módulo vía webhook.
|
||||
@@ -566,7 +566,7 @@ export default function UploadPage() {
|
||||
<div className="col-12 mt-2">
|
||||
<div className="alert alert-info py-2 small mb-0">
|
||||
<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{" "}
|
||||
<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.
|
||||
@@ -600,7 +600,7 @@ export default function UploadPage() {
|
||||
</h6>
|
||||
<ul className="small text-muted mb-0">
|
||||
<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>Bootstrap 5 (via CDN o npm) + Boxicons opcional</li>
|
||||
<li>
|
||||
|
||||
@@ -51,13 +51,19 @@ export default function SamShell({
|
||||
<Image src="/favicon.png" alt="SAM" width={28} height={28} />
|
||||
<span className="brand-text">SAM</span>
|
||||
</span>
|
||||
<i
|
||||
className={`bx ${open ? "bx-menu-alt-right" : "bx-menu"}`}
|
||||
<button
|
||||
type="button"
|
||||
className="border-0 bg-transparent p-0 text-reset"
|
||||
id="btn"
|
||||
onClick={() => setOpen(!open)}
|
||||
role="button"
|
||||
aria-label="Alternar menú"
|
||||
/>
|
||||
aria-expanded={open}
|
||||
>
|
||||
<i
|
||||
className={`bx ${open ? "bx-menu-alt-right" : "bx-menu"}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{backHref && (
|
||||
<div className="px-3 pt-2">
|
||||
@@ -92,13 +98,16 @@ export default function SamShell({
|
||||
<div className="job">{topSede || subtitle || "SAM"}</div>
|
||||
</div>
|
||||
{allowLogout && (
|
||||
<i
|
||||
className="bx bx-log-out ms-auto"
|
||||
role="button"
|
||||
<button
|
||||
type="button"
|
||||
className="border-0 bg-transparent p-0 ms-auto text-reset"
|
||||
title="Cerrar sesión"
|
||||
aria-label="Cerrar sesión"
|
||||
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>
|
||||
|
||||
@@ -358,7 +358,7 @@ export default function TableroView() {
|
||||
|
||||
{SECTIONS.map((section) => {
|
||||
const list = modules.filter(
|
||||
(m) => sectionOf(m.name, m.category) === section.id
|
||||
(m) => sectionOf(m.category) === section.id
|
||||
);
|
||||
if (list.length === 0) return null;
|
||||
return (
|
||||
|
||||
+30
-29
@@ -1,20 +1,31 @@
|
||||
// ============================================================
|
||||
// SAM IT - Auth: allowlist de correos + sesión HMAC-SHA256.
|
||||
// Sin dependencias: scrypt (node) para claves, WebCrypto para
|
||||
// firmar/verificar (compatible Edge middleware).
|
||||
// Env: IT_USERS="email:hash,email:hash" IT_SESSION_SECRET="..."
|
||||
// Hash: node scripts/hash-password.mjs <clave>
|
||||
// Compatible Edge middleware (solo WebCrypto + atob/btoa).
|
||||
// Env: IT_USERS="email,email..." IT_SESSION_SECRET="..."
|
||||
// ============================================================
|
||||
|
||||
export const COOKIE = "it_session";
|
||||
const MAX_AGE = 12 * 3600;
|
||||
|
||||
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 {
|
||||
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> {
|
||||
@@ -26,7 +37,7 @@ async function hmac(secret: string, msg: string): Promise<string> {
|
||||
["sign"]
|
||||
);
|
||||
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> {
|
||||
@@ -48,26 +59,18 @@ export function isAllowed(email: string): boolean {
|
||||
return parseUsers().has(email.trim().toLowerCase());
|
||||
}
|
||||
|
||||
// scrypt$N$r$p$saltHex$hashHex (node:crypto, timing-safe)
|
||||
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
try {
|
||||
const [tag, N, r, p, salt, hash] = stored.split("$");
|
||||
if (tag !== "scrypt" || !salt || !hash) return false;
|
||||
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;
|
||||
function requireSecret(): string {
|
||||
const secret = process.env.IT_SESSION_SECRET || "";
|
||||
if (!secret || secret.length < 32) {
|
||||
throw new Error(
|
||||
"IT_SESSION_SECRET no configurado o muy corto (mínimo 32 caracteres)"
|
||||
);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
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 payload = b64url(JSON.stringify({ email: email.toLowerCase(), exp }));
|
||||
return `${payload}.${await hmac(secret, payload)}`;
|
||||
@@ -84,14 +87,12 @@ export async function readSession(
|
||||
if (!payload || !sig) return null;
|
||||
const good = await hmac(secret, payload);
|
||||
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++) {
|
||||
if (sig[i] !== good[i]) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
diff |= sig.charCodeAt(i) ^ good.charCodeAt(i);
|
||||
}
|
||||
if (!ok) return null;
|
||||
if (diff !== 0) return null;
|
||||
const data = JSON.parse(unb64url(payload));
|
||||
if (!data.email || !data.exp || data.exp < Date.now() / 1000) return null;
|
||||
if (!isAllowed(data.email)) return null;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+10
-61
@@ -36,10 +36,6 @@ export function visibleModules(modules: ModuleState[]): ModuleState[] {
|
||||
return modules.filter((m) => m.visible !== false);
|
||||
}
|
||||
|
||||
export function isBackend(m: ModuleState): boolean {
|
||||
return (m.kind || "frontend") === "backend";
|
||||
}
|
||||
|
||||
export interface ApiResponse {
|
||||
backend: {
|
||||
repoUrl: string;
|
||||
@@ -228,58 +224,6 @@ export function isPagosModule(name: string): boolean {
|
||||
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.)
|
||||
const iconMap: Record<string, { icon: string; bg: string }> = {
|
||||
sam: { icon: "bx-layer", bg: "bg-primary" },
|
||||
@@ -305,11 +249,17 @@ export function getModuleMeta(name: string, storedColor?: string | null) {
|
||||
|
||||
export function getStatusInfo(m: ModuleState) {
|
||||
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" };
|
||||
if (m.container.includes("starting"))
|
||||
if (c.includes("starting") || c.includes("health: starting"))
|
||||
return { label: "Iniciando", cls: "status-starting" };
|
||||
return { label: "Activo", cls: "status-healthy" };
|
||||
if (/^up\b/.test(c.trim()))
|
||||
return { label: "Activo", cls: "status-healthy" };
|
||||
return { label: "Detenido", cls: "status-unhealthy" };
|
||||
}
|
||||
|
||||
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).
|
||||
// "Otras Aplicaciones" concentra la revisión prod/dev: módulos
|
||||
// integrados + backend + gestión.
|
||||
export function sectionOf(name: string, category?: string): string {
|
||||
export function sectionOf(category?: string): string {
|
||||
const c = (category || "").trim().toLowerCase();
|
||||
if (c && SECTIONS.some((s) => s.id === c)) return c;
|
||||
void name;
|
||||
return "otras-apps";
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -49,6 +49,9 @@ export type Health = "ok" | "starting" | "down";
|
||||
|
||||
export function healthOf(m: ModuleState): Health {
|
||||
if (!m.container) return "down";
|
||||
if (m.container.includes("starting")) return "starting";
|
||||
return "ok";
|
||||
const c = m.container.toLowerCase();
|
||||
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";
|
||||
}
|
||||
+29
-22
@@ -37,17 +37,41 @@ function createStore<T>(
|
||||
};
|
||||
|
||||
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) {
|
||||
started = true;
|
||||
load();
|
||||
timer = setInterval(load, ms);
|
||||
start();
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
}
|
||||
}
|
||||
listeners.add(cb);
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
if (listeners.size === 0 && timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
if (listeners.size === 0) {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
}
|
||||
started = false;
|
||||
}
|
||||
};
|
||||
@@ -123,20 +147,3 @@ const meStore = createStore(fetchMe, null as { email: string } | null, 60000);
|
||||
export function useMe() {
|
||||
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, () => "");
|
||||
}
|
||||
@@ -12,7 +12,7 @@ function isProtected(path: string): boolean {
|
||||
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();
|
||||
if (req.nextUrl.pathname === "/m/portal" || req.nextUrl.pathname.startsWith("/m/portal/")) {
|
||||
return NextResponse.redirect(IT_BOARD);
|
||||
Reference in new issue
Block a user