Files
amayo/src/server/server.ts

930 lines
30 KiB
TypeScript
Raw Normal View History

import { createServer, IncomingMessage, ServerResponse } from "node:http";
import { promises as fs } from "node:fs";
import { readFileSync } from "node:fs";
import { createHash, createHmac, timingSafeEqual } 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<string, string>;
} = {};
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<string, string> = {
".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<string, any>();
const STATE_STORE = new Map<string, { ts: number }>();
// 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<Record<string, string>>((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) {
// Sign the SID to prevent tampering
const secret = getSessionSecret();
const sig = createHmac("sha256", secret).update(sid).digest("base64url");
const token = `${sid}.${sig}`;
const isProd = process.env.NODE_ENV === "production";
const maxAge = 60 * 60 * 24 * 7; // 7 days
const sameSite = isProd ? "Lax" : "Lax"; // Lax works for OAuth redirects in most cases
const secure = isProd ? "; Secure" : "";
const cookie = `amayo_sid=${encodeURIComponent(
token
)}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=${sameSite}${secure}`;
res.setHeader("Set-Cookie", cookie);
}
function clearSessionCookie(res: ServerResponse) {
const isProd = process.env.NODE_ENV === "production";
const secure = isProd ? "; Secure" : "";
res.setHeader(
"Set-Cookie",
`amayo_sid=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax${secure}`
);
}
function getSessionSecret() {
return (
process.env.SESSION_SECRET ||
process.env.DISCORD_CLIENT_SECRET ||
"dev-session-secret"
);
}
function unsignSid(signed: string | undefined): string | null {
if (!signed) return null;
const decoded = decodeURIComponent(signed);
const parts = decoded.split(".");
if (parts.length !== 2) return null;
const [sid, sig] = parts;
try {
const secret = getSessionSecret();
const expected = createHmac("sha256", secret).update(sid).digest();
const got = Buffer.from(sig, "base64url");
// timing safe compare
if (expected.length !== got.length) return null;
if (!timingSafeEqual(expected, got)) return null;
return sid;
} catch {
return null;
}
}
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<string, Counter>();
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<string, string> = {}) {
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<string, string> = {}
) {
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<string, string> = {
"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<string, number> {
const header = (req.headers["accept-encoding"] as string) || "";
const map = new Map<string, number>();
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<void> => {
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<string, string> = {
"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<string, any> = {},
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 });
2025-10-07 12:12:51 -05:00
const defaultTitle = `${
locals.appName ?? pkg.name ?? "Amayo Bot"
} | Guía Completa`;
const html = await ejs.renderFile(
layoutFile,
{
head: null,
scripts: null,
// supply version to templates if not provided by caller
version: locals.version ?? pkg.version ?? "2.0.0",
...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<string, string> = {
"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;
}
// Explicit login route to render login page (avoid 404 when user visits /login)
if (url.pathname === "/login") {
await renderTemplate(req, res, "login", {
appName: pkg.name ?? "Amayo Bot",
});
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
storeState(state);
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 || !hasState(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 }),
}));
// create session with tokens to allow refresh
const now = Date.now();
const sid = createSession({
user: {
id: uid,
username: uname,
avatar: uavatar,
},
guilds: safeGuilds,
access_token: tokenJson.access_token,
refresh_token: tokenJson.refresh_token,
expires_at: now + Number(tokenJson.expires_in || 3600) * 1000,
});
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 signed = cookies["amayo_sid"];
const sid = unsignSid(signed);
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 signed = cookies["amayo_sid"];
const sid = unsignSid(signed);
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;
}
// Touch and refresh session tokens as user is active
try {
await refreshAccessTokenIfNeeded(session);
} catch (err) {
console.warn("refreshAccessTokenIfNeeded failed", err);
}
touchSession(sid!);
// 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");
}
}
);