const express = require('express'); const axios = require('axios'); const rateLimit = require('express-rate-limit'); const { requireAuth } = require('../middleware/firebase_auth'); const { requireAppCheck } = require('../middleware/app_check'); const router = express.Router(); // Configurable rate limit parameters const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || '3600000', 10); const ROUTE_RATE_LIMIT_MAX = parseInt( process.env.ROUTE_RATE_LIMIT_MAX || process.env.ROUTE_RATE_LIMIT || '50', 10 ); // Rate limiter: per user UID (with IP fallback) const routeLimiter = rateLimit({ windowMs: RATE_LIMIT_WINDOW_MS, max: ROUTE_RATE_LIMIT_MAX, keyGenerator: (req) => req.user?.uid || req.ip, validate: { keyGeneratorIpFallback: false }, handler: (req, res) => { res.status(429).json({ error: 'providerRateLimited', message: 'Route calculation rate limit exceeded' }); } }); router.post('/calculate', requireAppCheck, requireAuth, routeLimiter, async (req, res) => { const stadiaApiKey = process.env.STADIA_API_KEY; if (!stadiaApiKey) { console.error('[Routing] STADIA_API_KEY is not configured'); return res.status(503).json({ error: 'providerUnavailable', message: 'Routing provider is not configured' }); } const { locations, options } = req.body || {}; if (!Array.isArray(locations) || locations.length < 2) { return res.status(400).json({ error: 'invalidRequest', message: 'At least 2 locations are required' }); } if (locations.length > 25) { return res.status(400).json({ error: 'invalidRequest', message: 'Maximum of 25 locations allowed' }); } // Validate and sanitize each coordinate location const valhallaLocations = []; for (let i = 0; i < locations.length; i++) { const loc = locations[i]; if (!loc || typeof loc !== 'object') { return res.status(400).json({ error: 'invalidRequest', message: `Location at index ${i} must be an object` }); } const lat = loc.lat; const lon = loc.lon ?? loc.lng; if (typeof lat !== 'number' || typeof lon !== 'number' || !Number.isFinite(lat) || !Number.isFinite(lon)) { return res.status(400).json({ error: 'invalidRequest', message: `Invalid or non-finite coordinates at index ${i}` }); } if (lat < -90 || lat > 90 || lon < -180 || lon > 180) { return res.status(400).json({ error: 'invalidRequest', message: `Coordinates out of geographic bounds at index ${i}` }); } // Type is 'break' for origin, destination, and physical stops let type = 'break'; if (i > 0 && i < locations.length - 1 && loc.type === 'through') { type = 'through'; } valhallaLocations.push({ lat, lon, type }); } // Strictly enforce motorcycle profile server-side; do not accept arbitrary costing const payload = { locations: valhallaLocations, costing: 'motorcycle', directions_options: { units: 'miles' } }; // Optional avoidance options if (options && typeof options === 'object') { const costingOptions = {}; if (options.avoid_highways === true) costingOptions.use_highways = 0; if (options.avoid_tolls === true) costingOptions.use_tolls = 0; if (options.avoid_ferries === true) costingOptions.use_ferry = 0; if (Object.keys(costingOptions).length > 0) { payload.costing_options = { motorcycle: costingOptions }; } } try { const response = await axios.post('https://api.stadiamaps.com/route/v1', payload, { params: { api_key: stadiaApiKey }, timeout: 10000 }); const data = response.data; if (!data || !data.trip || !Array.isArray(data.trip.legs) || data.trip.legs.length === 0) { return res.status(404).json({ error: 'routeNotFound', message: 'No route could be calculated' }); } let totalDistanceMeters = 0; let totalDurationSeconds = 0; const polylineParts = []; for (const leg of data.trip.legs) { if (leg.summary) { totalDistanceMeters += (Number(leg.summary.length) || 0) * 1000; totalDurationSeconds += Math.round(Number(leg.summary.time) || 0); } if (leg.shape) { polylineParts.push(leg.shape); } } res.json({ provider: 'valhalla_stadia', // Each leg is its own precision-6 polyline. Do not concatenate encoded // strings because the next leg would inherit the prior leg's deltas. encodedPolylines: polylineParts, // Preserve the legacy field for single-leg clients. encodedPolyline: polylineParts.length === 1 ? polylineParts[0] : '', distanceMeters: Math.round(totalDistanceMeters * 10) / 10, durationSeconds: totalDurationSeconds, warnings: Array.isArray(data.trip.warnings) ? data.trip.warnings : [], attribution: '© Stadia Maps, © OpenStreetMap contributors' }); } catch (error) { if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) { return res.status(504).json({ error: 'providerTimeout', message: 'Routing provider timed out' }); } if (error.response?.status === 400 || error.response?.status === 404) { return res.status(404).json({ error: 'routeNotFound', message: 'No road route could be found between the specified locations' }); } console.error('[Routing] Upstream error occurred:', error.response?.status || error.message); res.status(502).json({ error: 'providerUnavailable', message: 'Routing service is temporarily unavailable' }); } }); module.exports = router;