166 lines
5.5 KiB
TypeScript
166 lines
5.5 KiB
TypeScript
/**
|
|
* 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 { renderShell } from "../../server/shell";
|
|
import { getModuleInfoList } from "../../shared/module";
|
|
import type { RSpaceModule } from "../../shared/module";
|
|
|
|
const routes = new Hono();
|
|
|
|
const SYNC_SERVER = process.env.MAPS_SYNC_URL || "http://localhost:3001";
|
|
|
|
// ── 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: routing (OSRM + c3nav) ──
|
|
routes.post("/api/routing", async (c) => {
|
|
const body = await c.req.json();
|
|
const { from, to, mode = "walking" } = body;
|
|
|
|
if (!from?.lat || !from?.lng || !to?.lat || !to?.lng) {
|
|
return c.json({ error: "from and to with lat/lng required" }, 400);
|
|
}
|
|
|
|
// Use OSRM for outdoor routing
|
|
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();
|
|
return c.json(data);
|
|
}
|
|
} 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"];
|
|
|
|
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";
|
|
return c.html(renderShell({
|
|
title: `${space} — Maps | rSpace`,
|
|
moduleId: "maps",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "light",
|
|
styles: `<link rel="stylesheet" href="/modules/maps/maps.css">`,
|
|
body: `<folk-map-viewer space="${space}"></folk-map-viewer>`,
|
|
scripts: `<script type="module" src="/modules/maps/folk-map-viewer.js"></script>`,
|
|
}));
|
|
});
|
|
|
|
// Room-specific page
|
|
routes.get("/:room", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const room = c.req.param("room");
|
|
return c.html(renderShell({
|
|
title: `${room} — Maps | rSpace`,
|
|
moduleId: "maps",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "light",
|
|
styles: `<link rel="stylesheet" href="/modules/maps/maps.css">`,
|
|
body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`,
|
|
scripts: `<script type="module" src="/modules/maps/folk-map-viewer.js"></script>`,
|
|
}));
|
|
});
|
|
|
|
export const mapsModule: RSpaceModule = {
|
|
id: "maps",
|
|
name: "rMaps",
|
|
icon: "\u{1F5FA}",
|
|
description: "Real-time collaborative location sharing and indoor/outdoor maps",
|
|
routes,
|
|
standaloneDomain: "rmaps.online",
|
|
};
|