fix(backend): Geocode-Cache begrenzen, Reset-Link reparieren
- Der Geocode-Cache war nur auf dem Erfolgspfad begrenzt. Die beiden Negativ-Pfade (cache.set(key, null)) schrieben ungebremst — über den öffentlichen GET /api/public/geocode genügten erfundene Postleitzahlen, um Speicher und die alle 60 s geschriebene Cache-Datei beliebig wachsen zu lassen. Bei NODE_OPTIONS=--max_old_space_size=256 eine reale Grenze. Alle Schreibpfade laufen jetzt über rememberInCache(); loadCache() beschneidet eine bereits gewachsene Datei auf das Limit. Geprüft: eine künstlich auf 5000 Einträge aufgeblähte Cache-Datei wird beim Start auf 1000 reduziert. - Der Cache war trotz Kommentar kein LRU, sondern ein FIFO: ein Treffer hat den Eintrag nicht ans Ende gerückt, häufig genutzte Adressen flogen zuerst raus. Treffer werden jetzt neu einsortiert. - /api/public/geocode bekommt einen eigenen Limiter (20/15 min). Der Endpunkt löst ausgehende Nominatim-Anfragen aus und teilt sich mit dem Geocoding im Admin-Bereich die globale Wartezeit von 1100 ms. Geprüft: Anfragen 21-24 liefern 429. - resetPassword prüfte 6 Zeichen, das Admin-Schema verlangt 12. Passwörter dazwischen kamen durch und scheiterten erst an der Mongoose-Validierung — beim Nutzer kam "Serverfehler" (500) an. Jetzt 400 mit klarer Meldung. - APP_URL wird aktiv gesetzt statt auskommentiert, und der Rückfallwert hängt den Unterpfad an. Vorher fiel appUrl auf CORS_ORIGIN zurück, und der Link in der Reset-Mail zeigte auf das Portal statt in die App. Geprüft: erzeugte URL lautet .../nachsuche/passwort-zuruecksetzen?token=… - Die beim Zusammenführen verlorengegangene Platzhalter-Prüfung für JWT_SECRET ist wieder da (fängt auch CHANGE_ME_IN_PRODUCTION). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
474383d17b
commit
868699a1bc
|
|
@ -19,3 +19,16 @@ CORS_ORIGIN=http://localhost:3000
|
||||||
GEOCODE_URL=https://nominatim.openstreetmap.org/search
|
GEOCODE_URL=https://nominatim.openstreetmap.org/search
|
||||||
GEOCODE_USER_AGENT=drohnenfuehrer-app/1.0 (admin@localhost)
|
GEOCODE_USER_AGENT=drohnenfuehrer-app/1.0 (admin@localhost)
|
||||||
GEOCODE_MIN_DELAY_MS=1100
|
GEOCODE_MIN_DELAY_MS=1100
|
||||||
|
|
||||||
|
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
|
||||||
|
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
|
||||||
|
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/drohnenfuehrer" zusammengesetzt.
|
||||||
|
APP_URL=http://localhost:8081/drohnenfuehrer
|
||||||
|
|
||||||
|
# SMTP fuer Passwort-Reset-Mails (optional).
|
||||||
|
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.
|
||||||
|
# SMTP_HOST=smtp.example.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=noreply@example.com
|
||||||
|
# SMTP_PASS=
|
||||||
|
# SMTP_FROM=noreply@example.com
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@ const config = {
|
||||||
geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'drohnenfuehrer-app/1.0 (admin@localhost)',
|
geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'drohnenfuehrer-app/1.0 (admin@localhost)',
|
||||||
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
|
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
|
||||||
smtpConfigured: !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS),
|
smtpConfigured: !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS),
|
||||||
appUrl: process.env.APP_URL || process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8081'
|
// Basis fuer Links in E-Mails (Passwort-Reset). MUSS den Unterpfad enthalten,
|
||||||
|
// unter dem die App ausgeliefert wird - der CORS_ORIGIN-Rueckfall kennt ihn
|
||||||
|
// nicht und erzeugt sonst Links, die auf dem Portal statt in der App landen.
|
||||||
|
appUrl: (process.env.APP_URL
|
||||||
|
|| `${(process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8081').replace(/\/+$/, '')}/${APP_NAME}`
|
||||||
|
).replace(/\/+$/, '')
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate required environment variables
|
// Validate required environment variables
|
||||||
|
|
@ -46,11 +51,21 @@ if (config.nodeEnv === 'production') {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for insecure defaults in production
|
// Check for insecure defaults in production.
|
||||||
if (config.jwtSecret === 'your-secret-key-change-in-production') {
|
// Nicht nur der eine Default-String: podman-compose.yml setzt z. B.
|
||||||
console.error('❌ Fehler: JWT_SECRET verwendet unsicheren Default-Wert!');
|
// CHANGE_ME_IN_PRODUCTION, was eine reine Gleichheitspruefung durchlaesst.
|
||||||
|
if (/change[-_ ]?me|change-in-production|your-secret|secret-key/i.test(config.jwtSecret)) {
|
||||||
|
console.error('❌ Fehler: JWT_SECRET verwendet einen Platzhalter-Wert!');
|
||||||
|
console.error(' Bitte ein zufaelliges Secret setzen, z. B. mit: openssl rand -hex 32');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ohne APP_URL wird der Link in der Passwort-Reset-Mail aus CORS_ORIGIN
|
||||||
|
// zusammengesetzt. Das funktioniert nur, solange der Unterpfad dem App-Namen
|
||||||
|
// entspricht - bei abweichendem Deployment fuehrt der Link ins Leere.
|
||||||
|
if (!process.env.APP_URL) {
|
||||||
|
console.warn(`⚠️ Warnung: APP_URL ist nicht gesetzt. Reset-Links verwenden "${config.appUrl}".`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
|
|
||||||
|
|
@ -195,11 +195,13 @@ const resetPassword = async (req, res) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate password length
|
// Muss zu minlength im Admin-Schema passen. Vorher stand hier 6: Passwoerter
|
||||||
if (newPassword.length < 6) {
|
// mit 6-11 Zeichen kamen durch und scheiterten erst an der Mongoose-
|
||||||
|
// Validierung, was als 500 "Serverfehler" beim Nutzer ankam.
|
||||||
|
if (newPassword.length < 12) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Passwort muss mindestens 6 Zeichen lang sein'
|
message: 'Passwort muss mindestens 12 Zeichen lang sein'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ const authLimiter = rateLimit({
|
||||||
// Strict rate limiter for invite / set-password endpoints
|
// Strict rate limiter for invite / set-password endpoints
|
||||||
const inviteLimiter = rateLimit({
|
const inviteLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
max: 10, // Max 10 attempts per windowMs
|
max: 10,
|
||||||
skipSuccessfulRequests: true,
|
skipSuccessfulRequests: true,
|
||||||
message: {
|
message: {
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -60,8 +60,32 @@ const inviteLimiter = rateLimit({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Limiter für die öffentliche PLZ-Suche.
|
||||||
|
// Der Endpunkt stößt ausgehende Anfragen an Nominatim an und teilt sich mit dem
|
||||||
|
// Geocoding im Admin-Bereich die globale Mindestwartezeit von geocodeMinDelayMs.
|
||||||
|
// Ohne eigenes Limit können anonyme Aufrufe das Anlegen von Führern ausbremsen
|
||||||
|
// und die Nominatim-Nutzungsregeln verletzen.
|
||||||
|
const geocodeLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 20,
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
message: 'Zu viele PLZ-Abfragen. Bitte warten Sie einen Moment.'
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
handler: (req, res) => {
|
||||||
|
logger.warn(`Geocode rate limit exceeded for IP: ${req.ip}`);
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Zu viele PLZ-Abfragen. Bitte warten Sie einen Moment.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apiLimiter,
|
apiLimiter,
|
||||||
authLimiter,
|
authLimiter,
|
||||||
inviteLimiter
|
inviteLimiter,
|
||||||
|
geocodeLimiter
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ const router = express.Router();
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const { auditLog } = require('../middleware/auditLogger');
|
const { auditLog } = require('../middleware/auditLogger');
|
||||||
const { validateGPS, validateAvailability } = require('../middleware/validator');
|
const { validateGPS, validateAvailability } = require('../middleware/validator');
|
||||||
|
const { geocodeLimiter } = require('../middleware/rateLimiter');
|
||||||
const {
|
const {
|
||||||
getAllUsers,
|
getAllUsers,
|
||||||
getUserById,
|
getUserById,
|
||||||
|
|
@ -25,7 +26,8 @@ const {
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
router.get('/public/users', getPublicUsers);
|
router.get('/public/users', getPublicUsers);
|
||||||
router.get('/public/geocode', getGeocodeByPostalCode);
|
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
|
||||||
|
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
|
||||||
|
|
||||||
// Protected routes (require authentication)
|
// Protected routes (require authentication)
|
||||||
router.get('/users', authenticateToken, getAllUsers);
|
router.get('/users', authenticateToken, getAllUsers);
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,41 @@ const logger = require('./logger');
|
||||||
|
|
||||||
const CACHE_FILE = path.join(__dirname, '..', 'geocode-cache.json');
|
const CACHE_FILE = path.join(__dirname, '..', 'geocode-cache.json');
|
||||||
const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds
|
const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds
|
||||||
|
const CACHE_MAX_ENTRIES = 1000;
|
||||||
|
|
||||||
const cache = new Map();
|
const cache = new Map();
|
||||||
let lastRequestTime = 0;
|
let lastRequestTime = 0;
|
||||||
let cacheModified = false;
|
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
|
// Load cache from file on startup
|
||||||
const loadCache = async () => {
|
const loadCache = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await fs.readFile(CACHE_FILE, 'utf8');
|
const data = await fs.readFile(CACHE_FILE, 'utf8');
|
||||||
const parsed = JSON.parse(data);
|
const parsed = JSON.parse(data);
|
||||||
Object.entries(parsed).forEach(([key, value]) => {
|
// 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);
|
cache.set(key, value);
|
||||||
});
|
});
|
||||||
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
|
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
|
||||||
|
|
@ -88,7 +112,12 @@ const geocodeAddress = async (address) => {
|
||||||
|
|
||||||
const cacheKey = normalized.toLowerCase();
|
const cacheKey = normalized.toLowerCase();
|
||||||
if (cache.has(cacheKey)) {
|
if (cache.has(cacheKey)) {
|
||||||
return cache.get(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;
|
const elapsed = Date.now() - lastRequestTime;
|
||||||
|
|
@ -106,8 +135,7 @@ const geocodeAddress = async (address) => {
|
||||||
lastRequestTime = Date.now();
|
lastRequestTime = Date.now();
|
||||||
|
|
||||||
if (!Array.isArray(results) || results.length === 0) {
|
if (!Array.isArray(results) || results.length === 0) {
|
||||||
cache.set(cacheKey, null);
|
rememberInCache(cacheKey, null);
|
||||||
cacheModified = true;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,15 +144,12 @@ const geocodeAddress = async (address) => {
|
||||||
const lng = parseFloat(hit.lon);
|
const lng = parseFloat(hit.lon);
|
||||||
|
|
||||||
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
||||||
cache.set(cacheKey, null);
|
rememberInCache(cacheKey, null);
|
||||||
cacheModified = true;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const coords = { lat, lng };
|
const coords = { lat, lng };
|
||||||
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); }
|
rememberInCache(cacheKey, coords);
|
||||||
cache.set(cacheKey, coords);
|
|
||||||
cacheModified = true;
|
|
||||||
return coords;
|
return coords;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Geocoding fehlgeschlagen', {
|
logger.warn('Geocoding fehlgeschlagen', {
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,8 @@ services:
|
||||||
# - SMTP_USER=user@example.com
|
# - SMTP_USER=user@example.com
|
||||||
# - SMTP_PASS=${SMTP_PASS}
|
# - SMTP_PASS=${SMTP_PASS}
|
||||||
# - SMTP_FROM=drohnenfuehrer@example.com
|
# - SMTP_FROM=drohnenfuehrer@example.com
|
||||||
# - APP_URL=https://example.com/drohnenfuehrer
|
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
|
||||||
|
- APP_URL=${APP_URL:-http://localhost:8081/drohnenfuehrer}
|
||||||
depends_on:
|
depends_on:
|
||||||
mongo:
|
mongo:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -19,3 +19,16 @@ CORS_ORIGIN=http://localhost:3000
|
||||||
GEOCODE_URL=https://nominatim.openstreetmap.org/search
|
GEOCODE_URL=https://nominatim.openstreetmap.org/search
|
||||||
GEOCODE_USER_AGENT=tracking-leaders-app/1.0 (admin@localhost)
|
GEOCODE_USER_AGENT=tracking-leaders-app/1.0 (admin@localhost)
|
||||||
GEOCODE_MIN_DELAY_MS=1100
|
GEOCODE_MIN_DELAY_MS=1100
|
||||||
|
|
||||||
|
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
|
||||||
|
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
|
||||||
|
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/nachsuche" zusammengesetzt.
|
||||||
|
APP_URL=http://localhost:8080/nachsuche
|
||||||
|
|
||||||
|
# SMTP fuer Passwort-Reset-Mails (optional).
|
||||||
|
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.
|
||||||
|
# SMTP_HOST=smtp.example.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=noreply@example.com
|
||||||
|
# SMTP_PASS=
|
||||||
|
# SMTP_FROM=noreply@example.com
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,12 @@ const config = {
|
||||||
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
|
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
|
||||||
// E-Mail / SMTP (required for password-reset emails; optional otherwise)
|
// E-Mail / SMTP (required for password-reset emails; optional otherwise)
|
||||||
smtpConfigured: !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS),
|
smtpConfigured: !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS),
|
||||||
appUrl: process.env.APP_URL || process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8080'
|
// Basis für Links in E-Mails (Passwort-Reset). MUSS den Unterpfad enthalten,
|
||||||
|
// unter dem die App ausgeliefert wird – der CORS_ORIGIN-Rückfall kennt ihn
|
||||||
|
// nicht und erzeugt sonst Links, die auf dem Portal statt in der App landen.
|
||||||
|
appUrl: (process.env.APP_URL
|
||||||
|
|| `${(process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8080').replace(/\/+$/, '')}/${APP_NAME}`
|
||||||
|
).replace(/\/+$/, '')
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate required environment variables
|
// Validate required environment variables
|
||||||
|
|
@ -47,11 +52,21 @@ if (config.nodeEnv === 'production') {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for insecure defaults in production
|
// Check for insecure defaults in production.
|
||||||
if (config.jwtSecret === 'your-secret-key-change-in-production') {
|
// Nicht nur der eine Default-String: podman-compose.yml setzt z. B.
|
||||||
console.error('❌ Fehler: JWT_SECRET verwendet unsicheren Default-Wert!');
|
// CHANGE_ME_IN_PRODUCTION, was eine reine Gleichheitsprüfung durchlässt.
|
||||||
|
if (/change[-_ ]?me|change-in-production|your-secret|changeme|secret-key/i.test(config.jwtSecret)) {
|
||||||
|
console.error('❌ Fehler: JWT_SECRET verwendet einen Platzhalter-Wert!');
|
||||||
|
console.error(' Bitte ein zufälliges Secret setzen, z. B. mit: openssl rand -hex 32');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ohne APP_URL wird der Link in der Passwort-Reset-Mail aus CORS_ORIGIN
|
||||||
|
// zusammengesetzt. Das funktioniert nur, solange der Unterpfad dem App-Namen
|
||||||
|
// entspricht – bei abweichendem Deployment führt der Link ins Leere.
|
||||||
|
if (!process.env.APP_URL) {
|
||||||
|
console.warn(`⚠️ Warnung: APP_URL ist nicht gesetzt. Reset-Links verwenden "${config.appUrl}".`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
|
|
||||||
|
|
@ -202,11 +202,13 @@ const resetPassword = async (req, res) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate password length
|
// Muss zu minlength im Admin-Schema passen. Vorher stand hier 6: Passwörter
|
||||||
if (newPassword.length < 6) {
|
// mit 6–11 Zeichen kamen durch und scheiterten erst an der Mongoose-
|
||||||
|
// Validierung, was als 500 "Serverfehler" beim Nutzer ankam.
|
||||||
|
if (newPassword.length < 12) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Passwort muss mindestens 6 Zeichen lang sein'
|
message: 'Passwort muss mindestens 12 Zeichen lang sein'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,32 @@ const inviteLimiter = rateLimit({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Limiter für die öffentliche PLZ-Suche.
|
||||||
|
// Der Endpunkt stößt ausgehende Anfragen an Nominatim an und teilt sich mit dem
|
||||||
|
// Geocoding im Admin-Bereich die globale Mindestwartezeit von geocodeMinDelayMs.
|
||||||
|
// Ohne eigenes Limit können anonyme Aufrufe das Anlegen von Führern ausbremsen
|
||||||
|
// und die Nominatim-Nutzungsregeln verletzen.
|
||||||
|
const geocodeLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 20,
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
message: 'Zu viele PLZ-Abfragen. Bitte warten Sie einen Moment.'
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
handler: (req, res) => {
|
||||||
|
logger.warn(`Geocode rate limit exceeded for IP: ${req.ip}`);
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Zu viele PLZ-Abfragen. Bitte warten Sie einen Moment.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apiLimiter,
|
apiLimiter,
|
||||||
authLimiter,
|
authLimiter,
|
||||||
inviteLimiter
|
inviteLimiter,
|
||||||
|
geocodeLimiter
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ const router = express.Router();
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const { auditLog } = require('../middleware/auditLogger');
|
const { auditLog } = require('../middleware/auditLogger');
|
||||||
const { validateGPS, validateAvailability } = require('../middleware/validator');
|
const { validateGPS, validateAvailability } = require('../middleware/validator');
|
||||||
|
const { geocodeLimiter } = require('../middleware/rateLimiter');
|
||||||
const {
|
const {
|
||||||
getAllUsers,
|
getAllUsers,
|
||||||
getUserById,
|
getUserById,
|
||||||
|
|
@ -25,7 +26,8 @@ const {
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
router.get('/public/users', getPublicUsers);
|
router.get('/public/users', getPublicUsers);
|
||||||
router.get('/public/geocode', getGeocodeByPostalCode);
|
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
|
||||||
|
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
|
||||||
|
|
||||||
// Protected routes (require authentication)
|
// Protected routes (require authentication)
|
||||||
router.get('/users', authenticateToken, getAllUsers);
|
router.get('/users', authenticateToken, getAllUsers);
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,41 @@ const logger = require('./logger');
|
||||||
|
|
||||||
const CACHE_FILE = path.join(__dirname, '..', 'geocode-cache.json');
|
const CACHE_FILE = path.join(__dirname, '..', 'geocode-cache.json');
|
||||||
const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds
|
const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds
|
||||||
|
const CACHE_MAX_ENTRIES = 1000;
|
||||||
|
|
||||||
const cache = new Map();
|
const cache = new Map();
|
||||||
let lastRequestTime = 0;
|
let lastRequestTime = 0;
|
||||||
let cacheModified = false;
|
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
|
// Load cache from file on startup
|
||||||
const loadCache = async () => {
|
const loadCache = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await fs.readFile(CACHE_FILE, 'utf8');
|
const data = await fs.readFile(CACHE_FILE, 'utf8');
|
||||||
const parsed = JSON.parse(data);
|
const parsed = JSON.parse(data);
|
||||||
Object.entries(parsed).forEach(([key, value]) => {
|
// 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);
|
cache.set(key, value);
|
||||||
});
|
});
|
||||||
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
|
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
|
||||||
|
|
@ -88,7 +112,12 @@ const geocodeAddress = async (address) => {
|
||||||
|
|
||||||
const cacheKey = normalized.toLowerCase();
|
const cacheKey = normalized.toLowerCase();
|
||||||
if (cache.has(cacheKey)) {
|
if (cache.has(cacheKey)) {
|
||||||
return cache.get(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;
|
const elapsed = Date.now() - lastRequestTime;
|
||||||
|
|
@ -106,8 +135,7 @@ const geocodeAddress = async (address) => {
|
||||||
lastRequestTime = Date.now();
|
lastRequestTime = Date.now();
|
||||||
|
|
||||||
if (!Array.isArray(results) || results.length === 0) {
|
if (!Array.isArray(results) || results.length === 0) {
|
||||||
cache.set(cacheKey, null);
|
rememberInCache(cacheKey, null);
|
||||||
cacheModified = true;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,15 +144,12 @@ const geocodeAddress = async (address) => {
|
||||||
const lng = parseFloat(hit.lon);
|
const lng = parseFloat(hit.lon);
|
||||||
|
|
||||||
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
||||||
cache.set(cacheKey, null);
|
rememberInCache(cacheKey, null);
|
||||||
cacheModified = true;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const coords = { lat, lng };
|
const coords = { lat, lng };
|
||||||
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); }
|
rememberInCache(cacheKey, coords);
|
||||||
cache.set(cacheKey, coords);
|
|
||||||
cacheModified = true;
|
|
||||||
return coords;
|
return coords;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Geocoding fehlgeschlagen', {
|
logger.warn('Geocoding fehlgeschlagen', {
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,8 @@ services:
|
||||||
# - SMTP_USER=user@example.com
|
# - SMTP_USER=user@example.com
|
||||||
# - SMTP_PASS=${SMTP_PASS}
|
# - SMTP_PASS=${SMTP_PASS}
|
||||||
# - SMTP_FROM=nachsuche@example.com
|
# - SMTP_FROM=nachsuche@example.com
|
||||||
# - APP_URL=https://example.com/nachsuche
|
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
|
||||||
|
- APP_URL=${APP_URL:-http://localhost:8080/nachsuche}
|
||||||
depends_on:
|
depends_on:
|
||||||
mongo:
|
mongo:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -19,3 +19,16 @@ CORS_ORIGIN=http://localhost:3000
|
||||||
GEOCODE_URL=https://nominatim.openstreetmap.org/search
|
GEOCODE_URL=https://nominatim.openstreetmap.org/search
|
||||||
GEOCODE_USER_AGENT=stoeberhunde-app/1.0 (admin@localhost)
|
GEOCODE_USER_AGENT=stoeberhunde-app/1.0 (admin@localhost)
|
||||||
GEOCODE_MIN_DELAY_MS=1100
|
GEOCODE_MIN_DELAY_MS=1100
|
||||||
|
|
||||||
|
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
|
||||||
|
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
|
||||||
|
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/stoeberhunde" zusammengesetzt.
|
||||||
|
APP_URL=http://localhost:8082/stoeberhunde
|
||||||
|
|
||||||
|
# SMTP fuer Passwort-Reset-Mails (optional).
|
||||||
|
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.
|
||||||
|
# SMTP_HOST=smtp.example.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=noreply@example.com
|
||||||
|
# SMTP_PASS=
|
||||||
|
# SMTP_FROM=noreply@example.com
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@ const config = {
|
||||||
geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'stoeberhunde-app/1.0 (admin@localhost)',
|
geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'stoeberhunde-app/1.0 (admin@localhost)',
|
||||||
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
|
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
|
||||||
smtpConfigured: !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS),
|
smtpConfigured: !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS),
|
||||||
appUrl: process.env.APP_URL || process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8082'
|
// Basis fuer Links in E-Mails (Passwort-Reset). MUSS den Unterpfad enthalten,
|
||||||
|
// unter dem die App ausgeliefert wird - der CORS_ORIGIN-Rueckfall kennt ihn
|
||||||
|
// nicht und erzeugt sonst Links, die auf dem Portal statt in der App landen.
|
||||||
|
appUrl: (process.env.APP_URL
|
||||||
|
|| `${(process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8082').replace(/\/+$/, '')}/${APP_NAME}`
|
||||||
|
).replace(/\/+$/, '')
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate required environment variables
|
// Validate required environment variables
|
||||||
|
|
@ -46,11 +51,21 @@ if (config.nodeEnv === 'production') {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for insecure defaults in production
|
// Check for insecure defaults in production.
|
||||||
if (config.jwtSecret === 'your-secret-key-change-in-production') {
|
// Nicht nur der eine Default-String: podman-compose.yml setzt z. B.
|
||||||
console.error('❌ Fehler: JWT_SECRET verwendet unsicheren Default-Wert!');
|
// CHANGE_ME_IN_PRODUCTION, was eine reine Gleichheitspruefung durchlaesst.
|
||||||
|
if (/change[-_ ]?me|change-in-production|your-secret|secret-key/i.test(config.jwtSecret)) {
|
||||||
|
console.error('❌ Fehler: JWT_SECRET verwendet einen Platzhalter-Wert!');
|
||||||
|
console.error(' Bitte ein zufaelliges Secret setzen, z. B. mit: openssl rand -hex 32');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ohne APP_URL wird der Link in der Passwort-Reset-Mail aus CORS_ORIGIN
|
||||||
|
// zusammengesetzt. Das funktioniert nur, solange der Unterpfad dem App-Namen
|
||||||
|
// entspricht - bei abweichendem Deployment fuehrt der Link ins Leere.
|
||||||
|
if (!process.env.APP_URL) {
|
||||||
|
console.warn(`⚠️ Warnung: APP_URL ist nicht gesetzt. Reset-Links verwenden "${config.appUrl}".`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
|
|
||||||
|
|
@ -195,11 +195,13 @@ const resetPassword = async (req, res) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate password length
|
// Muss zu minlength im Admin-Schema passen. Vorher stand hier 6: Passwoerter
|
||||||
if (newPassword.length < 6) {
|
// mit 6-11 Zeichen kamen durch und scheiterten erst an der Mongoose-
|
||||||
|
// Validierung, was als 500 "Serverfehler" beim Nutzer ankam.
|
||||||
|
if (newPassword.length < 12) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Passwort muss mindestens 6 Zeichen lang sein'
|
message: 'Passwort muss mindestens 12 Zeichen lang sein'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,32 @@ const inviteLimiter = rateLimit({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Limiter für die öffentliche PLZ-Suche.
|
||||||
|
// Der Endpunkt stößt ausgehende Anfragen an Nominatim an und teilt sich mit dem
|
||||||
|
// Geocoding im Admin-Bereich die globale Mindestwartezeit von geocodeMinDelayMs.
|
||||||
|
// Ohne eigenes Limit können anonyme Aufrufe das Anlegen von Führern ausbremsen
|
||||||
|
// und die Nominatim-Nutzungsregeln verletzen.
|
||||||
|
const geocodeLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 20,
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
message: 'Zu viele PLZ-Abfragen. Bitte warten Sie einen Moment.'
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
handler: (req, res) => {
|
||||||
|
logger.warn(`Geocode rate limit exceeded for IP: ${req.ip}`);
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Zu viele PLZ-Abfragen. Bitte warten Sie einen Moment.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apiLimiter,
|
apiLimiter,
|
||||||
authLimiter,
|
authLimiter,
|
||||||
inviteLimiter
|
inviteLimiter,
|
||||||
|
geocodeLimiter
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ const router = express.Router();
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const { auditLog } = require('../middleware/auditLogger');
|
const { auditLog } = require('../middleware/auditLogger');
|
||||||
const { validateGPS, validateAvailability } = require('../middleware/validator');
|
const { validateGPS, validateAvailability } = require('../middleware/validator');
|
||||||
|
const { geocodeLimiter } = require('../middleware/rateLimiter');
|
||||||
const {
|
const {
|
||||||
getAllUsers,
|
getAllUsers,
|
||||||
getUserById,
|
getUserById,
|
||||||
|
|
@ -25,7 +26,8 @@ const {
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
router.get('/public/users', getPublicUsers);
|
router.get('/public/users', getPublicUsers);
|
||||||
router.get('/public/geocode', getGeocodeByPostalCode);
|
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
|
||||||
|
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
|
||||||
|
|
||||||
// Protected routes (require authentication)
|
// Protected routes (require authentication)
|
||||||
router.get('/users', authenticateToken, getAllUsers);
|
router.get('/users', authenticateToken, getAllUsers);
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,41 @@ const logger = require('./logger');
|
||||||
|
|
||||||
const CACHE_FILE = path.join(__dirname, '..', 'geocode-cache.json');
|
const CACHE_FILE = path.join(__dirname, '..', 'geocode-cache.json');
|
||||||
const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds
|
const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds
|
||||||
|
const CACHE_MAX_ENTRIES = 1000;
|
||||||
|
|
||||||
const cache = new Map();
|
const cache = new Map();
|
||||||
let lastRequestTime = 0;
|
let lastRequestTime = 0;
|
||||||
let cacheModified = false;
|
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
|
// Load cache from file on startup
|
||||||
const loadCache = async () => {
|
const loadCache = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await fs.readFile(CACHE_FILE, 'utf8');
|
const data = await fs.readFile(CACHE_FILE, 'utf8');
|
||||||
const parsed = JSON.parse(data);
|
const parsed = JSON.parse(data);
|
||||||
Object.entries(parsed).forEach(([key, value]) => {
|
// 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);
|
cache.set(key, value);
|
||||||
});
|
});
|
||||||
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
|
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
|
||||||
|
|
@ -88,7 +112,12 @@ const geocodeAddress = async (address) => {
|
||||||
|
|
||||||
const cacheKey = normalized.toLowerCase();
|
const cacheKey = normalized.toLowerCase();
|
||||||
if (cache.has(cacheKey)) {
|
if (cache.has(cacheKey)) {
|
||||||
return cache.get(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;
|
const elapsed = Date.now() - lastRequestTime;
|
||||||
|
|
@ -106,8 +135,7 @@ const geocodeAddress = async (address) => {
|
||||||
lastRequestTime = Date.now();
|
lastRequestTime = Date.now();
|
||||||
|
|
||||||
if (!Array.isArray(results) || results.length === 0) {
|
if (!Array.isArray(results) || results.length === 0) {
|
||||||
cache.set(cacheKey, null);
|
rememberInCache(cacheKey, null);
|
||||||
cacheModified = true;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,15 +144,12 @@ const geocodeAddress = async (address) => {
|
||||||
const lng = parseFloat(hit.lon);
|
const lng = parseFloat(hit.lon);
|
||||||
|
|
||||||
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
||||||
cache.set(cacheKey, null);
|
rememberInCache(cacheKey, null);
|
||||||
cacheModified = true;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const coords = { lat, lng };
|
const coords = { lat, lng };
|
||||||
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); }
|
rememberInCache(cacheKey, coords);
|
||||||
cache.set(cacheKey, coords);
|
|
||||||
cacheModified = true;
|
|
||||||
return coords;
|
return coords;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Geocoding fehlgeschlagen', {
|
logger.warn('Geocoding fehlgeschlagen', {
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,8 @@ services:
|
||||||
# - SMTP_USER=user@example.com
|
# - SMTP_USER=user@example.com
|
||||||
# - SMTP_PASS=${SMTP_PASS}
|
# - SMTP_PASS=${SMTP_PASS}
|
||||||
# - SMTP_FROM=stoeberhunde@example.com
|
# - SMTP_FROM=stoeberhunde@example.com
|
||||||
# - APP_URL=https://example.com/stoeberhunde
|
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
|
||||||
|
- APP_URL=${APP_URL:-http://localhost:8082/stoeberhunde}
|
||||||
depends_on:
|
depends_on:
|
||||||
mongo:
|
mongo:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue