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;