diff --git a/.env.example b/.env.example index 21d4392..2337eb7 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/scripts/smokeQuestsAchievements.ts b/scripts/smokeQuestsAchievements.ts new file mode 100644 index 0000000..e8fdd41 --- /dev/null +++ b/scripts/smokeQuestsAchievements.ts @@ -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); +}); diff --git a/src/server/public/assets/css/dashboard-glass.css b/src/server/public/assets/css/dashboard-glass.css new file mode 100644 index 0000000..eae3e82 --- /dev/null +++ b/src/server/public/assets/css/dashboard-glass.css @@ -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); } +} diff --git a/src/server/server.ts b/src/server/server.ts index c64a4ff..c71ddd0 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -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 = { 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, @@ -247,10 +412,9 @@ const sendResponse = async ( ): 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 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); @@ -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)) { diff --git a/src/server/views/pages/dashboard.ejs b/src/server/views/pages/dashboard.ejs new file mode 100644 index 0000000..fdf2be8 --- /dev/null +++ b/src/server/views/pages/dashboard.ejs @@ -0,0 +1,43 @@ +
+
+ +
+
+

<%= appName %>

+

Panel de administración

+
+ + +
+
+
+ + + +
+
+ + diff --git a/src/server/views/pages/login.ejs b/src/server/views/pages/login.ejs new file mode 100644 index 0000000..ad0fbc2 --- /dev/null +++ b/src/server/views/pages/login.ejs @@ -0,0 +1,7 @@ +
+
+

Inicia sesión con Discord

+

Para administrar servidores y usar el dashboard debes autenticarte con Discord.

+ Continuar con Discord +
+
diff --git a/src/server/views/partials/dashboard_nav.ejs b/src/server/views/partials/dashboard_nav.ejs new file mode 100644 index 0000000..f2525bb --- /dev/null +++ b/src/server/views/partials/dashboard_nav.ejs @@ -0,0 +1,22 @@ +
+
+
+ ← Volver + | +

<%= appName %>

+
+
+ <% if (user) { %> +
+ avatar + <%= user.username %> +
+ Salir + <% } else { %> + Entrar + <% } %> +
+
+
+ +