Files
beh-rde-brief/src/utils/uebergabeFormat.ts
T
JUSNandClaude Opus 5.5 cf188af080 App: QR-Uebergabe mit dem Beratungs-PC
- Brief -> "Am Computer zeigen": schickt den Brief verschluesselt auf den
  Bildschirm der Beratung (zweisprachig, wenn eine Uebersetzung da ist)
- Archiv -> "Vom Computer uebernehmen": holt das Ergebnis vom PC ins
  verschluesselte Archiv, mit Erinnerungen
- Gleiches Paeckchen-Format wie die Webseite (expo-crypto combined =
  IV||Chiffrat||Tag); Test prueft beide Richtungen gegen WebCrypto
- Link-Einfuegen als Ersatz, wenn keine Kamera da ist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:46:29 +02:00

118 lines
4.6 KiB
TypeScript

/**
* Übergabe Handy ↔ PC — reine Hilfsfunktionen ohne native Module (testbar).
*
* Gleiches Protokoll wie die Webseite (webseite/uebergabe.js): Der QR-Code
* enthält hinter dem `#` eine Übergabe-ID (s), einen AES-256-Schlüssel (k)
* und die Art der Übergabe (a):
* a=k „Kamera": der PC wartet auf einen Brief → App schickt ihn hin
* a=m „Mitnehmen": der PC hat ein Ergebnis bereitgelegt → App holt es
* a=g Guthaben der Webseite (gilt nur im Browser, nicht in der App)
*/
import { BriefAnalyse, BriefEintrag, Fachbegriff, Uebersetzung } from '../types';
export type UebergabeArt = 'k' | 'm' | 'g';
export interface UebergabeLink {
art: UebergabeArt;
id: string;
schluessel: string; // base64url, 32 Byte
/** Worker, der die Päckchen hält (lokal beim Testen, sonst live). */
basis: string;
}
const LIVE_BASIS = 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev';
const LOKAL_BASIS = 'http://localhost:8799';
const ERLAUBTE_SEITEN = /^https:\/\/([a-z0-9-]+\.)?behoerdenklar\.(de|pages\.dev)\//;
const LOKALE_SEITE = /^http:\/\/(localhost|127\.0\.0\.1):\d+\//;
/** Liest einen gescannten QR-Code. null = kein BehördenKlar-Code. */
export function leseUebergabeLink(text: string): UebergabeLink | null {
const t = (text || '').trim();
const lokal = LOKALE_SEITE.test(t);
if (!ERLAUBTE_SEITEN.test(t) && !lokal) return null;
const raute = t.indexOf('#');
if (raute < 0) return null;
const werte: Record<string, string> = {};
for (const teil of t.slice(raute + 1).split('&')) {
const i = teil.indexOf('=');
if (i > 0) werte[teil.slice(0, i)] = decodeURIComponent(teil.slice(i + 1));
}
const art = werte.a as UebergabeArt;
if (art === 'g') return { art, id: '', schluessel: '', basis: '' };
if ((art !== 'k' && art !== 'm') || !/^[A-Za-z0-9_-]{22}$/.test(werte.s ?? '') || !/^[A-Za-z0-9_-]{43}$/.test(werte.k ?? '')) {
return null;
}
return { art, id: werte.s, schluessel: werte.k, basis: lokal ? LOKAL_BASIS : LIVE_BASIS };
}
export function base64UrlZuBase64(s: string): string {
const b = s.replace(/-/g, '+').replace(/_/g, '/');
return b + '='.repeat((4 - (b.length % 4)) % 4);
}
export function base64ZuBase64Url(s: string): string {
return s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** Was die Webseite als Ergebnis schickt bzw. erwartet. */
export interface WebPaket {
analyse: WebAnalyse;
analyse_de?: WebAnalyse;
sprache?: string;
}
/** Die Analyse der Webseite: wie BriefAnalyse, dafür mit Kontakt + Anruf-Hilfe. */
type WebAnalyse = Partial<BriefAnalyse> & {
kontakt?: unknown;
anruf?: unknown;
};
function textOder(s: unknown, ersatz = ''): string {
return typeof s === 'string' ? s : ersatz;
}
function alsBriefAnalyse(w: WebAnalyse): BriefAnalyse {
return {
brieftyp: textOder(w.brieftyp, 'Brief'),
absender: textOder(w.absender),
kernaussage: textOder(w.kernaussage),
erklaerung_einfach: textOder(w.erklaerung_einfach),
fachbegriffe: Array.isArray(w.fachbegriffe) ? (w.fachbegriffe as Fachbegriff[]) : [],
frist: w.frist ?? null,
termin: w.termin ?? null,
checkliste: Array.isArray(w.checkliste) ? w.checkliste.filter((x) => typeof x === 'string') : [],
// Die Web-Analyse kennt keine Antwort-Vorschläge — die App kann sie nicht erfinden
antwort_noetig: false,
antwort_optionen: [],
};
}
/** Ergebnis vom PC → Brief fürs Archiv. Deutsch ist die Grundlage, die
* andere Sprache landet im Übersetzungs-Cache (wie bei App-Übersetzungen). */
export function webPaketZuBrief(p: WebPaket, id: string, jetzt: Date): BriefEintrag {
const sprache = p.sprache && p.sprache !== 'de' ? p.sprache : null;
const deutsch = p.analyse_de ?? (sprache ? null : p.analyse);
const basis = alsBriefAnalyse(deutsch ?? p.analyse);
const uebersetzungen: Record<string, Uebersetzung> = {};
if (sprache) {
const f = alsBriefAnalyse(p.analyse);
uebersetzungen[sprache] = {
kernaussage: f.kernaussage,
erklaerung_einfach: f.erklaerung_einfach,
checkliste: f.checkliste,
fachbegriffe: f.fachbegriffe,
};
}
return { id, erstelltAm: jetzt.toISOString(), analyse: basis, uebersetzungen };
}
/** Brief aus dem Archiv → Ergebnis für den PC. Gibt es eine Übersetzung,
* bekommt der PC beide Sprachen (Deutsch zuerst für die Beratung). */
export function briefZuWebPaket(b: BriefEintrag): WebPaket {
const deutsch: WebAnalyse = { ...b.analyse };
const [sprache] = Object.keys(b.uebersetzungen ?? {});
if (!sprache) return { analyse: deutsch, sprache: 'de' };
return {
analyse: { ...b.analyse, ...b.uebersetzungen[sprache] },
analyse_de: deutsch,
sprache,
};
}