/** * Maps module — real-time collaborative location sharing. * * Port of rmaps-online. Rooms are ephemeral (no DB). Proxies to * the sync server for WebSocket-based location sharing. Embeds * MapLibre GL for outdoor maps and c3nav for indoor maps. */ import { Hono } from "hono"; import * as Automerge from "@automerge/automerge"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyToken, extractToken } from "../../server/auth"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { mapsSchema, mapsDocId } from './schemas'; import type { MapsDoc, MapAnnotation, SavedRoute, SavedMeetingPoint } from './schemas'; let _syncServer: SyncServer | null = null; const routes = new Hono(); const SYNC_SERVER = process.env.MAPS_SYNC_URL || "http://localhost:3001"; // ── Local-first helpers ── function ensureMapsDoc(space: string): MapsDoc { const docId = mapsDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init maps', (d) => { const init = mapsSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; }); _syncServer!.setDoc(docId, doc); } return doc; } // ── CRUD: Pins (annotations) ── routes.get("/api/pins", (c) => { if (!_syncServer) return c.json({ pins: [] }); const space = c.req.param("space") || "demo"; const doc = ensureMapsDoc(space); return c.json({ pins: Object.values(doc.annotations || {}) }); }); routes.post("/api/pins", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const space = c.req.param("space") || "demo"; const { type = 'pin', lat, lng, label = '' } = await c.req.json(); if (lat == null || lng == null) return c.json({ error: "lat and lng required" }, 400); const id = crypto.randomUUID(); const docId = mapsDocId(space); ensureMapsDoc(space); _syncServer.changeDoc(docId, `add pin ${id}`, (d) => { d.annotations[id] = { id, type: type as 'pin' | 'note' | 'area', lat, lng, label, authorDid: (claims.did as string) || claims.sub || null, createdAt: Date.now() }; }); const updated = _syncServer.getDoc(docId)!; return c.json(updated.annotations[id], 201); }); routes.delete("/api/pins/:pinId", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const space = c.req.param("space") || "demo"; const pinId = c.req.param("pinId"); const docId = mapsDocId(space); const doc = ensureMapsDoc(space); if (!doc.annotations[pinId]) return c.json({ error: "Not found" }, 404); _syncServer.changeDoc(docId, `delete pin ${pinId}`, (d) => { delete d.annotations[pinId]; }); return c.json({ ok: true }); }); // ── CRUD: Saved Routes ── routes.get("/api/saved-routes", (c) => { if (!_syncServer) return c.json({ routes: [] }); const space = c.req.param("space") || "demo"; const doc = ensureMapsDoc(space); return c.json({ routes: Object.values(doc.savedRoutes || {}) }); }); routes.post("/api/saved-routes", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const space = c.req.param("space") || "demo"; const { name, waypoints } = await c.req.json(); if (!name || !Array.isArray(waypoints) || waypoints.length < 2) return c.json({ error: "name and waypoints (min 2) required" }, 400); const id = crypto.randomUUID(); const docId = mapsDocId(space); ensureMapsDoc(space); _syncServer.changeDoc(docId, `save route ${id}`, (d) => { d.savedRoutes[id] = { id, name, waypoints, authorDid: (claims.did as string) || claims.sub || null, createdAt: Date.now() }; }); const updated = _syncServer.getDoc(docId)!; return c.json(updated.savedRoutes[id], 201); }); routes.delete("/api/saved-routes/:routeId", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const space = c.req.param("space") || "demo"; const routeId = c.req.param("routeId"); const docId = mapsDocId(space); const doc = ensureMapsDoc(space); if (!doc.savedRoutes[routeId]) return c.json({ error: "Not found" }, 404); _syncServer.changeDoc(docId, `delete route ${routeId}`, (d) => { delete d.savedRoutes[routeId]; }); return c.json({ ok: true }); }); // ── CRUD: Meeting Points ── routes.get("/api/meeting-points", (c) => { if (!_syncServer) return c.json({ meetingPoints: [] }); const space = c.req.param("space") || "demo"; const doc = ensureMapsDoc(space); return c.json({ meetingPoints: Object.values(doc.savedMeetingPoints || {}) }); }); routes.post("/api/meeting-points", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const space = c.req.param("space") || "demo"; const { name, lat, lng } = await c.req.json(); if (!name || lat == null || lng == null) return c.json({ error: "name, lat, and lng required" }, 400); const id = crypto.randomUUID(); const docId = mapsDocId(space); ensureMapsDoc(space); _syncServer.changeDoc(docId, `save meeting point ${id}`, (d) => { d.savedMeetingPoints[id] = { id, name, lat, lng, setBy: (claims.did as string) || claims.sub || null, createdAt: Date.now() }; }); const updated = _syncServer.getDoc(docId)!; return c.json(updated.savedMeetingPoints[id], 201); }); routes.delete("/api/meeting-points/:pointId", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const space = c.req.param("space") || "demo"; const pointId = c.req.param("pointId"); const docId = mapsDocId(space); const doc = ensureMapsDoc(space); if (!doc.savedMeetingPoints[pointId]) return c.json({ error: "Not found" }, 404); _syncServer.changeDoc(docId, `delete meeting point ${pointId}`, (d) => { delete d.savedMeetingPoints[pointId]; }); return c.json({ ok: true }); }); // ── Sync URL for client-side WebSocket connection ── routes.get("/api/sync-url", (c) => { const wsUrl = process.env.MAPS_SYNC_URL || "wss://maps-sync.rspace.online"; return c.json({ syncUrl: wsUrl }); }); // ── Proxy: sync server health ── routes.get("/api/health", async (c) => { try { const res = await fetch(`${SYNC_SERVER}/health`, { signal: AbortSignal.timeout(3000) }); if (res.ok) return c.json(await res.json()); return c.json({ status: "degraded", sync: false }); } catch { return c.json({ status: "degraded", sync: false }); } }); // ── Proxy: room stats ── routes.get("/api/stats", async (c) => { try { const res = await fetch(`${SYNC_SERVER}/stats`, { signal: AbortSignal.timeout(3000) }); if (res.ok) return c.json(await res.json()); } catch {} return c.json({ rooms: {} }); }); // ── Proxy: push notification VAPID key ── routes.get("/api/push/vapid-public-key", async (c) => { try { const res = await fetch(`${SYNC_SERVER}/push/vapid-public-key`, { signal: AbortSignal.timeout(3000) }); if (res.ok) return c.json(await res.json()); } catch {} return c.json({ error: "Push not available" }, 503); }); // ── Proxy: push subscribe/unsubscribe ── routes.post("/api/push/subscribe", async (c) => { const body = await c.req.json(); const res = await fetch(`${SYNC_SERVER}/push/subscribe`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return c.json(await res.json(), res.status as any); }); routes.post("/api/push/unsubscribe", async (c) => { const body = await c.req.json(); const res = await fetch(`${SYNC_SERVER}/push/unsubscribe`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return c.json(await res.json(), res.status as any); }); // ── Proxy: request location (ping) ── routes.post("/api/push/request-location", async (c) => { const body = await c.req.json(); const res = await fetch(`${SYNC_SERVER}/push/request-location`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return c.json(await res.json(), res.status as any); }); // ── Proxy: last-known-location (offline ping response) ── routes.post("/api/push/last-known-location", async (c) => { const body = await c.req.json(); try { const res = await fetch(`${SYNC_SERVER}/push/last-known-location`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (res.ok) return c.json(await res.json()); } catch {} return c.json({ received: true }); }); // ── Proxy: routing (OSRM + c3nav) ── routes.post("/api/routing", async (c) => { const body = await c.req.json(); const { from, to, mode = "walking", accessibility, indoor } = body; if (!from?.lat || !from?.lng || !to?.lat || !to?.lng) { return c.json({ error: "from and to with lat/lng required" }, 400); } const segments: { type: string; coordinates: [number, number][]; distance: number; duration: number; steps?: any[] }[] = []; let totalDistance = 0; let estimatedTime = 0; // ── Indoor routing via c3nav ── if (indoor?.event && (from.indoor || to.indoor)) { try { // If both are indoor, route fully indoors if (from.indoor && to.indoor) { const c3navRes = await fetch(`https://${indoor.event}.c3nav.de/api/v2/routing/route/`, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": "anonymous", Accept: "application/json", "User-Agent": "rMaps/1.0" }, body: JSON.stringify({ origin: { level: from.indoor.level, x: from.indoor.x, y: from.indoor.y }, destination: { level: to.indoor.level, x: to.indoor.x, y: to.indoor.y }, ...(accessibility?.avoidStairs ? { options: { avoid_stairs: true } } : {}), ...(accessibility?.wheelchair ? { options: { wheelchair: true } } : {}), }), signal: AbortSignal.timeout(8000), }); if (c3navRes.ok) { const data = await c3navRes.json(); const coords: [number, number][] = data.path?.map((p: any) => [p.lng || p.x, p.lat || p.y]) || []; const dist = data.distance || 0; const dur = data.duration || Math.round(dist / 1.2); segments.push({ type: "indoor", coordinates: coords, distance: dist, duration: dur }); totalDistance += dist; estimatedTime += dur; } } else { // Mixed indoor/outdoor: transition segment + outdoor OSRM const outdoorPoint = from.indoor ? to : from; const indoorPoint = from.indoor ? from : to; const transitionCoords: [number, number][] = [ [indoorPoint.lng, indoorPoint.lat], [outdoorPoint.lng, outdoorPoint.lat], ]; segments.push({ type: "transition", coordinates: transitionCoords, distance: 50, duration: 30 }); totalDistance += 50; estimatedTime += 30; // OSRM for the outdoor portion const profile = mode === "driving" ? "car" : "foot"; const osrmRes = await fetch( `https://router.project-osrm.org/route/v1/${profile}/${outdoorPoint.lng},${outdoorPoint.lat};${(from.indoor ? to : from).lng},${(from.indoor ? to : from).lat}?overview=full&geometries=geojson&steps=true`, { signal: AbortSignal.timeout(10000) }, ); if (osrmRes.ok) { const osrmData = await osrmRes.json(); const route = osrmData.routes?.[0]; if (route) { segments.push({ type: "outdoor", coordinates: route.geometry?.coordinates || [], distance: route.distance || 0, duration: route.duration || 0, steps: route.legs?.[0]?.steps, }); totalDistance += route.distance || 0; estimatedTime += route.duration || 0; } } } if (segments.length > 0) { return c.json({ success: true, route: { segments, totalDistance, estimatedTime: Math.round(estimatedTime) }, }); } } catch { /* fall through to outdoor-only routing */ } } // ── Outdoor-only routing via OSRM ── const profile = mode === "driving" ? "car" : "foot"; try { const res = await fetch( `https://router.project-osrm.org/route/v1/${profile}/${from.lng},${from.lat};${to.lng},${to.lat}?overview=full&geometries=geojson&steps=true`, { signal: AbortSignal.timeout(10000) }, ); if (res.ok) { const data = await res.json(); const route = data.routes?.[0]; if (route) { return c.json({ success: true, route: { segments: [{ type: "outdoor", coordinates: route.geometry?.coordinates || [], distance: route.distance || 0, duration: route.duration || 0, steps: route.legs?.[0]?.steps, }], totalDistance: route.distance || 0, estimatedTime: Math.round(route.duration || 0), }, }); } } } catch {} return c.json({ error: "Routing failed" }, 502); }); // ── Proxy: c3nav API ── const VALID_C3NAV_EVENTS = ["39c3", "38c3", "37c3", "eh22", "eh2025", "camp2023"]; const ALLOWED_C3NAV_ENDPOINTS = ["map/settings", "map/bounds", "map/locations", "map/locations/full", "map/projection"]; // ── c3nav tile proxy ── routes.get("/api/c3nav/tiles/:event/:level/:z/:x/:y", async (c) => { const event = c.req.param("event"); const level = parseInt(c.req.param("level"), 10); const z = c.req.param("z"); const x = c.req.param("x"); const y = c.req.param("y"); if (!VALID_C3NAV_EVENTS.includes(event)) return c.json({ error: "Invalid event" }, 400); if (isNaN(level) || level < -1 || level > 10) return c.json({ error: "Invalid level" }, 400); try { const res = await fetch(`https://tiles.${event}.c3nav.de/${level}/${z}/${x}/${y}.png`, { headers: { "User-Agent": "rMaps/1.0" }, signal: AbortSignal.timeout(8000), }); if (!res.ok) return c.json({ error: "Tile fetch failed" }, 502); const data = await res.arrayBuffer(); return new Response(data, { headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=3600", }, }); } catch { return c.json({ error: "c3nav tile proxy error" }, 502); } }); routes.get("/api/c3nav/:event", async (c) => { const event = c.req.param("event"); const endpoint = c.req.query("endpoint") || "map/bounds"; if (!VALID_C3NAV_EVENTS.includes(event)) return c.json({ error: "Invalid event" }, 400); const isAllowed = ALLOWED_C3NAV_ENDPOINTS.some((a) => endpoint === a || endpoint.startsWith(a + "/")); if (!isAllowed && !endpoint.startsWith("map/locations/")) return c.json({ error: "Endpoint not allowed" }, 403); try { const res = await fetch(`https://${event}.c3nav.de/api/v2/${endpoint}/`, { headers: { "X-API-Key": "anonymous", Accept: "application/json", "User-Agent": "rMaps/1.0" }, signal: AbortSignal.timeout(5000), }); if (res.ok) return c.json(await res.json()); return c.json({ error: "c3nav API error" }, res.status as any); } catch { return c.json({ error: "c3nav unreachable" }, 502); } }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; return c.html(renderShell({ title: `${space} — Maps | rSpace`, moduleId: "rmaps", spaceSlug: space, modules: getModuleInfoList(), body: ``, scripts: ` `, styles: ``, })); }); // Room-specific page routes.get("/:room", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const room = c.req.param("room"); return c.html(renderShell({ title: `${room} — Maps | rSpace`, moduleId: "rmaps", spaceSlug: space, modules: getModuleInfoList(), styles: ``, body: ``, scripts: ` `, })); }); // ── MI Integration ── export function getMapPinsForMI(space: string, limit = 10): { id: string; type: string; lat: number; lng: number; label: string; createdAt: number }[] { if (!_syncServer) return []; const docId = mapsDocId(space); const doc = _syncServer.getDoc(docId); if (!doc) return []; return Object.values(doc.annotations) .sort((a, b) => b.createdAt - a.createdAt) .slice(0, limit) .map((a) => ({ id: a.id, type: a.type, lat: a.lat, lng: a.lng, label: a.label, createdAt: a.createdAt })); } export const mapsModule: RSpaceModule = { id: "rmaps", name: "rMaps", icon: "🗺", description: "Real-time collaborative location sharing and indoor/outdoor maps", canvasShapes: ["folk-map"], canvasToolIds: ["create_map"], scoping: { defaultScope: 'global', userConfigurable: false }, docSchemas: [{ pattern: '{space}:maps:annotations', description: 'Map annotations, routes, and meeting points', init: mapsSchema.init }], routes, landingPage: renderLanding, standaloneDomain: "rmaps.online", async onInit(ctx) { _syncServer = ctx.syncServer; }, feeds: [ { id: "locations", name: "Locations", kind: "data", description: "Shared location pins, points of interest, and room positions", filterable: true, }, { id: "routes", name: "Routes", kind: "data", description: "Calculated routes and navigation paths between locations", }, ], acceptsFeeds: ["data"], outputPaths: [ { path: "routes", name: "Routes", icon: "🛤️", description: "Saved navigation routes" }, { path: "places", name: "Places", icon: "📍", description: "Saved locations and points of interest" }, ], };