const WebSocket = require('ws'); const http = require('http'); const express = require('express'); const path = require('path'); const fs = require('fs').promises; const webpush = require('web-push'); require('dotenv').config(); // 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 }); // Middleware to parse JSON and raw body for KML app.use(express.json()); app.use(express.text({ type: 'application/xml', limit: '10mb' })); // 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 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' }); } }); // 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); } }); } // 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, 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 === '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); }); ws.on('error', (err) => { console.error(`WebSocket error for user ${userId}:`, err); removeUser(userId); }); // 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`); // Load geocaches on startup await loadGeocaches(); // Load push subscriptions on startup await loadPushSubscriptions(); });