From 03bb55f9a18f065f4fd9d9c24962f1178146d9b3 Mon Sep 17 00:00:00 2001 From: elpatron Date: Sat, 30 May 2026 12:37:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(weather):=20OWM-Fallback=20=C3=BCber=20Ser?= =?UTF-8?q?ver-.env=20wenn=20kein=20User-Key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wetter-Proxy auf /api/weather/current nutzt optionalen Nutzer-Key aus den Einstellungen, sonst OpenWeatherMapAPIKey aus der Umgebung. Co-authored-by: Cursor --- README.md | 5 +- client/src/components/LogEntryEditor.tsx | 74 +++++++++++------------- client/src/i18n/locales/de.json | 4 +- client/src/i18n/locales/en.json | 4 +- client/src/services/weather.ts | 52 +++++++++++++++++ docker-compose.yml | 1 + server/src/index.ts | 9 ++- server/src/routes/weather.ts | 59 +++++++++++++++++++ 8 files changed, 160 insertions(+), 48 deletions(-) create mode 100644 client/src/services/weather.ts create mode 100644 server/src/routes/weather.ts diff --git a/README.md b/README.md index 484d0dd..d410522 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ kapteins-daagbok/ - **Node.js** 20+ - **npm** - **Docker** (für PostgreSQL in der Entwicklung oder den vollständigen Stack) -- Optional: OpenWeatherMap-API-Key (Wetter-Abruf in den Einstellungen) +- Optional: eigener OpenWeatherMap-API-Key in den Einstellungen (sonst serverseitiger Key aus `.env`) - Optional: VAPID-Schlüssel für Web Push (siehe Abschnitt Push-Benachrichtigungen) ## Lokale Entwicklung @@ -136,10 +136,11 @@ cp .env.example .env Für lokale Passkeys: `RP_ID=localhost`, `ORIGIN=http://localhost:5173` (bzw. die tatsächliche Frontend-URL). -Im `server/`-Verzeichnis eine `.env` mit `DATABASE_URL` anlegen, z. B.: +Im `server/`-Verzeichnis eine `.env` mit `DATABASE_URL` anlegen — oder den Key in der **Projekt-`.env`** (`OpenWeatherMapAPIKey=...`); das Backend lädt beide Dateien. ``` DATABASE_URL="postgresql://postgres:postgres@localhost:5432/daagbox?schema=public" +OpenWeatherMapAPIKey= # Fallback für Wetter-Abruf, wenn Nutzer keinen eigenen Key hat RP_ID=localhost ORIGIN=http://localhost:5173 # Optional — Web Push (npx web-push generate-vapid-keys) diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 9ae861e..8e3f79f 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -25,6 +25,7 @@ import { hashEntryForSigning } from '../utils/entryCanonicalHash.js' import { signLogEntry } from '../services/entrySigning.js' import { getLogbookAccess } from '../services/logbookAccess.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' +import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' import { getDecryptedTrack, saveUploadedTrack, @@ -640,24 +641,19 @@ export default function LogEntryEditor({ return } - const apiKey = localStorage.getItem('owm_api_key') - if (!apiKey) { - showAlert('GPS capturing failed, and no OpenWeatherMap API key is configured to perform location lookup.') - return - } - try { - const res = await fetch( - `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(locationQuery)}&appid=${apiKey}&units=metric` - ) - if (!res.ok) throw new Error('Location not found') - const data = await res.json() - if (data.coord) { - setEvGpsLat(Number(data.coord.lat).toFixed(6)) - setEvGpsLng(Number(data.coord.lon).toFixed(6)) + const data = await fetchOpenWeatherCurrent({ q: locationQuery }) + const coord = data.coord as { lat?: number; lon?: number } | undefined + if (coord?.lat !== undefined && coord?.lon !== undefined) { + setEvGpsLat(Number(coord.lat).toFixed(6)) + setEvGpsLng(Number(coord.lon).toFixed(6)) showAlert(`Coordinates loaded for "${locationQuery}" via OpenWeatherMap.`) } } catch (e) { + if (e instanceof WeatherApiError && e.code === 'NO_KEY') { + showAlert(t('settings.no_key')) + return + } showAlert('Failed to retrieve GPS location or look up coordinates by location name.') } } @@ -696,35 +692,26 @@ export default function LogEntryEditor({ return } - const apiKey = localStorage.getItem('owm_api_key') - if (!apiKey) { - showAlert(t('settings.no_key')) - return - } - setWeatherLoading(true) try { - let url = '' - if (hasGps) { - url = `https://api.openweathermap.org/data/2.5/weather?lat=${evGpsLat}&lon=${evGpsLng}&appid=${apiKey}&units=metric` - } else { - url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(fallbackLocation)}&appid=${apiKey}&units=metric` - } - - const res = await fetch(url) - - if (!res.ok) throw new Error('Weather API rejected the request') - - const data = await res.json() + const data = await fetchOpenWeatherCurrent( + hasGps + ? { lat: evGpsLat, lon: evGpsLng } + : { q: fallbackLocation } + ) + const coord = data.coord as { lat?: number; lon?: number } | undefined // If fetched by location, automatically pre-fill GPS coordinates - if (!hasGps && data.coord) { - setEvGpsLat(Number(data.coord.lat).toFixed(6)) - setEvGpsLng(Number(data.coord.lon).toFixed(6)) + if (!hasGps && coord?.lat !== undefined && coord?.lon !== undefined) { + setEvGpsLat(Number(coord.lat).toFixed(6)) + setEvGpsLng(Number(coord.lon).toFixed(6)) } + const wind = data.wind as { speed?: number; deg?: number } | undefined + const main = data.main as { pressure?: number } | undefined + // Convert wind speed m/s to Beaufort scale - const mps = data.wind.speed || 0 + const mps = wind?.speed || 0 let bft = 0 if (mps < 0.3) bft = 0 else if (mps < 1.6) bft = 1 @@ -741,22 +728,27 @@ export default function LogEntryEditor({ else bft = 12 setEvWindStrength(`${bft} Bft (${mps.toFixed(1)} m/s)`) - setEvWindPressure(String(data.main.pressure || '')) + setEvWindPressure(String(main?.pressure || '')) // Calculate wind compass direction sector - if (data.wind.deg !== undefined) { - const deg = data.wind.deg + if (wind?.deg !== undefined) { + const deg = wind.deg const sectors = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] const index = Math.round(deg / 22.5) % 16 setEvWindDirection(sectors[index]) } - if (data.weather && data.weather[0]) { - setEvWeatherIcon(data.weather[0].icon) + if (data.weather && Array.isArray(data.weather) && data.weather[0]) { + const first = data.weather[0] as { icon?: string } + if (first.icon) setEvWeatherIcon(first.icon) } showAlert(t('settings.weather_success')) } catch (err) { + if (err instanceof WeatherApiError && err.code === 'NO_KEY') { + showAlert(t('settings.no_key')) + return + } console.error('Weather prefilling failed:', err) showAlert(t('settings.weather_error')) } finally { diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 4e387bb..7c9f96a 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -300,8 +300,8 @@ "save": "Konfiguration speichern", "saving": "Wird gespeichert...", "saved": "Einstellungen erfolgreich gespeichert!", - "key_help": "Ein API-Schlüssel wird benötigt, um Wetterparameter und Seebedingungen automatisch anhand von GPS-Koordinaten abzurufen.", - "no_key": "Bitte hinterlegen Sie Ihren OpenWeatherMap API-Schlüssel in den Einstellungen, um Wetterdaten abzurufen.", + "key_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.", + "no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlegen Sie einen eigenen Schlüssel in den Einstellungen oder kontaktieren Sie den Betreiber.", "weather_success": "Wetterdaten erfolgreich abgerufen!", "weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfen Sie den API-Schlüssel und die Verbindung.", "weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 93fa3a9..a707a6e 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -300,8 +300,8 @@ "save": "Save Configuration", "saving": "Saving...", "saved": "Settings saved successfully!", - "key_help": "An API key is required to automatically fetch real-time weather and sea state parameters based on your vessel's GPS coordinates.", - "no_key": "Please set your OpenWeatherMap API Key in settings to enable weather auto-fill.", + "key_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.", + "no_key": "No OpenWeatherMap API key available. Add your own key in settings or contact the operator.", "weather_success": "Weather details fetched successfully!", "weather_error": "Failed to fetch weather. Check your API key and connection.", "weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.", diff --git a/client/src/services/weather.ts b/client/src/services/weather.ts new file mode 100644 index 0000000..efd1ff9 --- /dev/null +++ b/client/src/services/weather.ts @@ -0,0 +1,52 @@ +export class WeatherApiError extends Error { + code: 'NO_KEY' | 'REQUEST_FAILED' + + constructor(message: string, code: 'NO_KEY' | 'REQUEST_FAILED' = 'REQUEST_FAILED') { + super(message) + this.name = 'WeatherApiError' + this.code = code + } +} + +function buildWeatherHeaders(): Record { + const headers: Record = {} + const userId = localStorage.getItem('active_userid') + const userKey = localStorage.getItem('owm_api_key')?.trim() + + if (userId) headers['X-User-Id'] = userId + if (userKey) headers['X-OWM-Api-Key'] = userKey + + return headers +} + +export async function fetchOpenWeatherCurrent(params: { + lat?: string + lon?: string + q?: string +}): Promise> { + const searchParams = new URLSearchParams() + + if (params.lat && params.lon) { + searchParams.set('lat', params.lat) + searchParams.set('lon', params.lon) + } else if (params.q?.trim()) { + searchParams.set('q', params.q.trim()) + } else { + throw new WeatherApiError('lat/lon or location query required') + } + + const res = await fetch(`/api/weather/current?${searchParams.toString()}`, { + headers: buildWeatherHeaders() + }) + + if (res.status === 503) { + throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY') + } + + const data = await res.json() + if (!res.ok) { + throw new WeatherApiError('Weather API rejected the request') + } + + return data +} diff --git a/docker-compose.yml b/docker-compose.yml index 33199d1..2028163 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu} + OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-} command: sh -c "npx prisma db push && node dist/index.js" depends_on: db: diff --git a/server/src/index.ts b/server/src/index.ts index dffbe04..458a3bb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,15 +1,21 @@ import express from 'express' import cors from 'cors' import dotenv from 'dotenv' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import authRouter from './routes/auth.js' import logbooksRouter from './routes/logbooks.js' import syncRouter from './routes/sync.js' import collaborationRouter from './routes/collaboration.js' import signRouter from './routes/sign.js' import pushRouter from './routes/push.js' +import weatherRouter from './routes/weather.js' import { prisma } from './db.js' -dotenv.config() +const __dirname = dirname(fileURLToPath(import.meta.url)) + +dotenv.config({ path: resolve(__dirname, '../../.env') }) +dotenv.config({ path: resolve(__dirname, '../.env') }) const app = express() const PORT = process.env.PORT || 5000 @@ -24,6 +30,7 @@ app.use('/api/sync', syncRouter) app.use('/api/collaboration', collaborationRouter) app.use('/api/sign', signRouter) app.use('/api/push', pushRouter) +app.use('/api/weather', weatherRouter) // Health check endpoint app.get('/api/health', async (req, res) => { diff --git a/server/src/routes/weather.ts b/server/src/routes/weather.ts new file mode 100644 index 0000000..4343c18 --- /dev/null +++ b/server/src/routes/weather.ts @@ -0,0 +1,59 @@ +import { Router } from 'express' + +const router = Router() + +const requireUser = (req: any, res: any, next: any) => { + const userId = req.headers['x-user-id'] + if (!userId) { + return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' }) + } + req.userId = userId + next() +} + +function resolveOwmApiKey(userProvidedKey: unknown): string | null { + if (typeof userProvidedKey === 'string' && userProvidedKey.trim()) { + return userProvidedKey.trim() + } + const fromEnv = + process.env.OpenWeatherMapAPIKey?.trim() || + process.env.OPENWEATHERMAP_API_KEY?.trim() + return fromEnv || null +} + +router.get('/current', requireUser, async (req: any, res) => { + try { + const { lat, lon, q } = req.query + const apiKey = resolveOwmApiKey(req.headers['x-owm-api-key']) + + if (!apiKey) { + return res.status(503).json({ + error: 'No OpenWeatherMap API key configured (user settings or server environment)' + }) + } + + let url: URL + if (lat && lon) { + url = new URL('https://api.openweathermap.org/data/2.5/weather') + url.searchParams.set('lat', String(lat)) + url.searchParams.set('lon', String(lon)) + } else if (q && typeof q === 'string' && q.trim()) { + url = new URL('https://api.openweathermap.org/data/2.5/weather') + url.searchParams.set('q', q.trim()) + } else { + return res.status(400).json({ error: 'lat and lon, or q (location name) is required' }) + } + + url.searchParams.set('appid', apiKey) + url.searchParams.set('units', 'metric') + + const owmRes = await fetch(url) + const data = await owmRes.json() + return res.status(owmRes.status).json(data) + } catch (error: any) { + console.error('Error fetching OpenWeatherMap data:', error) + return res.status(502).json({ error: error.message || 'Weather lookup failed' }) + } +}) + +export default router