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_USER_AGENT=drohnenfuehrer-app/1.0 (admin@localhost)
|
||||
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)',
|
||||
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
|
||||
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
|
||||
|
|
@ -46,11 +51,21 @@ if (config.nodeEnv === 'production') {
|
|||
}
|
||||
});
|
||||
|
||||
// Check for insecure defaults in production
|
||||
if (config.jwtSecret === 'your-secret-key-change-in-production') {
|
||||
console.error('❌ Fehler: JWT_SECRET verwendet unsicheren Default-Wert!');
|
||||
// Check for insecure defaults in production.
|
||||
// Nicht nur der eine Default-String: podman-compose.yml setzt z. B.
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -195,11 +195,13 @@ const resetPassword = async (req, res) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (newPassword.length < 6) {
|
||||
// Muss zu minlength im Admin-Schema passen. Vorher stand hier 6: Passwoerter
|
||||
// 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({
|
||||
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
|
||||
const inviteLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10, // Max 10 attempts per windowMs
|
||||
max: 10,
|
||||
skipSuccessfulRequests: true,
|
||||
message: {
|
||||
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 = {
|
||||
apiLimiter,
|
||||
authLimiter,
|
||||
inviteLimiter
|
||||
inviteLimiter,
|
||||
geocodeLimiter
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const router = express.Router();
|
|||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { auditLog } = require('../middleware/auditLogger');
|
||||
const { validateGPS, validateAvailability } = require('../middleware/validator');
|
||||
const { geocodeLimiter } = require('../middleware/rateLimiter');
|
||||
const {
|
||||
getAllUsers,
|
||||
getUserById,
|
||||
|
|
@ -25,7 +26,8 @@ const {
|
|||
|
||||
// Public routes
|
||||
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)
|
||||
router.get('/users', authenticateToken, getAllUsers);
|
||||
|
|
|
|||
|
|
@ -6,17 +6,41 @@ 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);
|
||||
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);
|
||||
});
|
||||
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
|
||||
|
|
@ -88,7 +112,12 @@ const geocodeAddress = async (address) => {
|
|||
|
||||
const cacheKey = normalized.toLowerCase();
|
||||
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;
|
||||
|
|
@ -106,8 +135,7 @@ const geocodeAddress = async (address) => {
|
|||
lastRequestTime = Date.now();
|
||||
|
||||
if (!Array.isArray(results) || results.length === 0) {
|
||||
cache.set(cacheKey, null);
|
||||
cacheModified = true;
|
||||
rememberInCache(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -116,15 +144,12 @@ const geocodeAddress = async (address) => {
|
|||
const lng = parseFloat(hit.lon);
|
||||
|
||||
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
||||
cache.set(cacheKey, null);
|
||||
cacheModified = true;
|
||||
rememberInCache(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const coords = { lat, lng };
|
||||
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); }
|
||||
cache.set(cacheKey, coords);
|
||||
cacheModified = true;
|
||||
rememberInCache(cacheKey, coords);
|
||||
return coords;
|
||||
} catch (error) {
|
||||
logger.warn('Geocoding fehlgeschlagen', {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ services:
|
|||
# - SMTP_USER=user@example.com
|
||||
# - SMTP_PASS=${SMTP_PASS}
|
||||
# - 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:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -19,3 +19,16 @@ CORS_ORIGIN=http://localhost:3000
|
|||
GEOCODE_URL=https://nominatim.openstreetmap.org/search
|
||||
GEOCODE_USER_AGENT=tracking-leaders-app/1.0 (admin@localhost)
|
||||
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),
|
||||
// E-Mail / SMTP (required for password-reset emails; optional otherwise)
|
||||
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
|
||||
|
|
@ -47,11 +52,21 @@ if (config.nodeEnv === 'production') {
|
|||
}
|
||||
});
|
||||
|
||||
// Check for insecure defaults in production
|
||||
if (config.jwtSecret === 'your-secret-key-change-in-production') {
|
||||
console.error('❌ Fehler: JWT_SECRET verwendet unsicheren Default-Wert!');
|
||||
// Check for insecure defaults in production.
|
||||
// Nicht nur der eine Default-String: podman-compose.yml setzt z. B.
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -202,11 +202,13 @@ const resetPassword = async (req, res) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (newPassword.length < 6) {
|
||||
// Muss zu minlength im Admin-Schema passen. Vorher stand hier 6: Passwörter
|
||||
// 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({
|
||||
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 = {
|
||||
apiLimiter,
|
||||
authLimiter,
|
||||
inviteLimiter
|
||||
inviteLimiter,
|
||||
geocodeLimiter
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const router = express.Router();
|
|||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { auditLog } = require('../middleware/auditLogger');
|
||||
const { validateGPS, validateAvailability } = require('../middleware/validator');
|
||||
const { geocodeLimiter } = require('../middleware/rateLimiter');
|
||||
const {
|
||||
getAllUsers,
|
||||
getUserById,
|
||||
|
|
@ -25,7 +26,8 @@ const {
|
|||
|
||||
// Public routes
|
||||
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)
|
||||
router.get('/users', authenticateToken, getAllUsers);
|
||||
|
|
|
|||
|
|
@ -6,17 +6,41 @@ 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);
|
||||
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);
|
||||
});
|
||||
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
|
||||
|
|
@ -88,7 +112,12 @@ const geocodeAddress = async (address) => {
|
|||
|
||||
const cacheKey = normalized.toLowerCase();
|
||||
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;
|
||||
|
|
@ -106,8 +135,7 @@ const geocodeAddress = async (address) => {
|
|||
lastRequestTime = Date.now();
|
||||
|
||||
if (!Array.isArray(results) || results.length === 0) {
|
||||
cache.set(cacheKey, null);
|
||||
cacheModified = true;
|
||||
rememberInCache(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -116,15 +144,12 @@ const geocodeAddress = async (address) => {
|
|||
const lng = parseFloat(hit.lon);
|
||||
|
||||
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
||||
cache.set(cacheKey, null);
|
||||
cacheModified = true;
|
||||
rememberInCache(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const coords = { lat, lng };
|
||||
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); }
|
||||
cache.set(cacheKey, coords);
|
||||
cacheModified = true;
|
||||
rememberInCache(cacheKey, coords);
|
||||
return coords;
|
||||
} catch (error) {
|
||||
logger.warn('Geocoding fehlgeschlagen', {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ services:
|
|||
# - SMTP_USER=user@example.com
|
||||
# - SMTP_PASS=${SMTP_PASS}
|
||||
# - 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:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -19,3 +19,16 @@ CORS_ORIGIN=http://localhost:3000
|
|||
GEOCODE_URL=https://nominatim.openstreetmap.org/search
|
||||
GEOCODE_USER_AGENT=stoeberhunde-app/1.0 (admin@localhost)
|
||||
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)',
|
||||
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
|
||||
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
|
||||
|
|
@ -46,11 +51,21 @@ if (config.nodeEnv === 'production') {
|
|||
}
|
||||
});
|
||||
|
||||
// Check for insecure defaults in production
|
||||
if (config.jwtSecret === 'your-secret-key-change-in-production') {
|
||||
console.error('❌ Fehler: JWT_SECRET verwendet unsicheren Default-Wert!');
|
||||
// Check for insecure defaults in production.
|
||||
// Nicht nur der eine Default-String: podman-compose.yml setzt z. B.
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -195,11 +195,13 @@ const resetPassword = async (req, res) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (newPassword.length < 6) {
|
||||
// Muss zu minlength im Admin-Schema passen. Vorher stand hier 6: Passwoerter
|
||||
// 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({
|
||||
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 = {
|
||||
apiLimiter,
|
||||
authLimiter,
|
||||
inviteLimiter
|
||||
inviteLimiter,
|
||||
geocodeLimiter
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const router = express.Router();
|
|||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { auditLog } = require('../middleware/auditLogger');
|
||||
const { validateGPS, validateAvailability } = require('../middleware/validator');
|
||||
const { geocodeLimiter } = require('../middleware/rateLimiter');
|
||||
const {
|
||||
getAllUsers,
|
||||
getUserById,
|
||||
|
|
@ -25,7 +26,8 @@ const {
|
|||
|
||||
// Public routes
|
||||
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)
|
||||
router.get('/users', authenticateToken, getAllUsers);
|
||||
|
|
|
|||
|
|
@ -6,17 +6,41 @@ 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);
|
||||
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);
|
||||
});
|
||||
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
|
||||
|
|
@ -88,7 +112,12 @@ const geocodeAddress = async (address) => {
|
|||
|
||||
const cacheKey = normalized.toLowerCase();
|
||||
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;
|
||||
|
|
@ -106,8 +135,7 @@ const geocodeAddress = async (address) => {
|
|||
lastRequestTime = Date.now();
|
||||
|
||||
if (!Array.isArray(results) || results.length === 0) {
|
||||
cache.set(cacheKey, null);
|
||||
cacheModified = true;
|
||||
rememberInCache(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -116,15 +144,12 @@ const geocodeAddress = async (address) => {
|
|||
const lng = parseFloat(hit.lon);
|
||||
|
||||
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
||||
cache.set(cacheKey, null);
|
||||
cacheModified = true;
|
||||
rememberInCache(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const coords = { lat, lng };
|
||||
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); }
|
||||
cache.set(cacheKey, coords);
|
||||
cacheModified = true;
|
||||
rememberInCache(cacheKey, coords);
|
||||
return coords;
|
||||
} catch (error) {
|
||||
logger.warn('Geocoding fehlgeschlagen', {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ services:
|
|||
# - SMTP_USER=user@example.com
|
||||
# - SMTP_PASS=${SMTP_PASS}
|
||||
# - 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:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
Loading…
Reference in New Issue