You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
550 lines
18 KiB
550 lines
18 KiB
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();
|
|
});
|