import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; import { DatabaseService } from '../database/database.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; import { PluginRuntimeService } from './plugin-runtime.service'; import { stripEmoji } from './text-sanitize'; /** * GET /api/map-layers/:tripId — bounded vector overlays plugins draw on the trip map * via the `mapLayerProvider` hook. The marker hook (#587) covers points; this one * covers the shapes an integration needs to show a computed route, a reachable-range * corridor or a zone: polylines, polygons and metric circles. * * DECLARATIVE ONLY — same contract as map markers: a plugin never runs JS on the map * canvas, it returns geometry specs the host draws. Styling stays inside the tone * palette; the only free knobs are clamped numerics (width/opacity/radius) and a * dash enum, so a layer can never impersonate core UI or smuggle markup. Coordinates * are range-checked, strings are String()-coerced + emoji-stripped + length-capped, * and the point budget is capped per provider because every vertex is render cost. */ type Tone = 'default' | 'success' | 'warn' | 'danger'; type Dash = 'solid' | 'dash' | 'dot'; export interface MapLayerFeature { type: 'polyline' | 'polygon' | 'circle'; points?: Array<[number, number]>; // [lat,lng] — polyline/polygon center?: [number, number]; // circle radiusM?: number; // circle, metres tone: Tone; width: number; dash: Dash; opacity: number; fill: boolean; label?: string; } export interface MapLayer { pluginId: string; id: string; name?: string; features: MapLayerFeature[]; } const TONES: ReadonlySet = new Set(['default', 'success', 'warn', 'danger']); const DASHES: ReadonlySet = new Set(['solid', 'dash', 'dot']); const MAX_LAYERS = 4; // per provider const MAX_FEATURES = 150; // per provider, across its layers const MAX_POINTS = 8000; // per provider — total vertex budget, bounds the render cost const MAX_FEATURE_POINTS = 2000; // one feature can't eat the whole budget const MAX_RADIUS_M = 2_000_000; // 2000 km — anything larger is a "tint the planet" wash const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n); const clamp = (v: unknown, lo: number, hi: number, dflt: number): number => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt; }; function readPoint(raw: unknown): [number, number] | null { if (!Array.isArray(raw) || raw.length < 2) return null; const lat = Number(raw[0]); const lng = Number(raw[1]); if (!Number.isFinite(lat) || !Number.isFinite(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) return null; return [lat, lng]; } function readPoints(raw: unknown, budget: number): Array<[number, number]> | null { if (!Array.isArray(raw)) return null; // An oversized shape is dropped whole rather than truncated — a silently clipped // route would render as a different geometry than the plugin computed, which is // worse than rendering nothing. Same rule for a shape that overruns the budget. if (raw.length > MAX_FEATURE_POINTS || raw.length > budget) return null; const out: Array<[number, number]> = []; for (const p of raw) { const pt = readPoint(p); // One bad vertex invalidates the shape too, for the same reason. if (!pt) return null; out.push(pt); } return out; } // The normalizer bounds its OUTPUT, but a layer/feature that never validates // (truthy id, zero valid features) doesn't advance the output counters, so an // oversized raw array would otherwise be iterated in full on the host event loop. // Slice the raw list up front — well above any legitimate payload — so the work, // not just the result, is bounded (mirrors readPoints' length pre-check). const MAX_RAW_LAYERS = 1000; const MAX_RAW_FEATURES = 2000; function normalize(pluginId: string, raw: unknown): MapLayer[] { const list = Array.isArray(raw) ? (raw as Array>).slice(0, MAX_RAW_LAYERS) : []; const out: MapLayer[] = []; let features = 0; let points = 0; for (const l of list) { if (out.length >= MAX_LAYERS) break; if (!l || typeof l !== 'object') continue; const id = cap(l.id, 64); if (!id) continue; const feats: MapLayerFeature[] = []; const rawFeats = Array.isArray(l.features) ? (l.features as Array>).slice(0, MAX_RAW_FEATURES) : []; for (const f of rawFeats) { if (features >= MAX_FEATURES || points >= MAX_POINTS) break; if (!f || typeof f !== 'object') continue; const type = f.type; // Check the RAW value against the enums (not String(...)) — same rationale as // the marker tone guard: a toString() match must not let an object through. const tone: Tone = TONES.has(f.tone as string) ? (f.tone as Tone) : 'default'; const dash: Dash = DASHES.has(f.dash as string) ? (f.dash as Dash) : 'solid'; const base = { tone, dash, width: Math.round(clamp(f.width, 1, 8, 3)), opacity: clamp(f.opacity, 0.05, 1, 0.8), label: f.label != null ? cap(f.label, 80) : undefined, }; if (type === 'polyline' || type === 'polygon') { const min = type === 'polygon' ? 3 : 2; const pts = readPoints(f.points, MAX_POINTS - points); if (!pts || pts.length < min) continue; points += pts.length; features++; feats.push({ ...base, type, points: pts, fill: type === 'polygon' ? f.fill !== false : false }); } else if (type === 'circle') { const center = readPoint(f.center); const radiusM = clamp(f.radiusM, 1, MAX_RADIUS_M, 0); if (!center || radiusM < 1) continue; points += 1; features++; feats.push({ ...base, type, center, radiusM, fill: f.fill !== false }); } } if (feats.length === 0) continue; out.push({ pluginId, id, name: l.name != null ? cap(l.name, 60) : undefined, features: feats, }); } return out; } @Controller('api/map-layers') @UseGuards(JwtAuthGuard) export class MapLayersController { constructor( private readonly runtime: PluginRuntimeService, private readonly dbs: DatabaseService, ) {} @Get(':tripId') async get( @Param('tripId') tripIdRaw: string, @Req() req: Request & { user?: { id: number } }, ): Promise<{ layers: MapLayer[] }> { if (!pluginsEnabled()) return { layers: [] }; const tripId = Number(tripIdRaw); const userId = req.user?.id; if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { layers: [] }; const ids = this.runtime.providersOf('mapLayerProvider'); const perProvider = await Promise.all( ids.map(async (id): Promise => { try { const raw = await this.runtime.invokeHook(id, 'mapLayerProvider', 'getLayers', [tripId], userId, 5000); return normalize(id, raw); } catch { return []; // a slow / failing provider contributes nothing } }), ); return { layers: perProvider.flat() }; } }