921 lines
32 KiB
JavaScript
921 lines
32 KiB
JavaScript
import { WebSocketServer } from 'ws';
|
|
import { createServer } from 'http';
|
|
import { parse } from 'url';
|
|
import { randomUUID } from 'crypto';
|
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
import webpush from 'web-push';
|
|
import { verifyToken, extractTokenFromURL } from './verify-token.js';
|
|
|
|
const PORT = process.env.PORT || 3001;
|
|
const DATA_DIR = process.env.DATA_DIR || './data';
|
|
const STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes (match client-side cleanup)
|
|
const LOCATION_REQUEST_INTERVAL_MS = parseInt(process.env.LOCATION_REQUEST_INTERVAL || '60000', 10); // 60 seconds default
|
|
|
|
// VAPID keys for push notifications
|
|
// Generate with: npx web-push generate-vapid-keys
|
|
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || '';
|
|
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || '';
|
|
const VAPID_SUBJECT = process.env.VAPID_SUBJECT || 'mailto:push@rmaps.online';
|
|
|
|
// Configure web-push if keys are available
|
|
if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
|
|
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
|
|
console.log('Push notifications enabled');
|
|
} else {
|
|
console.log('Push notifications disabled (VAPID keys not configured)');
|
|
}
|
|
|
|
// ==================== PERSISTENCE ====================
|
|
|
|
// Ensure data directory exists
|
|
if (!existsSync(DATA_DIR)) {
|
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
console.log(`Created data directory: ${DATA_DIR}`);
|
|
}
|
|
|
|
// Load persisted state from disk
|
|
function loadFromDisk(filename) {
|
|
const path = `${DATA_DIR}/${filename}`;
|
|
try {
|
|
if (existsSync(path)) {
|
|
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
|
console.log(`Loaded ${filename}: ${Object.keys(data).length} entries`);
|
|
return data;
|
|
}
|
|
} catch (err) {
|
|
console.error(`Failed to load ${filename}:`, err.message);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Save state to disk (debounced)
|
|
let saveTimers = {};
|
|
function saveToDisk(filename, data) {
|
|
if (saveTimers[filename]) clearTimeout(saveTimers[filename]);
|
|
saveTimers[filename] = setTimeout(() => {
|
|
try {
|
|
writeFileSync(`${DATA_DIR}/${filename}`, JSON.stringify(data, null, 2));
|
|
} catch (err) {
|
|
console.error(`Failed to save ${filename}:`, err.message);
|
|
}
|
|
}, 2000); // 2-second debounce
|
|
}
|
|
|
|
// Room state storage: Map<roomSlug, RoomState>
|
|
const rooms = new Map();
|
|
|
|
// Client tracking: Map<WebSocket, { roomSlug, participantId, claims, readOnly }>
|
|
const clients = new Map();
|
|
|
|
// Push subscriptions: Map<roomSlug, Map<endpoint, { subscription, participantId, participantName }>>
|
|
// Using Map keyed by endpoint to deduplicate subscriptions from the same device
|
|
const pushSubscriptions = new Map();
|
|
|
|
// Restore rooms from disk
|
|
const savedRooms = loadFromDisk('rooms.json');
|
|
if (savedRooms) {
|
|
for (const [slug, room] of Object.entries(savedRooms)) {
|
|
// Mark all restored participants as offline (they'll re-join if they connect)
|
|
for (const p of Object.values(room.participants || {})) {
|
|
p.status = 'offline';
|
|
}
|
|
rooms.set(slug, room);
|
|
}
|
|
console.log(`Restored ${rooms.size} room(s) with offline participants`);
|
|
}
|
|
|
|
// Restore push subscriptions from disk
|
|
const savedSubs = loadFromDisk('push-subscriptions.json');
|
|
if (savedSubs) {
|
|
for (const [slug, entries] of Object.entries(savedSubs)) {
|
|
const subsMap = new Map();
|
|
for (const [endpoint, data] of Object.entries(entries)) {
|
|
subsMap.set(endpoint, data);
|
|
}
|
|
pushSubscriptions.set(slug, subsMap);
|
|
}
|
|
console.log(`Restored push subscriptions for ${pushSubscriptions.size} room(s)`);
|
|
}
|
|
|
|
// Persist helpers
|
|
function persistRooms() {
|
|
const obj = {};
|
|
for (const [slug, room] of rooms.entries()) {
|
|
obj[slug] = room;
|
|
}
|
|
saveToDisk('rooms.json', obj);
|
|
}
|
|
|
|
function persistPushSubscriptions() {
|
|
const obj = {};
|
|
for (const [slug, subsMap] of pushSubscriptions.entries()) {
|
|
if (subsMap.size > 0) {
|
|
obj[slug] = Object.fromEntries(subsMap.entries());
|
|
}
|
|
}
|
|
saveToDisk('push-subscriptions.json', obj);
|
|
}
|
|
|
|
function getRoomState(slug) {
|
|
if (!rooms.has(slug)) {
|
|
rooms.set(slug, {
|
|
id: randomUUID(),
|
|
slug: slug,
|
|
name: slug,
|
|
createdAt: new Date().toISOString(),
|
|
participants: {},
|
|
waypoints: [],
|
|
lastActivity: Date.now(),
|
|
visibility: 'public', // public | public_read | authenticated | members_only
|
|
ownerDID: null,
|
|
});
|
|
}
|
|
const room = rooms.get(slug);
|
|
room.lastActivity = Date.now();
|
|
return room;
|
|
}
|
|
|
|
/**
|
|
* Evaluate whether a connection should be allowed based on room visibility.
|
|
* Returns { allowed, readOnly, reason }
|
|
*/
|
|
function evaluateRoomAccess(room, claims, isRead = false) {
|
|
const { visibility, ownerDID } = room;
|
|
const isOwner = !!(claims && ownerDID && (claims.sub === ownerDID || claims.did === ownerDID));
|
|
|
|
if (visibility === 'public') {
|
|
return { allowed: true, readOnly: false, isOwner };
|
|
}
|
|
|
|
if (visibility === 'public_read') {
|
|
// Everyone can connect, but writes require auth
|
|
return { allowed: true, readOnly: !claims, isOwner };
|
|
}
|
|
|
|
if (visibility === 'authenticated') {
|
|
if (!claims) {
|
|
return { allowed: false, readOnly: false, isOwner: false, reason: 'Authentication required' };
|
|
}
|
|
return { allowed: true, readOnly: false, isOwner };
|
|
}
|
|
|
|
if (visibility === 'members_only') {
|
|
if (!claims) {
|
|
return { allowed: false, readOnly: false, isOwner: false, reason: 'Authentication required' };
|
|
}
|
|
// Further membership checks could be added here
|
|
return { allowed: true, readOnly: false, isOwner };
|
|
}
|
|
|
|
return { allowed: false, readOnly: false, isOwner: false, reason: 'Unknown visibility' };
|
|
}
|
|
|
|
function cleanupStaleParticipants(room) {
|
|
const now = Date.now();
|
|
const staleIds = [];
|
|
|
|
// Build set of participant IDs that have active push subscriptions
|
|
const pushParticipantIds = new Set();
|
|
const subsMap = pushSubscriptions.get(room.slug);
|
|
if (subsMap) {
|
|
for (const { participantId } of subsMap.values()) {
|
|
if (participantId) pushParticipantIds.add(participantId);
|
|
}
|
|
}
|
|
|
|
for (const [id, participant] of Object.entries(room.participants)) {
|
|
// Never clean up participants with push subscriptions — they're still reachable
|
|
if (pushParticipantIds.has(id)) continue;
|
|
|
|
const lastSeenMs = typeof participant.lastSeen === 'string'
|
|
? new Date(participant.lastSeen).getTime()
|
|
: participant.lastSeen;
|
|
if (lastSeenMs && now - lastSeenMs > STALE_THRESHOLD_MS) {
|
|
staleIds.push(id);
|
|
}
|
|
}
|
|
|
|
for (const id of staleIds) {
|
|
delete room.participants[id];
|
|
console.log(`Cleaned up stale participant: ${id} (no push subscription)`);
|
|
}
|
|
|
|
if (staleIds.length > 0) persistRooms();
|
|
return staleIds;
|
|
}
|
|
|
|
function broadcast(roomSlug, message, excludeWs = null) {
|
|
const messageStr = JSON.stringify(message);
|
|
let count = 0;
|
|
|
|
for (const [ws, clientInfo] of clients.entries()) {
|
|
if (clientInfo.roomSlug === roomSlug && ws !== excludeWs && ws.readyState === 1) {
|
|
ws.send(messageStr);
|
|
count++;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
// Send push notification to all subscriptions for a room
|
|
// excludeParticipantId: skip subscriptions belonging to this participant (e.g. don't notify yourself)
|
|
async function sendPushToRoom(roomSlug, notification, { excludeEndpoint = null, excludeParticipantId = null } = {}) {
|
|
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) return;
|
|
|
|
const subsMap = pushSubscriptions.get(roomSlug);
|
|
if (!subsMap || subsMap.size === 0) return;
|
|
|
|
const payload = JSON.stringify(notification);
|
|
const failedEndpoints = [];
|
|
|
|
for (const [endpoint, { subscription, participantId }] of subsMap.entries()) {
|
|
if (excludeEndpoint && endpoint === excludeEndpoint) continue;
|
|
if (excludeParticipantId && participantId === excludeParticipantId) continue;
|
|
|
|
try {
|
|
await webpush.sendNotification(subscription, payload);
|
|
console.log(`[${roomSlug}] Push sent to ${endpoint.slice(-20)}`);
|
|
} catch (error) {
|
|
console.error(`[${roomSlug}] Push failed:`, error.statusCode || error.message);
|
|
// Remove invalid subscriptions
|
|
if (error.statusCode === 404 || error.statusCode === 410) {
|
|
failedEndpoints.push(endpoint);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up failed subscriptions
|
|
if (failedEndpoints.length > 0) {
|
|
for (const endpoint of failedEndpoints) {
|
|
subsMap.delete(endpoint);
|
|
console.log(`[${roomSlug}] Removed invalid subscription`);
|
|
}
|
|
persistPushSubscriptions();
|
|
}
|
|
}
|
|
|
|
function handleMessage(ws, data) {
|
|
const clientInfo = clients.get(ws);
|
|
if (!clientInfo) return;
|
|
|
|
let message;
|
|
try {
|
|
message = JSON.parse(data);
|
|
} catch (e) {
|
|
console.error('Invalid JSON:', e.message);
|
|
return;
|
|
}
|
|
|
|
const room = getRoomState(clientInfo.roomSlug);
|
|
|
|
// Block write operations from readOnly connections
|
|
const writeOps = ['join', 'leave', 'location', 'status', 'waypoint_add', 'waypoint_remove'];
|
|
if (clientInfo.readOnly && writeOps.includes(message.type)) {
|
|
ws.send(JSON.stringify({ type: 'error', error: 'Read-only access — authenticate to interact' }));
|
|
return;
|
|
}
|
|
|
|
switch (message.type) {
|
|
case 'join': {
|
|
const participant = {
|
|
...message.participant,
|
|
lastSeen: new Date().toISOString()
|
|
};
|
|
|
|
// Deduplicate: remove any existing participant with same name but different ID (ghost entries)
|
|
for (const [existingId, existing] of Object.entries(room.participants)) {
|
|
if (existing.name === participant.name && existingId !== participant.id) {
|
|
console.log(`[${clientInfo.roomSlug}] Removing ghost participant: ${existingId} (replaced by ${participant.id})`);
|
|
delete room.participants[existingId];
|
|
broadcast(clientInfo.roomSlug, { type: 'leave', participantId: existingId }, ws);
|
|
}
|
|
}
|
|
|
|
room.participants[participant.id] = participant;
|
|
clientInfo.participantId = participant.id;
|
|
|
|
console.log(`[${clientInfo.roomSlug}] ${participant.name} joined (${participant.id})`);
|
|
|
|
// Broadcast join to others
|
|
broadcast(clientInfo.roomSlug, message, ws);
|
|
|
|
// Send push notification to others (exclude the joining user themselves)
|
|
sendPushToRoom(clientInfo.roomSlug, {
|
|
title: 'Friend Joined! 👋',
|
|
body: `${participant.name} ${participant.emoji} joined the room`,
|
|
tag: `join-${participant.id}`,
|
|
data: {
|
|
type: 'join',
|
|
roomSlug: clientInfo.roomSlug,
|
|
participantId: participant.id,
|
|
participantName: participant.name,
|
|
},
|
|
}, { excludeParticipantId: participant.id });
|
|
|
|
// Send current state to the new participant
|
|
ws.send(JSON.stringify({
|
|
type: 'full_state',
|
|
state: room
|
|
}));
|
|
persistRooms();
|
|
break;
|
|
}
|
|
|
|
case 'leave': {
|
|
const leavingParticipant = room.participants[message.participantId];
|
|
delete room.participants[message.participantId];
|
|
console.log(`[${clientInfo.roomSlug}] Participant left: ${message.participantId}`);
|
|
broadcast(clientInfo.roomSlug, message, ws);
|
|
persistRooms();
|
|
|
|
// Send push notification
|
|
if (leavingParticipant) {
|
|
sendPushToRoom(clientInfo.roomSlug, {
|
|
title: 'Friend Left',
|
|
body: `${leavingParticipant.name} ${leavingParticipant.emoji} left the room`,
|
|
tag: `leave-${message.participantId}`,
|
|
data: {
|
|
type: 'leave',
|
|
roomSlug: clientInfo.roomSlug,
|
|
participantId: message.participantId,
|
|
},
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'location': {
|
|
if (room.participants[message.participantId]) {
|
|
room.participants[message.participantId].location = message.location;
|
|
room.participants[message.participantId].lastSeen = new Date().toISOString();
|
|
|
|
// Broadcast to all OTHER participants
|
|
const count = broadcast(clientInfo.roomSlug, message, ws);
|
|
console.log(`[${clientInfo.roomSlug}] Location update from ${message.participantId} -> ${count} clients`);
|
|
persistRooms();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'status': {
|
|
if (room.participants[message.participantId]) {
|
|
room.participants[message.participantId].status = message.status;
|
|
room.participants[message.participantId].lastSeen = new Date().toISOString();
|
|
broadcast(clientInfo.roomSlug, message, ws);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'waypoint_add': {
|
|
room.waypoints.push(message.waypoint);
|
|
console.log(`[${clientInfo.roomSlug}] Waypoint added: ${message.waypoint.id}`);
|
|
broadcast(clientInfo.roomSlug, message, ws);
|
|
|
|
// Send push notification for meeting points
|
|
if (message.waypoint.type === 'meetup') {
|
|
const creator = room.participants[clientInfo.participantId];
|
|
sendPushToRoom(clientInfo.roomSlug, {
|
|
title: 'Meeting Point Set! 📍',
|
|
body: `${creator?.name || 'Someone'} set a meeting point: ${message.waypoint.name}`,
|
|
tag: `waypoint-${message.waypoint.id}`,
|
|
requireInteraction: true,
|
|
data: {
|
|
type: 'waypoint',
|
|
roomSlug: clientInfo.roomSlug,
|
|
waypointId: message.waypoint.id,
|
|
waypointName: message.waypoint.name,
|
|
},
|
|
actions: [
|
|
{ action: 'view', title: 'View on Map' },
|
|
{ action: 'dismiss', title: 'Dismiss' },
|
|
],
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'waypoint_remove': {
|
|
room.waypoints = room.waypoints.filter(w => w.id !== message.waypointId);
|
|
console.log(`[${clientInfo.roomSlug}] Waypoint removed: ${message.waypointId}`);
|
|
broadcast(clientInfo.roomSlug, message, ws);
|
|
break;
|
|
}
|
|
|
|
case 'request_state': {
|
|
cleanupStaleParticipants(room);
|
|
ws.send(JSON.stringify({
|
|
type: 'full_state',
|
|
state: room
|
|
}));
|
|
break;
|
|
}
|
|
|
|
default:
|
|
console.log(`Unknown message type: ${message.type}`);
|
|
}
|
|
}
|
|
|
|
function handleClose(ws) {
|
|
const clientInfo = clients.get(ws);
|
|
if (clientInfo) {
|
|
const room = rooms.get(clientInfo.roomSlug);
|
|
if (room && clientInfo.participantId && room.participants[clientInfo.participantId]) {
|
|
// Don't delete - mark as offline and preserve last location
|
|
room.participants[clientInfo.participantId].status = 'offline';
|
|
room.participants[clientInfo.participantId].lastSeen = new Date().toISOString();
|
|
broadcast(clientInfo.roomSlug, {
|
|
type: 'status',
|
|
participantId: clientInfo.participantId,
|
|
status: 'offline'
|
|
});
|
|
console.log(`[${clientInfo.roomSlug}] User went offline: ${clientInfo.participantId} (location preserved)`);
|
|
persistRooms();
|
|
}
|
|
clients.delete(ws);
|
|
}
|
|
}
|
|
|
|
// Periodic cleanup of empty rooms
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
let changed = false;
|
|
for (const [slug, room] of rooms.entries()) {
|
|
// Clean stale participants (skips those with push subscriptions)
|
|
cleanupStaleParticipants(room);
|
|
|
|
// Remove empty rooms older than 24 hours (only if no push subscriptions either)
|
|
const hasPushSubs = pushSubscriptions.has(slug) && pushSubscriptions.get(slug).size > 0;
|
|
if (Object.keys(room.participants).length === 0 && !hasPushSubs &&
|
|
now - room.lastActivity > 24 * 60 * 60 * 1000) {
|
|
rooms.delete(slug);
|
|
pushSubscriptions.delete(slug);
|
|
console.log(`Cleaned up empty room: ${slug}`);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) {
|
|
persistRooms();
|
|
persistPushSubscriptions();
|
|
}
|
|
}, 5 * 60 * 1000); // Every 5 minutes
|
|
|
|
// Automatic location request - periodically ask all clients for location updates via silent push
|
|
async function requestLocationFromAllRooms() {
|
|
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) return;
|
|
|
|
for (const [roomSlug, subsMap] of pushSubscriptions.entries()) {
|
|
if (subsMap.size === 0) continue;
|
|
|
|
const room = rooms.get(roomSlug);
|
|
// Only request if room has participants (active room)
|
|
if (!room || Object.keys(room.participants).length === 0) continue;
|
|
|
|
console.log(`[${roomSlug}] Requesting location from ${subsMap.size} subscribers`);
|
|
|
|
const failedEndpoints = [];
|
|
for (const [endpoint, { subscription }] of subsMap.entries()) {
|
|
try {
|
|
await webpush.sendNotification(subscription, JSON.stringify({
|
|
silent: true,
|
|
data: { type: 'location_request', roomSlug }
|
|
}));
|
|
} catch (error) {
|
|
if (error.statusCode === 404 || error.statusCode === 410) {
|
|
failedEndpoints.push(endpoint);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up failed subscriptions
|
|
if (failedEndpoints.length > 0) {
|
|
for (const endpoint of failedEndpoints) {
|
|
subsMap.delete(endpoint);
|
|
console.log(`[${roomSlug}] Removed stale push subscription`);
|
|
}
|
|
persistPushSubscriptions();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start automatic location request interval
|
|
if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY && LOCATION_REQUEST_INTERVAL_MS > 0) {
|
|
setInterval(requestLocationFromAllRooms, LOCATION_REQUEST_INTERVAL_MS);
|
|
console.log(`Automatic location requests enabled every ${LOCATION_REQUEST_INTERVAL_MS / 1000}s`);
|
|
}
|
|
|
|
// Parse JSON body from request
|
|
async function parseJsonBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let body = '';
|
|
req.on('data', chunk => body += chunk);
|
|
req.on('end', () => {
|
|
try {
|
|
resolve(JSON.parse(body));
|
|
} catch (e) {
|
|
reject(new Error('Invalid JSON'));
|
|
}
|
|
});
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
// Add CORS headers
|
|
function addCorsHeaders(res) {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
}
|
|
|
|
// Create HTTP server for health checks and push subscription endpoints
|
|
const server = createServer(async (req, res) => {
|
|
const { pathname } = parse(req.url);
|
|
addCorsHeaders(res);
|
|
|
|
// Handle CORS preflight
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
if (pathname === '/health') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
status: 'ok',
|
|
rooms: rooms.size,
|
|
clients: clients.size,
|
|
pushEnabled: !!(VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY),
|
|
uptime: process.uptime()
|
|
}));
|
|
} else if (pathname === '/stats') {
|
|
const roomStats = {};
|
|
for (const [slug, room] of rooms.entries()) {
|
|
roomStats[slug] = {
|
|
participants: Object.keys(room.participants).length,
|
|
waypoints: room.waypoints.length,
|
|
pushSubscriptions: pushSubscriptions.get(slug)?.size ?? 0,
|
|
lastActivity: room.lastActivity
|
|
};
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ rooms: roomStats, totalClients: clients.size }));
|
|
} else if (pathname === '/push/vapid-public-key') {
|
|
// Return VAPID public key for client
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ publicKey: VAPID_PUBLIC_KEY }));
|
|
} else if (pathname === '/push/subscribe' && req.method === 'POST') {
|
|
// Subscribe to push notifications
|
|
try {
|
|
const { subscription, roomSlug, participantId } = await parseJsonBody(req);
|
|
|
|
if (!subscription || !subscription.endpoint) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Invalid subscription' }));
|
|
return;
|
|
}
|
|
|
|
// Store subscription for the room (deduplicates by endpoint automatically via Map key)
|
|
if (roomSlug) {
|
|
if (!pushSubscriptions.has(roomSlug)) {
|
|
pushSubscriptions.set(roomSlug, new Map());
|
|
}
|
|
pushSubscriptions.get(roomSlug).set(subscription.endpoint, {
|
|
subscription,
|
|
participantId: participantId || null,
|
|
});
|
|
console.log(`[${roomSlug}] Push subscription added/updated for participant: ${participantId || 'unknown'}`);
|
|
persistPushSubscriptions();
|
|
}
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true }));
|
|
} catch (error) {
|
|
console.error('Push subscribe error:', error);
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Failed to subscribe' }));
|
|
}
|
|
} else if (pathname === '/push/unsubscribe' && req.method === 'POST') {
|
|
// Unsubscribe from push notifications
|
|
try {
|
|
const { endpoint, roomSlug } = await parseJsonBody(req);
|
|
|
|
if (roomSlug && pushSubscriptions.has(roomSlug)) {
|
|
const subsMap = pushSubscriptions.get(roomSlug);
|
|
if (subsMap.has(endpoint)) {
|
|
subsMap.delete(endpoint);
|
|
console.log(`[${roomSlug}] Push subscription removed`);
|
|
persistPushSubscriptions();
|
|
}
|
|
}
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true }));
|
|
} catch (error) {
|
|
console.error('Push unsubscribe error:', error);
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Failed to unsubscribe' }));
|
|
}
|
|
} else if (pathname === '/push/test' && req.method === 'POST') {
|
|
// Send test push notification
|
|
try {
|
|
const { roomSlug } = await parseJsonBody(req);
|
|
|
|
if (roomSlug) {
|
|
await sendPushToRoom(roomSlug, {
|
|
title: 'Test Notification 🔔',
|
|
body: 'Push notifications are working!',
|
|
tag: 'test',
|
|
data: { type: 'test', roomSlug },
|
|
});
|
|
}
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true }));
|
|
} catch (error) {
|
|
console.error('Push test error:', error);
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Failed to send test' }));
|
|
}
|
|
} else if (pathname?.startsWith('/room/') && pathname.endsWith('/config') && req.method === 'POST') {
|
|
// Set room visibility and config (requires auth + ownership)
|
|
try {
|
|
const roomSlug = pathname.replace('/room/', '').replace('/config', '');
|
|
const body = await parseJsonBody(req);
|
|
const authHeader = req.headers['authorization'] || '';
|
|
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
|
const claims = token ? await verifyToken(token) : null;
|
|
|
|
if (!claims) {
|
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Authentication required' }));
|
|
return;
|
|
}
|
|
|
|
const room = getRoomState(roomSlug);
|
|
|
|
// Only the owner (or first authenticated user) can change room config
|
|
if (room.ownerDID && room.ownerDID !== claims.sub && room.ownerDID !== claims.did) {
|
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Only the room owner can change settings' }));
|
|
return;
|
|
}
|
|
|
|
// Set owner if not set
|
|
if (!room.ownerDID) {
|
|
room.ownerDID = claims.sub || claims.did;
|
|
}
|
|
|
|
const validVisibilities = ['public', 'public_read', 'authenticated', 'members_only'];
|
|
if (body.visibility && validVisibilities.includes(body.visibility)) {
|
|
room.visibility = body.visibility;
|
|
}
|
|
if (body.name) {
|
|
room.name = body.name;
|
|
}
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
success: true,
|
|
room: { slug: room.slug, visibility: room.visibility, ownerDID: room.ownerDID, name: room.name }
|
|
}));
|
|
} catch (error) {
|
|
console.error('Room config error:', error);
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Failed to update room config' }));
|
|
}
|
|
} else if (pathname === '/push/request-location' && req.method === 'POST') {
|
|
// Manually trigger location request for a room or specific participant
|
|
// Online clients get a silent WebSocket message; offline clients get a VISIBLE push callout
|
|
try {
|
|
const { roomSlug, participantId, callerName } = await parseJsonBody(req);
|
|
|
|
if (!roomSlug) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'roomSlug required' }));
|
|
return;
|
|
}
|
|
|
|
let wsSent = 0;
|
|
let pushSent = 0;
|
|
let pushFailed = 0;
|
|
|
|
// Get room to access participant names for deduplication
|
|
const room = rooms.get(roomSlug);
|
|
|
|
// If targeting a specific participant, get their name for deduplication
|
|
let targetName = null;
|
|
if (participantId && room?.participants?.[participantId]) {
|
|
targetName = room.participants[participantId].name;
|
|
}
|
|
|
|
// Build a set of participantIds that are currently connected via WebSocket
|
|
const onlineParticipantIds = new Set();
|
|
for (const [ws, clientInfo] of clients.entries()) {
|
|
if (clientInfo.roomSlug === roomSlug && ws.readyState === 1 && clientInfo.participantId) {
|
|
onlineParticipantIds.add(clientInfo.participantId);
|
|
}
|
|
}
|
|
|
|
// Send WebSocket message to connected clients, deduplicated by name
|
|
const locationRequestMsg = JSON.stringify({ type: 'request_location', manual: true, callerName: pingerName });
|
|
const pingedNames = new Set();
|
|
|
|
for (const [ws, clientInfo] of clients.entries()) {
|
|
if (clientInfo.roomSlug === roomSlug && ws.readyState === 1) {
|
|
// Get participant info for this client
|
|
const participant = room?.participants?.[clientInfo.participantId];
|
|
const name = participant?.name;
|
|
|
|
// If targeting specific participant, only ping that name
|
|
if (targetName && name !== targetName) {
|
|
continue;
|
|
}
|
|
|
|
// Skip if we've already pinged this name
|
|
if (name && pingedNames.has(name)) {
|
|
console.log(`[${roomSlug}] Skipping duplicate ping for: ${name}`);
|
|
continue;
|
|
}
|
|
|
|
ws.send(locationRequestMsg);
|
|
wsSent++;
|
|
|
|
if (name) {
|
|
pingedNames.add(name);
|
|
}
|
|
}
|
|
}
|
|
console.log(`[${roomSlug}] Location request via WebSocket: ${wsSent} clients${targetName ? ` (target: ${targetName})` : ' (deduped by name)'}`);
|
|
|
|
// Send push notifications to subscribers that are NOT online via WebSocket
|
|
// Online users: silent push (they already got the WS message)
|
|
// Offline users: visible "callout" notification so they know someone is looking for them
|
|
const subsMap = pushSubscriptions.get(roomSlug);
|
|
if (subsMap && subsMap.size > 0) {
|
|
const failedEndpoints = [];
|
|
const pingerName = callerName || 'Someone';
|
|
|
|
for (const [endpoint, { subscription, participantId: subParticipantId }] of subsMap.entries()) {
|
|
// If targeting specific participant, skip others
|
|
if (targetName && subParticipantId) {
|
|
const subParticipant = room?.participants?.[subParticipantId];
|
|
if (subParticipant && subParticipant.name !== targetName) continue;
|
|
}
|
|
|
|
const isOnline = subParticipantId && onlineParticipantIds.has(subParticipantId);
|
|
|
|
try {
|
|
if (isOnline) {
|
|
// Online users already got the WS message; send silent push as backup
|
|
await webpush.sendNotification(subscription, JSON.stringify({
|
|
silent: true,
|
|
data: { type: 'location_request', roomSlug, manual: true, callerName: pingerName }
|
|
}));
|
|
} else {
|
|
// Offline users: send a VISIBLE callout notification
|
|
await webpush.sendNotification(subscription, JSON.stringify({
|
|
title: `📍 ${pingerName} pinged you for your location!`,
|
|
body: `Tap to share your location in ${roomSlug}`,
|
|
tag: `callout-${roomSlug}`,
|
|
data: {
|
|
type: 'callout',
|
|
roomSlug,
|
|
callerName: pingerName,
|
|
manual: true,
|
|
},
|
|
actions: [
|
|
{ action: 'view', title: 'Open Map' },
|
|
],
|
|
requireInteraction: true,
|
|
}));
|
|
}
|
|
pushSent++;
|
|
} catch (error) {
|
|
pushFailed++;
|
|
if (error.statusCode === 404 || error.statusCode === 410) {
|
|
failedEndpoints.push(endpoint);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up failed subscriptions
|
|
if (failedEndpoints.length > 0) {
|
|
for (const endpoint of failedEndpoints) {
|
|
subsMap.delete(endpoint);
|
|
}
|
|
persistPushSubscriptions();
|
|
}
|
|
}
|
|
|
|
// Collect last known locations of targeted participants for the response
|
|
const lastLocations = {};
|
|
if (room) {
|
|
for (const [pid, p] of Object.entries(room.participants)) {
|
|
if (targetName && p.name !== targetName) continue;
|
|
if (p.location) {
|
|
lastLocations[pid] = {
|
|
name: p.name,
|
|
location: p.location,
|
|
status: p.status,
|
|
lastSeen: p.lastSeen,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`[${roomSlug}] Manual location request: ${wsSent} WebSocket, ${pushSent} push sent, ${pushFailed} push failed`);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
success: true,
|
|
websocket: wsSent,
|
|
push: pushSent,
|
|
pushFailed,
|
|
total: wsSent + pushSent,
|
|
lastLocations,
|
|
}));
|
|
} catch (error) {
|
|
console.error('Location request error:', error);
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Failed to request location' }));
|
|
}
|
|
} else {
|
|
res.writeHead(404);
|
|
res.end('Not found');
|
|
}
|
|
});
|
|
|
|
// Create WebSocket server
|
|
const wss = new WebSocketServer({ server });
|
|
|
|
wss.on('connection', async (ws, req) => {
|
|
const { pathname } = parse(req.url);
|
|
|
|
// Extract room slug from path: /room/{slug}
|
|
const match = pathname?.match(/^\/room\/([^/]+)$/);
|
|
if (!match) {
|
|
console.log(`Invalid path: ${pathname}`);
|
|
ws.close(4000, 'Invalid room path');
|
|
return;
|
|
}
|
|
|
|
const roomSlug = decodeURIComponent(match[1]);
|
|
|
|
// Extract and verify auth token from query string
|
|
const token = extractTokenFromURL(req.url);
|
|
let claims = null;
|
|
if (token) {
|
|
claims = await verifyToken(token);
|
|
if (claims) {
|
|
console.log(`[${roomSlug}] Authenticated connection: ${claims.username || claims.sub}`);
|
|
}
|
|
}
|
|
|
|
// Check room access based on visibility
|
|
const room = getRoomState(roomSlug);
|
|
const access = evaluateRoomAccess(room, claims);
|
|
|
|
if (!access.allowed) {
|
|
console.log(`[${roomSlug}] Connection rejected: ${access.reason}`);
|
|
ws.close(4001, access.reason || 'Access denied');
|
|
return;
|
|
}
|
|
|
|
console.log(`New connection to room: ${roomSlug}${access.readOnly ? ' (read-only)' : ''}`);
|
|
|
|
// Register client with auth info
|
|
clients.set(ws, { roomSlug, participantId: null, claims, readOnly: access.readOnly });
|
|
|
|
// If authenticated and room has no owner, set this user as owner
|
|
if (claims && !room.ownerDID && Object.keys(room.participants).length === 0) {
|
|
room.ownerDID = claims.sub || claims.did;
|
|
console.log(`[${roomSlug}] Room owner set to: ${room.ownerDID}`);
|
|
}
|
|
|
|
// Set up handlers
|
|
ws.on('message', (data) => handleMessage(ws, data.toString()));
|
|
ws.on('close', () => handleClose(ws));
|
|
ws.on('error', (err) => {
|
|
console.error('WebSocket error:', err.message);
|
|
handleClose(ws);
|
|
});
|
|
|
|
// Send ping every 30 seconds to keep connection alive
|
|
const pingInterval = setInterval(() => {
|
|
if (ws.readyState === 1) {
|
|
ws.ping();
|
|
} else {
|
|
clearInterval(pingInterval);
|
|
}
|
|
}, 30000);
|
|
|
|
ws.on('close', () => clearInterval(pingInterval));
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`rmaps sync server listening on port ${PORT}`);
|
|
console.log(`WebSocket: ws://localhost:${PORT}/room/{slug}`);
|
|
console.log(`Health check: http://localhost:${PORT}/health`);
|
|
console.log(`Push API: http://localhost:${PORT}/push/subscribe`);
|
|
});
|