178 lines
5.6 KiB
JavaScript
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;
|