217 lines
6.5 KiB
JavaScript
217 lines
6.5 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 PLACE_SEARCH_RATE_LIMIT_MAX = parseInt(
|
|
process.env.PLACE_SEARCH_RATE_LIMIT_MAX || process.env.PLACES_RATE_LIMIT || '200',
|
|
10
|
|
);
|
|
const PLACE_RESOLVE_RATE_LIMIT_MAX = parseInt(
|
|
process.env.PLACE_RESOLVE_RATE_LIMIT_MAX || '200',
|
|
10
|
|
);
|
|
|
|
const searchLimiter = rateLimit({
|
|
windowMs: RATE_LIMIT_WINDOW_MS,
|
|
max: PLACE_SEARCH_RATE_LIMIT_MAX,
|
|
keyGenerator: (req) => req.user?.uid || req.ip,
|
|
validate: { keyGeneratorIpFallback: false },
|
|
handler: (req, res) =>
|
|
res.status(429).json({
|
|
error: 'providerRateLimited',
|
|
message: 'Places search rate limit exceeded'
|
|
})
|
|
});
|
|
|
|
const resolveLimiter = rateLimit({
|
|
windowMs: RATE_LIMIT_WINDOW_MS,
|
|
max: PLACE_RESOLVE_RATE_LIMIT_MAX,
|
|
keyGenerator: (req) => req.user?.uid || req.ip,
|
|
validate: { keyGeneratorIpFallback: false },
|
|
handler: (req, res) =>
|
|
res.status(429).json({
|
|
error: 'providerRateLimited',
|
|
message: 'Places resolve rate limit exceeded'
|
|
})
|
|
});
|
|
|
|
router.get('/search', requireAppCheck, requireAuth, searchLimiter, async (req, res) => {
|
|
const stadiaApiKey = process.env.STADIA_API_KEY;
|
|
if (!stadiaApiKey) {
|
|
return res.status(503).json({
|
|
error: 'providerUnavailable',
|
|
message: 'Places provider is not configured'
|
|
});
|
|
}
|
|
|
|
const query = req.query.query;
|
|
if (!query || typeof query !== 'string') {
|
|
return res.status(400).json({
|
|
error: 'invalidRequest',
|
|
message: 'Valid query parameter is required'
|
|
});
|
|
}
|
|
|
|
const clean = query.trim();
|
|
if (clean.length < 2) {
|
|
return res.status(400).json({
|
|
error: 'invalidRequest',
|
|
message: 'Query must be at least 2 characters'
|
|
});
|
|
}
|
|
|
|
if (clean.length > 200) {
|
|
return res.status(400).json({
|
|
error: 'invalidRequest',
|
|
message: 'Query cannot exceed 200 characters'
|
|
});
|
|
}
|
|
|
|
const params = {
|
|
text: clean,
|
|
api_key: stadiaApiKey
|
|
};
|
|
|
|
if (req.query.lat && req.query.lng) {
|
|
const lat = parseFloat(req.query.lat);
|
|
const lng = parseFloat(req.query.lng);
|
|
if (Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
|
params['focus.point.lat'] = lat;
|
|
params['focus.point.lon'] = lng;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const response = await axios.get('https://api.stadiamaps.com/geocoding/v1/autocomplete', {
|
|
params,
|
|
timeout: 5000
|
|
});
|
|
|
|
const features = response.data?.features || [];
|
|
const normalized = [];
|
|
|
|
for (const f of features) {
|
|
const props = f.properties || {};
|
|
const coords = Array.isArray(f.geometry?.coordinates) ? f.geometry.coordinates : null;
|
|
const lng = coords && coords.length >= 2 ? coords[0] : null;
|
|
const lat = coords && coords.length >= 2 ? coords[1] : null;
|
|
|
|
normalized.push({
|
|
providerId: String(props.id || props.gid || ''),
|
|
primaryText: props.name || props.label || '',
|
|
secondaryText: [props.region, props.country].filter(Boolean).join(', '),
|
|
formattedAddress: props.label || props.name || '',
|
|
latitude: typeof lat === 'number' && Number.isFinite(lat) ? lat : null,
|
|
longitude: typeof lng === 'number' && Number.isFinite(lng) ? lng : null
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
results: normalized,
|
|
attribution: 'Powered by Stadia Maps, \u00A9 OpenStreetMap contributors'
|
|
});
|
|
} catch (error) {
|
|
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
|
return res.status(504).json({
|
|
error: 'providerTimeout',
|
|
message: 'Places search timed out'
|
|
});
|
|
}
|
|
console.error('[Places] Search upstream error:', error.response?.status || error.message);
|
|
res.status(502).json({
|
|
error: 'placeSearchUnavailable',
|
|
message: 'Places search is temporarily unavailable'
|
|
});
|
|
}
|
|
});
|
|
|
|
router.get('/resolve', requireAppCheck, requireAuth, resolveLimiter, async (req, res) => {
|
|
const stadiaApiKey = process.env.STADIA_API_KEY;
|
|
if (!stadiaApiKey) {
|
|
return res.status(503).json({
|
|
error: 'providerUnavailable',
|
|
message: 'Places provider is not configured'
|
|
});
|
|
}
|
|
|
|
const placeId = req.query.placeId;
|
|
if (!placeId || typeof placeId !== 'string') {
|
|
return res.status(400).json({
|
|
error: 'invalidRequest',
|
|
message: 'placeId is required'
|
|
});
|
|
}
|
|
|
|
const cleanId = placeId.trim();
|
|
if (cleanId.length === 0 || cleanId.length > 200) {
|
|
return res.status(400).json({
|
|
error: 'invalidRequest',
|
|
message: 'placeId must be between 1 and 200 characters'
|
|
});
|
|
}
|
|
|
|
try {
|
|
const response = await axios.get('https://api.stadiamaps.com/geocoding/v1/place', {
|
|
params: {
|
|
ids: cleanId,
|
|
api_key: stadiaApiKey
|
|
},
|
|
timeout: 5000
|
|
});
|
|
|
|
const features = response.data?.features || [];
|
|
if (features.length === 0) {
|
|
return res.status(404).json({
|
|
error: 'placeNotFound',
|
|
message: 'Place could not be resolved'
|
|
});
|
|
}
|
|
|
|
const feature = features[0];
|
|
const props = feature.properties || {};
|
|
const coords = feature.geometry?.coordinates || [];
|
|
|
|
if (!Array.isArray(coords) || coords.length < 2) {
|
|
return res.status(404).json({
|
|
error: 'placeNotFound',
|
|
message: 'Place has no valid coordinates'
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
providerId: String(props.id || props.gid || cleanId),
|
|
displayName: props.name || props.label || 'Unknown',
|
|
formattedAddress: props.label || props.name || '',
|
|
latitude: coords[1],
|
|
longitude: coords[0],
|
|
attribution: 'Powered by Stadia Maps, \u00A9 OpenStreetMap contributors'
|
|
});
|
|
} catch (error) {
|
|
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
|
return res.status(504).json({
|
|
error: 'providerTimeout',
|
|
message: 'Place resolution timed out'
|
|
});
|
|
}
|
|
console.error('[Places] Resolve upstream error:', error.response?.status || error.message);
|
|
if (error.response?.status === 404) {
|
|
return res.status(404).json({
|
|
error: 'placeNotFound',
|
|
message: 'Place not found'
|
|
});
|
|
}
|
|
res.status(502).json({
|
|
error: 'placeSearchUnavailable',
|
|
message: 'Places service is temporarily unavailable'
|
|
});
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|