AeThex-OS/server/discord-routes.ts
MrPiglr b3c308b2c8 Add functional marketplace modules, bottom nav bar, root terminal, arcade games
- ModuleManager: Central tracking for installed marketplace modules
- DataAnalyzerWidget: Real-time CPU/RAM/Battery/Storage widget (unlocked by Data Analyzer module)
- BottomNavBar: Navigation bar for Projects/Chat/Marketplace/Settings
- RootShell: Real root command execution utility
- TerminalActivity: Full root shell with neofetch, sysinfo, real Linux commands
- Terminal Pro module: Adds aliases (ll, la, h), command history
- ArcadeActivity + SnakeGame: Pixel Arcade module unlocks retro games
- fade_in/fade_out animations for smooth transitions
2026-02-18 22:03:50 -07:00

329 lines
9.7 KiB
TypeScript

/**
* 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<string, { userId: string; expiresAt: number }>();
// ==================== 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;