const WebSocket = require('ws'); const http = require('http'); const express = require('express'); const path = require('path'); const fs = require('fs').promises; const crypto = require('crypto'); const webpush = require('web-push'); const jwt = require('jsonwebtoken'); const HikeMapDB = require('./database'); require('dotenv').config(); // JWT configuration const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(64).toString('hex'); const JWT_ACCESS_EXPIRY = process.env.JWT_ACCESS_EXPIRY || '15m'; const JWT_REFRESH_EXPIRY = process.env.JWT_REFRESH_EXPIRY || '7d'; // Parse expiry string to milliseconds function parseExpiry(expiry) { const match = expiry.match(/^(\d+)([smhd])$/); if (!match) return 15 * 60 * 1000; // default 15 minutes const value = parseInt(match[1]); const unit = match[2]; switch (unit) { case 's': return value * 1000; case 'm': return value * 60 * 1000; case 'h': return value * 60 * 60 * 1000; case 'd': return value * 24 * 60 * 60 * 1000; default: return 15 * 60 * 1000; } } // Database instance let db = null; // Configure web-push with VAPID keys if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY) { webpush.setVapidDetails( process.env.VAPID_EMAIL || 'mailto:admin@maps.bibbit.duckdns.org', process.env.VAPID_PUBLIC_KEY, process.env.VAPID_PRIVATE_KEY ); console.log('Push notifications configured'); } else { console.log('Push notifications not configured - missing VAPID keys'); } const app = express(); const server = http.createServer(app); const wss = new WebSocket.Server({ server }); // Server session ID - changes on each restart, used to force client re-auth const serverSessionId = require('crypto').randomBytes(16).toString('hex'); console.log(`Server session ID: ${serverSessionId.substring(0, 8)}...`); // Middleware to parse JSON and raw body for KML app.use(express.json()); app.use(express.text({ type: 'application/xml', limit: '10mb' })); // Disable caching for API routes app.use('/api', (req, res, next) => { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Pragma', 'no-cache'); res.set('Expires', '0'); next(); }); // Serve service-worker.js with no-cache (critical for updates) app.get('/service-worker.js', (req, res) => { res.set('Cache-Control', 'no-store, no-cache, must-revalidate'); res.set('Pragma', 'no-cache'); res.sendFile(path.join(__dirname, 'service-worker.js')); }); // Serve HTML files with no-cache to ensure fresh content app.get(['/', '/index.html', '/admin.html'], (req, res, next) => { res.set('Cache-Control', 'no-store, no-cache, must-revalidate'); res.set('Pragma', 'no-cache'); next(); }); // Serve static files - prioritize data directory for default.kml app.get('/default.kml', async (req, res) => { try { // Try to serve from data directory first (mounted volume) const dataPath = path.join('/app/data', 'default.kml'); await fs.access(dataPath); console.log('Serving default.kml from data directory'); res.sendFile(dataPath); } catch (err) { // Fall back to app directory const appPath = path.join(__dirname, 'default.kml'); console.log('Serving default.kml from app directory'); res.sendFile(appPath); } }); // Serve .well-known directory for app verification app.use('/.well-known', express.static(path.join(__dirname, '.well-known'))); // Serve monster images app.use('/mapgameimgs', express.static(path.join(__dirname, 'mapgameimgs'))); // Serve game music app.use('/mapgamemusic', express.static(path.join(__dirname, 'mapgamemusic'))); // Serve other static files app.use(express.static(path.join(__dirname))); // Store connected users const users = new Map(); // Store geocaches let geocaches = []; // Store push subscriptions let pushSubscriptions = []; // Geocache file path const getGeocachePath = async () => { let dataDir = __dirname; try { await fs.access('/app/data'); dataDir = '/app/data'; } catch (err) { // Use local directory if /app/data doesn't exist } return path.join(dataDir, 'geocaches.json'); }; // Load geocaches from file const loadGeocaches = async () => { try { const geocachePath = await getGeocachePath(); const data = await fs.readFile(geocachePath, 'utf8'); geocaches = JSON.parse(data); console.log(`Loaded ${geocaches.length} geocaches from file`); } catch (err) { if (err.code === 'ENOENT') { console.log('No geocaches file found, starting fresh'); } else { console.error('Error loading geocaches:', err); } geocaches = []; } }; // Save geocaches to file const saveGeocaches = async () => { try { const geocachePath = await getGeocachePath(); await fs.writeFile(geocachePath, JSON.stringify(geocaches, null, 2), 'utf8'); console.log(`Saved ${geocaches.length} geocaches to file`); } catch (err) { console.error('Error saving geocaches:', err); } }; // KML save endpoint app.post('/save-kml', async (req, res) => { try { const kmlContent = req.body; // Always use data directory when it exists (Docker), otherwise local let dataDir = __dirname; try { await fs.access('/app/data'); dataDir = '/app/data'; console.log('Using data directory for save'); } catch (err) { console.log('Using app directory for save'); } const defaultKmlPath = path.join(dataDir, 'default.kml'); // Create backup with timestamp const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupPath = path.join(dataDir, `default.kml.backup.${timestamp}`); try { // Try to backup existing file const existingContent = await fs.readFile(defaultKmlPath, 'utf8'); await fs.writeFile(backupPath, existingContent); console.log(`Backed up existing KML to: ${backupPath}`); } catch (err) { // No existing file to backup, that's OK console.log('No existing default.kml to backup'); } // Write new content await fs.writeFile(defaultKmlPath, kmlContent, 'utf8'); console.log('Saved new default.kml'); // Clean up old backups (keep only last 10) const files = await fs.readdir(dataDir); const backupFiles = files .filter(f => f.startsWith('default.kml.backup.')) .sort() .reverse(); for (let i = 10; i < backupFiles.length; i++) { await fs.unlink(path.join(dataDir, backupFiles[i])); console.log(`Deleted old backup: ${backupFiles[i]}`); } res.json({ success: true, message: 'Tracks saved to server successfully', backup: backupPath.split('/').pop() }); } catch (err) { console.error('Error saving KML:', err); res.status(500).send('Failed to save: ' + err.message); } }); // Push notification endpoints app.get('/vapid-public-key', (req, res) => { res.json({ publicKey: process.env.VAPID_PUBLIC_KEY || '' }); }); app.post('/subscribe', async (req, res) => { try { const subscription = req.body; // Store subscription (in production, use a database) const existingIndex = pushSubscriptions.findIndex( sub => sub.endpoint === subscription.endpoint ); if (existingIndex >= 0) { pushSubscriptions[existingIndex] = subscription; } else { pushSubscriptions.push(subscription); } // Save subscriptions to file try { const dataDir = await getGeocachePath(); const subsPath = path.join(path.dirname(dataDir), 'push-subscriptions.json'); await fs.writeFile(subsPath, JSON.stringify(pushSubscriptions, null, 2)); } catch (err) { console.error('Error saving subscriptions:', err); } res.json({ success: true }); } catch (err) { console.error('Subscription error:', err); res.status(500).json({ error: 'Failed to subscribe' }); } }); app.post('/unsubscribe', async (req, res) => { try { const { endpoint } = req.body; pushSubscriptions = pushSubscriptions.filter(sub => sub.endpoint !== endpoint); // Save updated subscriptions try { const dataDir = await getGeocachePath(); const subsPath = path.join(path.dirname(dataDir), 'push-subscriptions.json'); await fs.writeFile(subsPath, JSON.stringify(pushSubscriptions, null, 2)); } catch (err) { console.error('Error saving subscriptions:', err); } res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to unsubscribe' }); } }); // Send test notification to all users app.post('/test-notification', async (req, res) => { try { const { message } = req.body; if (!pushSubscriptions.length) { return res.json({ success: false, message: 'No users have notifications enabled' }); } const payload = { title: '🔔 HikeMap Test Notification', body: message || 'This is a test notification from HikeMap', icon: '/icon-192x192.png', badge: '/icon-72x72.png', vibrate: [200, 100, 200], data: { type: 'test', timestamp: Date.now() } }; // Send to all subscriptions const results = await Promise.allSettled( pushSubscriptions.map(subscription => webpush.sendNotification(subscription, JSON.stringify(payload)) .catch(err => { if (err.statusCode === 410) { // Subscription expired, remove it pushSubscriptions = pushSubscriptions.filter(sub => sub.endpoint !== subscription.endpoint ); } throw err; }) ) ); const successful = results.filter(r => r.status === 'fulfilled').length; console.log(`Test notification sent to ${successful}/${pushSubscriptions.length} subscribers`); res.json({ success: true, sent: successful, total: pushSubscriptions.length }); } catch (err) { console.error('Error sending test notification:', err); res.status(500).json({ error: 'Failed to send test notification' }); } }); // Send notification to a specific user app.post('/send-notification', async (req, res) => { try { const { title, body, type, userId } = req.body; if (!pushSubscriptions.length) { return res.json({ success: false, message: 'No subscriptions' }); } const payload = { title: title || 'HikeMap Notification', body: body || '', icon: '/icon-192x192.png', badge: '/icon-72x72.png', data: { type: type, timestamp: Date.now() } }; // Send to all subscriptions (in a real app, filter by userId) const results = await Promise.allSettled( pushSubscriptions.map(subscription => webpush.sendNotification(subscription, JSON.stringify(payload)) .catch(err => { if (err.statusCode === 410) { // Subscription expired, remove it pushSubscriptions = pushSubscriptions.filter(sub => sub.endpoint !== subscription.endpoint ); } throw err; }) ) ); const successful = results.filter(r => r.status === 'fulfilled').length; console.log(`Sent notification to ${successful}/${pushSubscriptions.length} subscribers`); res.json({ success: true, sent: successful }); } catch (err) { console.error('Error sending notification:', err); res.status(500).json({ error: 'Failed to send notification' }); } }); // ============================================ // Authentication Middleware // ============================================ function generateTokens(user) { const accessToken = jwt.sign( { userId: user.id, username: user.username }, JWT_SECRET, { expiresIn: JWT_ACCESS_EXPIRY } ); const refreshToken = jwt.sign( { userId: user.id, type: 'refresh' }, JWT_SECRET, { expiresIn: JWT_REFRESH_EXPIRY } ); return { accessToken, refreshToken }; } function authenticateToken(req, res, next) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) { return res.status(401).json({ error: 'Authentication required' }); } jwt.verify(token, JWT_SECRET, (err, decoded) => { if (err) { if (err.name === 'TokenExpiredError') { return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' }); } return res.status(403).json({ error: 'Invalid token' }); } req.user = decoded; next(); }); } function optionalAuth(req, res, next) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (token) { jwt.verify(token, JWT_SECRET, (err, decoded) => { if (!err) { req.user = decoded; } }); } next(); } // Admin-only middleware - requires valid auth AND admin status function adminOnly(req, res, next) { authenticateToken(req, res, () => { const user = db.getUserById(req.user.userId); if (!user || !user.is_admin) { return res.status(403).json({ error: 'Admin access required' }); } next(); }); } // ============================================ // Authentication Endpoints // ============================================ // Register new user app.post('/api/register', async (req, res) => { try { const { username, email, password } = req.body; // Validate input if (!username || !email || !password) { return res.status(400).json({ error: 'Username, email, and password are required' }); } if (username.length < 3 || username.length > 20) { return res.status(400).json({ error: 'Username must be 3-20 characters' }); } if (!/^[a-zA-Z0-9_]+$/.test(username)) { return res.status(400).json({ error: 'Username can only contain letters, numbers, and underscores' }); } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return res.status(400).json({ error: 'Invalid email format' }); } if (password.length < 8) { return res.status(400).json({ error: 'Password must be at least 8 characters' }); } const user = await db.createUser(username, email, password); const tokens = generateTokens(user); // Store refresh token hash const refreshTokenHash = crypto.createHash('sha256').update(tokens.refreshToken).digest('hex'); const expiresAt = new Date(Date.now() + parseExpiry(JWT_REFRESH_EXPIRY)).toISOString(); await db.storeRefreshToken(user.id, refreshTokenHash, expiresAt); console.log(`New user registered: ${username}`); res.status(201).json({ user: { id: user.id, username: user.username, email: user.email, total_points: 0, finds_count: 0, avatar_icon: 'account', avatar_color: '#4CAF50' }, ...tokens }); } catch (err) { console.error('Registration error:', err); if (err.message.includes('already exists')) { return res.status(409).json({ error: err.message }); } res.status(500).json({ error: 'Registration failed' }); } }); // Login app.post('/api/login', async (req, res) => { try { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } const user = await db.validateUser(username, password); if (!user) { return res.status(401).json({ error: 'Invalid username or password' }); } const tokens = generateTokens(user); // Store refresh token hash const refreshTokenHash = crypto.createHash('sha256').update(tokens.refreshToken).digest('hex'); const expiresAt = new Date(Date.now() + parseExpiry(JWT_REFRESH_EXPIRY)).toISOString(); await db.storeRefreshToken(user.id, refreshTokenHash, expiresAt); console.log(`User logged in: ${user.username}`); res.json({ user: { id: user.id, username: user.username, email: user.email, total_points: user.total_points, finds_count: user.finds_count, avatar_icon: user.avatar_icon, avatar_color: user.avatar_color, is_admin: user.is_admin }, ...tokens }); } catch (err) { console.error('Login error:', err); res.status(500).json({ error: 'Login failed' }); } }); // Refresh access token app.post('/api/refresh', async (req, res) => { try { const { refreshToken } = req.body; if (!refreshToken) { return res.status(400).json({ error: 'Refresh token required' }); } // Verify refresh token let decoded; try { decoded = jwt.verify(refreshToken, JWT_SECRET); } catch (err) { return res.status(401).json({ error: 'Invalid refresh token' }); } if (decoded.type !== 'refresh') { return res.status(401).json({ error: 'Invalid token type' }); } // Check if token exists in database const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex'); const storedToken = db.getRefreshToken(tokenHash); if (!storedToken) { return res.status(401).json({ error: 'Refresh token not found or expired' }); } // Get user const user = db.getUserById(decoded.userId); if (!user) { return res.status(401).json({ error: 'User not found' }); } // Generate new tokens const tokens = generateTokens(user); // Delete old refresh token and store new one db.deleteRefreshToken(tokenHash); const newTokenHash = crypto.createHash('sha256').update(tokens.refreshToken).digest('hex'); const expiresAt = new Date(Date.now() + parseExpiry(JWT_REFRESH_EXPIRY)).toISOString(); await db.storeRefreshToken(user.id, newTokenHash, expiresAt); res.json(tokens); } catch (err) { console.error('Token refresh error:', err); res.status(500).json({ error: 'Token refresh failed' }); } }); // Logout app.post('/api/logout', authenticateToken, async (req, res) => { try { const { refreshToken } = req.body; if (refreshToken) { const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex'); db.deleteRefreshToken(tokenHash); } res.json({ success: true }); } catch (err) { console.error('Logout error:', err); res.status(500).json({ error: 'Logout failed' }); } }); // Get current user app.get('/api/user/me', authenticateToken, (req, res) => { try { const user = db.getUserById(req.user.userId); if (!user) { return res.status(404).json({ error: 'User not found' }); } res.json(user); } catch (err) { console.error('Get user error:', err); res.status(500).json({ error: 'Failed to get user' }); } }); // Update user avatar app.put('/api/user/avatar', authenticateToken, (req, res) => { try { const { icon, color } = req.body; if (!icon || !color) { return res.status(400).json({ error: 'Icon and color are required' }); } db.updateUserAvatar(req.user.userId, icon, color); res.json({ success: true }); } catch (err) { console.error('Update avatar error:', err); res.status(500).json({ error: 'Failed to update avatar' }); } }); // ============================================ // Game Mechanics Endpoints // ============================================ // Points configuration const POINTS = { BASE_FIND: 100, FIRST_FINDER_BONUS: 50, CLOSE_FIND_BONUS: 25, // < 2m accuracy MESSAGE_BONUS: 10 }; // Calculate distance between two points (Haversine formula) function calculateDistance(lat1, lng1, lat2, lng2) { const R = 6371e3; // Earth radius in meters const phi1 = lat1 * Math.PI / 180; const phi2 = lat2 * Math.PI / 180; const deltaPhi = (lat2 - lat1) * Math.PI / 180; const deltaLambda = (lng2 - lng1) * Math.PI / 180; const a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) + Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } // Find (claim) a geocache app.post('/api/geocaches/:id/find', authenticateToken, (req, res) => { try { const geocacheId = req.params.id; const { lat, lng, accuracy } = req.body; // Find the geocache const geocache = geocaches.find(g => g.id === geocacheId); if (!geocache) { return res.status(404).json({ error: 'Geocache not found' }); } // Check if user already found it if (db.hasUserFoundGeocache(req.user.userId, geocacheId)) { return res.status(409).json({ error: 'You have already found this geocache' }); } // Validate GPS accuracy (reject if too inaccurate) if (accuracy && accuracy > 50) { return res.status(400).json({ error: 'GPS accuracy too low. Please wait for better signal.' }); } // Validate distance (must be within 25m) const distance = calculateDistance(lat, lng, geocache.lat, geocache.lng); if (distance > 25) { return res.status(400).json({ error: 'You are too far from the geocache', distance: Math.round(distance) }); } // Calculate points let points = POINTS.BASE_FIND; const isFirstFinder = db.isFirstFinder(geocacheId); if (isFirstFinder) { points += POINTS.FIRST_FINDER_BONUS; } if (distance < 2) { points += POINTS.CLOSE_FIND_BONUS; } // Record the find const result = db.recordFind(req.user.userId, geocacheId, points, isFirstFinder); // Get updated user const user = db.getUserById(req.user.userId); console.log(`User ${req.user.username} found geocache ${geocacheId} for ${points} points`); res.json({ success: true, points_earned: points, is_first_finder: isFirstFinder, total_points: user.total_points, finds_count: user.finds_count }); } catch (err) { console.error('Find geocache error:', err); if (err.message.includes('already found')) { return res.status(409).json({ error: err.message }); } res.status(500).json({ error: 'Failed to record find' }); } }); // Get geocache finders app.get('/api/geocaches/:id/finders', optionalAuth, (req, res) => { try { const geocacheId = req.params.id; const finders = db.getGeocacheFinders(geocacheId); res.json(finders); } catch (err) { console.error('Get finders error:', err); res.status(500).json({ error: 'Failed to get finders' }); } }); // Get leaderboard app.get('/api/leaderboard', optionalAuth, (req, res) => { try { const period = req.query.period || 'all'; // 'all', 'weekly', 'monthly' const limit = Math.min(parseInt(req.query.limit) || 50, 100); const leaderboard = db.getLeaderboard(period, limit); res.json(leaderboard); } catch (err) { console.error('Get leaderboard error:', err); res.status(500).json({ error: 'Failed to get leaderboard' }); } }); // Get user's find history app.get('/api/user/finds', authenticateToken, (req, res) => { try { const limit = Math.min(parseInt(req.query.limit) || 50, 100); const finds = db.getUserFinds(req.user.userId, limit); res.json(finds); } catch (err) { console.error('Get finds error:', err); res.status(500).json({ error: 'Failed to get finds' }); } }); // Get RPG stats for current user app.get('/api/user/rpg-stats', authenticateToken, (req, res) => { try { const stats = db.getRpgStats(req.user.userId); console.log('GET /api/user/rpg-stats for user', req.user.userId, '- atk:', stats ? stats.atk : 'NO STATS'); if (stats) { // Parse unlocked_skills from JSON string let unlockedSkills = ['basic_attack']; if (stats.unlocked_skills) { try { unlockedSkills = JSON.parse(stats.unlocked_skills); } catch (e) { console.error('Failed to parse unlocked_skills:', e); } } // Parse active_skills from JSON string (default to unlockedSkills for migration) let activeSkills = unlockedSkills; // Default: use unlocked skills for existing users if (stats.active_skills) { try { activeSkills = JSON.parse(stats.active_skills); } catch (e) { console.error('Failed to parse active_skills:', e); } } // Convert snake_case from DB to camelCase for client res.json({ name: stats.character_name, race: stats.race, class: stats.class, level: stats.level, xp: stats.xp, hp: stats.hp, maxHp: stats.max_hp, mp: stats.mp, maxMp: stats.max_mp, atk: stats.atk, def: stats.def, accuracy: stats.accuracy || 90, dodge: stats.dodge || 10, unlockedSkills: unlockedSkills, activeSkills: activeSkills, homeBaseLat: stats.home_base_lat, homeBaseLng: stats.home_base_lng, lastHomeSet: stats.last_home_set, isDead: !!stats.is_dead, homeBaseIcon: stats.home_base_icon || '00', dataVersion: stats.data_version || 1 }); } else { // No stats yet - return null so client creates defaults res.json(null); } } catch (err) { console.error('Get RPG stats error:', err); res.status(500).json({ error: 'Failed to get RPG stats' }); } }); // Check if user has a character app.get('/api/user/has-character', authenticateToken, (req, res) => { try { const hasCharacter = db.hasCharacter(req.user.userId); res.json({ hasCharacter }); } catch (err) { console.error('Check character error:', err); res.status(500).json({ error: 'Failed to check character' }); } }); // Create a new character app.post('/api/user/character', authenticateToken, (req, res) => { try { const characterData = req.body; // Validate required fields if (!characterData.name || characterData.name.length < 2 || characterData.name.length > 20) { return res.status(400).json({ error: 'Character name must be 2-20 characters' }); } if (!characterData.race) { return res.status(400).json({ error: 'Race is required' }); } if (!characterData.class) { return res.status(400).json({ error: 'Class is required' }); } // Only trail_runner is available for now if (characterData.class !== 'trail_runner') { return res.status(400).json({ error: 'This class is not available yet' }); } db.createCharacter(req.user.userId, characterData); res.json({ success: true }); } catch (err) { console.error('Create character error:', err); res.status(500).json({ error: 'Failed to create character' }); } }); // Save RPG stats for current user app.put('/api/user/rpg-stats', authenticateToken, (req, res) => { try { const stats = req.body; // Validate stats if (!stats || typeof stats !== 'object') { return res.status(400).json({ error: 'Invalid stats data' }); } // Pass client's data version for checking const clientVersion = stats.dataVersion || null; const result = db.saveRpgStats(req.user.userId, stats, clientVersion); if (result.success) { res.json({ success: true, dataVersion: result.newVersion }); } else { // Stale data - client needs to reload console.log(`[STALE DATA] User ${req.user.userId} tried to save version ${clientVersion}, server has ${result.currentVersion}`); res.status(409).json({ error: 'Data conflict - your data is out of date', reason: result.reason, currentVersion: result.currentVersion }); } } catch (err) { console.error('Save RPG stats error:', err); res.status(500).json({ error: 'Failed to save RPG stats' }); } }); // Beacon endpoint for saving stats on page close (no response needed) app.post('/api/user/rpg-stats-beacon', (req, res) => { try { const { token, stats } = req.body; if (!token || !stats) { return res.status(400).end(); } // Verify token manually let decoded; try { decoded = jwt.verify(token, JWT_SECRET); } catch (err) { return res.status(401).end(); } // Use version checking to prevent stale data overwrites const clientVersion = stats.dataVersion || null; const result = db.saveRpgStats(decoded.userId, stats, clientVersion); if (!result.success) { console.log(`[BEACON STALE] User ${decoded.userId} beacon rejected: version ${clientVersion} < ${result.currentVersion}`); } res.status(200).end(); } catch (err) { console.error('Beacon save error:', err); res.status(500).end(); } }); // Swap active skill (for skill loadout at home base) app.post('/api/user/swap-skill', authenticateToken, (req, res) => { try { const { tier, newSkillId, currentActiveSkills, unlockedSkills } = req.body; // Validate inputs if (tier === undefined || !newSkillId) { return res.status(400).json({ error: 'Tier and skill ID are required' }); } // Validate skill is unlocked if (!unlockedSkills || !unlockedSkills.includes(newSkillId)) { return res.status(400).json({ error: 'Skill is not unlocked' }); } // Build new active skills array // Remove any existing skill from the same tier, add new skill let newActiveSkills = currentActiveSkills ? [...currentActiveSkills] : ['basic_attack']; // Filter out the old skill from this tier (client sends the old skill ID via tier mapping) // Since we don't have skill tier info on server, trust client's currentActiveSkills // and just ensure the new skill replaces the old one from same tier // Add the new skill if not already present if (!newActiveSkills.includes(newSkillId)) { newActiveSkills.push(newSkillId); } // Save to database const stats = db.getRpgStats(req.user.userId); if (!stats) { return res.status(404).json({ error: 'Character not found' }); } // Parse existing data let existingUnlocked = ['basic_attack']; if (stats.unlocked_skills) { try { existingUnlocked = JSON.parse(stats.unlocked_skills); } catch (e) {} } db.saveRpgStats(req.user.userId, { ...stats, name: stats.character_name, maxHp: stats.max_hp, maxMp: stats.max_mp, unlockedSkills: existingUnlocked, activeSkills: newActiveSkills, homeBaseLat: stats.home_base_lat, homeBaseLng: stats.home_base_lng, lastHomeSet: stats.last_home_set, isDead: !!stats.is_dead }); res.json({ success: true, activeSkills: newActiveSkills }); } catch (err) { console.error('Swap skill error:', err); res.status(500).json({ error: 'Failed to swap skill' }); } }); // Check if user can set home base (once per day) app.get('/api/user/can-set-home', authenticateToken, (req, res) => { try { const canSet = db.canSetHomeBase(req.user.userId); res.json({ canSet }); } catch (err) { console.error('Check home base error:', err); res.status(500).json({ error: 'Failed to check home base availability' }); } }); // Set home base location app.post('/api/user/home-base', authenticateToken, (req, res) => { try { const { lat, lng } = req.body; if (lat === undefined || lng === undefined) { return res.status(400).json({ error: 'Latitude and longitude are required' }); } // Check if user can set home base (once per day) if (!db.canSetHomeBase(req.user.userId)) { return res.status(400).json({ error: 'You can only set your home base once per day' }); } db.setHomeBase(req.user.userId, lat, lng); res.json({ success: true, homeBaseLat: lat, homeBaseLng: lng }); } catch (err) { console.error('Set home base error:', err); res.status(500).json({ error: 'Failed to set home base' }); } }); // Get available homebase icons (auto-detected from mapgameimgs/bases directory) app.get('/api/homebase-icons', (req, res) => { try { const fs = require('fs'); const basesDir = path.join(__dirname, 'mapgameimgs', 'bases'); // Read directory and find homebaseXX-100.png files const files = fs.readdirSync(basesDir); const iconPattern = /^homebase(\d+)-100\.png$/; const icons = files .map(file => { const match = file.match(iconPattern); if (match) { return { id: match[1], filename: file, preview: `/mapgameimgs/bases/${file}`, // Use 100px, CSS scales down full: `/mapgameimgs/bases/${file}` }; } return null; }) .filter(Boolean) .sort((a, b) => a.id.localeCompare(b.id)); res.json(icons); } catch (err) { console.error('Get homebase icons error:', err); res.status(500).json({ error: 'Failed to get homebase icons' }); } }); // Get spawn settings (public - client needs these for spawn logic) app.get('/api/spawn-settings', (req, res) => { try { const settings = { spawnInterval: JSON.parse(db.getSetting('monsterSpawnInterval') || '20000'), spawnChance: JSON.parse(db.getSetting('monsterSpawnChance') || '50'), spawnDistance: JSON.parse(db.getSetting('monsterSpawnDistance') || '10'), mpRegenDistance: JSON.parse(db.getSetting('mpRegenDistance') || '5'), mpRegenAmount: JSON.parse(db.getSetting('mpRegenAmount') || '1'), hpRegenInterval: JSON.parse(db.getSetting('hpRegenInterval') || '10000'), hpRegenPercent: JSON.parse(db.getSetting('hpRegenPercent') || '1'), homeHpMultiplier: JSON.parse(db.getSetting('homeHpMultiplier') || '3'), homeRegenPercent: JSON.parse(db.getSetting('homeRegenPercent') || '5'), homeBaseRadius: JSON.parse(db.getSetting('homeBaseRadius') || '20') }; res.json(settings); } catch (err) { console.error('Get spawn settings error:', err); res.status(500).json({ error: 'Failed to get spawn settings' }); } }); // Update home base icon app.put('/api/user/home-base/icon', authenticateToken, (req, res) => { try { const { iconId } = req.body; if (!iconId) { return res.status(400).json({ error: 'Icon ID is required' }); } db.setHomeBaseIcon(req.user.userId, iconId); res.json({ success: true, homeBaseIcon: iconId }); } catch (err) { console.error('Set home base icon error:', err); res.status(500).json({ error: 'Failed to set home base icon' }); } }); // Handle player death app.post('/api/user/death', authenticateToken, (req, res) => { try { const result = db.handlePlayerDeath(req.user.userId, 10); // 10% XP penalty if (!result) { return res.status(404).json({ error: 'Player stats not found' }); } // Clear monster entourage db.clearMonsterEntourage(req.user.userId); res.json({ success: true, xpLost: result.xpLost, newXp: result.newXp }); } catch (err) { console.error('Handle death error:', err); res.status(500).json({ error: 'Failed to handle death' }); } }); // Respawn player at home base app.post('/api/user/respawn', authenticateToken, (req, res) => { try { const stats = db.getRpgStats(req.user.userId); if (!stats) { return res.status(404).json({ error: 'Player stats not found' }); } if (!stats.is_dead) { return res.status(400).json({ error: 'Player is not dead' }); } db.respawnPlayer(req.user.userId); res.json({ success: true, hp: stats.max_hp, mp: stats.max_mp }); } catch (err) { console.error('Respawn error:', err); res.status(500).json({ error: 'Failed to respawn' }); } }); // ============================================ // Player Buff Endpoints // ============================================ // Get all buffs for current user (with status info) app.get('/api/user/buffs', authenticateToken, (req, res) => { try { const buffs = db.getPlayerBuffs(req.user.userId); const now = Math.floor(Date.now() / 1000); // Format buffs with status info const formatted = buffs.map(b => { const cooldownEnds = b.activated_at + (b.cooldown_hours * 3600); return { buffType: b.buff_type, effectType: b.effect_type, effectValue: b.effect_value, activatedAt: b.activated_at, expiresAt: b.expires_at, cooldownHours: b.cooldown_hours, isActive: b.expires_at > now, isOnCooldown: cooldownEnds > now, expiresIn: Math.max(0, b.expires_at - now), cooldownEndsIn: Math.max(0, cooldownEnds - now) }; }); res.json(formatted); } catch (err) { console.error('Get buffs error:', err); res.status(500).json({ error: 'Failed to get buffs' }); } }); // Get specific buff status (for checking before activation) app.get('/api/user/buffs/:buffType', authenticateToken, (req, res) => { try { const buff = db.getBuffWithCooldown(req.user.userId, req.params.buffType); if (!buff) { // Never used - can activate res.json({ buffType: req.params.buffType, canActivate: true, isActive: false, isOnCooldown: false }); } else { res.json({ buffType: buff.buff_type, effectType: buff.effect_type, effectValue: buff.effect_value, canActivate: !buff.isOnCooldown, isActive: buff.isActive, isOnCooldown: buff.isOnCooldown, expiresIn: buff.expiresIn, cooldownEndsIn: buff.cooldownEndsIn }); } } catch (err) { console.error('Get buff status error:', err); res.status(500).json({ error: 'Failed to get buff status' }); } }); // Activate a buff (utility skill) app.post('/api/user/buffs/activate', authenticateToken, (req, res) => { try { const { buffType } = req.body; if (!buffType) { return res.status(400).json({ error: 'Buff type is required' }); } // Get buff configuration from database (utility skill's status_effect JSON) const config = db.getUtilitySkillConfig(buffType); if (!config) { return res.status(400).json({ error: 'Unknown buff type or not a utility skill' }); } // Check if buff can be activated (not on cooldown) if (!db.canActivateBuff(req.user.userId, buffType)) { const buff = db.getBuffWithCooldown(req.user.userId, buffType); return res.status(400).json({ error: 'Buff is on cooldown', cooldownEndsIn: buff.cooldownEndsIn }); } // Activate the buff using config from database db.activateBuff( req.user.userId, buffType, config.effectType, config.effectValue, config.durationHours, config.cooldownHours ); const buff = db.getBuffWithCooldown(req.user.userId, buffType); console.log(`User ${req.user.username} activated ${buffType} buff`); res.json({ success: true, buffType: buffType, effectType: config.effectType, effectValue: config.effectValue, expiresIn: buff.expiresIn, cooldownEndsIn: buff.cooldownEndsIn }); } catch (err) { console.error('Activate buff error:', err); res.status(500).json({ error: 'Failed to activate buff' }); } }); // Get MP regen multiplier for current user (used by client for walking regen) app.get('/api/user/mp-regen-multiplier', authenticateToken, (req, res) => { try { const multiplier = db.getBuffMultiplier(req.user.userId, 'mp_regen_multiplier'); res.json({ multiplier }); } catch (err) { console.error('Get MP regen multiplier error:', err); res.status(500).json({ error: 'Failed to get multiplier' }); } }); // Get all monster types (public endpoint - needed for game rendering) app.get('/api/monster-types', (req, res) => { try { const types = db.getAllMonsterTypes(true); // Only enabled monsters // Convert snake_case to camelCase and parse JSON dialogues const formatted = types.map(t => ({ id: t.id, name: t.name, icon: t.icon, baseHp: t.base_hp, baseAtk: t.base_atk, baseDef: t.base_def, baseMp: t.base_mp || 20, xpReward: t.xp_reward, accuracy: t.accuracy || 85, dodge: t.dodge || 5, minLevel: t.min_level || 1, maxLevel: t.max_level || 99, spawnWeight: t.spawn_weight || 100, levelScale: { hp: t.level_scale_hp, atk: t.level_scale_atk, def: t.level_scale_def, mp: t.level_scale_mp || 5 }, dialogues: JSON.parse(t.dialogues) })); res.json(formatted); } catch (err) { console.error('Get monster types error:', err); res.status(500).json({ error: 'Failed to get monster types' }); } }); // ============================================ // Skills Endpoints (Public - needed for combat) // ============================================ // Get all skills (public endpoint) app.get('/api/skills', (req, res) => { try { const skills = db.getAllSkills(true); // Only enabled skills // Convert snake_case to camelCase and parse JSON const formatted = skills.map(s => ({ id: s.id, name: s.name, description: s.description, type: s.type, mpCost: s.mp_cost, basePower: s.base_power, accuracy: s.accuracy, hitCount: s.hit_count, target: s.target, statusEffect: s.status_effect ? JSON.parse(s.status_effect) : null, playerUsable: !!s.player_usable, monsterUsable: !!s.monster_usable })); res.json(formatted); } catch (err) { console.error('Get skills error:', err); res.status(500).json({ error: 'Failed to get skills' }); } }); // Get all class skill names (public endpoint) app.get('/api/class-skill-names', (req, res) => { try { const names = db.getAllClassSkillNames(); // Convert snake_case to camelCase const formatted = names.map(n => ({ id: n.id, skillId: n.skill_id, classId: n.class_id, customName: n.custom_name, customDescription: n.custom_description })); res.json(formatted); } catch (err) { console.error('Get class skill names error:', err); res.status(500).json({ error: 'Failed to get class skill names' }); } }); // Get skills for a specific monster type (public endpoint) app.get('/api/monster-types/:id/skills', (req, res) => { try { const skills = db.getMonsterTypeSkills(req.params.id); // Convert snake_case to camelCase and parse JSON const formatted = skills.map(s => ({ id: s.id, skillId: s.skill_id, monsterTypeId: s.monster_type_id, weight: s.weight, minLevel: s.min_level, customName: s.custom_name, // Include skill details - use custom_name if set, otherwise base name name: s.custom_name || s.name, baseName: s.name, description: s.description, type: s.type, mpCost: s.mp_cost, basePower: s.base_power, accuracy: s.accuracy, hitCount: s.hit_count, target: s.target, statusEffect: s.status_effect ? JSON.parse(s.status_effect) : null })); res.json(formatted); } catch (err) { console.error('Get monster skills error:', err); res.status(500).json({ error: 'Failed to get monster skills' }); } }); // Get monster entourage for current user app.get('/api/user/monsters', authenticateToken, (req, res) => { try { const monsters = db.getMonsterEntourage(req.user.userId); // Convert snake_case to camelCase for client const formatted = monsters.map(m => ({ id: m.id, type: m.monster_type, level: m.level, hp: m.hp, maxHp: m.max_hp, atk: m.atk, def: m.def, position: m.position_lat && m.position_lng ? { lat: m.position_lat, lng: m.position_lng } : null, spawnTime: m.spawn_time, lastDialogueTime: m.last_dialogue_time })); res.json(formatted); } catch (err) { console.error('Get monsters error:', err); res.status(500).json({ error: 'Failed to get monsters' }); } }); // Save monster entourage for current user app.put('/api/user/monsters', authenticateToken, (req, res) => { try { const monsters = req.body; if (!Array.isArray(monsters)) { return res.status(400).json({ error: 'Invalid monsters data' }); } db.saveMonsterEntourage(req.user.userId, monsters); res.json({ success: true }); } catch (err) { console.error('Save monsters error:', err); res.status(500).json({ error: 'Failed to save monsters' }); } }); // Remove a specific monster (after combat) app.delete('/api/user/monsters/:monsterId', authenticateToken, (req, res) => { try { db.removeMonster(req.user.userId, req.params.monsterId); res.json({ success: true }); } catch (err) { console.error('Remove monster error:', err); res.status(500).json({ error: 'Failed to remove monster' }); } }); // ============================================ // Admin Endpoints // ============================================ // Serve admin page app.get('/admin', (req, res) => { res.sendFile(path.join(__dirname, 'admin.html')); }); // Get all monster types (admin - includes disabled) app.get('/api/admin/monster-types', adminOnly, (req, res) => { try { const types = db.getAllMonsterTypes(false); // Include disabled // Return with snake_case for frontend compatibility const formatted = types.map(t => ({ id: t.id, key: t.id, // Use id as key for compatibility name: t.name, icon: t.icon, min_level: t.min_level || 1, max_level: t.max_level || 5, base_hp: t.base_hp, base_atk: t.base_atk, base_def: t.base_def, base_mp: t.base_mp || 20, base_xp: t.xp_reward, spawn_weight: t.spawn_weight || 100, level_scale_mp: t.level_scale_mp || 5, dialogues: t.dialogues, enabled: !!t.enabled, created_at: t.created_at })); res.json({ monsterTypes: formatted }); } catch (err) { console.error('Admin get monster types error:', err); res.status(500).json({ error: 'Failed to get monster types' }); } }); // Create monster type app.post('/api/admin/monster-types', adminOnly, async (req, res) => { try { const data = req.body; // Accept either 'id' or 'key' as the monster identifier const monsterId = data.id || data.key; if (!monsterId || !data.name) { return res.status(400).json({ error: 'Missing required fields (key and name)' }); } // Ensure id is set for the database function data.id = monsterId; db.createMonsterType(data); // Copy default images for the new monster const monstersDir = path.join(__dirname, 'mapgameimgs', 'monsters'); const sizes = ['50', '100']; for (const size of sizes) { const defaultImg = path.join(monstersDir, `default${size}.png`); const newImg = path.join(monstersDir, `${monsterId}${size}.png`); try { // Only copy if the new image doesn't already exist await fs.access(newImg); } catch { // File doesn't exist, copy the default try { await fs.copyFile(defaultImg, newImg); console.log(`Created ${monsterId}${size}.png from default`); } catch (copyErr) { console.warn(`Could not copy default${size}.png:`, copyErr.message); } } } broadcastAdminChange('monster', { action: 'created' }); res.json({ success: true }); } catch (err) { console.error('Admin create monster type error:', err); res.status(500).json({ error: 'Failed to create monster type' }); } }); // Update monster type app.put('/api/admin/monster-types/:id', adminOnly, (req, res) => { try { const data = req.body; db.updateMonsterType(req.params.id, data); broadcastAdminChange('monster', { action: 'updated', id: req.params.id }); res.json({ success: true }); } catch (err) { console.error('Admin update monster type error:', err); res.status(500).json({ error: 'Failed to update monster type' }); } }); // Toggle monster enabled status app.patch('/api/admin/monster-types/:id/enabled', adminOnly, (req, res) => { try { const { enabled } = req.body; db.toggleMonsterEnabled(req.params.id, enabled); broadcastAdminChange('monster', { action: 'toggled', id: req.params.id }); res.json({ success: true }); } catch (err) { console.error('Admin toggle monster error:', err); res.status(500).json({ error: 'Failed to toggle monster' }); } }); // Delete monster type app.delete('/api/admin/monster-types/:id', adminOnly, (req, res) => { try { db.deleteMonsterType(req.params.id); broadcastAdminChange('monster', { action: 'deleted', id: req.params.id }); res.json({ success: true }); } catch (err) { console.error('Admin delete monster type error:', err); res.status(500).json({ error: 'Failed to delete monster type' }); } }); // Get all users app.get('/api/admin/users', adminOnly, (req, res) => { try { const users = db.getAllUsers(); // Return flat structure with snake_case for frontend compatibility const formatted = users.map(u => ({ id: u.id, username: u.username, email: u.email, created_at: u.created_at, total_points: u.total_points, finds_count: u.finds_count, avatar_icon: u.avatar_icon, avatar_color: u.avatar_color, is_admin: !!u.is_admin, character_name: u.character_name, race: u.race, class: u.class, level: u.level || 1, xp: u.xp || 0, hp: u.hp || 0, max_hp: u.max_hp || 0, mp: u.mp || 0, max_mp: u.max_mp || 0, atk: u.atk || 0, def: u.def || 0, unlocked_skills: u.unlocked_skills })); // Debug: log user 2's atk value const user2 = formatted.find(u => u.id === 2); if (user2) console.log('GET /api/admin/users - user 2 atk:', user2.atk); res.json({ users: formatted }); } catch (err) { console.error('Admin get users error:', err); res.status(500).json({ error: 'Failed to get users' }); } }); // Update user RPG stats app.put('/api/admin/users/:id', adminOnly, (req, res) => { try { const stats = req.body; const targetUserId = parseInt(req.params.id); console.log('Admin updating user', targetUserId, 'with stats:', JSON.stringify(stats)); const result = db.updateUserRpgStats(targetUserId, stats); console.log('Update result:', result); // Notify the user in real-time to refresh their stats const notified = sendToAuthUser(targetUserId, { type: 'statsUpdated' }); console.log('User notified via WebSocket:', notified); res.json({ success: true }); } catch (err) { console.error('Admin update user error:', err); res.status(500).json({ error: 'Failed to update user' }); } }); // Toggle admin status app.put('/api/admin/users/:id/admin', adminOnly, (req, res) => { try { const { isAdmin } = req.body; db.setUserAdmin(req.params.id, isAdmin); res.json({ success: true }); } catch (err) { console.error('Admin toggle admin error:', err); res.status(500).json({ error: 'Failed to toggle admin status' }); } }); // Reset user progress app.delete('/api/admin/users/:id/reset', adminOnly, (req, res) => { try { db.resetUserProgress(req.params.id); res.json({ success: true }); } catch (err) { console.error('Admin reset user error:', err); res.status(500).json({ error: 'Failed to reset user progress' }); } }); // Reset user home base app.delete('/api/admin/users/:id/home-base', adminOnly, (req, res) => { try { const targetUserId = parseInt(req.params.id); db.resetUserHomeBase(targetUserId); // Notify the user in real-time to refresh their stats sendToAuthUser(targetUserId, { type: 'statsUpdated' }); res.json({ success: true }); } catch (err) { console.error('Admin reset home base error:', err); res.status(500).json({ error: 'Failed to reset home base' }); } }); // Get game settings app.get('/api/admin/settings', adminOnly, (req, res) => { try { const settings = db.getAllSettings(); res.json({ settings }); } catch (err) { console.error('Admin get settings error:', err); res.status(500).json({ error: 'Failed to get settings' }); } }); // Update game settings app.put('/api/admin/settings', adminOnly, (req, res) => { console.log('[SETTINGS] Admin settings update received'); try { const settings = req.body; console.log('[SETTINGS] Settings to save:', Object.keys(settings)); for (const [key, value] of Object.entries(settings)) { db.setSetting(key, JSON.stringify(value)); } // Broadcast settings update to all connected clients console.log('[SETTINGS] Broadcasting settings update to all clients'); const clientCount = [...wss.clients].filter(c => c.readyState === 1).length; console.log(`[SETTINGS] Active WebSocket clients: ${clientCount}`); broadcast({ type: 'settings_updated', settings: settings }, null); // null = send to ALL clients including sender res.json({ success: true }); } catch (err) { console.error('Admin update settings error:', err); res.status(500).json({ error: 'Failed to update settings' }); } }); // ============================================ // Admin Skills Endpoints // ============================================ // Get all skills (admin - includes disabled) app.get('/api/admin/skills', adminOnly, (req, res) => { try { const skills = db.getAllSkills(false); // Include disabled const formatted = skills.map(s => ({ id: s.id, name: s.name, description: s.description, type: s.type, mp_cost: s.mp_cost, base_power: s.base_power, accuracy: s.accuracy, hit_count: s.hit_count, target: s.target, status_effect: s.status_effect, player_usable: !!s.player_usable, monster_usable: !!s.monster_usable, enabled: !!s.enabled, created_at: s.created_at })); res.json({ skills: formatted }); } catch (err) { console.error('Admin get skills error:', err); res.status(500).json({ error: 'Failed to get skills' }); } }); // Create skill app.post('/api/admin/skills', adminOnly, (req, res) => { try { const data = req.body; if (!data.id || !data.name) { return res.status(400).json({ error: 'Missing required fields (id and name)' }); } db.createSkill(data); broadcastAdminChange('skill', { action: 'created' }); res.json({ success: true }); } catch (err) { console.error('Admin create skill error:', err); res.status(500).json({ error: 'Failed to create skill' }); } }); // Update skill app.put('/api/admin/skills/:id', adminOnly, (req, res) => { try { const data = req.body; db.updateSkill(req.params.id, data); broadcastAdminChange('skill', { action: 'updated', id: req.params.id }); res.json({ success: true }); } catch (err) { console.error('Admin update skill error:', err); res.status(500).json({ error: 'Failed to update skill' }); } }); // Delete skill app.delete('/api/admin/skills/:id', adminOnly, (req, res) => { try { db.deleteSkill(req.params.id); broadcastAdminChange('skill', { action: 'deleted', id: req.params.id }); res.json({ success: true }); } catch (err) { console.error('Admin delete skill error:', err); res.status(500).json({ error: 'Failed to delete skill' }); } }); // Get all class skill names (admin) app.get('/api/admin/class-skill-names', adminOnly, (req, res) => { try { const names = db.getAllClassSkillNames(); const formatted = names.map(n => ({ id: n.id, skill_id: n.skill_id, class_id: n.class_id, custom_name: n.custom_name, custom_description: n.custom_description })); res.json({ classSkillNames: formatted }); } catch (err) { console.error('Admin get class skill names error:', err); res.status(500).json({ error: 'Failed to get class skill names' }); } }); // Create class skill name app.post('/api/admin/class-skill-names', adminOnly, (req, res) => { try { const data = req.body; if (!data.skill_id || !data.class_id || !data.custom_name) { return res.status(400).json({ error: 'Missing required fields' }); } db.createClassSkillName(data); res.json({ success: true }); } catch (err) { console.error('Admin create class skill name error:', err); res.status(500).json({ error: 'Failed to create class skill name' }); } }); // Update class skill name app.put('/api/admin/class-skill-names/:id', adminOnly, (req, res) => { try { const data = req.body; db.updateClassSkillName(req.params.id, data); res.json({ success: true }); } catch (err) { console.error('Admin update class skill name error:', err); res.status(500).json({ error: 'Failed to update class skill name' }); } }); // Delete class skill name app.delete('/api/admin/class-skill-names/:id', adminOnly, (req, res) => { try { db.deleteClassSkillName(req.params.id); res.json({ success: true }); } catch (err) { console.error('Admin delete class skill name error:', err); res.status(500).json({ error: 'Failed to delete class skill name' }); } }); // Get all monster skills (admin) app.get('/api/admin/monster-skills', adminOnly, (req, res) => { try { const skills = db.getAllMonsterSkills(); const formatted = skills.map(s => ({ id: s.id, monster_type_id: s.monster_type_id, skill_id: s.skill_id, weight: s.weight, min_level: s.min_level, custom_name: s.custom_name })); res.json({ monsterSkills: formatted }); } catch (err) { console.error('Admin get monster skills error:', err); res.status(500).json({ error: 'Failed to get monster skills' }); } }); // Create monster skill assignment app.post('/api/admin/monster-skills', adminOnly, (req, res) => { try { const data = req.body; if (!data.monster_type_id || !data.skill_id) { return res.status(400).json({ error: 'Missing required fields' }); } db.createMonsterSkill(data); res.json({ success: true }); } catch (err) { console.error('Admin create monster skill error:', err); res.status(500).json({ error: 'Failed to create monster skill' }); } }); // Update monster skill assignment app.put('/api/admin/monster-skills/:id', adminOnly, (req, res) => { try { const data = req.body; db.updateMonsterSkill(req.params.id, data); res.json({ success: true }); } catch (err) { console.error('Admin update monster skill error:', err); res.status(500).json({ error: 'Failed to update monster skill' }); } }); // Delete monster skill assignment app.delete('/api/admin/monster-skills/:id', adminOnly, (req, res) => { try { db.deleteMonsterSkill(req.params.id); res.json({ success: true }); } catch (err) { console.error('Admin delete monster skill error:', err); res.status(500).json({ error: 'Failed to delete monster skill' }); } }); // ===================== // CLASS API ENDPOINTS // ===================== // Get all classes (public - enabled only, for character creation) app.get('/api/classes', (req, res) => { try { const classes = db.getAllClasses(true); // Enabled only res.json(classes); } catch (err) { console.error('Get classes error:', err); res.status(500).json({ error: 'Failed to get classes' }); } }); // Get class skills (public - for character sheet display) app.get('/api/classes/:id/skills', (req, res) => { try { const skills = db.getClassSkills(req.params.id); res.json(skills); } catch (err) { console.error('Get class skills error:', err); res.status(500).json({ error: 'Failed to get class skills' }); } }); // Get skill choices for a specific level (for level-up modal) app.get('/api/classes/:id/skill-choices/:level', (req, res) => { try { const choices = db.getSkillChoicesForLevel(req.params.id, parseInt(req.params.level)); res.json(choices); } catch (err) { console.error('Get skill choices error:', err); res.status(500).json({ error: 'Failed to get skill choices' }); } }); // Admin: Get all classes (including disabled) app.get('/api/admin/classes', adminOnly, (req, res) => { try { const classes = db.getAllClasses(false); // All classes res.json(classes); } catch (err) { console.error('Admin get classes error:', err); res.status(500).json({ error: 'Failed to get classes' }); } }); // Admin: Get single class app.get('/api/admin/classes/:id', adminOnly, (req, res) => { try { const classData = db.getClass(req.params.id); if (!classData) { return res.status(404).json({ error: 'Class not found' }); } res.json(classData); } catch (err) { console.error('Admin get class error:', err); res.status(500).json({ error: 'Failed to get class' }); } }); // Admin: Create class app.post('/api/admin/classes', adminOnly, (req, res) => { try { db.createClass(req.body); const newClass = db.getClass(req.body.id); res.json(newClass); } catch (err) { console.error('Admin create class error:', err); res.status(500).json({ error: 'Failed to create class' }); } }); // Admin: Update class app.put('/api/admin/classes/:id', adminOnly, (req, res) => { try { db.updateClass(req.params.id, req.body); const updated = db.getClass(req.params.id); res.json(updated); } catch (err) { console.error('Admin update class error:', err); res.status(500).json({ error: 'Failed to update class' }); } }); // Admin: Delete class app.delete('/api/admin/classes/:id', adminOnly, (req, res) => { try { db.deleteClass(req.params.id); res.json({ success: true }); } catch (err) { console.error('Admin delete class error:', err); res.status(500).json({ error: 'Failed to delete class' }); } }); // Admin: Toggle class enabled app.put('/api/admin/classes/:id/toggle', adminOnly, (req, res) => { try { const classData = db.getClass(req.params.id); if (!classData) { return res.status(404).json({ error: 'Class not found' }); } db.toggleClassEnabled(req.params.id, !classData.enabled); res.json({ success: true, enabled: !classData.enabled }); } catch (err) { console.error('Admin toggle class error:', err); res.status(500).json({ error: 'Failed to toggle class' }); } }); // ===================== // CLASS SKILLS API ENDPOINTS // ===================== // Admin: Get all class skills app.get('/api/admin/class-skills', adminOnly, (req, res) => { try { const classSkills = db.getAllClassSkills(); res.json(classSkills); } catch (err) { console.error('Admin get class skills error:', err); res.status(500).json({ error: 'Failed to get class skills' }); } }); // Admin: Get skills for a specific class app.get('/api/admin/class-skills/:classId', adminOnly, (req, res) => { try { const skills = db.getClassSkills(req.params.classId); res.json(skills); } catch (err) { console.error('Admin get class skills error:', err); res.status(500).json({ error: 'Failed to get class skills' }); } }); // Admin: Create class skill app.post('/api/admin/class-skills', adminOnly, (req, res) => { try { db.createClassSkill(req.body); res.json({ success: true }); } catch (err) { console.error('Admin create class skill error:', err); res.status(500).json({ error: 'Failed to create class skill' }); } }); // Admin: Update class skill app.put('/api/admin/class-skills/:id', adminOnly, (req, res) => { try { db.updateClassSkill(req.params.id, req.body); res.json({ success: true }); } catch (err) { console.error('Admin update class skill error:', err); res.status(500).json({ error: 'Failed to update class skill' }); } }); // Admin: Delete class skill app.delete('/api/admin/class-skills/:id', adminOnly, (req, res) => { try { db.deleteClassSkill(req.params.id); res.json({ success: true }); } catch (err) { console.error('Admin delete class skill error:', err); res.status(500).json({ error: 'Failed to delete class skill' }); } }); // Function to send push notification to all subscribers async function sendPushNotification(title, body, data = {}) { const notification = { title, body, icon: '/icon-192x192.png', badge: '/icon-72x72.png', data: { ...data, timestamp: Date.now() } }; const promises = pushSubscriptions.map(subscription => { return webpush.sendNotification(subscription, JSON.stringify(notification)) .catch(err => { console.error('Push failed for:', subscription.endpoint, err.message); // Remove failed subscriptions if (err.statusCode === 410) { pushSubscriptions = pushSubscriptions.filter( sub => sub.endpoint !== subscription.endpoint ); } }); }); await Promise.all(promises); console.log(`Sent push notification: ${title}`); } // Load push subscriptions on startup async function loadPushSubscriptions() { try { const dataDir = await getGeocachePath(); const subsPath = path.join(path.dirname(dataDir), 'push-subscriptions.json'); const data = await fs.readFile(subsPath, 'utf8'); pushSubscriptions = JSON.parse(data); console.log(`Loaded ${pushSubscriptions.length} push subscriptions`); } catch (err) { if (err.code !== 'ENOENT') { console.error('Error loading push subscriptions:', err); } } } // Generate random user ID function generateUserId() { return Math.random().toString(36).substring(7); } // Broadcast to all clients except sender function broadcast(data, senderId) { const message = JSON.stringify(data); wss.clients.forEach(client => { if (client.readyState === WebSocket.OPEN && client.userId !== senderId) { client.send(message); } }); } // Broadcast admin changes to all clients function broadcastAdminChange(changeType, details = {}) { let clientCount = 0; wss.clients.forEach(c => { if (c.readyState === WebSocket.OPEN) clientCount++; }); console.log(`[ADMIN] Broadcasting ${changeType} change to ${clientCount} clients`); broadcast({ type: 'admin_update', changeType: changeType, details: details, timestamp: Date.now() }, null); } // Map authenticated user IDs to WebSocket connections for targeted messages const authUserConnections = new Map(); // authUserId (number) -> ws connection // Send message to a specific authenticated user function sendToAuthUser(authUserId, data) { const ws = authUserConnections.get(authUserId); if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data)); return true; } return false; } // Clean up disconnected user function removeUser(userId) { if (users.has(userId)) { users.delete(userId); broadcast({ type: 'userDisconnected', userId }, null); console.log(`User ${userId} disconnected. Active users: ${users.size}`); } } wss.on('connection', (ws) => { const userId = generateUserId(); ws.userId = userId; console.log(`User ${userId} connected. Active users: ${users.size + 1}`); // Send user their ID, current visible users, and geocaches const initMsg = { type: 'init', userId: userId, serverSessionId: serverSessionId, // For detecting server restarts users: Array.from(users.entries()) .filter(([id, data]) => data.visible !== false) // Only send visible users .map(([id, data]) => ({ userId: id, ...data })) }; console.log(`Sending init message to ${userId}`); ws.send(JSON.stringify(initMsg)); // Send all geocaches if (geocaches.length > 0) { console.log(`Sending ${geocaches.length} geocaches to ${userId}`); ws.send(JSON.stringify({ type: 'geocachesInit', geocaches: geocaches })); } else { console.log('No geocaches to send'); } ws.on('message', (message) => { try { const data = JSON.parse(message); if (data.type === 'auth') { // Check if client has a stale session (server restarted) if (data.serverSessionId && data.serverSessionId !== serverSessionId) { console.log(`[SESSION] User ${data.authUserId} has stale session ID, forcing logout`); ws.send(JSON.stringify({ type: 'force_logout', reason: 'Server restarted - please log in again' })); return; } // Register authenticated user's WebSocket connection if (data.authUserId) { // Check if this user already has an active connection (old tab) const existingConnection = authUserConnections.get(data.authUserId); console.log(`[SESSION] User ${data.authUserId} auth - existing connection:`, existingConnection ? 'yes' : 'no'); if (existingConnection && existingConnection !== ws) { console.log(`[SESSION] Existing connection state: ${existingConnection.readyState} (OPEN=${WebSocket.OPEN})`); if (existingConnection.readyState === WebSocket.OPEN) { // Force logout the old tab console.log(`[SESSION] Kicking old session for user ${data.authUserId}`); try { existingConnection.send(JSON.stringify({ type: 'force_logout', reason: 'Another session has started' })); console.log(`[SESSION] Sent force_logout to old connection`); } catch (e) { console.error(`[SESSION] Failed to send force_logout:`, e); } // Close the old connection after a brief delay setTimeout(() => { if (existingConnection.readyState === WebSocket.OPEN) { existingConnection.close(4000, 'Replaced by new session'); console.log(`[SESSION] Closed old connection`); } }, 1000); } } ws.authUserId = data.authUserId; authUserConnections.set(data.authUserId, ws); console.log(`Auth user ${data.authUserId} registered on WebSocket ${userId}`); } } else if (data.type === 'location') { // Store user location with icon info users.set(userId, { lat: data.lat, lng: data.lng, accuracy: data.accuracy, icon: data.icon, color: data.color, visible: data.visible !== false, // default to true if not specified timestamp: Date.now() }); // Broadcast to other users (including visibility status) broadcast({ type: 'userLocation', userId: userId, lat: data.lat, lng: data.lng, accuracy: data.accuracy, icon: data.icon, color: data.color, visible: data.visible !== false }, userId); } else if (data.type === 'iconUpdate') { // Update user's icon const userData = users.get(userId) || {}; userData.icon = data.icon; userData.color = data.color; users.set(userId, userData); // Broadcast icon update to other users if we have location and are visible if (userData.lat && userData.lng && userData.visible !== false) { broadcast({ type: 'userLocation', userId: userId, lat: userData.lat, lng: userData.lng, accuracy: userData.accuracy || 100, icon: data.icon, color: data.color, visible: true }, userId); } } else if (data.type === 'geocacheUpdate') { // Handle geocache creation/update if (data.geocache) { const existingIndex = geocaches.findIndex(g => g.id === data.geocache.id); if (existingIndex >= 0) { // Update existing geocache geocaches[existingIndex] = data.geocache; } else { // Add new geocache geocaches.push(data.geocache); // Send push notification for new geocache sendPushNotification( '📍 New Geocache!', 'A new geocache has been placed nearby', { type: 'geocache', geocacheId: data.geocache.id, lat: data.geocache.lat, lng: data.geocache.lng } ); } // Save to file saveGeocaches(); // Broadcast to all other users broadcast({ type: 'geocacheUpdate', geocache: data.geocache }, userId); } } else if (data.type === 'geocacheDelete') { // Handle geocache deletion if (data.geocacheId) { const index = geocaches.findIndex(g => g.id === data.geocacheId); if (index > -1) { geocaches.splice(index, 1); // Save to file saveGeocaches(); // Broadcast deletion to all other users broadcast({ type: 'geocacheDelete', geocacheId: data.geocacheId }, userId); } } } } catch (err) { console.error('Error processing message:', err); } }); ws.on('close', () => { removeUser(userId); // Clean up auth user mapping - but only if THIS connection is still the active one // (don't remove if a newer connection replaced us) if (ws.authUserId && authUserConnections.get(ws.authUserId) === ws) { authUserConnections.delete(ws.authUserId); console.log(`[SESSION] Removed auth mapping for user ${ws.authUserId} (connection closed)`); } }); ws.on('error', (err) => { console.error(`WebSocket error for user ${userId}:`, err); removeUser(userId); // Same check - only remove if we're still the active connection if (ws.authUserId && authUserConnections.get(ws.authUserId) === ws) { authUserConnections.delete(ws.authUserId); } }); // Heartbeat to detect disconnected clients ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); }); // Periodic cleanup of stale connections const heartbeatInterval = setInterval(() => { wss.clients.forEach(ws => { if (ws.isAlive === false) { removeUser(ws.userId); return ws.terminate(); } ws.isAlive = false; ws.ping(); }); }, 30000); wss.on('close', () => { clearInterval(heartbeatInterval); }); const PORT = process.env.PORT || 8080; server.listen(PORT, async () => { console.log(`Server running on port ${PORT}`); console.log(`Open http://localhost:${PORT} to view the map`); // Initialize database try { let dbPath; try { await fs.access('/app/data'); dbPath = '/app/data/hikemap.db'; } catch (err) { dbPath = path.join(__dirname, 'data', 'hikemap.db'); // Create data directory if it doesn't exist await fs.mkdir(path.dirname(dbPath), { recursive: true }); } db = new HikeMapDB(dbPath).init(); console.log('Database initialized'); // Clear all refresh tokens on startup (logs everyone out) db.clearAllRefreshTokens(); // Seed default monsters if they don't exist db.seedDefaultMonsters(); // Seed default skills if they don't exist db.seedDefaultSkills(); // Seed default classes if they don't exist db.seedDefaultClasses(); // Seed default game settings if they don't exist db.seedDefaultSettings(); // Clean expired tokens periodically setInterval(() => { try { db.cleanExpiredTokens(); } catch (err) { console.error('Error cleaning expired tokens:', err); } }, 60 * 60 * 1000); // Every hour } catch (err) { console.error('Failed to initialize database:', err); } // Load geocaches on startup await loadGeocaches(); // Load push subscriptions on startup await loadPushSubscriptions(); });