MotorSignal-App/server/waze_relay/routes/routing.js

178 lines
5.6 KiB
JavaScript

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;