Worker: Einrichtungs-Codes, verschluesselte Uebergabe Handy<->PC, zweisprachige Analyse

- /code loest Einrichtungs-Codes fuer Beratungsstellen ein (Guthaben mit
  Name + Ablaufdatum, max. 10 Fehlversuche pro IP und Tag). Leeres oder
  abgelaufenes Kontingent sagt "fragen Sie bei <Stelle> nach" statt "kaufen".
- /uebergabe/<id>: Durable Object haelt nur Chiffrat (AES-GCM, Schluessel
  nur im QR-Code), loescht nach 15 Minuten, 50 Schreibvorgaenge, Rate-Limit.
  Durable Object statt KV, weil KV fehlende Schluessel zwischenspeichert.
- /demo mit auchDeutsch: deutsch analysieren (Sonnet), dann mit Haiku in die
  Zielsprache uebersetzen - PC zeigt Deutsch, Handy die Muttersprache.
- LOKAL_TEST=1 (.dev.vars) erlaubt localhost fuer Tests mit wrangler dev.
- tools/einrichtungs-code.js: neu | stand | aufladen | verlaengern.

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 7621dd054a
commit 0eab2a0ee9
3 files changed
+392 -10

No files matched your search

+266 -10
View File
@@ -1,3 +1,5 @@
import { DurableObject } from 'cloudflare:workers';
/**
* BehördenKlar Backend-Proxy — Cloudflare Worker
*
@@ -36,6 +38,12 @@ interface Env {
TOKEN_SIGNING_SECRET: string;
/** Bezahlung: `wrangler secret put BREVO_API_KEY` (für die Guthaben-Link-Mail) */
BREVO_API_KEY: string;
/** Nur lokal (.dev.vars): "1" erlaubt Aufrufe von http://localhost */
LOKAL_TEST?: string;
/** Handy ↔ PC: verschlüsselte Übergabe (Durable Object, siehe unten) */
UEBERGABE: DurableObjectNamespace<Uebergabe>;
/** Handy ↔ PC: Schreib-Bremse pro IP (Rate-Limit-Binding in wrangler.toml) */
UEBERGABE_LIMIT: RateLimit;
}
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
@@ -71,6 +79,12 @@ function fehler(status: number, typ: string, meldung: string): Response {
/** Nur die eigene Webseite darf den Demo-Endpunkt aufrufen (CORS). */
const DEMO_ORIGIN_MUSTER = /^https:\/\/([a-z0-9-]+\.)?behoerdenklar\.(de|pages\.dev)$/;
/** Nur für `wrangler dev` mit LOKAL_TEST=1 in .dev.vars — live nie gesetzt. */
const LOKAL_MUSTER = /^http:\/\/localhost:\d+$/;
let lokalTest = false;
function originOk(origin: string | null): origin is string {
return !!origin && (DEMO_ORIGIN_MUSTER.test(origin) || (lokalTest && LOKAL_MUSTER.test(origin)));
}
/** ~5 MB Datei entsprechen ~6,7 Mio. Base64-Zeichen. */
const DEMO_MAX_BASE64 = 7_000_000;
@@ -82,6 +96,10 @@ const WEGWERF_DOMAINS = new Set([
'getnada.com', 'dispostable.com', 'maildrop.cc', 'throwawaymail.com',
]);
/** Was in jeder Sprache deutsch bzw. unverändert bleiben muss. */
const SPRACH_AUSNAHMEN = (sprachName: string) =>
`Ausnahme: das Feld "begriff" bei fachbegriffe bleibt der deutsche Originalbegriff aus dem Brief (der Nutzer muss ihn im Brief wiederfinden) — nur dessen "erklaerung" wird auf ${sprachName} geschrieben. Datumsangaben bleiben im ISO-Format. Ebenfalls NICHT übersetzen: das Feld "deutsch" bei anruf (das wird am Telefon auf Deutsch gesagt) und alle kontakt-Felder (sie stehen so im Brief).`;
/** Identisch zur App (src/services/analyse.ts) — gleiche Qualität in der Demo. */
/** Zielsprachen der Web-Demo — identisch zur Sprachauswahl der Webseite.
* Der Wert wird NUR als Name in den Prompt gesetzt, nie als freier Text,
@@ -178,7 +196,7 @@ const DEMO_SCHEMA = {
};
function demoCorsHeaders(origin: string | null): Record<string, string> {
const erlaubt = origin && DEMO_ORIGIN_MUSTER.test(origin) ? origin : 'https://behoerdenklar.de';
const erlaubt = originOk(origin) ? origin : 'https://behoerdenklar.de';
return {
'access-control-allow-origin': erlaubt,
'access-control-allow-methods': 'POST, OPTIONS',
@@ -199,6 +217,39 @@ async function emailHash(email: string, pepper: string): Promise<string> {
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
/** Fertige deutsche Analyse in eine andere Sprache übersetzen (Haiku, gleiches Schema).
* Gibt null zurück, wenn etwas schiefgeht — der Aufrufer zeigt dann Deutsch. */
async function uebersetzeAnalyse(env: Env, analyse: unknown, sprachName: string): Promise<unknown | null> {
try {
const r = await fetch(ANTHROPIC_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': env.ANTHROPIC_API_KEY,
'anthropic-version': ANTHROPIC_VERSION,
},
body: JSON.stringify({
model: 'claude-haiku-4-5',
max_tokens: MAX_TOKENS_OBERGRENZE,
system: `Du übersetzt die Erklärung eines deutschen Behördenbriefs für Menschen mit wenig Deutschkenntnissen. Du bekommst ein JSON-Objekt. Gib dasselbe Objekt mit derselben Struktur zurück, aber schreibe alle Textfelder auf ${sprachName}, in einfacher, klarer Sprache. Erfinde nichts dazu und lass nichts weg; null bleibt null. ${SPRACH_AUSNAHMEN(sprachName)}`,
output_config: { format: { type: 'json_schema', schema: DEMO_SCHEMA } },
messages: [{ role: 'user', content: JSON.stringify(analyse) }],
}),
});
if (!r.ok) {
console.log('Übersetzung fehlgeschlagen', r.status, (await r.text()).slice(0, 300));
return null;
}
const d = (await r.json()) as { stop_reason?: string; content?: { type: string; text?: string }[] };
const text = [...(d.content ?? [])].reverse().find((b) => b.type === 'text')?.text;
if (d.stop_reason !== 'end_turn' || !text) return null;
return JSON.parse(text);
} catch (e) {
console.log('Übersetzung fehlgeschlagen', String(e));
return null;
}
}
async function demoHandler(request: Request, env: Env): Promise<Response> {
const origin = request.headers.get('origin');
if (request.method === 'OPTIONS') {
@@ -207,11 +258,11 @@ async function demoHandler(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') {
return demoFehler(405, 'methode', 'Nur POST erlaubt.', origin);
}
if (!origin || !DEMO_ORIGIN_MUSTER.test(origin)) {
if (!originOk(origin)) {
return demoFehler(403, 'origin', 'Aufruf nur von der BehördenKlar-Webseite erlaubt.', origin);
}
let body: { bild?: string; mimeType?: string; turnstileToken?: string; email?: string; walletToken?: string; zielsprache?: string };
let body: { bild?: string; mimeType?: string; turnstileToken?: string; email?: string; walletToken?: string; zielsprache?: string; auchDeutsch?: boolean };
try {
body = await request.json();
} catch {
@@ -224,6 +275,10 @@ async function demoHandler(request: Request, env: Env): Promise<Response> {
const sprachCode = typeof body.zielsprache === 'string' && DEMO_SPRACHEN[body.zielsprache]
? body.zielsprache : 'de';
const sprachName = DEMO_SPRACHEN[sprachCode];
// Zweisprachig (Beratung am PC, Ratsuchende am Handy): erst auf Deutsch
// analysieren, dann mit Haiku übersetzen — wie in der App. Kostet den
// Nutzer weiterhin nur EINE Analyse.
const zweisprachig = body.auchDeutsch === true && sprachCode !== 'de';
if (typeof bild !== 'string' || bild.length === 0 || bild.length > DEMO_MAX_BASE64) {
return demoFehler(400, 'datei', 'Die Datei fehlt oder ist zu groß (max. 5 MB).', origin);
@@ -254,14 +309,25 @@ async function demoHandler(request: Request, env: Env): Promise<Response> {
// (die sind nur ein Kosten-Deckel für die kostenlose Stufe).
let walletId: string | null = null;
let walletRest = 0;
let einrichtungsName: string | null = null;
if (walletToken) {
walletId = await verifyWallet(walletToken, env.TOKEN_SIGNING_SECRET);
if (!walletId) {
return demoFehler(403, 'wallet_ungueltig', 'Ihr Zugangs-Token ist ungültig. Bitte laden Sie die Seite neu.', origin);
}
walletRest = parseInt((await env.RATE_LIMIT.get(`wallet:${walletId}`)) ?? '0', 10);
const [restWert, einrichtung] = await Promise.all([
env.RATE_LIMIT.get(`wallet:${walletId}`),
ladeEinrichtung(env, walletId),
]);
walletRest = parseInt(restWert ?? '0', 10);
einrichtungsName = einrichtung?.name ?? null;
if (einrichtung && einrichtung.ablauf < heute) {
return demoFehler(402, 'kontingent_abgelaufen', `Der Zugang über ${einrichtung.name} ist abgelaufen. Bitte fragen Sie dort nach.`, origin);
}
if (walletRest <= 0) {
return demoFehler(402, 'kein_guthaben', 'Ihr Guthaben ist aufgebraucht. Bitte kaufen Sie neues Guthaben.', origin);
return einrichtung
? demoFehler(402, 'kontingent_leer', `Die Analysen von ${einrichtung.name} sind aufgebraucht. Bitte fragen Sie dort nach.`, origin)
: demoFehler(402, 'kein_guthaben', 'Ihr Guthaben ist aufgebraucht. Bitte kaufen Sie neues Guthaben.', origin);
}
}
const bezahlt = walletId !== null;
@@ -336,9 +402,9 @@ async function demoHandler(request: Request, env: Env): Promise<Response> {
body: JSON.stringify({
model: 'claude-sonnet-5',
max_tokens: MAX_TOKENS_OBERGRENZE,
system: sprachCode === 'de'
system: sprachCode === 'de' || zweisprachig
? DEMO_SYSTEM_PROMPT
: `${DEMO_SYSTEM_PROMPT}\n\nWICHTIG — ZIELSPRACHE: Schreibe ALLE Textfelder deiner Antwort auf ${sprachName}, nicht auf Deutsch. Ausnahme: das Feld "begriff" bei fachbegriffe bleibt der deutsche Originalbegriff aus dem Brief (der Nutzer muss ihn im Brief wiederfinden) — nur dessen "erklaerung" wird auf ${sprachName} geschrieben. Datumsangaben bleiben im ISO-Format. Ebenfalls NICHT übersetzen: das Feld "deutsch" bei anruf (das wird am Telefon auf Deutsch gesagt) und alle kontakt-Felder (sie stehen so im Brief).`,
: `${DEMO_SYSTEM_PROMPT}\n\nWICHTIG — ZIELSPRACHE: Schreibe ALLE Textfelder deiner Antwort auf ${sprachName}, nicht auf Deutsch. ${SPRACH_AUSNAHMEN(sprachName)}`,
output_config: { format: { type: 'json_schema', schema: DEMO_SCHEMA } },
messages: [
{
@@ -408,12 +474,23 @@ async function demoHandler(request: Request, env: Env): Promise<Response> {
return demoFehler(502, 'ki', 'Die Antwort war unlesbar. Bitte erneut versuchen.', origin);
}
let analyseDe: unknown = undefined;
let ausgabeSprache = sprachCode;
if (zweisprachig) {
analyseDe = analyse;
const uebersetzt = await uebersetzeAnalyse(env, analyse, sprachName);
if (uebersetzt) analyse = uebersetzt;
else ausgabeSprache = 'de'; // lieber Deutsch als gar nichts
}
return Response.json(
{
analyse,
analyse_de: analyseDe,
versuch: bezahlt ? 'bezahlt' : email ? 2 : 1,
guthaben: bezahlt ? walletRest - 1 : undefined,
sprache: sprachCode,
einrichtung: einrichtungsName ?? undefined,
sprache: ausgabeSprache,
},
{ headers: demoCorsHeaders(origin) }
);
@@ -518,7 +595,7 @@ async function kaufenHandler(request: Request, env: Env): Promise<Response> {
const origin = request.headers.get('origin');
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: demoCorsHeaders(origin) });
if (request.method !== 'POST') return demoFehler(405, 'methode', 'Nur POST erlaubt.', origin);
if (!origin || !DEMO_ORIGIN_MUSTER.test(origin)) return demoFehler(403, 'origin', 'Aufruf nur von der BehördenKlar-Webseite erlaubt.', origin);
if (!originOk(origin)) return demoFehler(403, 'origin', 'Aufruf nur von der BehördenKlar-Webseite erlaubt.', origin);
let body: { priceId?: string; sprache?: string };
try {
@@ -571,7 +648,7 @@ async function kaufenHandler(request: Request, env: Env): Promise<Response> {
async function kaufStatusHandler(request: Request, env: Env): Promise<Response> {
const origin = request.headers.get('origin');
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: demoCorsHeaders(origin) });
if (!origin || !DEMO_ORIGIN_MUSTER.test(origin)) return demoFehler(403, 'origin', 'Nicht erlaubt.', origin);
if (!originOk(origin)) return demoFehler(403, 'origin', 'Nicht erlaubt.', origin);
const sessionId = new URL(request.url).searchParams.get('session_id') ?? '';
if (!/^cs_[A-Za-z0-9_]+$/.test(sessionId)) return demoFehler(400, 'session', 'Ungültige Sitzung.', origin);
@@ -593,6 +670,182 @@ async function kaufStatusHandler(request: Request, env: Env): Promise<Response>
return Response.json({ token, guthaben: rest }, { headers: demoCorsHeaders(origin) });
}
// ============================================================
// Einrichtungs-Codes — Beratungsstellen zahlen, Ratsuchende nicht
// ============================================================
//
// Ein Code (z. B. `MBE-K7QX`) zeigt auf ein Guthaben wie beim Kauf, nur dass
// es allen gehört, die den Code kennen. Angelegt wird er von Hand mit
// tools/einrichtungs-code.js (kein Secret nötig, nur wrangler-Login).
//
// KV: code:<CODE> -> walletId
// wallet:<walletId> -> Restguthaben (wie beim Kauf)
// einrichtung:<walletId> -> {"name":"MBE Donnersberg","ablauf":"2027-01-31"}
//
// Der zufällige Teil im Code macht Raten aussichtslos; zusätzlich sind
// Fehlversuche pro IP und Tag begrenzt.
const CODE_FEHLVERSUCHE_PRO_TAG = 10;
interface Einrichtung {
name: string;
/** letzter gültiger Tag, ISO-Datum */
ablauf: string;
}
async function ladeEinrichtung(env: Env, walletId: string): Promise<Einrichtung | null> {
const roh = await env.RATE_LIMIT.get(`einrichtung:${walletId}`);
if (!roh) return null;
try {
return JSON.parse(roh) as Einrichtung;
} catch {
return null;
}
}
/** POST /code — Einrichtungs-Code einlösen, signiertes Wallet-Token liefern. */
async function codeHandler(request: Request, env: Env): Promise<Response> {
const origin = request.headers.get('origin');
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: demoCorsHeaders(origin) });
if (request.method !== 'POST') return demoFehler(405, 'methode', 'Nur POST erlaubt.', origin);
if (!originOk(origin)) return demoFehler(403, 'origin', 'Nicht erlaubt.', origin);
let body: { code?: string };
try {
body = await request.json();
} catch {
return demoFehler(400, 'json', 'Ungültige Anfrage.', origin);
}
const code = typeof body.code === 'string' ? body.code.trim().toUpperCase().replace(/\s+/g, '') : '';
const ip = request.headers.get('cf-connecting-ip') ?? 'unbekannt';
const heute = new Date().toISOString().slice(0, 10);
const failKey = `codefail:${ip}:${heute}`;
const fehlversuche = parseInt((await env.RATE_LIMIT.get(failKey)) ?? '0', 10);
if (fehlversuche >= CODE_FEHLVERSUCHE_PRO_TAG) {
return demoFehler(429, 'code_gesperrt', 'Zu viele falsche Codes. Bitte versuchen Sie es morgen wieder.', origin);
}
const walletId = /^[A-Z0-9-]{4,40}$/.test(code) ? await env.RATE_LIMIT.get(`code:${code}`) : null;
const einrichtung = walletId ? await ladeEinrichtung(env, walletId) : null;
if (!walletId || !einrichtung) {
await env.RATE_LIMIT.put(failKey, String(fehlversuche + 1), { expirationTtl: 60 * 60 * 48 });
return demoFehler(404, 'code_unbekannt', 'Diesen Code kennen wir nicht. Bitte prüfen Sie die Schreibweise.', origin);
}
if (einrichtung.ablauf < heute) {
return demoFehler(410, 'kontingent_abgelaufen', `Der Zugang über ${einrichtung.name} ist abgelaufen. Bitte fragen Sie dort nach.`, origin);
}
const rest = parseInt((await env.RATE_LIMIT.get(`wallet:${walletId}`)) ?? '0', 10);
const token = await signWallet(walletId, env.TOKEN_SIGNING_SECRET);
return Response.json(
{ token, guthaben: rest, einrichtung: einrichtung.name, ablauf: einrichtung.ablauf },
{ headers: demoCorsHeaders(origin) }
);
}
// ============================================================
// Übergabe Handy ↔ PC — Ende-zu-Ende verschlüsselt
// ============================================================
//
// Ein Gerät zeigt einen QR-Code, das andere scannt ihn. Im QR-Code stehen
// eine zufällige Übergabe-ID und ein AES-Schlüssel — der Schlüssel steht
// hinter dem `#` der Adresse und wird deshalb NIE an einen Server geschickt.
// Hier liegen nur verschlüsselte Päckchen, die wir selbst nicht lesen
// können. Jede Übergabe löscht sich nach 15 Minuten von selbst.
//
// PUT /uebergabe/<id>/<fach> -> Päckchen ablegen (Text, verschlüsselt)
// GET /uebergabe/<id> -> alle Päckchen dieser Übergabe
// DELETE /uebergabe/<id> -> sofort löschen
//
// Durable Object statt KV, weil KV Abfragen auf noch nicht vorhandene
// Schlüssel zwischenspeichert — das Warten am PC würde bis zu einer Minute
// hängen. Ein Durable Object ist sofort konsistent.
//
// Ein Fach (z. B. „status", „ergebnis") wird überschrieben, nicht angehängt.
const UEBERGABE_LEBENSDAUER_MS = 15 * 60 * 1000;
const UEBERGABE_MAX_ZEICHEN = 400_000; // Analyse-JSON in zwei Sprachen, verschlüsselt
const UEBERGABE_MAX_SCHREIBVORGAENGE = 50;
const UEBERGABE_ID_MUSTER = /^[A-Za-z0-9_-]{22}$/; // 128 Bit, base64url
const UEBERGABE_FACH_MUSTER = /^[a-z]{2,16}$/;
export class Uebergabe extends DurableObject<Env> {
async lesen(): Promise<Record<string, string>> {
const eintraege = await this.ctx.storage.list<string>({ prefix: 'fach:' });
const faecher: Record<string, string> = {};
for (const [k, v] of eintraege) faecher[k.slice(5)] = v;
return faecher;
}
async schreiben(fach: string, wert: string): Promise<boolean> {
const zahl = (await this.ctx.storage.get<number>('zahl')) ?? 0;
if (zahl >= UEBERGABE_MAX_SCHREIBVORGAENGE) return false;
await this.ctx.storage.put({ [`fach:${fach}`]: wert, zahl: zahl + 1 });
if ((await this.ctx.storage.getAlarm()) === null) {
await this.ctx.storage.setAlarm(Date.now() + UEBERGABE_LEBENSDAUER_MS);
}
return true;
}
async loeschen(): Promise<void> {
await this.ctx.storage.deleteAlarm();
await this.ctx.storage.deleteAll();
}
async alarm(): Promise<void> {
await this.ctx.storage.deleteAll();
}
}
function uebergabeCors(origin: string | null): Record<string, string> {
return {
...demoCorsHeaders(origin),
'access-control-allow-methods': 'GET, PUT, DELETE, OPTIONS',
'cache-control': 'no-store',
};
}
async function uebergabeHandler(request: Request, env: Env, pfad: string): Promise<Response> {
const origin = request.headers.get('origin');
const cors = uebergabeCors(origin);
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors });
// Webseite: nur unsere eigene. Die App schickt keinen Origin-Header.
if (origin && !originOk(origin)) {
return Response.json({ code: 'origin', meldung: 'Nicht erlaubt.' }, { status: 403, headers: cors });
}
const [, , id, fach] = pfad.split('/'); // "", "uebergabe", id, fach?
if (!id || !UEBERGABE_ID_MUSTER.test(id)) {
return Response.json({ code: 'id', meldung: 'Ungültige Übergabe.' }, { status: 400, headers: cors });
}
const stub = env.UEBERGABE.get(env.UEBERGABE.idFromName(id));
if (request.method === 'GET' && !fach) {
return Response.json({ faecher: await stub.lesen() }, { headers: cors });
}
if (request.method === 'DELETE' && !fach) {
await stub.loeschen();
return new Response(null, { status: 204, headers: cors });
}
if (request.method === 'PUT' && fach && UEBERGABE_FACH_MUSTER.test(fach)) {
const ip = request.headers.get('cf-connecting-ip') ?? 'unbekannt';
const { success } = await env.UEBERGABE_LIMIT.limit({ key: ip });
if (!success) {
return Response.json({ code: 'zu_viele', meldung: 'Zu viele Anfragen. Bitte kurz warten.' }, { status: 429, headers: cors });
}
const wert = await request.text();
if (!wert || wert.length > UEBERGABE_MAX_ZEICHEN || !/^[A-Za-z0-9_-]+$/.test(wert)) {
return Response.json({ code: 'inhalt', meldung: 'Ungültiger Inhalt.' }, { status: 400, headers: cors });
}
if (!(await stub.schreiben(fach, wert))) {
return Response.json({ code: 'voll', meldung: 'Diese Übergabe ist abgeschlossen.' }, { status: 409, headers: cors });
}
return new Response(null, { status: 204, headers: cors });
}
return Response.json({ code: 'methode', meldung: 'Nicht erlaubt.' }, { status: 405, headers: cors });
}
/** POST /stripe-webhook — Sicherungsnetz: Signatur prüfen, Guthaben aktivieren, Link mailen. */
async function webhookHandler(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') return new Response('nur POST', { status: 405 });
@@ -641,10 +894,13 @@ async function webhookHandler(request: Request, env: Env): Promise<Response> {
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const pfad = new URL(request.url).pathname;
lokalTest = env.LOKAL_TEST === '1';
// Web-Demo + Bezahl-Routen haben eigene Regeln
if (pfad === '/demo') return demoHandler(request, env);
if (pfad === '/kaufen') return kaufenHandler(request, env);
if (pfad === '/kauf-status') return kaufStatusHandler(request, env);
if (pfad === '/code') return codeHandler(request, env);
if (pfad.startsWith('/uebergabe/')) return uebergabeHandler(request, env, pfad);
if (pfad === '/stripe-webhook') return webhookHandler(request, env);
if (request.method !== 'POST') {
+18
View File
@@ -18,3 +18,21 @@ DEMO_LIMIT_IP = "8"
[[kv_namespaces]]
binding = "RATE_LIMIT"
id = "7577adef8ceb4ed6bfaeef81a1d496af"
# Handy <-> PC: verschluesselte Uebergabe per QR-Code (siehe src/index.ts).
# SQLite-Durable-Objects laufen auch im kostenlosen Workers-Tarif.
[[durable_objects.bindings]]
name = "UEBERGABE"
class_name = "Uebergabe"
[[migrations]]
tag = "v1-uebergabe"
new_sqlite_classes = ["Uebergabe"]
# Schreib-Bremse fuer die Uebergabe: 60 Paeckchen pro Minute und IP
[[ratelimits]]
name = "UEBERGABE_LIMIT"
namespace_id = "1001"
[ratelimits.simple]
limit = 60
period = 60
+108
View File
@@ -0,0 +1,108 @@
/**
* Einrichtungs-Codes für Beratungsstellen anlegen und verwalten.
*
* node tools/einrichtungs-code.js neu "MBE Donnersberg" MBE --briefe 100 --monate 1
* node tools/einrichtungs-code.js stand MBE-K7QX
* node tools/einrichtungs-code.js aufladen MBE-K7QX 100
* node tools/einrichtungs-code.js verlaengern MBE-K7QX 2027-06-30
*
* Ein Code ist ein Guthaben wie beim Kauf, das allen gehört, die den Code
* kennen. Die Ratsuchenden zahlen nichts, die Einrichtung zahlt (oder testet
* gratis). Gilt bis zum Ablaufdatum oder bis die Briefe aufgebraucht sind.
*
* Braucht nur den wrangler-Login (npx wrangler login), kein Secret.
* Link für Handzettel/QR-Code: https://behoerdenklar.de/demo?code=<CODE>
*/
const { execFileSync } = require('child_process');
const crypto = require('crypto');
const path = require('path');
const PROXY = path.join(__dirname, '..', 'proxy');
const KV_ID = '7577adef8ceb4ed6bfaeef81a1d496af'; // RATE_LIMIT aus proxy/wrangler.toml
// ohne 0/O, 1/I/L: am Telefon und auf Papier nicht zu verwechseln
const ZEICHEN = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
function kv(...args) {
return execFileSync('npx', ['wrangler', 'kv', 'key', ...args, '--namespace-id', KV_ID, '--remote'],
{ cwd: PROXY, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
}
function lies(schluessel) {
try { return kv('get', schluessel, '--text'); } catch { return null; }
}
function schreib(schluessel, wert) { kv('put', schluessel, wert); }
function option(name, standard) {
const i = process.argv.indexOf('--' + name);
return i > 0 ? process.argv[i + 1] : standard;
}
function abbruch(text) { console.error('\x1b[31m' + text + '\x1b[0m'); process.exit(1); }
function zufall(laenge) {
let s = '';
for (const b of crypto.randomBytes(laenge)) s += ZEICHEN[b % ZEICHEN.length];
return s;
}
function plusMonate(monate) {
const d = new Date();
d.setMonth(d.getMonth() + monate);
return d.toISOString().slice(0, 10);
}
function ladeCode(code) {
code = String(code || '').toUpperCase();
const walletId = lies(`code:${code}`);
if (!walletId) abbruch(`Code ${code} gibt es nicht.`);
const einrichtung = JSON.parse(lies(`einrichtung:${walletId}`) || '{}');
const rest = parseInt(lies(`wallet:${walletId}`) || '0', 10);
return { code, walletId, einrichtung, rest };
}
function zeige({ code, einrichtung, rest }) {
const heute = new Date().toISOString().slice(0, 10);
console.log(`
Einrichtung: ${einrichtung.name}
Code: \x1b[1m${code}\x1b[0m
Übrig: ${rest} Briefe
Gültig bis: ${einrichtung.ablauf}${einrichtung.ablauf < heute ? ' \x1b[31m(abgelaufen)\x1b[0m' : ''}
Link: https://behoerdenklar.de/demo?code=${code}
`);
}
const [befehl, a, b] = process.argv.slice(2);
if (befehl === 'neu') {
const name = a;
const praefix = String(b || '').toUpperCase();
if (!name || !/^[A-Z]{2,12}$/.test(praefix)) {
abbruch('So geht es: node tools/einrichtungs-code.js neu "MBE Donnersberg" MBE --briefe 100 --monate 1');
}
const briefe = parseInt(option('briefe', '100'), 10);
const ablauf = option('bis', plusMonate(parseInt(option('monate', '1'), 10)));
const code = `${praefix}-${zufall(4)}`;
if (lies(`code:${code}`)) abbruch('Zufällig schon vergeben — einfach nochmal ausführen.');
const walletId = crypto.randomUUID();
schreib(`wallet:${walletId}`, String(briefe));
schreib(`einrichtung:${walletId}`, JSON.stringify({ name, ablauf }));
schreib(`code:${code}`, walletId);
console.log('\n \x1b[32m✓ Code angelegt\x1b[0m');
zeige({ code, einrichtung: { name, ablauf }, rest: briefe });
} else if (befehl === 'stand') {
zeige(ladeCode(a));
} else if (befehl === 'aufladen') {
const c = ladeCode(a);
const dazu = parseInt(b, 10);
if (!(dazu > 0)) abbruch('So geht es: node tools/einrichtungs-code.js aufladen MBE-K7QX 100');
schreib(`wallet:${c.walletId}`, String(c.rest + dazu));
console.log(`\n \x1b[32m✓ ${dazu} Briefe dazu\x1b[0m`);
zeige({ ...c, rest: c.rest + dazu });
} else if (befehl === 'verlaengern') {
const c = ladeCode(a);
if (!/^\d{4}-\d{2}-\d{2}$/.test(b || '')) abbruch('So geht es: node tools/einrichtungs-code.js verlaengern MBE-K7QX 2027-06-30');
const einrichtung = { ...c.einrichtung, ablauf: b };
schreib(`einrichtung:${c.walletId}`, JSON.stringify(einrichtung));
console.log('\n \x1b[32m✓ verlängert\x1b[0m');
zeige({ ...c, einrichtung });
} else {
console.log(require('fs').readFileSync(__filename, 'utf8').split('*/')[0].replace(/^\/\*\*?|^ \* ?/gm, ''));
}