feat: Añadir soporte para autenticación de Discord y gestión de sesiones, incluyendo rutas de OAuth y almacenamiento de estado

This commit is contained in:
Shni
2025-10-14 22:39:14 -05:00
parent f68d7ec0b0
commit 69653b38ad
7 changed files with 547 additions and 4 deletions

View File

@@ -41,6 +41,7 @@ GENAI_IMAGE_MODEL=
# Slash command registration context
# Your Discord Application ID
CLIENT=
CLIENT_SECRET=
# Test guild ID (for per-guild command registration)
guildTest=

View File

@@ -0,0 +1,99 @@
import "dotenv/config";
import { prisma } from "../src/core/database/prisma";
import { seedAchievements } from "../src/game/achievements/seed";
import {
generateDailyQuests,
updateQuestProgress,
claimQuestReward,
getPlayerQuests,
} from "../src/game/quests/service";
import {
checkAchievements,
getPlayerAchievements,
} from "../src/game/achievements/service";
async function ensureGuildAndUser(guildId: string, userId: string) {
await prisma.guild.upsert({
where: { id: guildId },
update: { name: "test" },
create: { id: guildId, name: "test", prefix: "!" },
});
await prisma.user.upsert({
where: { id: userId },
update: {},
create: { id: userId },
});
}
async function run() {
const guildId = "test-guild-quests";
const userId = "test-user-1";
await ensureGuildAndUser(guildId, userId);
console.log("Seeding achievements global...");
await seedAchievements(null);
console.log("Seeding achievements for guild...");
await seedAchievements(guildId);
console.log("Generating daily quests for guild...");
await generateDailyQuests(guildId);
console.log("Player quests before progress:");
console.log(await getPlayerQuests(userId, guildId));
// Buscar una quest de tipo fight_count
const quests = await prisma.quest.findMany({
where: { guildId, type: "daily" },
});
const q = quests.find((q) => (q.requirements as any).type === "fight_count");
if (!q) {
console.log("No daily fight quest found, picking first.");
}
const questToUse = q || quests[0];
console.log("Using quest:", questToUse?.key);
// Simular progreso para completarla
const req = (questToUse.requirements as any) || { count: 1 };
const needed = req.count || 1;
console.log("Incrementing progress by", needed);
const updates = await updateQuestProgress(
userId,
guildId,
req.type || "fight_count",
needed
);
console.log(
"Quests completed by updateQuestProgress:",
updates.map((u) => u.key)
);
// Intentar reclamar
const progressRow = await prisma.questProgress.findFirst({
where: { userId, guildId, questId: questToUse.id, completed: true },
});
if (progressRow) {
const claim = await claimQuestReward(userId, guildId, questToUse.id);
console.log("Claim result:", claim);
} else {
console.log("No completed quest progress to claim");
}
// Check achievements trigger
console.log("Checking achievements for fight_count");
const unlocked = await checkAchievements(userId, guildId, "fight_count");
console.log(
"Achievements unlocked:",
unlocked.map((a) => a.key)
);
console.log("Player achievements summary:");
console.log(await getPlayerAchievements(userId, guildId));
process.exit(0);
}
run().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,10 @@
/* Minimal glassmorphism + tailwind helpers */
.glass-card {
background: linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));
border: 1px solid rgba(255,255,255,0.06);
box-shadow: 0 6px 20px rgba(8,10,15,0.6);
}
@media (prefers-reduced-transparency: reduce) {
.glass-card { backdrop-filter: none; background: rgba(20,20,20,0.8); }
}

View File

@@ -9,6 +9,8 @@ import {
} 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");
@@ -44,6 +46,169 @@ const MIME_TYPES: Record<string, string> = {
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) {
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,
@@ -251,7 +416,6 @@ const sendResponse = async (
? "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);
@@ -453,6 +617,203 @@ export const server = createServer(
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)) {

View File

@@ -0,0 +1,43 @@
<div class="max-w-6xl mx-auto p-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Card principal -->
<div class="col-span-2">
<div class="backdrop-blur-md bg-white/10 border border-white/10 rounded-xl p-6 shadow-lg glass-card">
<h1 class="text-3xl font-bold mb-2"><%= appName %></h1>
<p class="text-sm text-slate-200/80 mb-4">Panel de administración</p>
<div class="mt-4">
<label class="block text-xs text-slate-300 mb-2">Selecciona servidor</label>
<select id="guildSelector" class="w-full rounded-md p-3 bg-white/6 text-white focus:outline-none">
<% if (guilds && guilds.length) { %>
<% guilds.forEach(g => { %>
<option value="<%= g.id %>"><%= g.name %> (<%= g.id %>)</option>
<% }) %>
<% } else { %>
<option disabled>No tienes servidores gestionados</option>
<% } %>
</select>
</div>
</div>
</div>
<!-- Sidebar navegación móvil estilo app -->
<aside class="col-span-1 md:col-span-1">
<nav class="bg-white/6 backdrop-blur rounded-xl p-4 glass-card">
<ul class="flex md:flex-col gap-2">
<li><a href="/dashboard/overview" class="block p-2 rounded-md hover:bg-white/5">Overview</a></li>
<li><a href="/dashboard/members" class="block p-2 rounded-md hover:bg-white/5">Miembros</a></li>
<li><a href="/dashboard/settings" class="block p-2 rounded-md hover:bg-white/5">Ajustes</a></li>
<li><a href="/dashboard/areas" class="block p-2 rounded-md hover:bg-white/5">Game Areas</a></li>
<li><a href="/dashboard/mobs" class="block p-2 rounded-md hover:bg-white/5">Mobs</a></li>
</ul>
</nav>
</aside>
</div>
</div>
<script>
document.getElementById('guildSelector')?.addEventListener('change', (e) => {
const v = e.target.value;
if (v) window.location.href = `/dashboard/${v}/overview`;
});
</script>

View File

@@ -0,0 +1,7 @@
<div class="min-h-screen flex items-center justify-center">
<div class="w-full max-w-md p-6 glass-card backdrop-blur-md rounded-xl">
<h2 class="text-2xl font-bold mb-4">Inicia sesión con Discord</h2>
<p class="text-sm mb-6">Para administrar servidores y usar el dashboard debes autenticarte con Discord.</p>
<a class="inline-block px-4 py-3 rounded-md bg-indigo-600 hover:bg-indigo-700 text-white" href="/auth/discord">Continuar con Discord</a>
</div>
</div>

View File

@@ -0,0 +1,22 @@
<header class="w-full bg-transparent p-3 md:p-4 fixed top-0 left-0 right-0 z-20">
<div class="max-w-6xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="/" class="text-white font-bold">← Volver</a>
<span class="text-white/80">|</span>
<h3 class="text-white font-semibold"><%= appName %></h3>
</div>
<div class="flex items-center gap-3">
<% if (user) { %>
<div class="flex items-center gap-2">
<img src="<%= user.avatar || '/assets/images/snap1.svg' %>" class="w-8 h-8 rounded-full" alt="avatar">
<span class="text-white"><%= user.username %></span>
</div>
<a href="/auth/logout" class="text-sm text-white/70 px-3 py-2 rounded-md hover:bg-white/5">Salir</a>
<% } else { %>
<a href="/auth/discord" class="text-sm text-white/70 px-3 py-2 rounded-md hover:bg-white/5">Entrar</a>
<% } %>
</div>
</div>
</header>
<div style="height:56px"></div>