From 87011eee3c13ead9d34c4fb4a4b7c90ee9a993fc Mon Sep 17 00:00:00 2001 From: thomas Date: Sun, 30 Aug 2026 20:47:26 +0200 Subject: [PATCH] fix(pwa): Offline-Seite ausliefern, Fremdabhaengigkeiten der Karte entfernen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Der Service-Worker-Cache wurde nie invalidiert: die letzte Runde hat offline.html in allen vier Zielen ersetzt, sw.js aber nicht angefasst. Ein Browser installiert einen Service Worker nur neu, wenn sich sw.js selbst aendert — bestehende Installationen haetten die alte, englische, ungestaltete Offline-Seite dauerhaft behalten. CACHE_NAME hochgezaehlt. Dabei faengt der Install jetzt einen fehlgeschlagenen Abruf ab; vorher wurde bei einem Fehler der ganze Service Worker nicht aktiv. - Die Leaflet-Marker kamen von cdnjs.cloudflare.com — in einer PWA, die im Wald ohne Empfang benutzt wird, waren sie damit kaputt, und die IP jedes Besuchers ging an ein fremdes CDN. Verwiesen wurde ausserdem auf Leaflet 1.7.1 bei installiertem 1.9.4. Die Bilder liegen im Paket und werden jetzt gebuendelt; Vite bettet sie als data:-URI ein (zusammen ~4 KB). - Die Adress-Autovervollstaendigung im Benutzerformular rief Nominatim weiterhin direkt aus dem Browser auf, obwohl d9fecb6 dafuer einen Proxy eingefuehrt hat — nur die PLZ-Suche nutzte ihn. Neu: searchAddresses() in utils/geocode.js (teilt Wartezeit und Cache mit geocodeAddress) plus GET /api/public/geocode/search unter demselben geocodeLimiter. Die Antwort ist auf die Felder beschraenkt, die das Formular liest. Der Debounce steigt von 400 auf 600 ms, weil die Anfragen jetzt durch die serverseitige Mindestwartezeit laufen. Geprueft: im Bundle kein cdnjs und kein nominatim mehr; der neue Endpunkt liefert Treffer mit den erwarteten Feldern, zu kurze Begriffe 400. Co-Authored-By: Claude Opus 5 --- .../backend/controllers/userController.js | 31 +++++++- drohnenfuehrer/backend/routes/userRoutes.js | 4 +- drohnenfuehrer/frontend/public/sw.js | 6 +- .../frontend/src/components/map/MapView.js | 14 +++- .../frontend/src/components/users/UserForm.js | 14 ++-- drohnenfuehrer/frontend/src/services/users.js | 16 ++++ .../backend/controllers/userController.js | 31 +++++++- nachsuche/backend/routes/userRoutes.js | 4 +- nachsuche/backend/utils/geocode.js | 74 ++++++++++++++++++- nachsuche/frontend/public/sw.js | 6 +- .../frontend/src/components/map/MapView.js | 14 +++- .../frontend/src/components/users/UserForm.js | 14 ++-- nachsuche/frontend/src/services/users.js | 16 ++++ portal/sw.js | 6 +- .../backend/controllers/userController.js | 31 +++++++- stoeberhunde/backend/routes/userRoutes.js | 4 +- stoeberhunde/frontend/public/sw.js | 6 +- .../frontend/src/components/map/MapView.js | 14 +++- .../frontend/src/components/users/UserForm.js | 14 ++-- stoeberhunde/frontend/src/services/users.js | 16 ++++ 20 files changed, 280 insertions(+), 55 deletions(-) diff --git a/drohnenfuehrer/backend/controllers/userController.js b/drohnenfuehrer/backend/controllers/userController.js index 42f07c7..9f3679e 100644 --- a/drohnenfuehrer/backend/controllers/userController.js +++ b/drohnenfuehrer/backend/controllers/userController.js @@ -1,5 +1,5 @@ const User = require('../models/User'); -const { geocodeAddress } = require('../utils/geocode'); +const { geocodeAddress, searchAddresses } = require('../utils/geocode'); const logger = require('../utils/logger'); const { escapeCell } = require('../utils/csv'); const config = require('../config/env'); @@ -729,6 +729,32 @@ const getGeocodeByPostalCode = async (req, res) => { } }; +/** + * GET /api/public/geocode/search?q=... + * Freitext-Adresssuche fuer die Autovervollstaendigung im Benutzerformular. + * Laeuft ueber den Server, damit Mindestwartezeit, Cache und der von Nominatim + * geforderte User-Agent greifen und keine Nutzer-IP beim Dienst landet. + */ +const searchAddressSuggestions = async (req, res) => { + const { q } = req.query; + const query = String(q || '').trim(); + + if (query.length < 3) { + return res.status(400).json({ success: false, message: 'Suchbegriff zu kurz (min. 3 Zeichen)' }); + } + if (query.length > 200) { + return res.status(400).json({ success: false, message: 'Suchbegriff zu lang' }); + } + + try { + const results = await searchAddresses(query, req.query.limit); + res.json({ success: true, data: results }); + } catch (error) { + logger.error('Fehler bei der Adresssuche:', error); + res.status(500).json({ success: false, message: 'Fehler bei der Adresssuche' }); + } +}; + module.exports = { getAllUsers, getUserById, @@ -746,5 +772,6 @@ module.exports = { bulkDeleteUsers, uploadUserPhoto, deleteUserPhoto, - getGeocodeByPostalCode + getGeocodeByPostalCode, + searchAddressSuggestions }; diff --git a/drohnenfuehrer/backend/routes/userRoutes.js b/drohnenfuehrer/backend/routes/userRoutes.js index 0697be1..fa2cfeb 100644 --- a/drohnenfuehrer/backend/routes/userRoutes.js +++ b/drohnenfuehrer/backend/routes/userRoutes.js @@ -21,13 +21,15 @@ const { bulkDeleteUsers, uploadUserPhoto, deleteUserPhoto, - getGeocodeByPostalCode + getGeocodeByPostalCode, + searchAddressSuggestions } = require('../controllers/userController'); // Public routes router.get('/public/users', getPublicUsers); // Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus. router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode); +router.get('/public/geocode/search', geocodeLimiter, searchAddressSuggestions); // Protected routes (require authentication) router.get('/users', authenticateToken, getAllUsers); diff --git a/drohnenfuehrer/frontend/public/sw.js b/drohnenfuehrer/frontend/public/sw.js index f267364..2a664d9 100644 --- a/drohnenfuehrer/frontend/public/sw.js +++ b/drohnenfuehrer/frontend/public/sw.js @@ -1,11 +1,13 @@ // Service Worker: nur Offline-Fallback, keine Asset-Caches // Vite erzeugt content-addressierte Hashes, kein manuelles Caching nötig -const CACHE_NAME = 'drohnenfuehrer-offline-v1'; +const CACHE_NAME = 'drohnenfuehrer-offline-v2'; self.addEventListener('install', event => { event.waitUntil( - caches.open(CACHE_NAME).then(cache => cache.add('offline.html')) + caches.open(CACHE_NAME) + .then(cache => cache.add('offline.html')) + .catch(err => console.warn('Offline-Seite konnte nicht gecacht werden:', err)) ); self.skipWaiting(); }); diff --git a/drohnenfuehrer/frontend/src/components/map/MapView.js b/drohnenfuehrer/frontend/src/components/map/MapView.js index 4a18216..3f4a3e0 100644 --- a/drohnenfuehrer/frontend/src/components/map/MapView.js +++ b/drohnenfuehrer/frontend/src/components/map/MapView.js @@ -2,14 +2,20 @@ import React, { useEffect } from 'react'; import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; +import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; +import markerIcon from 'leaflet/dist/images/marker-icon.png'; +import markerShadow from 'leaflet/dist/images/marker-shadow.png'; import './MapView.css'; -// Fix für Standard-Marker-Icons in Leaflet +// Standard-Marker-Icons aus dem installierten Leaflet-Paket buendeln. +// Vorher kamen sie von cdnjs — in einer Offline-PWA fuer den Wald waren die +// Marker damit ohne Empfang kaputt, und die IP jedes Besuchers ging an ein +// fremdes CDN. Die Dateien liegen ohnehin in leaflet/dist/images. delete L.Icon.Default.prototype._getIconUrl; L.Icon.Default.mergeOptions({ - iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png', - iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png', - shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png', + iconRetinaUrl: markerIcon2x, + iconUrl: markerIcon, + shadowUrl: markerShadow, }); // Komponente zum Aktualisieren der Kartenansicht diff --git a/drohnenfuehrer/frontend/src/components/users/UserForm.js b/drohnenfuehrer/frontend/src/components/users/UserForm.js index 9afb559..040660e 100644 --- a/drohnenfuehrer/frontend/src/components/users/UserForm.js +++ b/drohnenfuehrer/frontend/src/components/users/UserForm.js @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { useConfigContext } from '../../contexts/ConfigContext'; +import { searchAddresses } from '../../services/users'; import './UserForm.css'; const formatSuggestionAddress = (addr) => { @@ -125,17 +126,13 @@ const UserForm = ({ user, onSave, onCancel }) => { searchTimer.current = setTimeout(async () => { try { - const res = await fetch( - `https://nominatim.openstreetmap.org/search?format=json&countrycodes=de&addressdetails=1&limit=5&q=${encodeURIComponent(value)}`, - { headers: { 'User-Agent': 'drohnenfuehrer-app/1.0 (admin@kasimirat.de)' } } - ); - const data = await res.json(); - setSuggestions(Array.isArray(data) ? data : []); + const result = await searchAddresses(value); + setSuggestions(result.data); setShowSuggestions(true); } catch { setSuggestions([]); } - }, 400); + }, 600); }; const selectSuggestion = (s) => { @@ -233,7 +230,8 @@ const UserForm = ({ user, onSave, onCancel }) => {
- + {/* Gruppenueberschrift: gehoert nicht zu einem einzelnen Feld */} + GPS-Koordinaten {gpsAutoSet ? (

✓ GPS automatisch aus Adresse übernommen

) : ( diff --git a/drohnenfuehrer/frontend/src/services/users.js b/drohnenfuehrer/frontend/src/services/users.js index fc5d22a..bdecf68 100644 --- a/drohnenfuehrer/frontend/src/services/users.js +++ b/drohnenfuehrer/frontend/src/services/users.js @@ -214,6 +214,22 @@ export const uploadUserPhoto = async (id, photoDataUrl) => { } }; +// Freitext-Adresssuche ueber das eigene Backend statt direkt bei Nominatim: +// dort greifen Mindestwartezeit, Cache und der vorgeschriebene User-Agent, +// und es wandert keine Nutzer-IP zu einem fremden Dienst. +export const searchAddresses = async (query) => { + try { + const response = await api.get('/public/geocode/search', { params: { q: query } }); + return { success: true, data: response.data.data || [] }; + } catch (error) { + return { + success: false, + data: [], + message: error.response?.data?.message || 'Fehler bei der Adresssuche' + }; + } +}; + // Einmal-Token, mit dem ein Fuehrer sein erstes Passwort setzt. // Laeuft ueber die Admin-Session (Cookie), nicht ueber den Fuehrer-Token. export const generateInviteToken = async (id) => { diff --git a/nachsuche/backend/controllers/userController.js b/nachsuche/backend/controllers/userController.js index 8d203ae..a0b81b2 100644 --- a/nachsuche/backend/controllers/userController.js +++ b/nachsuche/backend/controllers/userController.js @@ -1,5 +1,5 @@ const User = require('../models/User'); -const { geocodeAddress } = require('../utils/geocode'); +const { geocodeAddress, searchAddresses } = require('../utils/geocode'); const logger = require('../utils/logger'); const { escapeCell } = require('../utils/csv'); const config = require('../config/env'); @@ -733,6 +733,32 @@ const getGeocodeByPostalCode = async (req, res) => { } }; +/** + * GET /api/public/geocode/search?q=... + * Freitext-Adresssuche fuer die Autovervollstaendigung im Benutzerformular. + * Laeuft ueber den Server, damit Mindestwartezeit, Cache und der von Nominatim + * geforderte User-Agent greifen und keine Nutzer-IP beim Dienst landet. + */ +const searchAddressSuggestions = async (req, res) => { + const { q } = req.query; + const query = String(q || '').trim(); + + if (query.length < 3) { + return res.status(400).json({ success: false, message: 'Suchbegriff zu kurz (min. 3 Zeichen)' }); + } + if (query.length > 200) { + return res.status(400).json({ success: false, message: 'Suchbegriff zu lang' }); + } + + try { + const results = await searchAddresses(query, req.query.limit); + res.json({ success: true, data: results }); + } catch (error) { + logger.error('Fehler bei der Adresssuche:', error); + res.status(500).json({ success: false, message: 'Fehler bei der Adresssuche' }); + } +}; + module.exports = { getAllUsers, getUserById, @@ -750,5 +776,6 @@ module.exports = { bulkDeleteUsers, uploadUserPhoto, deleteUserPhoto, - getGeocodeByPostalCode + getGeocodeByPostalCode, + searchAddressSuggestions }; diff --git a/nachsuche/backend/routes/userRoutes.js b/nachsuche/backend/routes/userRoutes.js index 0697be1..fa2cfeb 100644 --- a/nachsuche/backend/routes/userRoutes.js +++ b/nachsuche/backend/routes/userRoutes.js @@ -21,13 +21,15 @@ const { bulkDeleteUsers, uploadUserPhoto, deleteUserPhoto, - getGeocodeByPostalCode + getGeocodeByPostalCode, + searchAddressSuggestions } = require('../controllers/userController'); // Public routes router.get('/public/users', getPublicUsers); // Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus. router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode); +router.get('/public/geocode/search', geocodeLimiter, searchAddressSuggestions); // Protected routes (require authentication) router.get('/users', authenticateToken, getAllUsers); diff --git a/nachsuche/backend/utils/geocode.js b/nachsuche/backend/utils/geocode.js index 973cfdf..46d5348 100644 --- a/nachsuche/backend/utils/geocode.js +++ b/nachsuche/backend/utils/geocode.js @@ -161,6 +161,76 @@ const geocodeAddress = async (address) => { } }; -module.exports = { - geocodeAddress +// 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 }; diff --git a/nachsuche/frontend/public/sw.js b/nachsuche/frontend/public/sw.js index f1324a4..ed28878 100644 --- a/nachsuche/frontend/public/sw.js +++ b/nachsuche/frontend/public/sw.js @@ -1,11 +1,13 @@ // Service Worker: nur Offline-Fallback, keine Asset-Caches // Vite erzeugt content-addressierte Hashes, kein manuelles Caching nötig -const CACHE_NAME = 'nachsuchenfuehrer-offline-v2'; +const CACHE_NAME = 'nachsuchenfuehrer-offline-v3'; self.addEventListener('install', event => { event.waitUntil( - caches.open(CACHE_NAME).then(cache => cache.add('offline.html')) + caches.open(CACHE_NAME) + .then(cache => cache.add('offline.html')) + .catch(err => console.warn('Offline-Seite konnte nicht gecacht werden:', err)) ); self.skipWaiting(); }); diff --git a/nachsuche/frontend/src/components/map/MapView.js b/nachsuche/frontend/src/components/map/MapView.js index 4a18216..3f4a3e0 100644 --- a/nachsuche/frontend/src/components/map/MapView.js +++ b/nachsuche/frontend/src/components/map/MapView.js @@ -2,14 +2,20 @@ import React, { useEffect } from 'react'; import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; +import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; +import markerIcon from 'leaflet/dist/images/marker-icon.png'; +import markerShadow from 'leaflet/dist/images/marker-shadow.png'; import './MapView.css'; -// Fix für Standard-Marker-Icons in Leaflet +// Standard-Marker-Icons aus dem installierten Leaflet-Paket buendeln. +// Vorher kamen sie von cdnjs — in einer Offline-PWA fuer den Wald waren die +// Marker damit ohne Empfang kaputt, und die IP jedes Besuchers ging an ein +// fremdes CDN. Die Dateien liegen ohnehin in leaflet/dist/images. delete L.Icon.Default.prototype._getIconUrl; L.Icon.Default.mergeOptions({ - iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png', - iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png', - shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png', + iconRetinaUrl: markerIcon2x, + iconUrl: markerIcon, + shadowUrl: markerShadow, }); // Komponente zum Aktualisieren der Kartenansicht diff --git a/nachsuche/frontend/src/components/users/UserForm.js b/nachsuche/frontend/src/components/users/UserForm.js index f78688a..664ee01 100644 --- a/nachsuche/frontend/src/components/users/UserForm.js +++ b/nachsuche/frontend/src/components/users/UserForm.js @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { useConfigContext } from '../../contexts/ConfigContext'; +import { searchAddresses } from '../../services/users'; import './UserForm.css'; const formatSuggestionAddress = (addr) => { @@ -125,17 +126,13 @@ const UserForm = ({ user, onSave, onCancel }) => { searchTimer.current = setTimeout(async () => { try { - const res = await fetch( - `https://nominatim.openstreetmap.org/search?format=json&countrycodes=de&addressdetails=1&limit=5&q=${encodeURIComponent(value)}`, - { headers: { 'User-Agent': 'nachsuche-app/1.0 (admin@kasimirat.de)' } } - ); - const data = await res.json(); - setSuggestions(Array.isArray(data) ? data : []); + const result = await searchAddresses(value); + setSuggestions(result.data); setShowSuggestions(true); } catch { setSuggestions([]); } - }, 400); + }, 600); }; const selectSuggestion = (s) => { @@ -233,7 +230,8 @@ const UserForm = ({ user, onSave, onCancel }) => {
- + {/* Gruppenueberschrift: gehoert nicht zu einem einzelnen Feld */} + GPS-Koordinaten {gpsAutoSet ? (

✓ GPS automatisch aus Adresse übernommen

) : ( diff --git a/nachsuche/frontend/src/services/users.js b/nachsuche/frontend/src/services/users.js index 40ba044..87b0890 100644 --- a/nachsuche/frontend/src/services/users.js +++ b/nachsuche/frontend/src/services/users.js @@ -214,6 +214,22 @@ export const uploadUserPhoto = async (id, photoDataUrl) => { } }; +// Freitext-Adresssuche ueber das eigene Backend statt direkt bei Nominatim: +// dort greifen Mindestwartezeit, Cache und der vorgeschriebene User-Agent, +// und es wandert keine Nutzer-IP zu einem fremden Dienst. +export const searchAddresses = async (query) => { + try { + const response = await api.get('/public/geocode/search', { params: { q: query } }); + return { success: true, data: response.data.data || [] }; + } catch (error) { + return { + success: false, + data: [], + message: error.response?.data?.message || 'Fehler bei der Adresssuche' + }; + } +}; + // Einmal-Token, mit dem ein Fuehrer sein erstes Passwort setzt. // Laeuft ueber die Admin-Session (Cookie), nicht ueber den Fuehrer-Token. export const generateInviteToken = async (id) => { diff --git a/portal/sw.js b/portal/sw.js index 56cb5bb..cc85b81 100644 --- a/portal/sw.js +++ b/portal/sw.js @@ -1,9 +1,11 @@ // Service Worker: Offline-Fallback für Portal-Selektor -const CACHE_NAME = 'portal-offline-v2'; +const CACHE_NAME = 'portal-offline-v3'; self.addEventListener('install', event => { event.waitUntil( - caches.open(CACHE_NAME).then(cache => cache.add('offline.html')) + caches.open(CACHE_NAME) + .then(cache => cache.add('offline.html')) + .catch(err => console.warn('Offline-Seite konnte nicht gecacht werden:', err)) ); self.skipWaiting(); }); diff --git a/stoeberhunde/backend/controllers/userController.js b/stoeberhunde/backend/controllers/userController.js index e661ca6..73e91e3 100644 --- a/stoeberhunde/backend/controllers/userController.js +++ b/stoeberhunde/backend/controllers/userController.js @@ -1,5 +1,5 @@ const User = require('../models/User'); -const { geocodeAddress } = require('../utils/geocode'); +const { geocodeAddress, searchAddresses } = require('../utils/geocode'); const logger = require('../utils/logger'); const { escapeCell } = require('../utils/csv'); const config = require('../config/env'); @@ -729,6 +729,32 @@ const getGeocodeByPostalCode = async (req, res) => { } }; +/** + * GET /api/public/geocode/search?q=... + * Freitext-Adresssuche fuer die Autovervollstaendigung im Benutzerformular. + * Laeuft ueber den Server, damit Mindestwartezeit, Cache und der von Nominatim + * geforderte User-Agent greifen und keine Nutzer-IP beim Dienst landet. + */ +const searchAddressSuggestions = async (req, res) => { + const { q } = req.query; + const query = String(q || '').trim(); + + if (query.length < 3) { + return res.status(400).json({ success: false, message: 'Suchbegriff zu kurz (min. 3 Zeichen)' }); + } + if (query.length > 200) { + return res.status(400).json({ success: false, message: 'Suchbegriff zu lang' }); + } + + try { + const results = await searchAddresses(query, req.query.limit); + res.json({ success: true, data: results }); + } catch (error) { + logger.error('Fehler bei der Adresssuche:', error); + res.status(500).json({ success: false, message: 'Fehler bei der Adresssuche' }); + } +}; + module.exports = { getAllUsers, getUserById, @@ -746,5 +772,6 @@ module.exports = { bulkDeleteUsers, uploadUserPhoto, deleteUserPhoto, - getGeocodeByPostalCode + getGeocodeByPostalCode, + searchAddressSuggestions }; diff --git a/stoeberhunde/backend/routes/userRoutes.js b/stoeberhunde/backend/routes/userRoutes.js index 0697be1..fa2cfeb 100644 --- a/stoeberhunde/backend/routes/userRoutes.js +++ b/stoeberhunde/backend/routes/userRoutes.js @@ -21,13 +21,15 @@ const { bulkDeleteUsers, uploadUserPhoto, deleteUserPhoto, - getGeocodeByPostalCode + getGeocodeByPostalCode, + searchAddressSuggestions } = require('../controllers/userController'); // Public routes router.get('/public/users', getPublicUsers); // Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus. router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode); +router.get('/public/geocode/search', geocodeLimiter, searchAddressSuggestions); // Protected routes (require authentication) router.get('/users', authenticateToken, getAllUsers); diff --git a/stoeberhunde/frontend/public/sw.js b/stoeberhunde/frontend/public/sw.js index 71ccc59..3b71ec8 100644 --- a/stoeberhunde/frontend/public/sw.js +++ b/stoeberhunde/frontend/public/sw.js @@ -1,11 +1,13 @@ // Service Worker: nur Offline-Fallback, keine Asset-Caches // Vite erzeugt content-addressierte Hashes, kein manuelles Caching nötig -const CACHE_NAME = 'stoeberhunde-offline-v1'; +const CACHE_NAME = 'stoeberhunde-offline-v2'; self.addEventListener('install', event => { event.waitUntil( - caches.open(CACHE_NAME).then(cache => cache.add('offline.html')) + caches.open(CACHE_NAME) + .then(cache => cache.add('offline.html')) + .catch(err => console.warn('Offline-Seite konnte nicht gecacht werden:', err)) ); self.skipWaiting(); }); diff --git a/stoeberhunde/frontend/src/components/map/MapView.js b/stoeberhunde/frontend/src/components/map/MapView.js index 4a18216..3f4a3e0 100644 --- a/stoeberhunde/frontend/src/components/map/MapView.js +++ b/stoeberhunde/frontend/src/components/map/MapView.js @@ -2,14 +2,20 @@ import React, { useEffect } from 'react'; import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; +import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; +import markerIcon from 'leaflet/dist/images/marker-icon.png'; +import markerShadow from 'leaflet/dist/images/marker-shadow.png'; import './MapView.css'; -// Fix für Standard-Marker-Icons in Leaflet +// Standard-Marker-Icons aus dem installierten Leaflet-Paket buendeln. +// Vorher kamen sie von cdnjs — in einer Offline-PWA fuer den Wald waren die +// Marker damit ohne Empfang kaputt, und die IP jedes Besuchers ging an ein +// fremdes CDN. Die Dateien liegen ohnehin in leaflet/dist/images. delete L.Icon.Default.prototype._getIconUrl; L.Icon.Default.mergeOptions({ - iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png', - iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png', - shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png', + iconRetinaUrl: markerIcon2x, + iconUrl: markerIcon, + shadowUrl: markerShadow, }); // Komponente zum Aktualisieren der Kartenansicht diff --git a/stoeberhunde/frontend/src/components/users/UserForm.js b/stoeberhunde/frontend/src/components/users/UserForm.js index 351bea8..67f577c 100644 --- a/stoeberhunde/frontend/src/components/users/UserForm.js +++ b/stoeberhunde/frontend/src/components/users/UserForm.js @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { useConfigContext } from '../../contexts/ConfigContext'; +import { searchAddresses } from '../../services/users'; import './UserForm.css'; const formatSuggestionAddress = (addr) => { @@ -125,17 +126,13 @@ const UserForm = ({ user, onSave, onCancel }) => { searchTimer.current = setTimeout(async () => { try { - const res = await fetch( - `https://nominatim.openstreetmap.org/search?format=json&countrycodes=de&addressdetails=1&limit=5&q=${encodeURIComponent(value)}`, - { headers: { 'User-Agent': 'stoeberhunde-app/1.0 (admin@kasimirat.de)' } } - ); - const data = await res.json(); - setSuggestions(Array.isArray(data) ? data : []); + const result = await searchAddresses(value); + setSuggestions(result.data); setShowSuggestions(true); } catch { setSuggestions([]); } - }, 400); + }, 600); }; const selectSuggestion = (s) => { @@ -233,7 +230,8 @@ const UserForm = ({ user, onSave, onCancel }) => {
- + {/* Gruppenueberschrift: gehoert nicht zu einem einzelnen Feld */} + GPS-Koordinaten {gpsAutoSet ? (

✓ GPS automatisch aus Adresse übernommen

) : ( diff --git a/stoeberhunde/frontend/src/services/users.js b/stoeberhunde/frontend/src/services/users.js index fcf20df..ec56bca 100644 --- a/stoeberhunde/frontend/src/services/users.js +++ b/stoeberhunde/frontend/src/services/users.js @@ -214,6 +214,22 @@ export const uploadUserPhoto = async (id, photoDataUrl) => { } }; +// Freitext-Adresssuche ueber das eigene Backend statt direkt bei Nominatim: +// dort greifen Mindestwartezeit, Cache und der vorgeschriebene User-Agent, +// und es wandert keine Nutzer-IP zu einem fremden Dienst. +export const searchAddresses = async (query) => { + try { + const response = await api.get('/public/geocode/search', { params: { q: query } }); + return { success: true, data: response.data.data || [] }; + } catch (error) { + return { + success: false, + data: [], + message: error.response?.data?.message || 'Fehler bei der Adresssuche' + }; + } +}; + // Einmal-Token, mit dem ein Fuehrer sein erstes Passwort setzt. // Laeuft ueber die Admin-Session (Cookie), nicht ueber den Fuehrer-Token. export const generateInviteToken = async (id) => {