import type { KartenAnfrage, KartenAntwort } from '../shared/ipc'; /** * Abruf amtlicher Luftbilder. * * SICHERHEITSENTWURF * * Der Renderer bleibt netzseitig vollstaendig abgeriegelt: Seine * Inhaltssicherheitsrichtlinie lautet weiterhin `default-src 'none'` und * `connect-src 'self'`. Er kann selbst keine Verbindung nach aussen aufbauen. * * Stattdessen fuehrt der Hauptprozess den Abruf aus - auf ausdrueckliche * Anforderung des Anwenders, gegen eine feste Liste erlaubter Rechnernamen, und * gibt ausschliesslich die Bilddaten zurueck. Damit kann eine praeparierte * Projektdatei oder ein Fehler in der Oberflaeche keine Verbindung zu einem * beliebigen Ziel aufbauen und keine Daten abfliessen lassen. * * Die Adressliste steht bewusst hier im Hauptprozess und nicht in den * Projektdaten: Sie darf nicht aus einer geoeffneten Datei stammen. */ /** * Erlaubte Rechnernamen. * * Es sind die Kartendienste der Landesvermessungsverwaltungen und des Bundes. * Jeder Eintrag wurde mit einem echten Bildabruf geprueft. Die Liste gehoert * mit src/services/kartendienste.ts zusammen; ein Test stellt sicher, dass * beide Seiten uebereinstimmen - andernfalls liesse sich ein Dienst zwar * auswaehlen, der Abruf schluege aber fehl. * * Ein blosses Freischalten beliebiger Adressen kommt nicht in Frage - dann * waere der Renderer ueber den Umweg des Hauptprozesses doch wieder mit dem * Netz verbunden. */ const ERLAUBTE_RECHNER: readonly string[] = [ // Bund 'sgx.geodatenzentrum.de', // Laender 'www.wms.nrw.de', 'wms.nrw.de', 'geoservices.bayern.de', 'isk.geobasis-bb.de', 'geodienste.sachsen.de', 'opendata.lgln.niedersachsen.de', 'dienste.gdi-sh.de', 'geo4.service24.rlp.de', 'www.gds-srv.hessen.de', 'www.geodaten-mv.de', 'www.geodatenportal.sachsen-anhalt.de', 'owsproxy.lgl-bw.de', ]; /** * Obergrenze der Antwortgroesse JE ABRUF. * * Ein Abruf ist eine KACHEL von hoechstens 1600 x 1600 Bildpunkten * (KACHEL_KANTE in src/services/kartendienste.ts), nicht mehr ein ganzes Bild. * Damit laesst sich die Grenze ausrechnen statt schaetzen: * * 1600 x 1600 = 2,56 Mio Bildpunkte * als JPEG-Luftbild, 0,2 bis 0,4 Byte je Bildpunkt * = 0,5 bis 1,0 MB (gemessen: einige 100 kB) * als PNG-Karte, schlimmstenfalls voellig unkomprimierbar, 24 bit * = 2,56 Mio x 3 Byte + ein Filterbyte je * Bildzeile = 7.681.600 Byte = 7,33 MiB * * 8 MiB deckt damit auch den unguenstigsten Fall ab, den ein eingetragener * Dienst ueberhaupt liefern kann, und liegt zugleich bei 40 % der frueheren * Grenze von 20 MiB. Die frueheren 20 MiB waren fuer ein ganzes Bild bemessen * und haetten an einer Kachel nie angeschlagen - sie schuetzten praktisch nicht. * * MITZUZIEHEN: KACHEL_KANTE in src/services/kartendienste.ts. Waechst die * Kachelkante, waechst die Datenmenge mit dem QUADRAT. Und diese Grenze bleibt * unterhalb von MAX_BILD_ZEICHEN in src/domain/model/schema.ts (dort in * base64-Zeichen, also Byte x 4/3). * * Diese zweite Kopplung ist eine Plausibilitaetsschranke und keine Absicherung * eines Durchreichewegs: Was der Dienst liefert, steht nie in der Projektdatei. * Auch ein Mosaik aus einer einzigen Kachel laeuft ueber eine Zeichenflaeche und * wird von dort neu herausgeschrieben (uebernehmeZeichenflaeche in * src/ui/lageplan/bildImport.ts); jene Stelle prueft die Projektgrenze selbst, * verdichtet notfalls nach und bricht sonst mit einer Meldung ab. Ein Bild, das * der Hauptprozess durchlaesst und das beim naechsten Oeffnen verworfen wird, * kann auf diesem Weg nicht entstehen. Waere hier mehr zugelassen als dort, * stuende die Zahl trotzdem falsch: Sie soll erkennbar unter der Grenze liegen, * die die Projektdatei zieht. */ export const MAX_BYTES_JE_ABRUF = 8 * 1024 * 1024; /** * Groesse der Fehlermeldung, die bei einer Nicht-Bild-Antwort gelesen wird. * * Auch eine Fehlermeldung kommt aus fremder Hand und wird nur bis hierhin * gelesen. Gezeigt werden davon ohnehin 400 Zeichen. */ const MAX_MELDUNG_BYTES = 8 * 1024; /** * Wartezeit bis zum Abbruch - JE ABRUF, nicht je Bild. * * Der Wert bleibt bei 120 s, weil die Messung, aus der er stammt, unveraendert * gilt: Ein Bild mit 1600 Bildpunkten Kantenlaenge braucht beim Dienst * Nordrhein-Westfalens je nach Auslastung ueber eine halbe Minute - der Server * rechnet es erst aus. Mit den urspruenglich angesetzten 45 Sekunden scheiterte * der Regelfall reihenweise. Genau dieses 1600er Bild IST jetzt die Kachel; an * der Groesse eines einzelnen Abrufs hat sich nichts geaendert. * * Geaendert hat sich, was diese Zahl nicht mehr ist: die Wartezeit fuer EIN * BILD. Ein Mosaik mit 7000 Bildpunkten Kante besteht aus 5 x 5 = 25 Kacheln. * Im schlechtesten Fall sind das 25 x 120 s = 50 Minuten, mit je einer * Wiederholung 100 Minuten. So lange darf niemand vor einem Fortschrittsbalken * sitzen, ohne abbrechen zu koennen. * * Diese Gesamtdauer laesst sich hier nicht begrenzen: Der Hauptprozess sieht * immer nur den einzelnen Abruf und weiss nichts vom Kachelplan. Sie gehoert an * die Stelle, die den Kachelplan abarbeitet - dort ein Gesamtbudget und eine * Abbruchmoeglichkeit fuer den Anwender. Der Wert wird dafuer ausgegeben, damit * die Rechnung dort nicht ein zweites Mal geraten wird. */ export const ZEITGRENZE_JE_ABRUF_MS = 120_000; export function istErlaubt(adresse: string): boolean { try { const url = new URL(adresse); // Nur verschluesselte Verbindungen. if (url.protocol !== 'https:') return false; return ERLAUBTE_RECHNER.includes(url.hostname.toLowerCase()); } catch { return false; } } export function erlaubteRechner(): readonly string[] { return ERLAUBTE_RECHNER; } /** * Uebersetzt einen Netzfehler in eine Meldung, mit der ein Anwender etwas * anfangen kann. * * Ohne diese Uebersetzung erschiene an der Oberflaeche etwa "terminated" - * die Meldung der Netzschicht, wenn die Gegenstelle die Verbindung abbricht. * Das ist richtig, sagt dem Anwender aber nicht, was er tun soll. */ function lesbareUrsache(fehler: unknown): string { if (!(fehler instanceof Error)) return String(fehler); if (fehler.name === 'AbortError') { return ( 'Der Kartendienst hat nicht rechtzeitig geantwortet. Versuchen Sie es noch einmal ' + 'oder wählen Sie einen kleineren Ausschnitt.' ); } const ursache = (fehler as { cause?: { code?: string; message?: string } }).cause; const code = ursache?.code ?? ''; const text = `${fehler.message} ${ursache?.message ?? ''}`.toLowerCase(); if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') { return 'Der Kartendienst war nicht erreichbar. Besteht eine Verbindung zum Internet?'; } if (code === 'ECONNREFUSED' || code === 'ECONNRESET' || text.includes('terminated')) { return ( 'Die Verbindung wurde vom Kartendienst abgebrochen - meist wegen Auslastung. ' + 'Versuchen Sie es noch einmal oder wählen Sie einen kleineren Ausschnitt.' ); } if (code.startsWith('CERT_') || text.includes('certificate')) { return 'Das Sicherheitszertifikat des Kartendienstes konnte nicht geprüft werden.'; } return ursache?.message ?? fehler.message; } /** * Meldung fuer eine Antwort ueber der Abrufgrenze. * * Der frueher hier stehende Rat "Waehlen Sie einen kleineren Ausschnitt" ist * seit der Kachelung falsch: Die Kachelgroesse haengt nicht mehr davon ab, wie * gross der Anwender seinen Ausschnitt waehlt. Ein kleinerer Ausschnitt wuerde * denselben Fehler noch einmal erzeugen, und der Anwender suchte den Fehler bei * sich. Der Fehler liegt beim Dienst - das muss die Meldung sagen. */ function zuGross(gemeldeteBytes: number | null): string { const grenze = (MAX_BYTES_JE_ABRUF / (1024 * 1024)).toFixed(0); // Dezimalkomma wie im uebrigen Programm - die Meldung liest ein Anwender. const gemeldet = ((gemeldeteBytes ?? 0) / (1024 * 1024)).toFixed(1).replace('.', ','); const umfang = gemeldeteBytes === null ? '' : ` (angekündigt waren ${gemeldet} MB)`; return ( `Der Kartendienst lieferte für eine einzelne Kachel mehr als ${grenze} MB${umfang}. ` + 'Der Abruf wurde abgebrochen. Das ist keine gültige Kartenkachel - bitte einen anderen ' + 'Dienst wählen.' ); } /** Die beiden Formate, die ein eingetragener Kartendienst liefern darf. */ type Bildformat = 'image/png' | 'image/jpeg'; /** * Bildanfaenge dieser Formate. * * WARUM DER INHALTSTYP ALLEIN NICHT REICHT * * Er faengt nur den Dienst ab, der ehrlich meldet, was er schickt. Ueberlastete * MapProxy- und UMN-Installationen antworten mit Status 200, * "content-type: image/jpeg" und LEEREM Koerper; ein vorgeschalteter * Zwischenspeicher schiebt seine HTML-Fehlerseite unter demselben Typ durch. * Beides ging bisher mit ok=true an die Anzeige, und die verbuchte die Kachel * als geholt, statt sie zu wiederholen: Erst das Zusammensetzen des Mosaiks * scheiterte - Minuten spaeter, an einer Stelle, die den Dienst nicht mehr * fragen kann. * * Geprueft werden die ersten Bytes und sonst nichts. Eine Bilddekodierung * gehoert nicht in den Hauptprozess: Sie waere die zweite nach der in der * Anzeige und liesse fremde Daten durch fremden Kode laufen. * * Die Pruefung haengt am GEMELDETEN Typ. Damit faellt zugleich der Dienst auf, * der etwa image/gif liefert - er wurde bisher als JPEG etikettiert, weil die * Zuordnung nur "png oder sonst jpeg" kannte. */ const BILDANFAENGE: Readonly> = { 'image/png': [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 'image/jpeg': [0xff, 0xd8, 0xff], }; function beginntMit(daten: Buffer, signatur: readonly number[]): boolean { if (daten.byteLength < signatur.length) return false; return signatur.every((byte, stelle) => daten[stelle] === byte); } /** * Meldung fuer eine Antwort, die einen Bild-Inhaltstyp traegt, aber kein Bild * ist. * * Sie nennt den Dienst als Verursacher. Ohne das sucht der Anwender den Fehler * bei sich - an seiner Koordinate, seiner Ausschnittgroesse - und findet ihn * dort nie. */ function keinBild(befund: string): string { return ( `${befund} Das ist eine Fehlantwort des Dienstes - meist Auslastung oder ein ` + 'vorgeschalteter Zwischenspeicher. Versuchen Sie es noch einmal oder wählen Sie einen ' + 'anderen Dienst.' ); } /** Fehler, bei denen ein zweiter Versuch Aussicht auf Erfolg hat. */ function lohntWiederholung(fehler: unknown): boolean { if (!(fehler instanceof Error)) return false; if (fehler.name === 'AbortError') return false; const ursache = (fehler as { cause?: { code?: string } }).cause; const code = ursache?.code ?? ''; return code === 'ECONNRESET' || code === 'EAI_AGAIN' || fehler.message.includes('terminated'); } /** * Holt ein Kartenbild und gibt es als Daten-URL zurueck. * * Bei einem abgebrochenen Verbindungsaufbau wird einmal wiederholt: Die Dienste * rechnen das Bild erst aus und trennen unter Last gelegentlich die Verbindung. * Ein zweiter Versuch fuehrt dann meist zum Ziel und erspart dem Anwender eine * Fehlermeldung, auf die er ohnehin nur mit einem erneuten Klick antworten * koennte. Wiederholt wird ausschliesslich bei Verbindungsfehlern - nicht bei * einer Fehlermeldung des Dienstes und nicht nach Ablauf der Wartezeit. * * Mit der Kachelung wiegt die Wiederholung schwerer als frueher: Ein Mosaik * braucht bis zu 25 Abrufe, die Wahrscheinlichkeit, dass einer davon unter Last * abbricht, ist entsprechend hoeher - und eine einzige fehlende Kachel macht das * ganze Bild unbrauchbar. */ export async function holeKartenbild(anfrage: KartenAnfrage): Promise { // Der einzige Netzaufruf des Programms beginnt in dieser Zeile, und die // Nutzlast kommt ueber IPC aus dem Renderer. `KartenAnfrage` ist die // Verabredung beider Seiten, nicht ihre Pruefung - der Linter beanstandet // die Absicherung deshalb zu Unrecht. Was hier keine Zeichenkette ist, wird // zu einer, die in der Rechnerliste nicht vorkommt - `undefined` und `null` // zur leeren, alles andere zu '[object Object]' oder '42' -, und scheitert // gleich darunter an istErlaubt. Ohne die Absicherung stuende an ihrer // Stelle eine Ausnahme im Hauptprozess. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Fremddaten hinter einer Typzusage; siehe darueber const adresse = String(anfrage?.url ?? ''); if (!istErlaubt(adresse)) { return { ok: false, datenUrl: null, fehler: 'Die Adresse ist nicht freigegeben. Zugelassen sind ausschließlich die im Programm ' + `hinterlegten amtlichen Kartendienste (${ERLAUBTE_RECHNER.join(', ')}).`, }; } try { return await einVersuch(adresse); } catch (fehler) { if (lohntWiederholung(fehler)) { try { return await einVersuch(adresse); } catch (zweiter) { return { ok: false, datenUrl: null, fehler: `Abruf fehlgeschlagen: ${lesbareUrsache(zweiter)}`, }; } } return { ok: false, datenUrl: null, fehler: `Abruf fehlgeschlagen: ${lesbareUrsache(fehler)}` }; } } /** * Liest den Antwortkoerper, hoert aber nach `grenze` Byte auf. * * WARUM NICHT EINFACH arrayBuffer() UND DANACH MESSEN * * Dann laege die ganze Antwort bereits im Speicher, und die Grenze haette nichts * mehr verhindert - sie hielte den Anwender lediglich davon ab, das Bild zu * verwenden. Ein Dienst, der irrtuemlich ein unbegrenztes Bild ausliefert (oder * ein Rechnername, der einmal in falsche Haende geraet), koennte den * Hauptprozess ueber den Speicher raeumen. Mit ihm faellt die ganze Anwendung * samt ungesichertem Signalzeitenplan. * * Deshalb wird gelesen, mitgezaehlt und die Verbindung abgebrochen, sobald die * Grenze ueberschritten ist. Zurueck kommt auch dann, was bis dahin gelesen * wurde - fuer eine Fehlermeldung reicht der Anfang. */ async function liesHoechstens( antwort: Response, grenze: number, ): Promise<{ daten: Buffer; vollstaendig: boolean }> { const koerper = antwort.body; if (koerper === null) { // Notweg fuer Umgebungen ohne Datenstrom. Hier bleibt nur, alles zu lesen. const alles = Buffer.from(await antwort.arrayBuffer()); return alles.byteLength > grenze ? { daten: alles.subarray(0, grenze), vollstaendig: false } : { daten: alles, vollstaendig: true }; } const leser = koerper.getReader(); const teile: Uint8Array[] = []; let gelesen = 0; try { for (;;) { const stueck = await leser.read(); if (stueck.done) break; /* * Die Zusicherung sieht ueberfluessig aus und ist es nicht. * * Der Linter meldet sie als no-unnecessary-type-assertion. Gemessen mit * `npx eslint electron/karte.ts`: Ohne sie verengt der Uebersetzer * `wert` auf Uint8Array, und die Abfrage darunter wird ihrerseits zur * Warnung - die Datei traegt vorher wie nachher drei. Die Warnung * wandert also nur, und was dabei unter Druck geriete, ist die Abfrage: * Sie steht am Datenstrom eines fremden Dienstes, wo ein leeres Stueck * die Zaehlung der gelesenen Byte verderben wuerde. Die Zusicherung * haelt sie sichtbar. */ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- haelt die Abfrage eine Zeile darunter am Leben; siehe darueber const wert: Uint8Array | undefined = stueck.value as Uint8Array | undefined; if (wert === undefined) continue; teile.push(wert); gelesen += wert.byteLength; if (gelesen > grenze) { return { daten: Buffer.concat(teile).subarray(0, grenze), vollstaendig: false }; } } } finally { // Schliesst die Verbindung, wenn oben vorzeitig verlassen wurde. Nach einem // vollstaendig gelesenen Koerper ist der Aufruf wirkungslos. await leser.cancel().catch(() => undefined); } return { daten: Buffer.concat(teile), vollstaendig: true }; } async function einVersuch(adresse: string): Promise { const abbruch = new AbortController(); const wecker = setTimeout(() => { abbruch.abort(); }, ZEITGRENZE_JE_ABRUF_MS); try { const antwort = await fetch(adresse, { signal: abbruch.signal, redirect: 'error', headers: { Accept: 'image/jpeg,image/png,*/*' }, }); if (!antwort.ok) { return { ok: false, datenUrl: null, fehler: `Der Kartendienst antwortete mit ${antwort.status} ${antwort.statusText}.`, }; } const typ = (antwort.headers.get('content-type') ?? '').toLowerCase(); // Ein WMS meldet Fehler ueblicherweise als XML mit Status 200 - deshalb // wird der Inhaltstyp geprueft und die Meldung durchgereicht. if (!typ.startsWith('image/')) { const { daten } = await liesHoechstens(antwort, MAX_MELDUNG_BYTES); const text = daten.toString('utf8').slice(0, 400); return { ok: false, datenUrl: null, fehler: `Der Kartendienst lieferte kein Bild (${typ || 'ohne Typangabe'}): ${text}`, }; } // Die angekuendigte Laenge zuerst: Steht sie schon ueber der Grenze, muss // gar nicht erst geladen werden. Sie ist nur ein Hinweis - verlassen wird // sich die Pruefung auf das Mitzaehlen beim Lesen. const angekuendigt = Number(antwort.headers.get('content-length') ?? ''); if (Number.isFinite(angekuendigt) && angekuendigt > MAX_BYTES_JE_ABRUF) { return { ok: false, datenUrl: null, fehler: zuGross(angekuendigt) }; } const { daten, vollstaendig } = await liesHoechstens(antwort, MAX_BYTES_JE_ABRUF); if (!vollstaendig) { return { ok: false, datenUrl: null, fehler: zuGross(null) }; } // Ein leerer Koerper ergibt "data:image/jpeg;base64," - eine Daten-URL ohne // Nutzlast, die jede spaetere Pruefung verwirft. Hier abzuweisen ist der // einzige Ort, an dem die Wiederholung je Kachel noch greift. if (daten.byteLength === 0) { return { ok: false, datenUrl: null, fehler: keinBild('Der Kartendienst lieferte einen leeren Bildkörper (0 Byte).'), }; } const format: Bildformat = typ.includes('png') ? 'image/png' : 'image/jpeg'; if (!beginntMit(daten, BILDANFAENGE[format])) { return { ok: false, datenUrl: null, fehler: keinBild( `Der Kartendienst meldete ${typ}, die empfangenen Daten beginnen aber nicht wie ein ` + `${format === 'image/png' ? 'PNG' : 'JPEG'}.`, ), }; } return { ok: true, datenUrl: `data:${format};base64,${daten.toString('base64')}`, fehler: null, }; // Verbindungsfehler werden bewusst nicht hier abgefangen, sondern nach // aussen gereicht: Nur dort ist bekannt, ob schon ein Versuch gescheitert // ist, und nur dort laesst sich sinnvoll wiederholen. } finally { clearTimeout(wecker); } }