lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ electron karte.ts

18,6 KB Rohdatei
electron/karte.ts — 455 Zeilen
1 import type { KartenAnfrage, KartenAntwort } from '../shared/ipc';
2
3 /**
4 * Abruf amtlicher Luftbilder.
5 *
6 * SICHERHEITSENTWURF
7 *
8 * Der Renderer bleibt netzseitig vollstaendig abgeriegelt: Seine
9 * Inhaltssicherheitsrichtlinie lautet weiterhin `default-src 'none'` und
10 * `connect-src 'self'`. Er kann selbst keine Verbindung nach aussen aufbauen.
11 *
12 * Stattdessen fuehrt der Hauptprozess den Abruf aus - auf ausdrueckliche
13 * Anforderung des Anwenders, gegen eine feste Liste erlaubter Rechnernamen, und
14 * gibt ausschliesslich die Bilddaten zurueck. Damit kann eine praeparierte
15 * Projektdatei oder ein Fehler in der Oberflaeche keine Verbindung zu einem
16 * beliebigen Ziel aufbauen und keine Daten abfliessen lassen.
17 *
18 * Die Adressliste steht bewusst hier im Hauptprozess und nicht in den
19 * Projektdaten: Sie darf nicht aus einer geoeffneten Datei stammen.
20 */
21
22 /**
23 * Erlaubte Rechnernamen.
24 *
25 * Es sind die Kartendienste der Landesvermessungsverwaltungen und des Bundes.
26 * Jeder Eintrag wurde mit einem echten Bildabruf geprueft. Die Liste gehoert
27 * mit src/services/kartendienste.ts zusammen; ein Test stellt sicher, dass
28 * beide Seiten uebereinstimmen - andernfalls liesse sich ein Dienst zwar
29 * auswaehlen, der Abruf schluege aber fehl.
30 *
31 * Ein blosses Freischalten beliebiger Adressen kommt nicht in Frage - dann
32 * waere der Renderer ueber den Umweg des Hauptprozesses doch wieder mit dem
33 * Netz verbunden.
34 */
35 const ERLAUBTE_RECHNER: readonly string[] = [
36 // Bund
37 'sgx.geodatenzentrum.de',
38 // Laender
39 'www.wms.nrw.de',
40 'wms.nrw.de',
41 'geoservices.bayern.de',
42 'isk.geobasis-bb.de',
43 'geodienste.sachsen.de',
44 'opendata.lgln.niedersachsen.de',
45 'dienste.gdi-sh.de',
46 'geo4.service24.rlp.de',
47 'www.gds-srv.hessen.de',
48 'www.geodaten-mv.de',
49 'www.geodatenportal.sachsen-anhalt.de',
50 'owsproxy.lgl-bw.de',
51 ];
52
53 /**
54 * Obergrenze der Antwortgroesse JE ABRUF.
55 *
56 * Ein Abruf ist eine KACHEL von hoechstens 1600 x 1600 Bildpunkten
57 * (KACHEL_KANTE in src/services/kartendienste.ts), nicht mehr ein ganzes Bild.
58 * Damit laesst sich die Grenze ausrechnen statt schaetzen:
59 *
60 * 1600 x 1600 = 2,56 Mio Bildpunkte
61 * als JPEG-Luftbild, 0,2 bis 0,4 Byte je Bildpunkt
62 * = 0,5 bis 1,0 MB (gemessen: einige 100 kB)
63 * als PNG-Karte, schlimmstenfalls voellig unkomprimierbar, 24 bit
64 * = 2,56 Mio x 3 Byte + ein Filterbyte je
65 * Bildzeile = 7.681.600 Byte = 7,33 MiB
66 *
67 * 8 MiB deckt damit auch den unguenstigsten Fall ab, den ein eingetragener
68 * Dienst ueberhaupt liefern kann, und liegt zugleich bei 40 % der frueheren
69 * Grenze von 20 MiB. Die frueheren 20 MiB waren fuer ein ganzes Bild bemessen
70 * und haetten an einer Kachel nie angeschlagen - sie schuetzten praktisch nicht.
71 *
72 * MITZUZIEHEN: KACHEL_KANTE in src/services/kartendienste.ts. Waechst die
73 * Kachelkante, waechst die Datenmenge mit dem QUADRAT. Und diese Grenze bleibt
74 * unterhalb von MAX_BILD_ZEICHEN in src/domain/model/schema.ts (dort in
75 * base64-Zeichen, also Byte x 4/3).
76 *
77 * Diese zweite Kopplung ist eine Plausibilitaetsschranke und keine Absicherung
78 * eines Durchreichewegs: Was der Dienst liefert, steht nie in der Projektdatei.
79 * Auch ein Mosaik aus einer einzigen Kachel laeuft ueber eine Zeichenflaeche und
80 * wird von dort neu herausgeschrieben (uebernehmeZeichenflaeche in
81 * src/ui/lageplan/bildImport.ts); jene Stelle prueft die Projektgrenze selbst,
82 * verdichtet notfalls nach und bricht sonst mit einer Meldung ab. Ein Bild, das
83 * der Hauptprozess durchlaesst und das beim naechsten Oeffnen verworfen wird,
84 * kann auf diesem Weg nicht entstehen. Waere hier mehr zugelassen als dort,
85 * stuende die Zahl trotzdem falsch: Sie soll erkennbar unter der Grenze liegen,
86 * die die Projektdatei zieht.
87 */
88 export const MAX_BYTES_JE_ABRUF = 8 * 1024 * 1024;
89
90 /**
91 * Groesse der Fehlermeldung, die bei einer Nicht-Bild-Antwort gelesen wird.
92 *
93 * Auch eine Fehlermeldung kommt aus fremder Hand und wird nur bis hierhin
94 * gelesen. Gezeigt werden davon ohnehin 400 Zeichen.
95 */
96 const MAX_MELDUNG_BYTES = 8 * 1024;
97
98 /**
99 * Wartezeit bis zum Abbruch - JE ABRUF, nicht je Bild.
100 *
101 * Der Wert bleibt bei 120 s, weil die Messung, aus der er stammt, unveraendert
102 * gilt: Ein Bild mit 1600 Bildpunkten Kantenlaenge braucht beim Dienst
103 * Nordrhein-Westfalens je nach Auslastung ueber eine halbe Minute - der Server
104 * rechnet es erst aus. Mit den urspruenglich angesetzten 45 Sekunden scheiterte
105 * der Regelfall reihenweise. Genau dieses 1600er Bild IST jetzt die Kachel; an
106 * der Groesse eines einzelnen Abrufs hat sich nichts geaendert.
107 *
108 * Geaendert hat sich, was diese Zahl nicht mehr ist: die Wartezeit fuer EIN
109 * BILD. Ein Mosaik mit 7000 Bildpunkten Kante besteht aus 5 x 5 = 25 Kacheln.
110 * Im schlechtesten Fall sind das 25 x 120 s = 50 Minuten, mit je einer
111 * Wiederholung 100 Minuten. So lange darf niemand vor einem Fortschrittsbalken
112 * sitzen, ohne abbrechen zu koennen.
113 *
114 * Diese Gesamtdauer laesst sich hier nicht begrenzen: Der Hauptprozess sieht
115 * immer nur den einzelnen Abruf und weiss nichts vom Kachelplan. Sie gehoert an
116 * die Stelle, die den Kachelplan abarbeitet - dort ein Gesamtbudget und eine
117 * Abbruchmoeglichkeit fuer den Anwender. Der Wert wird dafuer ausgegeben, damit
118 * die Rechnung dort nicht ein zweites Mal geraten wird.
119 */
120 export const ZEITGRENZE_JE_ABRUF_MS = 120_000;
121
122 export function istErlaubt(adresse: string): boolean {
123 try {
124 const url = new URL(adresse);
125 // Nur verschluesselte Verbindungen.
126 if (url.protocol !== 'https:') return false;
127 return ERLAUBTE_RECHNER.includes(url.hostname.toLowerCase());
128 } catch {
129 return false;
130 }
131 }
132
133 export function erlaubteRechner(): readonly string[] {
134 return ERLAUBTE_RECHNER;
135 }
136
137 /**
138 * Uebersetzt einen Netzfehler in eine Meldung, mit der ein Anwender etwas
139 * anfangen kann.
140 *
141 * Ohne diese Uebersetzung erschiene an der Oberflaeche etwa "terminated" -
142 * die Meldung der Netzschicht, wenn die Gegenstelle die Verbindung abbricht.
143 * Das ist richtig, sagt dem Anwender aber nicht, was er tun soll.
144 */
145 function lesbareUrsache(fehler: unknown): string {
146 if (!(fehler instanceof Error)) return String(fehler);
147 if (fehler.name === 'AbortError') {
148 return (
149 'Der Kartendienst hat nicht rechtzeitig geantwortet. Versuchen Sie es noch einmal ' +
150 'oder wählen Sie einen kleineren Ausschnitt.'
151 );
152 }
153
154 const ursache = (fehler as { cause?: { code?: string; message?: string } }).cause;
155 const code = ursache?.code ?? '';
156 const text = `${fehler.message} ${ursache?.message ?? ''}`.toLowerCase();
157
158 if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
159 return 'Der Kartendienst war nicht erreichbar. Besteht eine Verbindung zum Internet?';
160 }
161 if (code === 'ECONNREFUSED' || code === 'ECONNRESET' || text.includes('terminated')) {
162 return (
163 'Die Verbindung wurde vom Kartendienst abgebrochen - meist wegen Auslastung. ' +
164 'Versuchen Sie es noch einmal oder wählen Sie einen kleineren Ausschnitt.'
165 );
166 }
167 if (code.startsWith('CERT_') || text.includes('certificate')) {
168 return 'Das Sicherheitszertifikat des Kartendienstes konnte nicht geprüft werden.';
169 }
170 return ursache?.message ?? fehler.message;
171 }
172
173 /**
174 * Meldung fuer eine Antwort ueber der Abrufgrenze.
175 *
176 * Der frueher hier stehende Rat "Waehlen Sie einen kleineren Ausschnitt" ist
177 * seit der Kachelung falsch: Die Kachelgroesse haengt nicht mehr davon ab, wie
178 * gross der Anwender seinen Ausschnitt waehlt. Ein kleinerer Ausschnitt wuerde
179 * denselben Fehler noch einmal erzeugen, und der Anwender suchte den Fehler bei
180 * sich. Der Fehler liegt beim Dienst - das muss die Meldung sagen.
181 */
182 function zuGross(gemeldeteBytes: number | null): string {
183 const grenze = (MAX_BYTES_JE_ABRUF / (1024 * 1024)).toFixed(0);
184 // Dezimalkomma wie im uebrigen Programm - die Meldung liest ein Anwender.
185 const gemeldet = ((gemeldeteBytes ?? 0) / (1024 * 1024)).toFixed(1).replace('.', ',');
186 const umfang = gemeldeteBytes === null ? '' : ` (angekündigt waren ${gemeldet} MB)`;
187 return (
188 `Der Kartendienst lieferte für eine einzelne Kachel mehr als ${grenze} MB${umfang}. ` +
189 'Der Abruf wurde abgebrochen. Das ist keine gültige Kartenkachel - bitte einen anderen ' +
190 'Dienst wählen.'
191 );
192 }
193
194 /** Die beiden Formate, die ein eingetragener Kartendienst liefern darf. */
195 type Bildformat = 'image/png' | 'image/jpeg';
196
197 /**
198 * Bildanfaenge dieser Formate.
199 *
200 * WARUM DER INHALTSTYP ALLEIN NICHT REICHT
201 *
202 * Er faengt nur den Dienst ab, der ehrlich meldet, was er schickt. Ueberlastete
203 * MapProxy- und UMN-Installationen antworten mit Status 200,
204 * "content-type: image/jpeg" und LEEREM Koerper; ein vorgeschalteter
205 * Zwischenspeicher schiebt seine HTML-Fehlerseite unter demselben Typ durch.
206 * Beides ging bisher mit ok=true an die Anzeige, und die verbuchte die Kachel
207 * als geholt, statt sie zu wiederholen: Erst das Zusammensetzen des Mosaiks
208 * scheiterte - Minuten spaeter, an einer Stelle, die den Dienst nicht mehr
209 * fragen kann.
210 *
211 * Geprueft werden die ersten Bytes und sonst nichts. Eine Bilddekodierung
212 * gehoert nicht in den Hauptprozess: Sie waere die zweite nach der in der
213 * Anzeige und liesse fremde Daten durch fremden Kode laufen.
214 *
215 * Die Pruefung haengt am GEMELDETEN Typ. Damit faellt zugleich der Dienst auf,
216 * der etwa image/gif liefert - er wurde bisher als JPEG etikettiert, weil die
217 * Zuordnung nur "png oder sonst jpeg" kannte.
218 */
219 const BILDANFAENGE: Readonly<Record<Bildformat, readonly number[]>> = {
220 'image/png': [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
221 'image/jpeg': [0xff, 0xd8, 0xff],
222 };
223
224 function beginntMit(daten: Buffer, signatur: readonly number[]): boolean {
225 if (daten.byteLength < signatur.length) return false;
226 return signatur.every((byte, stelle) => daten[stelle] === byte);
227 }
228
229 /**
230 * Meldung fuer eine Antwort, die einen Bild-Inhaltstyp traegt, aber kein Bild
231 * ist.
232 *
233 * Sie nennt den Dienst als Verursacher. Ohne das sucht der Anwender den Fehler
234 * bei sich - an seiner Koordinate, seiner Ausschnittgroesse - und findet ihn
235 * dort nie.
236 */
237 function keinBild(befund: string): string {
238 return (
239 `${befund} Das ist eine Fehlantwort des Dienstes - meist Auslastung oder ein ` +
240 'vorgeschalteter Zwischenspeicher. Versuchen Sie es noch einmal oder wählen Sie einen ' +
241 'anderen Dienst.'
242 );
243 }
244
245 /** Fehler, bei denen ein zweiter Versuch Aussicht auf Erfolg hat. */
246 function lohntWiederholung(fehler: unknown): boolean {
247 if (!(fehler instanceof Error)) return false;
248 if (fehler.name === 'AbortError') return false;
249 const ursache = (fehler as { cause?: { code?: string } }).cause;
250 const code = ursache?.code ?? '';
251 return code === 'ECONNRESET' || code === 'EAI_AGAIN' || fehler.message.includes('terminated');
252 }
253
254 /**
255 * Holt ein Kartenbild und gibt es als Daten-URL zurueck.
256 *
257 * Bei einem abgebrochenen Verbindungsaufbau wird einmal wiederholt: Die Dienste
258 * rechnen das Bild erst aus und trennen unter Last gelegentlich die Verbindung.
259 * Ein zweiter Versuch fuehrt dann meist zum Ziel und erspart dem Anwender eine
260 * Fehlermeldung, auf die er ohnehin nur mit einem erneuten Klick antworten
261 * koennte. Wiederholt wird ausschliesslich bei Verbindungsfehlern - nicht bei
262 * einer Fehlermeldung des Dienstes und nicht nach Ablauf der Wartezeit.
263 *
264 * Mit der Kachelung wiegt die Wiederholung schwerer als frueher: Ein Mosaik
265 * braucht bis zu 25 Abrufe, die Wahrscheinlichkeit, dass einer davon unter Last
266 * abbricht, ist entsprechend hoeher - und eine einzige fehlende Kachel macht das
267 * ganze Bild unbrauchbar.
268 */
269 export async function holeKartenbild(anfrage: KartenAnfrage): Promise<KartenAntwort> {
270 // Der einzige Netzaufruf des Programms beginnt in dieser Zeile, und die
271 // Nutzlast kommt ueber IPC aus dem Renderer. `KartenAnfrage` ist die
272 // Verabredung beider Seiten, nicht ihre Pruefung - der Linter beanstandet
273 // die Absicherung deshalb zu Unrecht. Was hier keine Zeichenkette ist, wird
274 // zu einer, die in der Rechnerliste nicht vorkommt - `undefined` und `null`
275 // zur leeren, alles andere zu '[object Object]' oder '42' -, und scheitert
276 // gleich darunter an istErlaubt. Ohne die Absicherung stuende an ihrer
277 // Stelle eine Ausnahme im Hauptprozess.
278 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Fremddaten hinter einer Typzusage; siehe darueber
279 const adresse = String(anfrage?.url ?? '');
280
281 if (!istErlaubt(adresse)) {
282 return {
283 ok: false,
284 datenUrl: null,
285 fehler:
286 'Die Adresse ist nicht freigegeben. Zugelassen sind ausschließlich die im Programm ' +
287 `hinterlegten amtlichen Kartendienste (${ERLAUBTE_RECHNER.join(', ')}).`,
288 };
289 }
290
291 try {
292 return await einVersuch(adresse);
293 } catch (fehler) {
294 if (lohntWiederholung(fehler)) {
295 try {
296 return await einVersuch(adresse);
297 } catch (zweiter) {
298 return {
299 ok: false,
300 datenUrl: null,
301 fehler: `Abruf fehlgeschlagen: ${lesbareUrsache(zweiter)}`,
302 };
303 }
304 }
305 return { ok: false, datenUrl: null, fehler: `Abruf fehlgeschlagen: ${lesbareUrsache(fehler)}` };
306 }
307 }
308
309 /**
310 * Liest den Antwortkoerper, hoert aber nach `grenze` Byte auf.
311 *
312 * WARUM NICHT EINFACH arrayBuffer() UND DANACH MESSEN
313 *
314 * Dann laege die ganze Antwort bereits im Speicher, und die Grenze haette nichts
315 * mehr verhindert - sie hielte den Anwender lediglich davon ab, das Bild zu
316 * verwenden. Ein Dienst, der irrtuemlich ein unbegrenztes Bild ausliefert (oder
317 * ein Rechnername, der einmal in falsche Haende geraet), koennte den
318 * Hauptprozess ueber den Speicher raeumen. Mit ihm faellt die ganze Anwendung
319 * samt ungesichertem Signalzeitenplan.
320 *
321 * Deshalb wird gelesen, mitgezaehlt und die Verbindung abgebrochen, sobald die
322 * Grenze ueberschritten ist. Zurueck kommt auch dann, was bis dahin gelesen
323 * wurde - fuer eine Fehlermeldung reicht der Anfang.
324 */
325 async function liesHoechstens(
326 antwort: Response,
327 grenze: number,
328 ): Promise<{ daten: Buffer; vollstaendig: boolean }> {
329 const koerper = antwort.body;
330 if (koerper === null) {
331 // Notweg fuer Umgebungen ohne Datenstrom. Hier bleibt nur, alles zu lesen.
332 const alles = Buffer.from(await antwort.arrayBuffer());
333 return alles.byteLength > grenze
334 ? { daten: alles.subarray(0, grenze), vollstaendig: false }
335 : { daten: alles, vollstaendig: true };
336 }
337
338 const leser = koerper.getReader();
339 const teile: Uint8Array[] = [];
340 let gelesen = 0;
341 try {
342 for (;;) {
343 const stueck = await leser.read();
344 if (stueck.done) break;
345 /*
346 * Die Zusicherung sieht ueberfluessig aus und ist es nicht.
347 *
348 * Der Linter meldet sie als no-unnecessary-type-assertion. Gemessen mit
349 * `npx eslint electron/karte.ts`: Ohne sie verengt der Uebersetzer
350 * `wert` auf Uint8Array, und die Abfrage darunter wird ihrerseits zur
351 * Warnung - die Datei traegt vorher wie nachher drei. Die Warnung
352 * wandert also nur, und was dabei unter Druck geriete, ist die Abfrage:
353 * Sie steht am Datenstrom eines fremden Dienstes, wo ein leeres Stueck
354 * die Zaehlung der gelesenen Byte verderben wuerde. Die Zusicherung
355 * haelt sie sichtbar.
356 */
357 // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- haelt die Abfrage eine Zeile darunter am Leben; siehe darueber
358 const wert: Uint8Array | undefined = stueck.value as Uint8Array | undefined;
359 if (wert === undefined) continue;
360 teile.push(wert);
361 gelesen += wert.byteLength;
362 if (gelesen > grenze) {
363 return { daten: Buffer.concat(teile).subarray(0, grenze), vollstaendig: false };
364 }
365 }
366 } finally {
367 // Schliesst die Verbindung, wenn oben vorzeitig verlassen wurde. Nach einem
368 // vollstaendig gelesenen Koerper ist der Aufruf wirkungslos.
369 await leser.cancel().catch(() => undefined);
370 }
371 return { daten: Buffer.concat(teile), vollstaendig: true };
372 }
373
374 async function einVersuch(adresse: string): Promise<KartenAntwort> {
375 const abbruch = new AbortController();
376 const wecker = setTimeout(() => {
377 abbruch.abort();
378 }, ZEITGRENZE_JE_ABRUF_MS);
379
380 try {
381 const antwort = await fetch(adresse, {
382 signal: abbruch.signal,
383 redirect: 'error',
384 headers: { Accept: 'image/jpeg,image/png,*/*' },
385 });
386
387 if (!antwort.ok) {
388 return {
389 ok: false,
390 datenUrl: null,
391 fehler: `Der Kartendienst antwortete mit ${antwort.status} ${antwort.statusText}.`,
392 };
393 }
394
395 const typ = (antwort.headers.get('content-type') ?? '').toLowerCase();
396 // Ein WMS meldet Fehler ueblicherweise als XML mit Status 200 - deshalb
397 // wird der Inhaltstyp geprueft und die Meldung durchgereicht.
398 if (!typ.startsWith('image/')) {
399 const { daten } = await liesHoechstens(antwort, MAX_MELDUNG_BYTES);
400 const text = daten.toString('utf8').slice(0, 400);
401 return {
402 ok: false,
403 datenUrl: null,
404 fehler: `Der Kartendienst lieferte kein Bild (${typ || 'ohne Typangabe'}): ${text}`,
405 };
406 }
407
408 // Die angekuendigte Laenge zuerst: Steht sie schon ueber der Grenze, muss
409 // gar nicht erst geladen werden. Sie ist nur ein Hinweis - verlassen wird
410 // sich die Pruefung auf das Mitzaehlen beim Lesen.
411 const angekuendigt = Number(antwort.headers.get('content-length') ?? '');
412 if (Number.isFinite(angekuendigt) && angekuendigt > MAX_BYTES_JE_ABRUF) {
413 return { ok: false, datenUrl: null, fehler: zuGross(angekuendigt) };
414 }
415
416 const { daten, vollstaendig } = await liesHoechstens(antwort, MAX_BYTES_JE_ABRUF);
417 if (!vollstaendig) {
418 return { ok: false, datenUrl: null, fehler: zuGross(null) };
419 }
420
421 // Ein leerer Koerper ergibt "data:image/jpeg;base64," - eine Daten-URL ohne
422 // Nutzlast, die jede spaetere Pruefung verwirft. Hier abzuweisen ist der
423 // einzige Ort, an dem die Wiederholung je Kachel noch greift.
424 if (daten.byteLength === 0) {
425 return {
426 ok: false,
427 datenUrl: null,
428 fehler: keinBild('Der Kartendienst lieferte einen leeren Bildkörper (0 Byte).'),
429 };
430 }
431
432 const format: Bildformat = typ.includes('png') ? 'image/png' : 'image/jpeg';
433 if (!beginntMit(daten, BILDANFAENGE[format])) {
434 return {
435 ok: false,
436 datenUrl: null,
437 fehler: keinBild(
438 `Der Kartendienst meldete ${typ}, die empfangenen Daten beginnen aber nicht wie ein ` +
439 `${format === 'image/png' ? 'PNG' : 'JPEG'}.`,
440 ),
441 };
442 }
443
444 return {
445 ok: true,
446 datenUrl: `data:${format};base64,${daten.toString('base64')}`,
447 fehler: null,
448 };
449 // Verbindungsfehler werden bewusst nicht hier abgefangen, sondern nach
450 // aussen gereicht: Nur dort ist bekannt, ob schon ein Versuch gescheitert
451 // ist, und nur dort laesst sich sinnvoll wiederholen.
452 } finally {
453 clearTimeout(wecker);
454 }
455 }