Demo-Seite: Einrichtungs-Code, Handy als Kamera fuer den PC, Mitnehmen per QR

- ?code=... oder Eingabefeld: kostenlos ueber die Beratungsstelle
- PC: "Lieber mit dem Handy fotografieren" - QR scannen, am Handy
  fotografieren, Ergebnis erscheint am PC auf Deutsch und am Handy in der
  Muttersprache (Umschalter zwischen beiden Sprachen)
- PC: "Ergebnis aufs Handy mitnehmen" und "Guthaben auch auf dem Handy"
- uebergabe.js: AES-256-GCM per WebCrypto, Schluessel nur hinter dem #
- qrcode.js (MIT, Kazuhiko Arase) selbst gehostet, keine Drittanfragen
- Datenschutzerklaerung: Abschnitte 9a (Einrichtungs-Code) und 9b (Uebergabe)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
JUSNandClaude Opus 5.5 committed 2026-10-22 18:46:29 +02:00
1 parent 0eab2a0ee9
commit 014737bd95
4 files changed
+2890 -16

No files matched your search

+42 -1
View File
@@ -29,7 +29,7 @@
<body>
<div class="wrap">
<h1>Datenschutzerklärung</h1>
<p class="stand">für die App und Webseite „BehördenKlar" — Stand: 18.08.2026</p>
<p class="stand">für die App und Webseite „BehördenKlar" — Stand: 22.10.2026</p>
<div class="karte">
<h2>1. Verantwortlicher</h2>
@@ -215,6 +215,47 @@
für die gesetzlichen Fristen auf (Art. 6 Abs. 1 lit. c DSGVO).
</p>
<h2>9a. Zugang über eine Beratungsstelle (Einrichtungs-Code)</h2>
<p>
Beratungsstellen können ihren Ratsuchenden einen Code geben, mit dem
BehördenKlar für sie kostenlos ist. Der Code verweist auf ein
Guthaben der Einrichtung, das wie in Abschnitt 9 beschrieben unter
einer zufälligen Kennung verwaltet wird. Wir erfahren dabei nicht, wer
den Code nutzt, und die Beratungsstelle sieht keine Briefe oder
Ergebnisse, nur wie viele Analysen noch übrig sind. Gegen das Erraten
von Codes zählen wir falsche Eingaben pro IP-Adresse (Speicherung
max. 48 Stunden, Art. 6 Abs. 1 lit. f DSGVO).
</p>
<h2>9b. Übergabe zwischen Handy und Computer</h2>
<p>
Sie können ein Ergebnis per QR-Code von einem Gerät auf ein anderes
bringen, zum Beispiel vom Computer der Beratungsstelle auf Ihr Handy,
oder mit dem Handy fotografieren und das Ergebnis am Computer anzeigen.
Dabei gilt:
</p>
<ul>
<li>
<strong>Ende-zu-Ende-Verschlüsselung.</strong> Das Ergebnis wird auf
dem einen Gerät mit AES-256 verschlüsselt und erst auf dem anderen
Gerät wieder entschlüsselt. Den Schlüssel enthält nur der QR-Code
(im Teil der Adresse nach dem „#", den Browser nicht an Server
senden). <strong>Wir können den Inhalt nicht lesen.</strong>
</li>
<li>
<strong>Kurze Zwischenspeicherung.</strong> Das verschlüsselte
Ergebnis liegt bis zur Abholung auf unserem Server (Cloudflare),
höchstens 15 Minuten, und wird beim Abholen bzw. danach automatisch
gelöscht. Zum Schutz vor Missbrauch begrenzen wir die Zahl der
Übertragungen pro IP-Adresse (Art. 6 Abs. 1 lit. f DSGVO).
</li>
<li>
Wer den QR-Code fotografiert oder abfotografiert, kann das Ergebnis
innerhalb dieser 15 Minuten öffnen. Zeigen Sie den Code deshalb nur
der Person, für die er bestimmt ist.
</li>
</ul>
<h2>10. Reichweitenmessung (Webseite)</h2>
<p>
Auf dieser Webseite nutzen wir Cloudflare Web Analytics (Cloudflare,
+430 -15
View File
@@ -208,6 +208,25 @@
background: linear-gradient(104deg, transparent 1%, var(--marker) 3%, var(--marker) 97%, transparent 99%);
font-size: 16px;
}
/* Handy <-> PC */
.cta-zwei { background: #fff; color: var(--amtsblau); font-size: 16.5px; padding: 13px 20px; margin-top: 12px; }
.verbunden {
display: none; margin-bottom: 16px; padding: 12px 16px;
background: #eaf1fa; border: 2px solid var(--amtsblau); font-size: 16px;
}
.qr-box { display: none; margin-top: 16px; padding: 18px; background: #fff; border: 2px dashed var(--amtsblau); text-align: center; }
.qr-box .qr { width: 220px; height: 220px; margin: 10px auto; }
.qr-box .qr svg { width: 100%; height: 100%; display: block; }
.qr-box ol { text-align: start; margin: 8px auto 0; max-width: 420px; padding-inline-start: 22px; font-size: 15.5px; }
.qr-box .zustand { margin-top: 12px; font-weight: 700; font-size: 16.5px; }
.qr-box .abbrechen { margin-top: 10px; background: none; border: none; color: var(--grau); text-decoration: underline; font-family: inherit; font-size: 14.5px; cursor: pointer; }
.umschalter { display: none; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
.umschalter button {
font-family: inherit; font-size: 15px; padding: 6px 14px; cursor: pointer;
border: 1.5px solid var(--tinte); background: #fff; color: var(--tinte);
}
.umschalter button[aria-pressed="true"] { background: var(--tinte); color: var(--papier); }
.link-knopf { background: none; border: none; color: var(--amtsblau); font-family: inherit; font-size: inherit; text-decoration: underline; cursor: pointer; padding: 0; }
footer { margin-top: 48px; font-size: 14.5px; color: var(--grau); }
footer a { color: var(--grau); }
</style>
@@ -264,13 +283,21 @@
<div class="antrag">
<p class="formular-kopf">Antrag auf Klartext · 2 Gratis-Analysen</p>
<p class="verbunden" id="verbundenBanner"></p>
<div id="eingabe">
<p id="kaufOk" style="display: none; background: #e7f4ea; border: 1px solid #b7dfc1; color: #1a7f37; padding: 12px 14px; border-radius: 8px; margin-bottom: 14px; font-weight: 600;">
✅ Kauf erfolgreich! Ihr Guthaben wurde aktiviert.
</p>
<p id="guthabenBanner" class="bonus-zeile" style="display: none;">
💳 Ihr Guthaben: <strong id="guthabenZahl">0</strong> Analysen — laden Sie den nächsten Brief hoch.
<span id="guthabenText">💳 Ihr Guthaben:</span> <strong id="guthabenZahl">0</strong> Analysen — laden Sie den nächsten Brief hoch.
<br><button type="button" class="link-knopf" id="guthabenHandyKnopf" style="font-size: 14.5px;">📱 Auch auf dem Handy nutzen</button>
</p>
<div class="qr-box" id="guthabenQrBox">
<strong>Guthaben aufs Handy holen</strong>
<div class="qr" id="guthabenQr"></div>
<p class="klein" style="margin-top: 0;">Mit der Handy-Kamera scannen. Danach können Sie Briefe auch direkt am Handy erklären lassen.</p>
</div>
<p style="margin-bottom: 14px; font-size: 15.5px;">
🌐 <label for="sprachWahl">Erklärung in dieser Sprache:</label>
<select id="sprachWahl" style="font-family: inherit; font-size: 15.5px; padding: 5px 8px; border: 1.5px solid var(--tinte); background: #fff; margin-inline-start: 6px;"></select>
@@ -303,8 +330,31 @@
<input type="file" id="datei" accept="image/jpeg,image/png,application/pdf" style="display: none;">
<button class="cta" id="startKnopf" disabled>📷 Brief auswählen &amp; erklären lassen</button>
<button class="cta cta-zwei" id="handyKnopf" type="button" style="display: none;">📱 Lieber mit dem Handy fotografieren</button>
<div class="qr-box" id="handyBox">
<strong>Mit dem Handy fotografieren</strong>
<div class="qr" id="handyQr"></div>
<ol>
<li>Handy-Kamera auf den Code richten und den Link öffnen.</li>
<li>Am Handy den Brief fotografieren.</li>
<li>Die Erklärung erscheint hier <em>und</em> auf dem Handy.</li>
</ol>
<label class="konsens" id="handyDeutschZeile" style="justify-content: center; margin: 14px 0 0;">
<input type="checkbox" id="handyDeutsch" checked>
<span>Hier am Bildschirm auf Deutsch zeigen, am Handy in der gewählten Sprache</span>
</label>
<p class="zustand" id="handyZustand">⏳ Warte auf das Handy …</p>
<p class="klein" style="margin-top: 6px;">Verschlüsselt: Den Schlüssel kennt nur dieser QR-Code. Der Code gilt 15 Minuten.</p>
<button type="button" class="abbrechen" id="handyAbbrechen">Abbrechen</button>
</div>
<div class="cf-turnstile" data-sitekey="0x4AAAAAAESi2p41B915R4hW" data-action="turnstile-spin-v1" style="margin-top: 14px;"></div>
<script>
// Lokaler Test (wrangler dev): offizieller Turnstile-Testschlüssel, der immer durchlässt
if (/^(localhost|127\.0\.0\.1)$/.test(location.hostname)) {
document.querySelector('.cf-turnstile').setAttribute('data-sitekey', '1x00000000000000000000AA');
}
</script>
<p class="klein">
Geht mit Foto (JPG/PNG) oder PDF, max. 5 MB. Tipp: gutes Licht,
@@ -318,6 +368,13 @@
Ihrem Handy, nicht von uns:</strong> Diese Seite greift weder auf
Kamera noch auf Mikrofon zu.
</p>
<details id="codeTeil" style="margin-top: 14px; border-top: 1px solid rgba(33, 33, 29, 0.2);">
<summary>🤝 Sie haben einen Code von einer Beratungsstelle?</summary>
<p>Dann ist BehördenKlar für Sie kostenlos. Geben Sie den Code hier ein:</p>
<input class="wl-input" id="codeFeld" placeholder="z. B. MBE-K7QX" autocapitalize="characters" autocomplete="off" spellcheck="false" style="margin-top: 10px; text-transform: uppercase;">
<button class="cta" id="codeKnopf" type="button">Code einlösen</button>
</details>
</div>
<div class="status" id="status">
@@ -377,6 +434,7 @@
<section id="ergebnis">
<h2><span class="nr">§ 3</span> Ihr Brief in Klartext</h2>
<div class="umschalter" id="sprachUmschalter" role="group" aria-label="Sprache der Erklärung"></div>
<div class="brief-karte">
<span class="karten-label neutral" id="ergBrieftyp">Brief</span>
@@ -415,6 +473,15 @@
<div id="ergBegriffe"></div>
</div>
<div id="mitnehmenTeil" style="display: none; margin-top: 22px;">
<button class="cta cta-zwei" id="mitnehmenKnopf" type="button">📱 Ergebnis aufs Handy mitnehmen</button>
<div class="qr-box" id="mitnehmenBox">
<strong>Ergebnis aufs Handy</strong>
<div class="qr" id="mitnehmenQr"></div>
<p class="klein" style="margin-top: 0;">Mit der Handy-Kamera scannen. Dort gibt es den Kalender-Eintrag und die Anruf-Hilfe. Verschlüsselt, der Code gilt 15 Minuten.</p>
</div>
</div>
<p class="hinweis-recht">
⚖️ BehördenKlar erklärt Briefe, ersetzt aber keine Rechtsberatung. Bei
wichtigen Entscheidungen hilft eine Beratungsstelle oder ein Anwalt.
@@ -465,10 +532,14 @@
</div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script src="qrcode.js"></script>
<script src="uebergabe.js"></script>
<script>
(function () {
'use strict';
var BASIS = 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev';
var BASIS = /^(localhost|127\.0\.0\.1)$/.test(location.hostname)
? 'http://localhost:8799' // lokaler Test mit `wrangler dev`
: 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev';
var ENDPUNKT = BASIS + '/demo';
var KAUFEN_ENDPUNKT = BASIS + '/kaufen';
var STATUS_ENDPUNKT = BASIS + '/kauf-status';
@@ -508,6 +579,10 @@
var werbung = document.getElementById('werbung');
var guthabenBanner = document.getElementById('guthabenBanner');
var guthabenZahl = document.getElementById('guthabenZahl');
var guthabenText = document.getElementById('guthabenText');
var codeTeil = document.getElementById('codeTeil');
var codeFeld = document.getElementById('codeFeld');
var codeKnopf = document.getElementById('codeKnopf');
var kaufOk = document.getElementById('kaufOk');
var paketListe = document.getElementById('paketListe');
var kaufKonsens = document.getElementById('kaufKonsens');
@@ -521,6 +596,32 @@
function guthaben() { return parseInt(localStorage.getItem(GUTHABEN_SCHLUESSEL) || '0', 10); }
function setzeGuthaben(n) { localStorage.setItem(GUTHABEN_SCHLUESSEL, String(Math.max(0, n | 0))); }
function hatGuthaben() { return !!walletToken() && guthaben() > 0; }
// Einrichtungs-Code: Name der Beratungsstelle, über die das Guthaben läuft
var EINRICHTUNG_SCHLUESSEL = 'bk_einrichtung';
function einrichtung() { return localStorage.getItem(EINRICHTUNG_SCHLUESSEL) || ''; }
function setzeEinrichtung(name) {
if (name) localStorage.setItem(EINRICHTUNG_SCHLUESSEL, name);
else localStorage.removeItem(EINRICHTUNG_SCHLUESSEL);
}
// Handy ↔ PC: Ist dieses Gerät gerade das Handy, das für einen PC
// fotografiert? Ein Guthaben aus dem QR-Code (w) gilt nur für diese
// Sitzung und wird hier NICHT gespeichert.
var handyModus = null;
var leseModus = false; // Ergebnis vom PC übernommen, nur anzeigen
var istPc = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches;
function walletFuerAnalyse() {
if (handyModus && handyModus.w) return handyModus.w;
return hatGuthaben() ? walletToken() : '';
}
function meldeAnPc(daten, fach) {
if (!handyModus) return Promise.resolve();
return BKUebergabe.senden(handyModus.ue, fach || 'status', daten).catch(function () {});
}
function zeigeGuthabenBanner(n) {
guthabenText.textContent = einrichtung() ? '🤝 Kostenlos über ' + einrichtung() + ':' : '💳 Ihr Guthaben:';
guthabenZahl.textContent = n;
guthabenBanner.style.display = 'block';
}
// Schlüsselname bewusst 'v2': setzt evtl. falsch gezählte Stände (aus der
// Testphase) bei bestehenden Besuchern automatisch einmalig zurück.
@@ -529,17 +630,27 @@
function setzeBenutzt(n) { localStorage.setItem(SPEICHER_SCHLUESSEL, String(n)); }
function zeigeStand() {
// Bezahlter Modus: Eingabe offen, Guthaben-Stand zeigen, Gratis-Sperre egal
if (hatGuthaben()) {
if (handyModus && handyModus.w) {
eingabe.style.display = 'block';
guthabenBanner.style.display = 'block';
guthabenZahl.textContent = guthaben();
guthabenBanner.style.display = 'none';
emailTeil.style.display = 'none';
fehlerBox.style.display = 'none';
kaufBlock.style.display = 'none';
codeTeil.style.display = 'none';
return;
}
// Bezahlter Modus: Eingabe offen, Guthaben-Stand zeigen, Gratis-Sperre egal
if (hatGuthaben()) {
eingabe.style.display = 'block';
zeigeGuthabenBanner(guthaben());
emailTeil.style.display = 'none';
fehlerBox.style.display = 'none';
kaufBlock.style.display = 'none';
codeTeil.style.display = 'none';
return;
}
guthabenBanner.style.display = 'none';
codeTeil.style.display = 'block';
// Kauf ist IMMER möglich — auch sofort, ohne die Gratis-Analysen zu nutzen.
kaufBlock.style.display = 'block';
// 2. Versuch: E-Mail-Feld einblenden; alles verbraucht: Gratis-Weg sperren
@@ -556,7 +667,7 @@
konsens.addEventListener('change', function () { startKnopf.disabled = !konsens.checked; });
startKnopf.addEventListener('click', function () {
fehlerBox.style.display = 'none';
if (!hatGuthaben() && benutzt() >= 1 && !emailFeld.value.trim()) {
if (!walletFuerAnalyse() && benutzt() >= 1 && !emailFeld.value.trim()) {
emailTeil.style.display = 'block';
emailFeld.focus();
zeigeFehler('Bitte geben Sie zuerst Ihre E-Mail-Adresse ein — dann wählen Sie den Brief aus.');
@@ -625,7 +736,8 @@
try {
var istPdf = datei.type === 'application/pdf';
var base64 = istPdf ? await pdfAlsBase64(datei) : await bildVerkleinern(datei);
var bezahlt = hatGuthaben();
var wallet = walletFuerAnalyse();
var bezahlt = !!wallet;
var body = {
bild: base64,
mimeType: istPdf ? 'application/pdf' : 'image/jpeg',
@@ -633,8 +745,10 @@
};
var email = emailFeld.value.trim();
body.zielsprache = zielsprache();
if (bezahlt) body.walletToken = walletToken();
if (bezahlt) body.walletToken = wallet;
else if (benutzt() >= 1 && email) body.email = email;
if (handyModus && handyModus.d) body.auchDeutsch = true;
meldeAnPc({ schritt: 'analyse' });
var antwort = await fetch(ENDPUNKT, {
method: 'POST',
@@ -647,6 +761,12 @@
status.style.display = 'none';
eingabe.style.display = 'block';
if (window.turnstile) window.turnstile.reset();
meldeAnPc({ schritt: 'fehler', meldung: daten.meldung || '' });
// Guthaben vom PC: Fehler nur anzeigen, am Handy nichts umstellen
if (handyModus && handyModus.w) {
zeigeFehler(daten.meldung || 'Etwas ist schiefgelaufen. Bitte erneut versuchen.');
return;
}
if (daten.code === 'email_noetig') {
// Nur das E-Mail-Feld zeigen — NICHT als verbraucht markieren.
// (Sonst würde ein serverseitiges "E-Mail nötig", z. B. bei
@@ -660,6 +780,14 @@
zeigeStand();
return;
}
if (daten.code === 'kontingent_leer' || daten.code === 'kontingent_abgelaufen') {
setzeWallet('');
setzeGuthaben(0);
setzeEinrichtung(null);
zeigeStand();
zeigeFehler(daten.meldung);
return;
}
if (daten.code === 'kein_guthaben') {
setzeGuthaben(0);
guthabenBanner.style.display = 'none';
@@ -674,15 +802,29 @@
// Token erneuern — sonst würde die nächste Analyse ein "Seite neu laden" verlangen
if (window.turnstile) window.turnstile.reset();
// Handy fotografiert für den PC: Ergebnis verschlüsselt hinüberschicken
await meldeAnPc({
analyse: daten.analyse, analyse_de: daten.analyse_de, sprache: daten.sprache,
guthaben: daten.guthaben, einrichtung: daten.einrichtung,
}, 'ergebnis');
var analyse = bereiteVersionen(daten, false);
if (handyModus && handyModus.w) {
zeigeAnalyse(analyse);
eingabe.style.display = 'block';
document.getElementById('ergebnis').scrollIntoView({ behavior: 'smooth' });
return;
}
if (daten.versuch === 'bezahlt' || typeof daten.guthaben === 'number') {
setzeGuthaben(daten.guthaben);
zeigeErgebnis(daten.analyse, null, daten.guthaben);
zeigeErgebnis(analyse, null, daten.guthaben);
return;
}
setzeBenutzt(Math.max(benutzt(), daten.versuch));
if (email) document.getElementById('wlEmail').value = email;
zeigeErgebnis(daten.analyse, daten.versuch);
zeigeErgebnis(analyse, daten.versuch);
} catch (e) {
status.style.display = 'none';
eingabe.style.display = 'block';
@@ -708,7 +850,33 @@
return { farbe: farbe, text: punkt + ' ' + text };
}
function zeigeErgebnis(analyse, versuch, guthabenRest) {
// Eine Analyse kann in zwei Sprachen vorliegen (PC Deutsch, Handy Muttersprache).
var versionen = [];
var aktiveVersion = 0;
var letzteDaten = null; // { analyse, analyse_de, sprache } — für „Mitnehmen"
function bereiteVersionen(d, deutschZuerst) {
letzteDaten = { analyse: d.analyse, analyse_de: d.analyse_de, sprache: d.sprache || 'de' };
versionen = [{ code: letzteDaten.sprache, analyse: d.analyse }];
if (d.analyse_de) versionen[deutschZuerst ? 'unshift' : 'push']({ code: 'de', analyse: d.analyse_de });
aktiveVersion = 0;
return versionen[0].analyse;
}
function zeichneUmschalter() {
var box = document.getElementById('sprachUmschalter');
box.innerHTML = '';
box.style.display = versionen.length > 1 ? 'flex' : 'none';
versionen.forEach(function (v, i) {
var b = document.createElement('button');
b.type = 'button';
b.textContent = SPRACH_NAMEN[v.code] || v.code;
b.setAttribute('aria-pressed', i === aktiveVersion ? 'true' : 'false');
b.addEventListener('click', function () { aktiveVersion = i; zeigeAnalyse(v.analyse); });
box.appendChild(b);
});
}
/** Nur die Anzeige einer Analyse — ohne Guthaben/Gratis-Ablauf. */
function zeigeAnalyse(analyse) {
status.style.display = 'none';
// Optionale Karten zuruecksetzen. Sonst bleibt beim zweiten Brief z. B.
// die Checkliste des ERSTEN Briefs stehen — zwei Briefe vermischt.
@@ -751,8 +919,15 @@
zeigeKalender(analyse);
zeigeAnruf(analyse);
zeichneUmschalter();
document.getElementById('mitnehmenTeil').style.display = istPc && !handyModus && !leseModus ? 'block' : 'none';
document.getElementById('mitnehmenBox').style.display = 'none';
document.getElementById('ergebnis').style.display = 'block';
}
function zeigeErgebnis(analyse, versuch, guthabenRest) {
zeigeAnalyse(analyse);
// Bezahlter Modus: Guthaben-Stand zeigen, Eingabe für den nächsten Brief öffnen
if (typeof guthabenRest === 'number') {
@@ -760,8 +935,7 @@
kaufOk.style.display = 'none';
if (guthabenRest > 0) {
eingabe.style.display = 'block';
guthabenBanner.style.display = 'block';
guthabenZahl.textContent = guthabenRest;
zeigeGuthabenBanner(guthabenRest);
emailTeil.style.display = 'none';
kaufBlock.style.display = 'none';
} else {
@@ -1039,6 +1213,7 @@
if (res.ok && res.d && res.d.token) {
setzeWallet(res.d.token);
setzeGuthaben(res.d.guthaben);
setzeEinrichtung(null);
status.style.display = 'none';
kaufOk.style.display = 'block';
zeigeStand();
@@ -1055,10 +1230,241 @@
});
}
// ---- Einrichtungs-Code einlösen ------------------------------------
function loeseCodeEin(code) {
code = String(code || '').trim().toUpperCase();
if (!code) { codeFeld.focus(); return; }
if (walletToken() && guthaben() > 0 && !einrichtung() &&
!window.confirm('Sie haben noch gekauftes Guthaben. Wenn Sie den Code einlösen, nutzt diese Seite den Code. Ihr gekauftes Guthaben bleibt über den Link in Ihrer Kauf-E-Mail erhalten. Weiter?')) {
return;
}
codeKnopf.disabled = true;
fehlerBox.style.display = 'none';
fetch(BASIS + '/code', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: code }),
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (res) {
codeKnopf.disabled = false;
if (!res.ok || !res.d.token) {
codeTeil.open = true;
codeFeld.value = code;
zeigeFehler(res.d.meldung || 'Der Code konnte nicht eingelöst werden.');
return;
}
setzeWallet(res.d.token);
setzeGuthaben(res.d.guthaben);
setzeEinrichtung(res.d.einrichtung);
zeigeStand();
if (res.d.guthaben <= 0) {
zeigeFehler('Die Analysen von ' + res.d.einrichtung + ' sind aufgebraucht. Bitte fragen Sie dort nach.');
return;
}
kaufOk.textContent = '✅ Code eingelöst. BehördenKlar ist für Sie kostenlos, über ' + res.d.einrichtung + '.';
kaufOk.style.display = 'block';
})
.catch(function () {
codeKnopf.disabled = false;
zeigeFehler('Verbindung fehlgeschlagen. Bitte prüfen Sie Ihr Internet und versuchen Sie es erneut.');
});
}
codeKnopf.addEventListener('click', function () { loeseCodeEin(codeFeld.value); });
codeFeld.addEventListener('keydown', function (e) { if (e.key === 'Enter') loeseCodeEin(codeFeld.value); });
// ---- Handy ↔ PC ------------------------------------------------------
var verbundenBanner = document.getElementById('verbundenBanner');
var handyKnopf = document.getElementById('handyKnopf');
var handyBox = document.getElementById('handyBox');
var handyQr = document.getElementById('handyQr');
var handyZustand = document.getElementById('handyZustand');
var handyDeutsch = document.getElementById('handyDeutsch');
var handyDeutschZeile = document.getElementById('handyDeutschZeile');
var UEBERGABE_MS = BKUebergabe.LEBENSDAUER_MIN * 60 * 1000;
function zeigeQr(el, adresse) {
el.innerHTML = BKUebergabe.qrSvg(adresse);
el.setAttribute('data-link', adresse); // zum Testen ohne Kamera
}
// PC: QR-Code zeigen und warten, bis das Handy fotografiert hat
var pcWarten = null; // { ue, bis, timer }
var pruefeLaeuft = false;
function zeichneHandyQr() {
if (!pcWarten) return;
var sprache = zielsprache();
handyDeutschZeile.style.display = sprache === 'de' ? 'none' : 'flex';
zeigeQr(handyQr, BKUebergabe.adresse({
a: 'k', s: pcWarten.ue.id, k: pcWarten.ue.k, l: sprache,
d: sprache !== 'de' && handyDeutsch.checked ? '1' : '',
w: hatGuthaben() ? walletToken() : '',
}));
}
function stoppeWarten() {
if (pcWarten && pcWarten.timer) clearInterval(pcWarten.timer);
pcWarten = null;
}
function schliesseHandyBox() {
handyBox.style.display = 'none';
handyKnopf.style.display = istPc ? 'block' : 'none';
}
handyKnopf.addEventListener('click', async function () {
stoppeWarten();
fehlerBox.style.display = 'none';
try {
pcWarten = { ue: await BKUebergabe.neu(), bis: Date.now() + UEBERGABE_MS };
} catch (e) {
zeigeFehler('Dieser Browser kann die verschlüsselte Übergabe nicht. Bitte einen aktuellen Browser verwenden.');
return;
}
zeichneHandyQr();
handyZustand.textContent = '⏳ Warte auf das Handy …';
handyKnopf.style.display = 'none';
handyBox.style.display = 'block';
handyBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
pcWarten.timer = setInterval(pruefeHandy, 1500);
});
handyDeutsch.addEventListener('change', zeichneHandyQr);
document.getElementById('handyAbbrechen').addEventListener('click', function () {
if (pcWarten) BKUebergabe.loeschen(pcWarten.ue);
stoppeWarten();
schliesseHandyBox();
});
async function pruefeHandy() {
if (!pcWarten || pruefeLaeuft) return;
if (Date.now() > pcWarten.bis) {
stoppeWarten();
schliesseHandyBox();
zeigeFehler('Der QR-Code ist abgelaufen. Tippen Sie noch einmal auf „Lieber mit dem Handy fotografieren".');
return;
}
pruefeLaeuft = true;
try {
var ue = pcWarten.ue;
var f = await BKUebergabe.holen(ue);
if (!pcWarten || pcWarten.ue !== ue) return; // inzwischen abgebrochen
if (f.ergebnis) {
stoppeWarten();
BKUebergabe.loeschen(ue);
schliesseHandyBox();
zeigeVomHandy(f.ergebnis);
} else if (f.status) {
handyZustand.textContent =
f.status.schritt === 'analyse' ? '⏳ Das Handy hat fotografiert. Der Brief wird erklärt …' :
f.status.schritt === 'fehler' ? '⚠️ Am Handy hat es nicht geklappt: ' + (f.status.meldung || '') + ' Bitte dort noch einmal versuchen.' :
'✅ Handy verbunden. Jetzt am Handy den Brief fotografieren.';
}
} catch (e) {
// kurz keine Verbindung — beim nächsten Durchlauf erneut versuchen
} finally {
pruefeLaeuft = false;
}
}
function zeigeVomHandy(e) {
// Hat das Handy das Guthaben dieses PCs benutzt, den neuen Stand übernehmen
if (typeof e.guthaben === 'number' && hatGuthaben()) setzeGuthaben(e.guthaben);
zeigeAnalyse(bereiteVersionen(e, true));
werbung.style.display = 'none';
zeigeStand();
document.getElementById('ergebnis').scrollIntoView({ behavior: 'smooth' });
}
// PC: Ergebnis per QR-Code aufs Handy mitgeben
document.getElementById('mitnehmenKnopf').addEventListener('click', async function () {
var knopf = this;
if (!letzteDaten) return;
knopf.disabled = true;
try {
var ue = await BKUebergabe.neu();
await BKUebergabe.senden(ue, 'ergebnis', letzteDaten);
zeigeQr(document.getElementById('mitnehmenQr'), BKUebergabe.adresse({ a: 'm', s: ue.id, k: ue.k }));
document.getElementById('mitnehmenBox').style.display = 'block';
} catch (e) {
zeigeFehler('Das Ergebnis konnte nicht vorbereitet werden. Bitte prüfen Sie die Internetverbindung.');
}
knopf.disabled = false;
});
// PC: Guthaben bzw. Zugang der Beratungsstelle aufs Handy holen
document.getElementById('guthabenHandyKnopf').addEventListener('click', function () {
var box = document.getElementById('guthabenQrBox');
if (box.style.display === 'block') { box.style.display = 'none'; return; }
zeigeQr(document.getElementById('guthabenQr'), BKUebergabe.adresse({
a: 'g', w: walletToken(), n: guthaben(), e: einrichtung(),
}));
box.style.display = 'block';
});
/** Handy: Seite wurde über einen QR-Code vom PC geöffnet. */
async function pruefeUebergabe(h) {
if (!h || !h.a) return;
if (h.a === 'g') { uebernehmeGuthaben(h); return; }
var ue = await BKUebergabe.oeffne(h.s, h.k).catch(function () { return null; });
if (!ue) {
zeigeFehler('Dieser QR-Code ist ungültig. Bitte am Computer einen neuen erzeugen.');
return;
}
if (h.a === 'k') {
handyModus = { ue: ue, w: h.w || '', d: h.d === '1' };
if (h.l && SPRACH_NAMEN[h.l]) {
try { localStorage.setItem(SPRACH_SCHLUESSEL, h.l); } catch (e) {}
document.getElementById('sprachWahl').value = h.l;
}
verbundenBanner.textContent = '🔗 Mit dem Computer verbunden. Fotografieren Sie jetzt Ihren Brief. Die Erklärung erscheint hier und auf dem Bildschirm.';
verbundenBanner.style.display = 'block';
handyKnopf.style.display = 'none';
zeigeStand();
meldeAnPc({ schritt: 'verbunden' });
return;
}
if (h.a === 'm') {
leseModus = true;
eingabe.style.display = 'none';
kaufBlock.style.display = 'none';
status.querySelector('.gross').textContent = 'Das Ergebnis wird geladen …';
status.style.display = 'block';
var f = null;
try { f = await BKUebergabe.holen(ue); } catch (e) {}
status.style.display = 'none';
if (!f || !f.ergebnis) {
leseModus = false;
zeigeStand();
zeigeFehler('Dieser QR-Code ist abgelaufen oder wurde schon benutzt (er gilt ' + BKUebergabe.LEBENSDAUER_MIN + ' Minuten und nur einmal). Bitte am Computer einen neuen erzeugen.');
return;
}
BKUebergabe.loeschen(ue);
verbundenBanner.textContent = '📱 Vom Computer übernommen. Tragen Sie die Frist gleich in Ihren Kalender ein, dann vergessen Sie nichts.';
verbundenBanner.style.display = 'block';
zeigeAnalyse(bereiteVersionen(f.ergebnis, false));
document.getElementById('ergebnis').scrollIntoView({ behavior: 'smooth' });
}
}
function uebernehmeGuthaben(h) {
if (!h.w) return;
if (walletToken() && walletToken() !== h.w && guthaben() > 0 &&
!window.confirm('Auf diesem Handy ist schon Guthaben gespeichert. Durch das vom Computer ersetzen? Ihr bisheriges Guthaben bleibt über den Link in Ihrer Kauf-E-Mail erhalten.')) {
return;
}
setzeWallet(h.w);
setzeGuthaben(parseInt(h.n, 10) || 1);
setzeEinrichtung(h.e || null);
kaufOk.textContent = h.e ? '✅ Übernommen. BehördenKlar ist für Sie kostenlos, über ' + h.e + '.' : '✅ Guthaben übernommen.';
kaufOk.style.display = 'block';
zeigeStand();
}
function pruefeRueckkehr() {
var p = new URLSearchParams(window.location.search);
var w = p.get('wallet');
if (w) { setzeWallet(w); setzeGuthaben(Math.max(guthaben(), 1)); reinigeUrl(); return; }
if (w) { setzeWallet(w); setzeGuthaben(Math.max(guthaben(), 1)); setzeEinrichtung(null); reinigeUrl(); return; }
var c = p.get('code');
if (c) { reinigeUrl(); loeseCodeEin(c); return; }
var kauf = p.get('kauf');
if (kauf === 'abbruch') {
reinigeUrl();
@@ -1093,12 +1499,21 @@
});
feld.addEventListener('change', function () {
try { localStorage.setItem(SPRACH_SCHLUESSEL, feld.value); } catch (e) {}
zeichneHandyQr();
});
})();
rendrePakete();
var startHash = BKUebergabe.ausAdresse();
if (startHash && window.history.replaceState) {
// Schlüssel nicht im Browser-Verlauf stehen lassen
window.history.replaceState(null, document.title, window.location.pathname + window.location.search);
}
pruefeRueckkehr();
zeigeStand();
handyKnopf.style.display = istPc ? 'block' : 'none';
document.getElementById('guthabenHandyKnopf').style.display = istPc ? '' : 'none';
pruefeUebergabe(startHash);
})();
</script>
<!-- Cloudflare Web Analytics: cookielose Reichweitenmessung, keine Nutzer-Profile -->
+2297
View File
File diff suppressed because it is too large. Load diff
+121
View File
@@ -0,0 +1,121 @@
/**
* Übergabe Handy ↔ PC — Ende-zu-Ende verschlüsselt.
*
* Ein Gerät erzeugt eine Übergabe (zufällige ID + AES-256-Schlüssel) und
* zeigt sie als QR-Code. Beides steht hinter dem `#` der Adresse; dieser
* Teil wird von Browsern NIE an einen Server geschickt. Der Worker sieht nur
* verschlüsselte Päckchen und löscht sie nach 15 Minuten.
*
* Päckchen-Format: base64url( IV[12 Byte] || AES-GCM-Chiffrat ) eines JSON.
* Die App (src/utils/uebergabe.ts) benutzt exakt dasselbe Format.
*/
(function (w) {
'use strict';
var LOKAL = /^(localhost|127\.0\.0\.1)$/.test(w.location.hostname);
var BASIS = (LOKAL ? 'http://localhost:8799' : 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev') + '/uebergabe/';
function b64u(bytes) {
var s = '';
for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function ausB64u(text) {
var s = atob(text.replace(/-/g, '+').replace(/_/g, '/'));
var b = new Uint8Array(s.length);
for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
return b;
}
function zufall(n) { return w.crypto.getRandomValues(new Uint8Array(n)); }
function schluessel(roh) {
return w.crypto.subtle.importKey('raw', roh, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}
/** Neue Übergabe: { id, k, key } — k ist der Schlüssel als Text für den QR-Code. */
async function neu() {
var roh = zufall(32);
return { id: b64u(zufall(16)), k: b64u(roh), key: await schluessel(roh) };
}
/** Übergabe aus ID + Schlüsseltext (aus dem QR-Code) öffnen. */
async function oeffne(id, k) {
if (!/^[A-Za-z0-9_-]{22}$/.test(id || '') || !/^[A-Za-z0-9_-]{43}$/.test(k || '')) return null;
return { id: id, k: k, key: await schluessel(ausB64u(k)) };
}
async function verschluesseln(ue, daten) {
var iv = zufall(12);
var klar = new TextEncoder().encode(JSON.stringify(daten));
var ct = new Uint8Array(await w.crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, ue.key, klar));
var alles = new Uint8Array(iv.length + ct.length);
alles.set(iv, 0); alles.set(ct, iv.length);
return b64u(alles);
}
/** Entschlüsseln; null, wenn das Päckchen nicht von dieser Übergabe stammt. */
async function entschluesseln(ue, text) {
try {
var b = ausB64u(text);
var klar = await w.crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.slice(0, 12) }, ue.key, b.slice(12));
return JSON.parse(new TextDecoder().decode(klar));
} catch (e) { return null; }
}
/** Päckchen in ein Fach legen (z. B. „status", „ergebnis"). */
async function senden(ue, fach, daten) {
var r = await fetch(BASIS + ue.id + '/' + fach, { method: 'PUT', body: await verschluesseln(ue, daten) });
if (!r.ok) throw new Error('Übergabe fehlgeschlagen (' + r.status + ')');
}
/** Alle Fächer holen und entschlüsseln: { status: {...}, ergebnis: {...} }. */
async function holen(ue) {
var r = await fetch(BASIS + ue.id, { cache: 'no-store' });
if (!r.ok) throw new Error('Übergabe nicht erreichbar (' + r.status + ')');
var faecher = (await r.json()).faecher || {};
var aus = {};
for (var f in faecher) {
var d = await entschluesseln(ue, faecher[f]);
if (d) aus[f] = d;
}
return aus;
}
function loeschen(ue) {
return fetch(BASIS + ue.id, { method: 'DELETE' }).catch(function () {});
}
/** Adresse für den QR-Code. `werte` landet komplett hinter dem `#`. */
function adresse(werte) {
var teile = [];
for (var k in werte) if (werte[k] !== undefined && werte[k] !== null && werte[k] !== '') {
teile.push(k + '=' + encodeURIComponent(werte[k]));
}
return w.location.origin + w.location.pathname + '#' + teile.join('&');
}
/** Werte hinter dem `#` lesen (oder null, wenn keine da sind). */
function ausAdresse() {
var h = w.location.hash.replace(/^#/, '');
if (!h) return null;
var werte = {};
h.split('&').forEach(function (t) {
var i = t.indexOf('=');
if (i > 0) werte[t.slice(0, i)] = decodeURIComponent(t.slice(i + 1));
});
return werte;
}
/** QR-Code als SVG-Text (braucht qrcode.js). */
function qrSvg(text) {
var qr = w.qrcode(0, 'M');
qr.addData(text);
qr.make();
return qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true });
}
w.BKUebergabe = {
neu: neu, oeffne: oeffne, senden: senden, holen: holen, loeschen: loeschen,
adresse: adresse, ausAdresse: ausAdresse, qrSvg: qrSvg,
LEBENSDAUER_MIN: 15,
};
})(window);