import { createServer, IncomingMessage, ServerResponse } from "node:http"; import { promises as fs } from "node:fs"; import { readFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { gzipSync, brotliCompressSync, constants as zlibConstants, } from "node:zlib"; import path from "node:path"; import ejs from "ejs"; import { prisma } from "../core/database/prisma"; import { randomUUID } from "node:crypto"; const publicDir = path.join(__dirname, "public"); const viewsDir = path.join(__dirname, "views"); // Compresión síncrona (rápida para tamaños pequeños de HTML/CSS/JS) // Cargar metadatos del proyecto para usarlos como variables en las vistas let pkg: { name?: string; version?: string; dependencies?: Record; } = {}; try { const pkgPath = path.join(__dirname, "../../package.json"); pkg = JSON.parse(readFileSync(pkgPath, "utf8")); } catch { // Ignorar si no se puede leer; usaremos valores por defecto } const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml", ".webp": "image/webp", ".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2", }; const PORT = Number(process.env.PORT) || 3000; // Simple in-memory stores (keep small, restart clears data) const SESSIONS = new Map(); const STATE_STORE = new Map(); // Configurable limits and TTLs (tune for production) const STATE_TTL_MS = Number(process.env.STATE_TTL_MS) || 5 * 60 * 1000; // 5 minutes const SESSION_TTL_MS = Number(process.env.SESSION_TTL_MS) || 7 * 24 * 60 * 60 * 1000; // 7 days const MAX_SESSIONS = Number(process.env.MAX_SESSIONS) || 2000; // cap entries to control RAM function parseCookies(req: IncomingMessage) { const raw = (req.headers.cookie as string) || ""; return raw .split(";") .map((s) => s.trim()) .filter(Boolean) .reduce>((acc, cur) => { const idx = cur.indexOf("="); if (idx === -1) return acc; const k = cur.slice(0, idx).trim(); const v = cur.slice(idx + 1).trim(); acc[k] = decodeURIComponent(v); return acc; }, {}); } function setSessionCookie(res: ServerResponse, sid: string) { const cookie = `amayo_sid=${encodeURIComponent( sid )}; HttpOnly; Path=/; Max-Age=${60 * 60 * 24 * 7}`; // 7 days res.setHeader("Set-Cookie", cookie); } function clearSessionCookie(res: ServerResponse) { res.setHeader("Set-Cookie", `amayo_sid=; HttpOnly; Path=/; Max-Age=0`); } function storeState(state: string) { STATE_STORE.set(state, { ts: Date.now() }); } function hasState(state: string) { const v = STATE_STORE.get(state); if (!v) return false; if (Date.now() - v.ts > STATE_TTL_MS) { STATE_STORE.delete(state); return false; } return true; } function createSession(data: any) { // Evict oldest if over cap if (SESSIONS.size >= MAX_SESSIONS) { // delete ~10% oldest entries const toRemove = Math.max(1, Math.floor(MAX_SESSIONS * 0.1)); const it = SESSIONS.keys(); for (let i = 0; i < toRemove; i++) { const k = it.next().value; if (!k) break; SESSIONS.delete(k); } } const sid = randomUUID(); SESSIONS.set(sid, { ...data, created: Date.now(), lastSeen: Date.now() }); return sid; } function touchSession(sid: string) { const s = SESSIONS.get(sid); if (!s) return; s.lastSeen = Date.now(); } async function refreshAccessTokenIfNeeded(session: any) { // session expected to have refresh_token and expires_at (ms) if (!session) return session; const now = Date.now(); if (!session.refresh_token) return session; // If token expires in next 60s, refresh if (!session.expires_at || session.expires_at - now <= 60 * 1000) { try { const clientId = process.env.CLIENT || ""; const clientSecret = process.env.CLIENT_SECRET || ""; const tokenRes = await fetch("https://discord.com/api/oauth2/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, grant_type: "refresh_token", refresh_token: session.refresh_token, } as any).toString(), }); if (!tokenRes.ok) throw new Error("Refresh failed"); const tokenJson = await tokenRes.json(); session.access_token = tokenJson.access_token; session.refresh_token = tokenJson.refresh_token || session.refresh_token; session.expires_at = Date.now() + Number(tokenJson.expires_in || 3600) * 1000; } catch (err) { console.warn("Token refresh error", err); } } return session; } // Periodic cleanup for memory-sensitive stores setInterval(() => { const now = Date.now(); // Cleanup state store for (const [k, v] of STATE_STORE.entries()) { if (now - v.ts > STATE_TTL_MS) STATE_STORE.delete(k); } // Cleanup sessions older than TTL for (const [k, s] of SESSIONS.entries()) { if (now - (s.lastSeen || s.created || 0) > SESSION_TTL_MS) SESSIONS.delete(k); } }, Math.max(30_000, Math.min(5 * 60_000, STATE_TTL_MS))); // --- Input sanitization helpers --- function stripControlChars(s: string) { return s.replace(/\x00|[\x00-\x1F\x7F]+/g, ""); } function sanitizeString(v: unknown, opts?: { max?: number }) { if (v == null) return ""; let s = String(v); s = stripControlChars(s); // Remove script tags and angle-bracket injections s = s.replace(/<\/?\s*script[^>]*>/gi, ""); s = s.replace(/[<>]/g, ""); const max = opts?.max ?? 200; if (s.length > max) s = s.slice(0, max); return s.trim(); } function validateDiscordId(id: unknown) { if (!id) return false; const s = String(id); return /^\d{17,20}$/.test(s); } async function safeUpsertGuild(g: any) { if (!g) return; if (!validateDiscordId(g.id)) { console.warn("Skipping upsert: invalid guild id", g && g.id); return; } const gid = String(g.id); const name = sanitizeString(g.name ?? gid, { max: 100 }); try { await prisma.guild.upsert({ where: { id: gid }, update: { name }, create: { id: gid, name, prefix: "!" }, }); } catch (err) { console.warn("safeUpsertGuild failed for", gid, err); } } // --- Basic hardening: blocklist patterns and lightweight rate limiting for suspicious paths --- const BLOCKED_PATTERNS: RegExp[] = [ /\b(npci|upi|bhim|aadhaar|aadhar|cts|fastag|bbps|rgcs|nuup|apbs|hdfc|ergo|securities|banking|insurance)\b/i, ]; const SUSP_LENGTH = 18; // long single-segment slugs tend to be bot probes const RATE_WINDOW_MS = 60_000; // 1 minute window const RATE_MAX_SUSPICIOUS = 20; // allow up to 20 suspicious hits per minute per IP type Counter = { count: number; resetAt: number }; const suspiciousCounters = new Map(); function getClientIp(req: IncomingMessage): string { const cf = (req.headers["cf-connecting-ip"] as string) || ""; const xff = (req.headers["x-forwarded-for"] as string) || ""; const chain = (cf || xff) .split(",") .map((s) => s.trim()) .filter(Boolean); return ( chain[0] || (req.socket && (req.socket as any).remoteAddress) || "unknown" ); } function isSingleSegment(pathname: string) { // e.g. /foo-bar, no additional slashes return /^\/[A-Za-z0-9._-]+$/.test(pathname); } function isSuspiciousPath(pathname: string): boolean { if ( !pathname || pathname === "/" || pathname === "/index" || pathname === "/index.html" ) return false; if (BLOCKED_PATTERNS.some((re) => re.test(pathname))) return true; if (isSingleSegment(pathname) && pathname.length > SUSP_LENGTH) return true; return false; } function hitSuspicious(ip: string): { allowed: boolean; resetIn: number; remaining: number; } { const now = Date.now(); let bucket = suspiciousCounters.get(ip); if (!bucket || now >= bucket.resetAt) { bucket = { count: 0, resetAt: now + RATE_WINDOW_MS }; } bucket.count += 1; suspiciousCounters.set(ip, bucket); const allowed = bucket.count <= RATE_MAX_SUSPICIOUS; return { allowed, resetIn: Math.max(0, bucket.resetAt - now), remaining: Math.max(0, RATE_MAX_SUSPICIOUS - bucket.count), }; } function applySecurityHeaders(base: Record = {}) { return { "Strict-Transport-Security": "max-age=15552000; includeSubDomains; preload", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", "X-Frame-Options": "DENY", // Mild CSP to avoid breaking inline styles/scripts already present; adjust as needed "Content-Security-Policy": "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; script-src 'self' 'unsafe-inline' https:; font-src 'self' https: data:; frame-src 'self' https://ko-fi.com https://*.ko-fi.com; child-src 'self' https://ko-fi.com https://*.ko-fi.com", ...base, }; } function buildBaseCsp(frameAncestors: string = "'self'") { // Use a mild CSP; add frame-ancestors dynamically per request. return ( "default-src 'self'; " + "img-src 'self' data: https:; " + "style-src 'self' 'unsafe-inline' https:; " + "script-src 'self' 'unsafe-inline' https:; " + "font-src 'self' https: data:; " + "frame-src 'self' https://ko-fi.com https://*.ko-fi.com; " + "child-src 'self' https://ko-fi.com https://*.ko-fi.com; " + `frame-ancestors ${frameAncestors}` ); } function applySecurityHeadersForRequest( req: IncomingMessage, base: Record = {} ) { const host = ((req.headers.host as string) || "").toLowerCase(); const isDocsHost = host === "docs.amayo.dev" || host.endsWith(".docs.amayo.dev"); // Allow embedding only from https://top.gg for docs.amayo.dev; otherwise, self only and keep XFO deny. const csp = isDocsHost ? buildBaseCsp("'self' https://top.gg") : buildBaseCsp("'self'"); const headers: Record = { "Strict-Transport-Security": "max-age=15552000; includeSubDomains; preload", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", // X-Frame-Options is omitted for docs.amayo.dev to rely on CSP frame-ancestors allowing only top.gg ...(isDocsHost ? {} : { "X-Frame-Options": "DENY" }), "Content-Security-Policy": csp, ...base, }; return headers; } function computeEtag(buf: Buffer): string { // Weak ETag derived from content sha1 and length const hash = createHash("sha1").update(buf).digest("base64"); return `W/"${buf.length.toString(16)}-${hash}"`; } function acceptsEncoding(req: IncomingMessage, enc: string): boolean { const ae = (req.headers["accept-encoding"] as string) || ""; return ae .split(",") .map((s) => s.trim()) .includes(enc); } // Parse Accept-Encoding with q-values and return a map of encoding -> q function parseAcceptEncoding(req: IncomingMessage): Map { const header = (req.headers["accept-encoding"] as string) || ""; const map = new Map(); if (!header) { // If header missing, identity is acceptable map.set("identity", 1); return map; } const parts = header.split(","); for (const raw of parts) { const part = raw.trim(); if (!part) continue; const [name, ...params] = part.split(";"); let q = 1; for (const p of params) { const [k, v] = p.split("=").map((s) => s.trim()); if (k === "q" && v) { const n = Number(v); if (!Number.isNaN(n)) q = n; } } map.set(name.toLowerCase(), q); } // Ensure identity exists unless explicitly disabled (q=0) if (!map.has("identity")) map.set("identity", 1); return map; } // Choose the best compression given the request and mime type. function pickEncoding( req: IncomingMessage, mime: string ): "br" | "gzip" | "identity" { if (!shouldCompress(mime)) return "identity"; const encs = parseAcceptEncoding(req); const qBr = encs.get("br") ?? 0; const qGzip = encs.get("gzip") ?? 0; if (qBr > 0) return "br"; if (qGzip > 0) return "gzip"; return "identity"; } function shouldCompress(mime: string): boolean { return ( mime.startsWith("text/") || mime.includes("json") || mime.includes("javascript") || mime.includes("svg") ); } const resolvePath = (pathname: string): string => { const decoded = decodeURIComponent(pathname); let target = decoded; if (target.endsWith("/")) { target = `${target}index.html`; } if (!path.extname(target)) { target = `${target}.html`; } return path.join(publicDir, target); }; const sendResponse = async ( req: IncomingMessage, res: ServerResponse, filePath: string, statusCode = 200 ): Promise => { const extension = path.extname(filePath).toLowerCase(); const mimeType = MIME_TYPES[extension] || "application/octet-stream"; const cacheControl = extension.match(/\.(?:html)$/) ? "no-cache" : "public, max-age=86400, immutable"; const stat = await fs.stat(filePath).catch(() => undefined); const data = await fs.readFile(filePath); const etag = computeEtag(data); // Conditional requests const inm = (req.headers["if-none-match"] as string) || ""; if (inm && inm === etag) { res.writeHead( 304, applySecurityHeadersForRequest(req, { ETag: etag, "Cache-Control": cacheControl, ...(stat ? { "Last-Modified": stat.mtime.toUTCString() } : {}), }) ); res.end(); return; } let body: any = data; const headers: Record = { "Content-Type": mimeType, "Cache-Control": cacheControl, ETag: etag, ...(stat ? { "Last-Modified": stat.mtime.toUTCString() } : {}), }; // Prefer Brotli over Gzip when supported const chosen = pickEncoding(req, mimeType); try { if (chosen === "br") { body = brotliCompressSync(data, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4, // fast, good ratio for text [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT, }, }); headers["Content-Encoding"] = "br"; headers["Vary"] = "Accept-Encoding"; } else if (chosen === "gzip") { body = gzipSync(data); headers["Content-Encoding"] = "gzip"; headers["Vary"] = "Accept-Encoding"; } } catch { // Si falla compresión, enviar sin comprimir } res.writeHead(statusCode, applySecurityHeadersForRequest(req, headers)); res.end(body); }; const renderTemplate = async ( req: IncomingMessage, res: ServerResponse, template: string, locals: Record = {}, statusCode = 200 ) => { const pageFile = path.join(viewsDir, "pages", `${template}.ejs`); const layoutFile = path.join(viewsDir, "layouts", "layout.ejs"); const pageBody = await ejs.renderFile(pageFile, locals, { async: true }); const defaultTitle = `${ locals.appName ?? pkg.name ?? "Amayo Bot" } | Guía Completa`; const html = await ejs.renderFile( layoutFile, { head: null, scripts: null, ...locals, title: locals.title ?? defaultTitle, body: pageBody, }, { async: true } ); const htmlBuffer = Buffer.from(html, "utf8"); const etag = computeEtag(htmlBuffer); // Conditional ETag for dynamic page (fresh each deploy change) const inm = (req.headers["if-none-match"] as string) || ""; if (inm && inm === etag) { res.writeHead( 304, applySecurityHeadersForRequest(req, { ETag: etag, "Cache-Control": "no-cache", }) ); res.end(); return; } let respBody: any = htmlBuffer; const headers: Record = { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache", ETag: etag, }; const chosenDyn = pickEncoding(req, "text/html; charset=utf-8"); try { if (chosenDyn === "br") { respBody = brotliCompressSync(htmlBuffer, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4, [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT, }, }); headers["Content-Encoding"] = "br"; headers["Vary"] = "Accept-Encoding"; } else if (chosenDyn === "gzip") { respBody = gzipSync(htmlBuffer); headers["Content-Encoding"] = "gzip"; headers["Vary"] = "Accept-Encoding"; } } catch { // continuar sin comprimir } res.writeHead(statusCode, applySecurityHeadersForRequest(req, headers)); res.end(respBody); }; export const server = createServer( async (req: IncomingMessage, res: ServerResponse) => { try { // 🔒 Forzar HTTPS en producción (Heroku) if (process.env.NODE_ENV === "production") { const proto = req.headers["x-forwarded-proto"]; if (proto && proto !== "https") { res.writeHead(301, { Location: `https://${req.headers.host}${req.url}`, }); return res.end(); } } const url = new URL( req.url ?? "/", `http://${req.headers.host ?? "localhost"}` ); // Basic hardening: respond to robots.txt quickly (optional: disallow all or keep current) if (url.pathname === "/robots.txt") { const robots = "User-agent: *\nAllow: /\n"; // change to Disallow: / if you want to discourage polite crawlers res.writeHead( 200, applySecurityHeadersForRequest(req, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, max-age=86400", }) ); return res.end(robots); } const clientIp = getClientIp(req); if (isSuspiciousPath(url.pathname)) { // Hard block known-bad keyword probes if (BLOCKED_PATTERNS.some((re) => re.test(url.pathname))) { const headers = applySecurityHeadersForRequest(req, { "Content-Type": "text/plain; charset=utf-8", }); res.writeHead(403, headers); return res.end("Forbidden"); } // Rate limit repetitive suspicious hits per IP const rate = hitSuspicious(clientIp); if (!rate.allowed) { const headers = applySecurityHeadersForRequest(req, { "Content-Type": "text/plain; charset=utf-8", "Retry-After": String(Math.ceil(rate.resetIn / 1000)), "X-RateLimit-Limit": String(RATE_MAX_SUSPICIOUS), "X-RateLimit-Remaining": String(rate.remaining), }); res.writeHead(429, headers); return res.end("Too Many Requests"); } } // Ruta dinámica: renderizar index con EJS if ( url.pathname === "/" || url.pathname === "/index" || url.pathname === "/index.html" ) { const now = new Date(); const currentDateHuman = now.toLocaleDateString("es-ES", { month: "long", year: "numeric", }); const djsVersion = pkg?.dependencies?.["discord.js"] ?? "15.0.0-dev"; await renderTemplate(req, res, "index", { appName: pkg.name ?? "Amayo Bot", version: pkg.version ?? "2.0.0", djsVersion, currentDateHuman, }); return; } // --- Auth routes (Discord OAuth minimal flow) --- if (url.pathname === "/auth/discord") { // Redirect to Discord OAuth2 authorize const clientId = process.env.DISCORD_CLIENT_ID || ""; const redirectUri = process.env.DISCORD_REDIRECT_URI || `http://${req.headers.host}/auth/callback`; const state = randomUUID(); // Store state in a temp session map SESSIONS.set(state, { ts: Date.now() }); const scopes = encodeURIComponent("identify guilds"); const urlAuth = `https://discord.com/api/oauth2/authorize?response_type=code&client_id=${encodeURIComponent( clientId )}&scope=${scopes}&state=${state}&redirect_uri=${encodeURIComponent( redirectUri )}`; res.writeHead( 302, applySecurityHeadersForRequest(req, { Location: urlAuth }) ); return res.end(); } if (url.pathname === "/auth/callback") { const qs = Object.fromEntries(url.searchParams.entries()); const { code, state } = qs as any; // Validate state if (!state || !SESSIONS.has(state)) { res.writeHead(400, applySecurityHeadersForRequest(req)); return res.end("Invalid OAuth state"); } const clientId = process.env.DISCORD_CLIENT_ID || ""; const clientSecret = process.env.DISCORD_CLIENT_SECRET || ""; const redirectUri = process.env.DISCORD_REDIRECT_URI || `http://${req.headers.host}/auth/callback`; if (!code) { res.writeHead(400, applySecurityHeadersForRequest(req)); return res.end("Missing code"); } try { // Exchange code for token const tokenRes = await fetch("https://discord.com/api/oauth2/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, grant_type: "authorization_code", code, redirect_uri: redirectUri, } as any).toString(), }); if (!tokenRes.ok) throw new Error("Token exchange failed"); const tokenJson = await tokenRes.json(); const accessToken = tokenJson.access_token; // Fetch user const userRes = await fetch("https://discord.com/api/users/@me", { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!userRes.ok) throw new Error("Failed fetching user"); const userJson = await userRes.json(); // Fetch guilds const guildsRes = await fetch( "https://discord.com/api/users/@me/guilds", { headers: { Authorization: `Bearer ${accessToken}` }, } ); const guildsJson = guildsRes.ok ? await guildsRes.json() : []; // Filter guilds where user is owner or has ADMINISTRATOR bit const ADMIN_BIT = 0x8; const adminGuilds = ( Array.isArray(guildsJson) ? guildsJson : [] ).filter((g: any) => { try { const perms = Number(g.permissions || 0); return g.owner || (perms & ADMIN_BIT) === ADMIN_BIT; } catch { return false; } }); // Upsert guilds to DB safely for (const g of adminGuilds) { await safeUpsertGuild({ id: g.id, name: g.name }); } // Sanitize user and guilds before storing in session const uid = validateDiscordId(userJson?.id) ? String(userJson.id) : null; const uname = sanitizeString(userJson?.username ?? "DiscordUser", { max: 100, }); const uavatar = sanitizeString(userJson?.avatar ?? "", { max: 200 }); const safeGuilds = (adminGuilds || []).map((g: any) => ({ id: String(g.id), name: sanitizeString(g.name ?? g.id, { max: 100 }), })); const sid = randomUUID(); const session = { user: { id: uid, username: uname, avatar: uavatar, }, guilds: safeGuilds, }; SESSIONS.set(sid, session); setSessionCookie(res, sid); res.writeHead( 302, applySecurityHeadersForRequest(req, { Location: "/dashboard" }) ); return res.end(); } catch (err: any) { console.error("OAuth callback error:", err); res.writeHead(500, applySecurityHeadersForRequest(req)); return res.end("OAuth error"); } } if (url.pathname === "/auth/logout") { const cookies = parseCookies(req); const sid = cookies["amayo_sid"]; if (sid) SESSIONS.delete(sid); clearSessionCookie(res); res.writeHead( 302, applySecurityHeadersForRequest(req, { Location: "/" }) ); return res.end(); } // Dashboard routes if ( url.pathname === "/dashboard" || url.pathname.startsWith("/dashboard") ) { const cookies = parseCookies(req); const sid = cookies["amayo_sid"]; const session = sid ? SESSIONS.get(sid) : null; const user = session?.user ?? null; // If not authenticated, redirect to login page if (!user) { await renderTemplate(req, res, "login", { appName: pkg.name ?? "Amayo Bot", }); return; } // Simple guild list: for demo, fetch guilds where guild.staff contains user.id (not implemented fully) const guilds = await (async () => { try { const rows = await prisma.guild.findMany({ take: 10 }); return rows.map((r) => ({ id: r.id, name: r.name })); } catch { return []; } })(); // /dashboard -> main dashboard if (url.pathname === "/dashboard" || url.pathname === "/dashboard/") { await renderTemplate(req, res, "dashboard", { appName: pkg.name ?? "Amayo Bot", user, guilds, }); return; } // Dashboard subpaths (e.g. /dashboard/:guildId/overview) const parts = url.pathname.split("/").filter(Boolean); // parts[0] === 'dashboard' if (parts.length >= 2) { const guildId = parts[1]; const page = parts[2] || "overview"; // For now render same dashboard with selected guild context stub await renderTemplate(req, res, "dashboard", { appName: pkg.name ?? "Amayo Bot", user, guilds, selectedGuild: guildId, page, }); return; } } const filePath = resolvePath(url.pathname); if (!filePath.startsWith(publicDir)) { res.writeHead(403); res.end("Forbidden"); return; } try { await sendResponse(req, res, filePath); } catch (error: any) { if (error.code === "ENOENT") { const notFoundPath = path.join(publicDir, "404.html"); try { await sendResponse(req, res, notFoundPath, 404); } catch { res.writeHead( 404, applySecurityHeadersForRequest(req, { "Content-Type": "text/plain; charset=utf-8", }) ); res.end("404 - Recurso no encontrado"); } } else if (error.code === "EISDIR") { const indexPath = path.join(filePath, "index.html"); await sendResponse(req, res, indexPath); } else { console.error("[Server] Error al servir archivo:", error); res.writeHead( 500, applySecurityHeadersForRequest(req, { "Content-Type": "text/plain; charset=utf-8", }) ); res.end("500 - Error interno del servidor"); } } } catch (error) { console.error("[Server] Error inesperado:", error); res.writeHead( 500, applySecurityHeadersForRequest(req, { "Content-Type": "text/plain; charset=utf-8", }) ); res.end("500 - Error interno"); } } );