const https = require('https'); const fs = require('fs').promises; const path = require('path'); const config = require('../config/env'); const logger = require('./logger'); const CACHE_FILE = path.join(__dirname, '..', 'geocode-cache.json'); const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds const CACHE_MAX_ENTRIES = 1000; const cache = new Map(); let lastRequestTime = 0; let cacheModified = false; /** * Einziger Schreibpfad in den Cache, inklusive Größenbegrenzung. * * Vorher war nur der Erfolgspfad begrenzt; die beiden Negativ-Pfade * (Adresse nicht gefunden / unbrauchbare Koordinaten) haben ungebremst * geschrieben. Über den öffentlichen /api/public/geocode genügten damit * erfundene Postleitzahlen, um Speicher und Cache-Datei beliebig wachsen * zu lassen — bei --max_old_space_size=256 eine reale Grenze. */ const rememberInCache = (key, value) => { // Map behält die Einfügereihenfolge: ein vorhandener Schlüssel muss neu // eingefügt werden, damit er als "zuletzt benutzt" ans Ende rückt. cache.delete(key); while (cache.size >= CACHE_MAX_ENTRIES) { cache.delete(cache.keys().next().value); } cache.set(key, value); cacheModified = true; }; // Load cache from file on startup const loadCache = async () => { try { const data = await fs.readFile(CACHE_FILE, 'utf8'); const parsed = JSON.parse(data); // Nur die letzten CACHE_MAX_ENTRIES übernehmen – eine früher unbegrenzt // gewachsene Datei darf den Cache nicht wieder aufblähen. const entries = Object.entries(parsed).slice(-CACHE_MAX_ENTRIES); entries.forEach(([key, value]) => { cache.set(key, value); }); logger.info(`Geocoding cache loaded: ${cache.size} entries`); } catch (error) { if (error.code !== 'ENOENT') { logger.warn('Failed to load geocoding cache:', error.message); } } }; // Save cache to file const saveCache = async () => { if (!cacheModified) return; try { const obj = Object.fromEntries(cache); await fs.writeFile(CACHE_FILE, JSON.stringify(obj, null, 2)); cacheModified = false; logger.info(`Geocoding cache saved: ${cache.size} entries`); } catch (error) { logger.error('Failed to save geocoding cache:', error.message); } }; // Periodically save cache setInterval(saveCache, CACHE_SAVE_INTERVAL).unref(); // Save on process exit // Cache beim Herunterfahren sichern – ohne process.exit(): das Beenden gehört // dem Shutdown-Handler in server.js, der sonst mittendrin abgeschnitten wird. const flushOnShutdown = () => { saveCache().catch(err => logger.error('Failed to flush geocoding cache:', err.message)); }; process.on('SIGINT', flushOnShutdown); process.on('SIGTERM', flushOnShutdown); // Initialize cache loading loadCache().catch(err => logger.error('Cache initialization error:', err)); const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); const fetchJson = (url, headers) => new Promise((resolve, reject) => { const req = https.get(url, { headers }, (res) => { let data = ''; res.on('data', chunk => { data += chunk; }); res.on('end', () => { if (res.statusCode && res.statusCode >= 400) { return reject(new Error(`Geocode HTTP ${res.statusCode}`)); } try { resolve(JSON.parse(data)); } catch (error) { reject(error); } }); }); req.on('error', reject); req.end(); }); const geocodeAddress = async (address) => { const normalized = (address || '').trim(); if (!normalized) { return null; } const cacheKey = normalized.toLowerCase(); if (cache.has(cacheKey)) { // Treffer ans Ende rücken, damit die Verdrängung wirklich den am längsten // ungenutzten Eintrag trifft und nicht bloß den ältesten eingefügten. const hit = cache.get(cacheKey); cache.delete(cacheKey); cache.set(cacheKey, hit); return hit; } const elapsed = Date.now() - lastRequestTime; if (elapsed < config.geocodeMinDelayMs) { await sleep(config.geocodeMinDelayMs - elapsed); } const url = `${config.geocodeUrl}?format=json&limit=1&countrycodes=de&q=${encodeURIComponent(normalized)}`; const headers = { 'User-Agent': config.geocodeUserAgent }; try { const results = await fetchJson(url, headers); lastRequestTime = Date.now(); if (!Array.isArray(results) || results.length === 0) { rememberInCache(cacheKey, null); return null; } const hit = results[0]; const lat = parseFloat(hit.lat); const lng = parseFloat(hit.lon); if (Number.isNaN(lat) || Number.isNaN(lng)) { rememberInCache(cacheKey, null); return null; } const coords = { lat, lng }; rememberInCache(cacheKey, coords); return coords; } catch (error) { logger.warn('Geocoding fehlgeschlagen', { address: normalized, error: error.message }); lastRequestTime = Date.now(); return null; } }; // Nur die Felder, die das Adressformular im Frontend tatsächlich auswertet. // Alles andere aus der Nominatim-Antwort wird verworfen. const ADDRESS_PARTS = [ 'road', 'house_number', 'postcode', 'city', 'town', 'village', 'municipality', 'hamlet', 'county', 'state' ]; const trimSuggestion = (hit) => { const address = {}; for (const key of ADDRESS_PARTS) { if (hit.address && hit.address[key]) address[key] = hit.address[key]; } return { lat: hit.lat, lon: hit.lon, display_name: hit.display_name, address }; }; /** * Freitext-Adresssuche mit mehreren Treffern — für die Autovervollständigung * im Benutzerformular. * * Läuft bewusst über den Server: vorher hat der Browser Nominatim direkt * angefragt, wodurch weder die Mindestwartezeit noch der vorgeschriebene * User-Agent griffen und die IP jedes Admins beim Dienst landete. * * Teilt sich Wartezeit und Cache mit geocodeAddress; der Cache-Schlüssel ist * mit einem Präfix versehen, damit die Einträge sich nicht überschneiden. */ const searchAddresses = async (query, limit = 5) => { const normalized = (query || '').trim(); if (normalized.length < 3) { return []; } const count = Math.min(Math.max(parseInt(limit, 10) || 5, 1), 10); const cacheKey = `search:${count}:${normalized.toLowerCase()}`; if (cache.has(cacheKey)) { const hit = cache.get(cacheKey); cache.delete(cacheKey); cache.set(cacheKey, hit); return hit || []; } const elapsed = Date.now() - lastRequestTime; if (elapsed < config.geocodeMinDelayMs) { await sleep(config.geocodeMinDelayMs - elapsed); } const url = `${config.geocodeUrl}?format=json&addressdetails=1&limit=${count}` + `&countrycodes=de&q=${encodeURIComponent(normalized)}`; try { const results = await fetchJson(url, { 'User-Agent': config.geocodeUserAgent }); lastRequestTime = Date.now(); const list = Array.isArray(results) ? results.map(trimSuggestion) : []; rememberInCache(cacheKey, list); return list; } catch (error) { logger.warn('Adresssuche fehlgeschlagen', { query: normalized, error: error.message }); lastRequestTime = Date.now(); return []; } }; module.exports = { geocodeAddress, searchAddresses };