/** * Discord Integration Routes * OAuth and activity sync for Discord accounts * Ported from aethex-forge */ import { Router, Request, Response } from "express"; import { supabase } from "./supabase.js"; import { requireAuth } from "./auth.js"; import crypto from "crypto"; const router = Router(); // Helper to get user ID from session function getUserId(req: Request): string | null { return (req.session as any)?.userId || null; } // Discord API base URL const DISCORD_API = "https://discord.com/api/v10"; const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID; const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET; const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI || "http://localhost:5000/api/discord/oauth/callback"; // Store pending linking sessions const linkingSessions = new Map(); // ==================== OAUTH ROUTES ==================== /** * GET /api/discord/oauth/start - Start Discord OAuth flow */ router.get("/oauth/start", requireAuth, async (req: Request, res: Response) => { try { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: "Unauthorized" }); if (!DISCORD_CLIENT_ID) { return res.status(500).json({ error: "Discord OAuth not configured" }); } // Create state token for CSRF protection const state = crypto.randomUUID(); linkingSessions.set(state, { userId, expiresAt: Date.now() + 600000 // 10 minutes }); const params = new URLSearchParams({ client_id: DISCORD_CLIENT_ID, redirect_uri: DISCORD_REDIRECT_URI, response_type: "code", scope: "identify guilds", state }); res.json({ authUrl: `https://discord.com/api/oauth2/authorize?${params.toString()}` }); } catch (err: any) { console.error("[Discord] OAuth start error:", err); res.status(500).json({ error: err.message }); } }); /** * GET /api/discord/oauth/callback - Handle Discord OAuth callback */ router.get("/oauth/callback", async (req: Request, res: Response) => { try { const { code, state } = req.query; if (!code || !state) { return res.status(400).json({ error: "Missing code or state" }); } // Verify state const session = linkingSessions.get(state as string); if (!session || session.expiresAt < Date.now()) { linkingSessions.delete(state as string); return res.status(400).json({ error: "Invalid or expired state" }); } linkingSessions.delete(state as string); const userId = session.userId; if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET) { return res.status(500).json({ error: "Discord OAuth not configured" }); } // Exchange code for token const tokenResponse = await fetch(`${DISCORD_API}/oauth2/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: DISCORD_CLIENT_ID, client_secret: DISCORD_CLIENT_SECRET, grant_type: "authorization_code", code: code as string, redirect_uri: DISCORD_REDIRECT_URI }) }); if (!tokenResponse.ok) { throw new Error("Failed to exchange code for token"); } const tokenData = await tokenResponse.json(); const accessToken = tokenData.access_token; // Get Discord user info const userResponse = await fetch(`${DISCORD_API}/users/@me`, { headers: { Authorization: `Bearer ${accessToken}` } }); if (!userResponse.ok) { throw new Error("Failed to get Discord user info"); } const discordUser = await userResponse.json(); // Store Discord link in database await supabase .from("user_linked_accounts") .upsert({ user_id: userId, provider: "discord", provider_user_id: discordUser.id, provider_username: discordUser.username, provider_avatar: discordUser.avatar ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` : null, access_token: accessToken, refresh_token: tokenData.refresh_token, token_expires_at: tokenData.expires_in ? new Date(Date.now() + tokenData.expires_in * 1000).toISOString() : null, linked_at: new Date().toISOString() }, { onConflict: "user_id,provider" }); // Redirect to success page res.redirect("/hub/settings?discord_linked=true"); } catch (err: any) { console.error("[Discord] OAuth callback error:", err); res.redirect("/hub/settings?discord_error=true"); } }); // ==================== LINKING ROUTES ==================== /** * POST /api/discord/link - Link Discord using verification code * Alternative to OAuth - for Discord bots to verify users */ router.post("/link", requireAuth, async (req: Request, res: Response) => { try { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: "Unauthorized" }); const { discord_id, verification_code } = req.body; if (!discord_id || !verification_code) { return res.status(400).json({ error: "discord_id and verification_code are required" }); } // Verify the code (stored by Discord bot) const { data: pendingLink, error: lookupError } = await supabase .from("discord_pending_links") .select("*") .eq("discord_id", discord_id) .eq("verification_code", verification_code) .gt("expires_at", new Date().toISOString()) .single(); if (lookupError || !pendingLink) { return res.status(400).json({ error: "Invalid or expired verification code" }); } // Link the account await supabase .from("user_linked_accounts") .upsert({ user_id: userId, provider: "discord", provider_user_id: discord_id, provider_username: pendingLink.discord_username, linked_at: new Date().toISOString() }, { onConflict: "user_id,provider" }); // Delete the pending link await supabase .from("discord_pending_links") .delete() .eq("id", pendingLink.id); res.json({ success: true, message: "Discord account linked successfully" }); } catch (err: any) { console.error("[Discord] Link error:", err); res.status(500).json({ error: err.message }); } }); /** * DELETE /api/discord/link - Unlink Discord account */ router.delete("/link", requireAuth, async (req: Request, res: Response) => { try { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: "Unauthorized" }); await supabase .from("user_linked_accounts") .delete() .eq("user_id", userId) .eq("provider", "discord"); res.json({ success: true, message: "Discord account unlinked" }); } catch (err: any) { console.error("[Discord] Unlink error:", err); res.status(500).json({ error: err.message }); } }); /** * GET /api/discord/status - Check Discord link status */ router.get("/status", requireAuth, async (req: Request, res: Response) => { try { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: "Unauthorized" }); const { data, error } = await supabase .from("user_linked_accounts") .select("provider_user_id, provider_username, provider_avatar, linked_at") .eq("user_id", userId) .eq("provider", "discord") .single(); if (error && error.code !== "PGRST116") throw error; res.json({ linked: !!data, discord: data ? { id: data.provider_user_id, username: data.provider_username, avatar: data.provider_avatar, linkedAt: data.linked_at } : null }); } catch (err: any) { console.error("[Discord] Status error:", err); res.status(500).json({ error: err.message }); } }); // ==================== ACTIVITY AUTH ROUTE ==================== /** * POST /api/discord/activity-auth - Authenticate Discord activity * Used by Discord Activities SDK for embedded apps */ router.post("/activity-auth", async (req: Request, res: Response) => { try { const { access_token } = req.body; if (!access_token) { return res.status(400).json({ error: "access_token is required" }); } // Verify token with Discord const discordResponse = await fetch(`${DISCORD_API}/users/@me`, { headers: { Authorization: `Bearer ${access_token}` } }); if (!discordResponse.ok) { if (discordResponse.status === 401) { return res.status(401).json({ error: "Invalid or expired access token" }); } throw new Error(`Discord API error: ${discordResponse.statusText}`); } const discordUser = await discordResponse.json(); const discord_id = discordUser.id; // Find linked user account const { data: linkedAccount } = await supabase .from("user_linked_accounts") .select("user_id") .eq("provider", "discord") .eq("provider_user_id", discord_id) .single(); if (!linkedAccount) { // User not linked - return Discord info for potential signup return res.json({ linked: false, discord: { id: discord_id, username: discordUser.username, globalName: discordUser.global_name, avatar: discordUser.avatar ? `https://cdn.discordapp.com/avatars/${discord_id}/${discordUser.avatar}.png` : null } }); } // Get user data const { data: user } = await supabase .from("users") .select("id, email") .eq("id", linkedAccount.user_id) .single(); res.json({ linked: true, user: user ? { id: user.id, email: user.email, discord_id } : null }); } catch (err: any) { console.error("[Discord] Activity auth error:", err); res.status(500).json({ error: err.message }); } }); export default router;