const express = require('express'); const session = require('express-session'); const cookieParser = require('cookie-parser'); const cors = require('cors'); const path = require('path'); const crypto = require('crypto'); function createWebServer(discordClient, supabase, options = {}) { const app = express(); const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID; const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET; const SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'); // Production URL for OAuth const BASE_URL = process.env.BASE_URL || 'https://bot.aethex.dev'; app.use(cors({ origin: true, credentials: true })); app.use(cookieParser()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(session({ secret: SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, maxAge: 7 * 24 * 60 * 60 * 1000 } })); app.use((req, res, next) => { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); next(); }); app.use(express.static(path.join(__dirname, '../public'))); app.get('/auth/discord', (req, res) => { if (!DISCORD_CLIENT_SECRET) { return res.status(500).json({ error: 'Discord OAuth not configured. Please set DISCORD_CLIENT_SECRET.' }); } const state = crypto.randomBytes(16).toString('hex'); req.session.oauthState = state; const redirectUri = `${BASE_URL}/auth/callback`; const scope = 'identify guilds'; const authUrl = `https://discord.com/api/oauth2/authorize?` + `client_id=${DISCORD_CLIENT_ID}` + `&redirect_uri=${encodeURIComponent(redirectUri)}` + `&response_type=code` + `&scope=${encodeURIComponent(scope)}` + `&state=${state}`; res.redirect(authUrl); }); app.get('/auth/callback', async (req, res) => { const { code, state } = req.query; if (!code) { return res.redirect('/?error=no_code'); } if (state !== req.session.oauthState) { return res.redirect('/?error=invalid_state'); } try { const redirectUri = `${BASE_URL}/auth/callback`; const tokenResponse = await fetch('https://discord.com/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, redirect_uri: redirectUri }) }); if (!tokenResponse.ok) { console.error('Token exchange failed:', await tokenResponse.text()); return res.redirect('/?error=token_failed'); } const tokens = await tokenResponse.json(); const userResponse = await fetch('https://discord.com/api/users/@me', { headers: { Authorization: `Bearer ${tokens.access_token}` } }); if (!userResponse.ok) { return res.redirect('/?error=user_fetch_failed'); } const user = await userResponse.json(); const guildsResponse = await fetch('https://discord.com/api/users/@me/guilds', { headers: { Authorization: `Bearer ${tokens.access_token}` } }); let guilds = []; if (guildsResponse.ok) { guilds = await guildsResponse.json(); } req.session.user = { id: user.id, username: user.username, discriminator: user.discriminator, avatar: user.avatar, globalName: user.global_name, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, guilds: guilds.map(g => ({ id: g.id, name: g.name, icon: g.icon, owner: g.owner, permissions: g.permissions, isAdmin: (parseInt(g.permissions) & 0x20) === 0x20 })) }; res.redirect('/dashboard'); } catch (error) { console.error('OAuth callback error:', error); res.redirect('/?error=auth_failed'); } }); app.get('/auth/logout', (req, res) => { req.session.destroy(); res.redirect('/'); }); app.get('/api/me', (req, res) => { if (!req.session.user) { return res.status(401).json({ error: 'Not authenticated' }); } const user = req.session.user; res.json({ id: user.id, username: user.username, discriminator: user.discriminator, avatar: user.avatar, globalName: user.globalName, avatarUrl: user.avatar ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png` : `https://cdn.discordapp.com/embed/avatars/${parseInt(user.discriminator || '0') % 5}.png`, guilds: user.guilds }); }); // Profile handler function async function handleProfileRequest(req, res, userId) { if (!supabase) { return res.status(503).json({ error: 'Database not available' }); } const targetUserId = userId || req.session.user?.id; if (!targetUserId) { return res.status(400).json({ error: 'User ID required' }); } try { const { data: link } = await supabase .from('discord_links') .select('user_id') .eq('discord_id', targetUserId) .maybeSingle(); if (!link) { return res.json({ linked: false, message: 'Discord account not linked' }); } const { data: profile } = await supabase .from('user_profiles') .select('*') .eq('id', link.user_id) .maybeSingle(); if (!profile) { return res.json({ linked: true, profile: null }); } res.json({ linked: true, profile: { id: profile.id, username: profile.username, xp: profile.xp || 0, prestigeLevel: profile.prestige_level || 0, totalXpEarned: profile.total_xp_earned || 0, dailyStreak: profile.daily_streak || 0, title: profile.title, bio: profile.bio, avatarUrl: profile.avatar_url, createdAt: profile.created_at } }); } catch (error) { console.error('Profile fetch error:', error); res.status(500).json({ error: 'Failed to fetch profile' }); } } // Route for current user's profile app.get('/api/profile', (req, res) => handleProfileRequest(req, res, null)); // Route for specific user's profile app.get('/api/profile/:userId', (req, res) => handleProfileRequest(req, res, req.params.userId)); app.get('/api/stats/:userId/:guildId', async (req, res) => { if (!supabase) { return res.status(503).json({ error: 'Database not available' }); } const { userId, guildId } = req.params; try { const { data: link } = await supabase .from('discord_links') .select('user_id') .eq('discord_id', userId) .maybeSingle(); if (!link) { return res.json({ stats: null }); } const { data: stats } = await supabase .from('user_stats') .select('*') .eq('user_id', link.user_id) .eq('guild_id', guildId) .maybeSingle(); res.json({ stats }); } catch (error) { res.status(500).json({ error: 'Failed to fetch stats' }); } }); app.get('/api/achievements/:guildId', async (req, res) => { if (!supabase) { return res.status(503).json({ error: 'Database not available' }); } const { guildId } = req.params; const userId = req.session.user?.id; try { const { data: achievements } = await supabase .from('achievements') .select('*') .eq('guild_id', guildId) .eq('hidden', false) .order('created_at', { ascending: true }); let userAchievements = []; if (userId) { const { data: link } = await supabase .from('discord_links') .select('user_id') .eq('discord_id', userId) .maybeSingle(); if (link) { const { data } = await supabase .from('user_achievements') .select('achievement_id, earned_at') .eq('user_id', link.user_id) .eq('guild_id', guildId); userAchievements = data || []; } } res.json({ achievements, userAchievements }); } catch (error) { res.status(500).json({ error: 'Failed to fetch achievements' }); } }); app.get('/api/quests/:guildId', async (req, res) => { if (!supabase) { return res.status(503).json({ error: 'Database not available' }); } const { guildId } = req.params; const userId = req.session.user?.id; try { const now = new Date().toISOString(); const { data: quests } = await supabase .from('quests') .select('*') .eq('guild_id', guildId) .eq('active', true) .or(`starts_at.is.null,starts_at.lte.${now}`) .or(`expires_at.is.null,expires_at.gt.${now}`) .order('created_at', { ascending: false }); let userQuests = []; if (userId) { const { data: link } = await supabase .from('discord_links') .select('user_id') .eq('discord_id', userId) .maybeSingle(); if (link) { const { data } = await supabase .from('user_quests') .select('*') .eq('user_id', link.user_id) .eq('guild_id', guildId); userQuests = data || []; } } res.json({ quests, userQuests }); } catch (error) { res.status(500).json({ error: 'Failed to fetch quests' }); } }); app.get('/api/shop/:guildId', async (req, res) => { if (!supabase) { return res.status(503).json({ error: 'Database not available' }); } const { guildId } = req.params; try { const { data: items } = await supabase .from('shop_items') .select('*') .eq('guild_id', guildId) .eq('available', true) .order('price', { ascending: true }); res.json({ items: items || [] }); } catch (error) { res.status(500).json({ error: 'Failed to fetch shop items' }); } }); app.get('/api/inventory/:guildId', async (req, res) => { if (!supabase) { return res.status(503).json({ error: 'Database not available' }); } const userId = req.session.user?.id; if (!userId) { return res.status(401).json({ error: 'Not authenticated' }); } const { guildId } = req.params; try { const { data: link } = await supabase .from('discord_links') .select('user_id') .eq('discord_id', userId) .maybeSingle(); if (!link) { return res.json({ inventory: [] }); } const { data: inventory } = await supabase .from('user_inventory') .select(` *, shop_items (name, description, item_type, value) `) .eq('user_id', link.user_id) .eq('guild_id', guildId); res.json({ inventory: inventory || [] }); } catch (error) { res.status(500).json({ error: 'Failed to fetch inventory' }); } }); app.get('/api/leaderboard/:guildId', async (req, res) => { if (!supabase) { return res.status(503).json({ error: 'Database not available' }); } const { guildId } = req.params; const { type = 'all', limit = 50 } = req.query; try { let tableName = 'leaderboard_alltime'; if (type === 'weekly') tableName = 'leaderboard_weekly'; else if (type === 'monthly') tableName = 'leaderboard_monthly'; const { data: leaderboard } = await supabase .from(tableName) .select(` *, user_profiles!inner (username, avatar_url, prestige_level) `) .eq('guild_id', guildId) .order('xp', { ascending: false }) .limit(parseInt(limit)); res.json({ leaderboard: leaderboard || [], type }); } catch (error) { res.status(500).json({ error: 'Failed to fetch leaderboard' }); } }); app.get('/api/bot-stats', (req, res) => { const stats = { guilds: discordClient.guilds.cache.size, users: discordClient.guilds.cache.reduce((acc, g) => acc + g.memberCount, 0), commands: discordClient.commands?.size || 0, uptime: Math.floor(process.uptime()), status: discordClient.user ? 'online' : 'offline', botName: discordClient.user?.username || 'AeThex', botAvatar: discordClient.user?.displayAvatarURL() || null }; res.json(stats); }); app.get('/api/guild/:guildId/config', async (req, res) => { if (!supabase) { return res.status(503).json({ error: 'Database not available' }); } const userId = req.session.user?.id; if (!userId) { return res.status(401).json({ error: 'Not authenticated' }); } const { guildId } = req.params; const userGuild = req.session.user.guilds.find(g => g.id === guildId); if (!userGuild || !userGuild.isAdmin) { return res.status(403).json({ error: 'No admin access to this server' }); } try { const { data: xpConfig } = await supabase .from('xp_config') .select('*') .eq('guild_id', guildId) .maybeSingle(); const { data: serverConfig } = await supabase .from('server_config') .select('*') .eq('guild_id', guildId) .maybeSingle(); res.json({ xpConfig, serverConfig }); } catch (error) { res.status(500).json({ error: 'Failed to fetch config' }); } }); app.get('/health', (req, res) => { res.json({ status: 'ok', bot: discordClient.user ? 'online' : 'offline', uptime: process.uptime() }); }); app.get('/dashboard', (req, res) => { res.sendFile(path.join(__dirname, '../public/dashboard.html')); }); app.get('/leaderboard', (req, res) => { res.redirect('/dashboard?page=leaderboard'); }); app.get('/', (req, res) => { res.sendFile(path.join(__dirname, '../public/index.html')); }); // Catch-all route for SPA - use middleware instead of wildcard app.use((req, res, next) => { if (req.method === 'GET' && !req.path.startsWith('/api/')) { res.sendFile(path.join(__dirname, '../public/index.html')); } else if (req.path.startsWith('/api/')) { res.status(404).json({ error: 'API endpoint not found' }); } else { next(); } }); return app; } module.exports = { createWebServer };