242 lines
7.4 KiB
JavaScript
242 lines
7.4 KiB
JavaScript
const https = require('https');
|
||
const fs = require('fs').promises;
|
||
const path = require('path');
|
||
const config = require('../config/env');
|
||
const logger = require('./logger');
|
||
|
||
// Pfad konfigurierbar: im Container zeigt GEOCODE_CACHE_FILE in ein
|
||
// Verzeichnis-Volume. Ein Named Volume ist immer ein Verzeichnis und kann
|
||
// nicht auf einen Dateipfad gemountet werden — vorher lief jeder Lese- und
|
||
// Schreibversuch in EISDIR, still verschluckt vom try/catch.
|
||
const CACHE_FILE = process.env.GEOCODE_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
|
||
};
|