lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
| 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 | } |