Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

43
tools/check-all.sh Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Alle Prüfungen in einem Durchgang.
#
# bash tools/check-all.sh
#
# Falls das Ausführungsbit fehlt (etwa nach dem Entpacken eines Archivs):
# chmod +x tools/*.sh
#
# Vor jedem docker compose build sinnvoll. Bricht beim ersten Problem ab
# und gibt Rückgabewert 1 zurück - damit lässt es sich als Git-Hook oder
# in einer CI verwenden.
set -uo pipefail
cd "$(dirname "$0")/.."
failed=0
run() {
local label="$1"; shift
printf '\n\033[1m== %s\033[0m\n' "$label"
if "$@"; then
:
else
echo " -> fehlgeschlagen"
failed=$((failed + 1))
fi
}
[ -f .env ] && run "Konfiguration (.env)" bash tools/check-env.sh .env
run "Konfigurationsvorlage" bash tools/check-env.sh .env.example
run "Berechtigungen" python3 tools/check-routes.py
run "Datenbankschema" python3 tools/check-schema.py
run "nginx-Konfiguration" python3 tools/check-nginx.py web
run "JavaScript-Module" node tools/check-js.mjs
run "Strichcode-Decoder" node tools/test-barcode.mjs
run "Python-Syntax" sh -c 'cd backend && python3 -m compileall -q app alembic && echo "in Ordnung"'
printf '\n'
if [ "$failed" -eq 0 ]; then
echo "Alle Prüfungen bestanden."
else
echo "$failed Prüfung(en) fehlgeschlagen."
exit 1
fi

52
tools/check-env.sh Normal file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Sucht doppelte Variablen in der .env.
#
# ./tools/check-env.sh [.env]
#
# Hintergrund: Docker Compose nimmt bei mehrfach definierten Variablen
# die LETZTE. Eine vergessene leere Vorlagenzeile unterhalb des
# eingefügten Werts schaltet die betroffene Funktion damit still wieder
# ab - ohne Fehlermeldung, nur mit einem Verhalten, das niemand erklären
# kann.
set -euo pipefail
FILE="${1:-.env}"
[ -f "$FILE" ] || { echo "$FILE nicht gefunden." >&2; exit 2; }
problems=0
# Doppelte Schlüssel
dupes=$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$FILE" \
| cut -d= -f1 | sort | uniq -d || true)
if [ -n "$dupes" ]; then
echo "Mehrfach definiert (Docker Compose nimmt jeweils die letzte Zeile):"
while read -r key; do
[ -z "$key" ] && continue
echo " $key"
grep -n "^$key=" "$FILE" | sed 's/^/ Zeile /'
problems=$((problems + 1))
done <<< "$dupes"
fi
# Leerzeichen vor dem Gleichheitszeichen - solche Zeilen ignoriert Compose
spaced=$(grep -nE '^[A-Za-z_][A-Za-z0-9_]* +=' "$FILE" || true)
if [ -n "$spaced" ]; then
echo "Leerzeichen vor dem '=' (wird von Docker Compose ignoriert):"
echo "$spaced" | sed 's/^/ Zeile /'
problems=$((problems + 1))
fi
# Anführungszeichen - die landen wörtlich im Wert
quoted=$(grep -nE '^[A-Za-z_][A-Za-z0-9_]*="' "$FILE" || true)
if [ -n "$quoted" ]; then
echo "Anführungszeichen im Wert (Compose übernimmt sie wörtlich):"
echo "$quoted" | sed 's/^/ Zeile /'
problems=$((problems + 1))
fi
if [ "$problems" -eq 0 ]; then
echo "$FILE: keine Auffälligkeiten"
else
exit 1
fi

243
tools/check-js.mjs Normal file
View File

@@ -0,0 +1,243 @@
#!/usr/bin/env node
/**
* Sucht Bezeichner, die verwendet, aber nirgends deklariert oder
* importiert werden.
*
* node tools/check-js.mjs
*
* Hintergrund: Ohne Build-Schritt gibt es keinen Linter, und der Browser
* meldet "x is not defined" erst, wenn die betroffene Zeile tatsächlich
* ausgeführt wird. Eine Funktion, die nur im Menü eines Eintrags
* gebraucht wird, kann so lange fehlen, ohne dass es auffällt.
*
* Das ist bewusst eine grobe Prüfung ohne echten Parser: Sie kennt keine
* Blockgeltungsbereiche und meldet daher nichts, was irgendwo in der
* Datei deklariert ist. Für den Zweck - "ganz vergessen" statt "am
* falschen Ort" - reicht das.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
const ROOT = process.argv[2] || "web/html/js";
/** Was der Browser mitbringt. Bewusst knapp gehalten - je kürzer, desto
* mehr findet die Prüfung. */
const GLOBALS = new Set([
// Sprache
"Array", "Boolean", "Date", "Error", "Infinity", "Intl", "JSON", "Map",
"Math", "NaN", "Number", "Object", "Promise", "RegExp", "Set", "String",
"Symbol", "TypeError", "Uint8Array", "Uint8ClampedArray", "WeakMap",
"globalThis", "isNaN", "parseFloat", "parseInt", "structuredClone",
"undefined", "queueMicrotask", "console",
// Browser
"AbortController", "Blob", "CustomEvent", "DOMException", "Event",
"EventSource", "FileReader", "FormData", "Headers", "IDBKeyRange",
"Image", "ImageData", "Node", "Request", "Response", "URL",
"URLSearchParams", "alert", "clearInterval", "clearTimeout", "confirm",
"crypto", "document", "fetch", "history", "indexedDB", "localStorage",
"location", "navigator", "performance", "prompt", "requestAnimationFrame",
"Notification", "PushManager", "ServiceWorkerRegistration",
"self", "sessionStorage", "setInterval", "setTimeout", "window",
"BarcodeDetector", "Element", "HTMLElement", "MediaStream", "TextDecoder",
"TextEncoder", "atob", "btoa", "decodeURIComponent", "encodeURIComponent",
// Service Worker
"caches", "clients", "registration", "skipWaiting",
// Schlüsselwörter, die als Wort auftauchen
"arguments", "as", "async", "await", "break", "case", "catch", "class", "const",
"continue", "default", "delete", "do", "else", "export", "extends",
"false", "finally", "for", "from", "function", "get", "if", "import",
"in", "instanceof", "let", "new", "null", "of", "return", "set", "static",
"super", "switch", "this", "throw", "true", "try", "typeof", "var", "void",
"while", "yield",
]);
/** Entfernt Kommentare und Zeichenkettenliterale. Der Inhalt von
* Template-Ausdrücken ${...} bleibt erhalten - dort steht echter Code. */
function stripNoise(source) {
let out = "";
let i = 0;
const n = source.length;
while (i < n) {
const two = source.slice(i, i + 2);
if (two === "//") {
while (i < n && source[i] !== "\n") i++;
continue;
}
if (two === "/*") {
i += 2;
while (i < n && source.slice(i, i + 2) !== "*/") i++;
i += 2;
continue;
}
if (source[i] === '"' || source[i] === "'") {
const quote = source[i++];
while (i < n && source[i] !== quote) {
if (source[i] === "\\") i++;
i++;
}
i++;
out += '""';
continue;
}
if (source[i] === "`") {
i++;
while (i < n && source[i] !== "`") {
if (source[i] === "\\") { i += 2; continue; }
if (source.slice(i, i + 2) === "${") {
// Ausdruck übernehmen, Klammern zählen - und rekursiv säubern,
// denn darin können wieder Zeichenketten stehen.
i += 2;
let depth = 1;
let inner = "";
while (i < n && depth > 0) {
if (source[i] === "{") depth++;
else if (source[i] === "}") depth--;
if (depth > 0) inner += source[i];
i++;
}
out += " " + stripNoise(inner) + " ";
continue;
}
i++;
}
i++;
out += '""';
continue;
}
// Regulärer Ausdruck? Ein / ist nur dann Beginn eines Literals, wenn
// davor ein Operator oder Klammeranfang steht - sonst ist es eine
// Division. Ohne diese Unterscheidung landen die Flags (g, i, s) und
// Zeichenklassen (\D, \w) in der Bezeichnerliste.
if (source[i] === "/") {
const head = out.replace(/\s+$/, "");
const before = head.slice(-1);
// Nach einem Schlüsselwort steht ebenfalls ein Literal, keine
// Division: `return /x/.test(s)` ist gültiges JavaScript.
const keyword = /(?:^|[^\w$])(return|typeof|instanceof|in|of|case|do|else|void|delete|await|yield|new|throw)$/.test(head);
if (before === "" || keyword || "(,=:[!&|?{};+-*%<>~^".includes(before)) {
i++;
let inClass = false;
while (i < n) {
if (source[i] === "\\") { i += 2; continue; }
if (source[i] === "[") inClass = true;
else if (source[i] === "]") inClass = false;
else if (source[i] === "/" && !inClass) break;
else if (source[i] === "\n") break;
i++;
}
i++;
while (i < n && /[a-z]/.test(source[i])) i++; // Flags
out += "0";
continue;
}
}
out += source[i++];
}
return out;
}
function declaredNames(code) {
const names = new Set();
const add = (raw) => {
// Destrukturierung entfernen, dann jeden Teil einzeln betrachten.
for (const part of raw.replace(/[{}[\]]/g, "").split(",")) {
// "a = 1" -> a, "a: b" -> b (der zweite Name ist die Bindung)
const pieces = part.split("=")[0].split(":");
const name = pieces[pieces.length - 1].trim().replace(/^\.\.\./, "");
if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
}
};
// Importe
for (const m of code.matchAll(/import\s+\*\s+as\s+([\w$]+)/g)) names.add(m[1]);
for (const m of code.matchAll(/import\s+\{([^}]*)\}/g)) {
for (const part of m[1].split(",")) {
const name = part.trim().split(/\s+as\s+/).pop().trim();
if (name) names.add(name);
}
}
for (const m of code.matchAll(/import\s+([\w$]+)\s+from/g)) names.add(m[1]);
// Funktionen und Klassen
for (const m of code.matchAll(/(?:function|class)\s+([\w$]+)/g)) names.add(m[1]);
// Variablen, auch destrukturiert
for (const m of code.matchAll(/(?:const|let|var)\s+(\{[^}]*\}|\[[^\]]*\]|[\w$]+)/g)) {
add(m[1]);
}
// Parameterlisten: alles zwischen ( ) vor einem => oder {
for (const m of code.matchAll(/\(([^()]*)\)\s*(?:=>|\{)/g)) add(m[1]);
// Einzelner Pfeilparameter ohne Klammern
for (const m of code.matchAll(/(?:^|[^\w$.])([\w$]+)\s*=>/gm)) names.add(m[1]);
// catch (e)
for (const m of code.matchAll(/catch\s*\(\s*([\w$]+)/g)) names.add(m[1]);
// for (const x of ...) ist oben abgedeckt; benannte Objektmethoden
for (const m of code.matchAll(/([\w$]+)\s*\([^()]*\)\s*\{/g)) names.add(m[1]);
return names;
}
function usedNames(code) {
const names = new Set();
// Kein Punkt davor (sonst wäre es ein Eigenschaftszugriff), kein
// Doppelpunkt danach (sonst wäre es ein Objektschlüssel oder Label).
for (const m of code.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)/g)) {
const name = m[1];
const after = code.slice(m.index + name.length, m.index + name.length + 40);
if (/^\s*:/.test(after)) continue;
names.add(name);
}
return names;
}
function walk(dir, files = []) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) walk(path, files);
else if (entry.name.endsWith(".js")) files.push(path);
}
return files;
}
let problems = 0;
const targets = walk(ROOT);
targets.push("web/html/sw.js");
for (const file of targets) {
let source;
try {
source = readFileSync(file, "utf8");
} catch {
continue;
}
const code = stripNoise(source);
const declared = declaredNames(code);
const used = usedNames(code);
const unknown = [...used].filter(
(name) => !declared.has(name) && !GLOBALS.has(name)
).sort();
if (unknown.length) {
console.log(`\n ${file}`);
for (const name of unknown) {
// Zeilennummer des ersten Vorkommens für die Fehlersuche
const line = source.split("\n").findIndex((l) =>
new RegExp(`(^|[^\\w$.])${name}\\b`).test(l)) + 1;
console.log(` Zeile ${line}: ${name} wird verwendet, aber nirgends deklariert`);
problems++;
}
}
}
console.log(
problems
? `\n${problems} möglicherweise undefinierte(r) Bezeichner`
: `${targets.length} Dateien geprüft, keine undefinierten Bezeichner`
);
process.exit(problems ? 1 : 0);

139
tools/check-nginx.py Normal file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Statische Prüfung der nginx-Konfiguration im web-Container.
Findet drei Fehlerklassen, die uns bereits begegnet sind und die nginx
erst beim Start bemerkt - oder gar nicht:
1. Doppelte Direktiven im selben Kontext.
nginx bricht mit "directive is duplicate" ab. Passiert leicht, wenn
eine location proxy_common.conf einbindet und etwas wiederholt, das
dort schon steht.
2. Fehlende Sicherheitskopfzeilen.
nginx vererbt add_header NUR, wenn die untergeordnete Ebene gar kein
add_header setzt. Ein einziges Cache-Control in einer location lässt
alle Kopfzeilen des server-Blocks verschwinden - ohne Fehlermeldung.
3. Ein types-Block im server-Kontext.
Der ersetzt die geerbte MIME-Tabelle vollständig, statt sie zu
ergänzen. CSS und JavaScript kommen dann als
application/octet-stream an und der Browser lehnt ES-Module ab.
Aufruf:
python3 tools/check-nginx.py # prüft web/
python3 tools/check-nginx.py pfad/zu/web
"""
import re
import sys
from pathlib import Path
# add_header darf mehrfach auftreten - jedes weitere hängt an, statt zu
# ersetzen. Alle anderen hier geprüften Direktiven dürfen es nicht.
REPEATABLE = {"add_header", "proxy_set_header", "try_files", "include",
"limit_req", "gzip_types", "error_page", "set"}
def directive_names(text: str) -> list[str]:
out = []
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
out.append(line.split()[0])
return out
def check(web_dir: Path) -> int:
conf_path = web_dir / "nginx.conf"
if not conf_path.is_file():
print(f"nginx.conf nicht gefunden unter {conf_path}", file=sys.stderr)
return 2
conf = conf_path.read_text(encoding="utf-8")
problems: list[str] = []
includes = {}
for name in ("proxy_common.conf", "security_headers.conf"):
path = web_dir / name
if path.is_file():
includes[name] = set(directive_names(path.read_text(encoding="utf-8")))
else:
problems.append(f"{name} fehlt, wird aber eingebunden")
includes[name] = set()
# ---- 3. types-Block ----
if re.search(r"^\s*types\s*\{", conf, re.M):
problems.append(
"types-Block gefunden: ersetzt im server-Kontext die gesamte "
"MIME-Tabelle. Stattdessen default_type in der betroffenen "
"location setzen."
)
# ---- Klammernbilanz ----
if conf.count("{") != conf.count("}"):
problems.append(
f"Geschweifte Klammern unausgeglichen: "
f"{conf.count('{')} auf, {conf.count('}')} zu"
)
# ---- pro location ----
for match in re.finditer(r"(location[^{]*)\{(.*?\n )\}", conf, re.S):
header = match.group(1).strip()
body = match.group(2)
lines = [
line.strip()
for line in body.splitlines()
if line.strip() and not line.strip().startswith("#")
]
included = {name for name in includes if any(name in line for line in lines)}
own = [line.split()[0] for line in lines if not line.startswith("include")]
for name in sorted(set(own)):
if own.count(name) > 1 and name not in REPEATABLE:
problems.append(f"{header}: {name} steht {own.count(name)}× im Block")
for inc in included:
if name in includes[inc] and name not in REPEATABLE:
problems.append(
f"{header}: {name} steht auch in {inc} nginx bricht "
"mit \"directive is duplicate\" ab"
)
if "add_header" in own and "security_headers.conf" not in included:
problems.append(
f"{header}: setzt add_header, bindet aber security_headers.conf "
"nicht ein die Kopfzeilen des server-Blocks gehen hier verloren"
)
# ---- Ereigniskanal ----
sse = re.search(r"location[^{]*events[^{]*\{(.*?\n )\}", conf, re.S)
if sse:
body = sse.group(1)
if "gzip off" not in body:
problems.append(
"Ereigniskanal: gzip off fehlt Komprimierung sammelt die "
"Ereignisse, statt sie einzeln durchzureichen"
)
common = (web_dir / "proxy_common.conf")
if common.is_file() and "proxy_buffering off" not in common.read_text():
problems.append(
"proxy_common.conf: proxy_buffering off fehlt ohne das "
"puffert nginx die Server-Sent Events"
)
else:
problems.append("Kein location-Block für den Ereigniskanal gefunden")
if problems:
print(f"{len(problems)} Problem(e) in {conf_path}:\n")
for p in problems:
print(f" - {p}")
return 1
print(f"{conf_path}: keine Probleme gefunden")
return 0
if __name__ == "__main__":
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("web")
raise SystemExit(check(target))

88
tools/check-routes.py Normal file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Listet alle Endpunkte samt ihrer Berechtigungsabhängigkeiten.
python3 tools/check-routes.py
Findet Endpunkte, die weder eine Abhängigkeit mit Berechtigungsprüfung
haben noch ausdrücklich als offen eingetragen sind. Damit fällt auf, wenn
beim Hinzufügen einer Route die Absicherung vergessen wurde - was beim
Schreiben leicht passiert, weil FastAPI das nicht anmahnt.
Rein statisch: Zwei Fälle kann das Skript nicht sehen und sie sind unten
als Ausnahmen vermerkt - Endpunkte, die ihre Prüfung im Rumpf machen
(DELETE auf Mitgliedschaften: Eigentümer oder man selbst) und solche,
die über einen Token im Pfad geschützt sind.
"""
import ast, pathlib, re
GUARDS = {
"CurrentUser": "angemeldet",
"VerifiedUser": "bestätigt",
"AdminUser": "ADMIN",
"ReadableList": "Listen-Leser",
"EditableList": "Listen-Bearbeiter",
"OwnedList": "Listen-EIGENTÜMER",
"SharableList": "Teilen-Berechtigt",
}
# Endpunkte, die bewusst ohne Anmeldung erreichbar sind
INTENDED_PUBLIC = {
"/api/auth/register", "/api/auth/login", "/api/auth/verify",
"/api/auth/password/reset-request", "/api/auth/password/reset",
# Willkommensstrecke: Wer den Link oeffnet, HAT noch kein Passwort
# und kann sich deshalb nicht anmelden. Der Token ist der Nachweis.
"/api/auth/welcome/{token}", "/api/auth/welcome/complete",
# Bestaetigung einer Adressaenderung - ebenfalls per Token, weil der
# Klick aus dem Postfach kommt.
"/api/auth/email-change/{token}",
"/api/config", "/manifest.webmanifest", "/api/push/config",
"/healthz", "/readyz",
}
# Öffentlich, aber durch einen Token im Pfad geschützt
TOKEN_GUARDED = ("/api/public/",)
rows = []
for path in sorted(pathlib.Path("backend/app/routers").glob("*.py")):
src = path.read_text()
prefix = ""
m = re.search(r'APIRouter\((?:[^)]*?)prefix="([^"]*)"', src, re.S)
if m:
prefix = m.group(1)
tree = ast.parse(src)
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for dec in node.decorator_list:
if not (isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute)):
continue
if dec.func.attr.upper() not in {"GET","POST","PUT","PATCH","DELETE"}:
continue
method = dec.func.attr.upper()
route = prefix + (dec.args[0].value if dec.args else "?")
guards = []
for arg in node.args.args + node.args.kwonlyargs:
ann = ast.unparse(arg.annotation) if arg.annotation else ""
for name, label in GUARDS.items():
if ann == name:
guards.append(label)
rows.append((route, method, guards, path.name))
print(f"{'Pfad':52} {'Methode':7} Schutz")
print("-" * 100)
problems = 0
for route, method, guards, file in sorted(rows):
if guards:
mark = ", ".join(guards)
elif route in INTENDED_PUBLIC:
mark = "offen (gewollt)"
elif route.startswith(TOKEN_GUARDED):
mark = "Token im Pfad"
else:
mark = ">>> KEIN SCHUTZ <<<"
problems += 1
print(f"{route:52} {method:7} {mark}")
print("-" * 100)
print(f"{len(rows)} Endpunkte, {problems} ohne erkennbaren Schutz")

134
tools/check-schema.py Normal file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Vergleicht db/schema.sql mit den SQLAlchemy-Modellen.
python3 tools/check-schema.py
Die SQL-Datei ist von Hand geschrieben und kann beim nächsten
Modell-Umbau vergessen werden. Dieser Vergleich findet fehlende oder
überzählige Tabellen und Spalten - bevor jemand die Datei zum Aufsetzen
einer neuen Instanz benutzt und sich wundert.
Arbeitet rein textlich, ohne SQLAlchemy zu laden: Damit läuft die
Prüfung auch außerhalb des Containers.
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MODELS = ROOT / "backend" / "app" / "models.py"
SCHEMA = ROOT / "db" / "schema.sql"
# Tabellen, die nicht aus Modellen stammen
EXTRA_TABLES = {"alembic_version"}
def from_models() -> dict[str, set[str]]:
"""__tablename__ und mapped_column-Namen je Klasse einsammeln."""
source = MODELS.read_text(encoding="utf-8")
tables: dict[str, set[str]] = {}
current: str | None = None
for line in source.splitlines():
table = re.match(r'\s*__tablename__\s*=\s*"([^"]+)"', line)
if table:
current = table.group(1)
tables[current] = set()
continue
if current is None:
continue
# Beziehungen sind keine Spalten
if "relationship(" in line:
continue
column = re.match(r"\s*(\w+):\s*Mapped\[", line)
if column:
tables[current].add(column.group(1))
return tables
def from_schema() -> dict[str, set[str]]:
"""CREATE TABLE ... ( ... ) auswerten."""
source = SCHEMA.read_text(encoding="utf-8")
tables: dict[str, set[str]] = {}
for match in re.finditer(
r"CREATE TABLE `(\w+)` \((.*?)\n\) ENGINE", source, re.S
):
name, body = match.group(1), match.group(2)
columns = set()
for line in body.splitlines():
line = line.strip()
if line.startswith("--") or not line:
continue
# Spaltendefinitionen beginnen mit `name` gefolgt von einem Typ
col = re.match(r"`(\w+)`\s+[A-Z]", line)
if col:
columns.add(col.group(1))
tables[name] = columns
return tables
def main() -> int:
models = from_models()
schema = from_schema()
problems = 0
missing = set(models) - set(schema)
surplus = set(schema) - set(models) - EXTRA_TABLES
for name in sorted(missing):
print(f" FEHLT in schema.sql: Tabelle {name}")
problems += 1
for name in sorted(surplus):
print(f" ÜBERZÄHLIG in schema.sql: Tabelle {name}")
problems += 1
for name in sorted(set(models) & set(schema)):
only_model = models[name] - schema[name]
only_schema = schema[name] - models[name]
for column in sorted(only_model):
print(f" FEHLT in schema.sql: {name}.{column}")
problems += 1
for column in sorted(only_schema):
print(f" ÜBERZÄHLIG in schema.sql: {name}.{column}")
problems += 1
# Migrationsstand muss zur höchsten Revision passen
versions = ROOT / "backend" / "alembic" / "versions"
revisions = sorted(
m.group(1)
for f in versions.glob("*.py")
if (m := re.search(r'^revision:\s*str\s*=\s*"(\w+)"', f.read_text(), re.M))
)
head = revisions[-1] if revisions else None
stamped = re.search(
r"INSERT INTO `alembic_version`.*VALUES \('(\w+)'\)", SCHEMA.read_text()
)
if head and (not stamped or stamped.group(1) != head):
print(
f" Migrationsstand passt nicht: schema.sql trägt "
f"{stamped.group(1) if stamped else 'nichts'}, "
f"höchste Revision ist {head}"
)
problems += 1
if problems:
print(f"\n{problems} Abweichung(en) zwischen Modellen und schema.sql")
return 1
print(
f"{len(models)} Tabellen, "
f"{sum(len(c) for c in models.values())} Spalten "
f"schema.sql stimmt mit den Modellen überein (Revision {head})"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

210
tools/test-barcode.mjs Normal file
View File

@@ -0,0 +1,210 @@
// Test des Strichcode-Decoders ohne Kamera.
//
// node tools/test-barcode.mjs
//
// Erzeugt synthetische EAN-13-, EAN-8- und UPC-A-Signale, verzerrt sie
// (Unschärfe, Rauschen, unterschiedliche Skalierung, umgedreht) und
// prüft, ob der Decoder sie zurückliest - und ob er auf leeren Flächen
// und Rauschen schweigt.
import {
checksumValid,
decodeImage,
decodeLine,
} from "../web/html/js/barcode.js";
// Deterministischer Zufallszahlengenerator (xorshift32).
//
// Math.random() hätte hier einen Test ergeben, der mal durchgeht und mal
// nicht - genau das ist beim ersten Gesamtdurchlauf passiert. Ein Test
// mit wechselndem Ergebnis ist schlimmer als keiner: Man gewöhnt sich an,
// ihn zu ignorieren.
let _seed = 0x2f6f4e01;
function rnd() {
_seed ^= _seed << 13; _seed >>>= 0;
_seed ^= _seed >> 17;
_seed ^= _seed << 5; _seed >>>= 0;
return _seed / 0x100000000;
}
function reseed(value) { _seed = value >>> 0 || 1; }
// --- Kodierer zum Gegentesten (nur im Test, nicht in der App) ---
const L = ["0001101","0011001","0010011","0111101","0100011",
"0110001","0101111","0111011","0110111","0001011"];
const R_ = L.map((s) => [...s].map((b) => (b === "0" ? "1" : "0")).join(""));
const G = R_.map((s) => [...s].reverse().join(""));
const R = L.map((s) => [...s].map((b) => (b === "0" ? "1" : "0")).join(""));
const PARITY = ["LLLLLL","LLGLGG","LLGGLG","LLGGGL","LGLLGG",
"LGGLLG","LGGGLL","LGLGLG","LGLGGL","LGGLGL"];
function checkDigit(body) {
const d = [...body].map(Number);
let sum = 0;
for (let i = d.length - 1, w = 3; i >= 0; i--, w = 4 - w) sum += d[i] * w;
return (10 - (sum % 10)) % 10;
}
function encodeEAN13(body12) {
const code = body12 + checkDigit(body12);
const first = Number(code[0]);
const parity = PARITY[first];
let bits = "101";
for (let i = 0; i < 6; i++) {
const d = Number(code[i + 1]);
bits += parity[i] === "L" ? L[d] : G[d];
}
bits += "01010";
for (let i = 0; i < 6; i++) bits += R[Number(code[i + 7])];
bits += "101";
return { code, bits };
}
function encodeEAN8(body7) {
const code = body7 + checkDigit(body7);
let bits = "101";
for (let i = 0; i < 4; i++) bits += L[Number(code[i])];
bits += "01010";
for (let i = 4; i < 8; i++) bits += R[Number(code[i])];
bits += "101";
return { code, bits };
}
/** Bitmuster -> Helligkeitszeile, mit Rand, Skalierung und Rauschen. */
function toLine(bits, { scale = 3, quiet = null, noise = 0, blur = 0 } = {}) {
// Ruhezone in MODULEN, nicht in Pixeln: Die Norm verlangt 7 bis 11
// Module. Ein fester Pixelwert wäre bei großer Skalierung zu schmal
// und würde einen Fehler vortäuschen, den es in der Wirklichkeit
// nicht gibt.
const margin = quiet ?? scale * 8;
const px = [];
for (let i = 0; i < margin; i++) px.push(255);
for (const b of bits) {
for (let s = 0; s < scale; s++) px.push(b === "1" ? 20 : 235);
}
for (let i = 0; i < margin; i++) px.push(255);
let out = px;
if (blur) {
out = px.map((_, i) => {
let sum = 0, n = 0;
for (let k = -blur; k <= blur; k++) {
if (px[i + k] !== undefined) { sum += px[i + k]; n++; }
}
return sum / n;
});
}
if (noise) {
out = out.map((v) => Math.max(0, Math.min(255, v + (rnd() - 0.5) * noise)));
}
return Uint8Array.from(out.map(Math.round));
}
function lineToImageData(line, height = 30) {
const width = line.length;
const data = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const p = (y * width + x) * 4;
data[p] = data[p + 1] = data[p + 2] = line[x];
data[p + 3] = 255;
}
}
return { width, height, data };
}
let pass = 0, fail = 0;
const check = (name, ok, extra = "") => {
if (ok) { pass++; console.log(` ok ${name}`); }
else { fail++; console.log(` FEHL ${name} ${extra}`); }
};
console.log("--- Prüfziffer ---");
check("EAN-13 gültig", checksumValid("4001234567890") === (checkDigit("400123456789") === 0));
check("bekannte EAN-13", checksumValid("4006381333931"));
check("verfälschte EAN-13 abgelehnt", !checksumValid("4006381333932"));
check("EAN-8", checksumValid("96385074"));
console.log("\n--- Sauberes Signal ---");
for (const body of ["400123456789", "978020137", "012345678", "590123412345".slice(0,12)]) {
const b12 = body.padEnd(12, "0").slice(0, 12);
const { code, bits } = encodeEAN13(b12);
check(`EAN-13 ${code}`, decodeLine(toLine(bits)) === code, `-> ${decodeLine(toLine(bits))}`);
}
{
const { code, bits } = encodeEAN8("9638507");
check(`EAN-8 ${code}`, decodeLine(toLine(bits)) === code, `-> ${decodeLine(toLine(bits))}`);
}
console.log("\n--- Umgedreht gehalten ---");
{
const { code, bits } = encodeEAN13("400123456789");
const line = toLine(bits);
check(`rückwärts ${code}`, decodeLine(Uint8Array.from([...line].reverse())) === code);
}
console.log("\n--- Unscharf und verrauscht ---");
for (const [blur, noise, scale] of [[1, 20, 3], [2, 30, 4], [1, 40, 5], [3, 25, 6]]) {
const { code, bits } = encodeEAN13("426000123456");
const got = decodeLine(toLine(bits, { blur, noise, scale }));
check(`blur=${blur} noise=${noise} scale=${scale}`, got === code, `-> ${got}`);
}
console.log("\n--- Knappe Ruhezone ---");
{
const { code, bits } = encodeEAN13("426000123456");
// 6 Module Rand: unterschreitet die Norm, wird aber noch gelesen
const ok6 = decodeLine(toLine(bits, { scale: 4, quiet: 24 }));
check("6 Module Rand wird gelesen", ok6 === code, `-> ${ok6}`);
// 2 Module Rand: wird abgelehnt. Das ist der Preis dafür, dass
// Rauschen keine Fehltreffer erzeugt.
const ok2 = decodeLine(toLine(bits, { scale: 4, quiet: 8 }));
check("2 Module Rand wird abgelehnt", ok2 === null, `-> ${ok2}`);
}
console.log("\n--- Ganzes Bild ---");
{
const { code, bits } = encodeEAN13("400123456789");
const line = toLine(bits, { blur: 1, noise: 25, scale: 4 });
check("decodeImage", decodeImage(lineToImageData(line)) === code);
}
console.log("\n--- Kein Strichcode im Bild ---");
{
const flat = Uint8Array.from({ length: 400 }, () => 200);
check("gleichmäßige Fläche -> null", decodeLine(flat) === null);
// Fehltreffer statistisch messen statt einmal zu würfeln.
//
// Eine einzelne Zeile kann in reinem Rauschen zufällig eine gültige
// Kombination ergeben - Startzeichen, zwölf Ziffern, passende
// Prüfziffer. Die Ruhezonenprüfung drückt das stark, ganz ausschließen
// lässt es sich nicht.
//
// Entscheidend ist die Ebene darüber: decodeImage verlangt, dass ZWEI
// Abtastlinien dasselbe Ergebnis liefern. Genau das ist im Sucher der
// Anwendung im Einsatz.
let lineHits = 0;
const LINE_TRIES = 500;
for (let i = 0; i < LINE_TRIES; i++) {
reseed(0x1000 + i);
const noise = Uint8Array.from({ length: 400 }, () => (rnd() * 255) | 0);
if (decodeLine(noise)) lineHits++;
}
console.log(` info Einzelzeile auf Rauschen: ${lineHits}/${LINE_TRIES} Fehltreffer`);
check(`Einzelzeile: Fehltrefferquote unter 2 %`, lineHits / LINE_TRIES < 0.02,
`(${lineHits}/${LINE_TRIES})`);
let imageHits = 0;
const IMAGE_TRIES = 200;
for (let i = 0; i < IMAGE_TRIES; i++) {
reseed(0x9000 + i);
const noise = Uint8Array.from({ length: 400 }, () => (rnd() * 255) | 0);
if (decodeImage(lineToImageData(noise))) imageHits++;
}
console.log(` info Ganzes Bild auf Rauschen: ${imageHits}/${IMAGE_TRIES} Fehltreffer`);
check("Ganzes Bild: keine Fehltreffer auf Rauschen", imageHits === 0,
`(${imageHits}/${IMAGE_TRIES})`);
}
console.log(`\n${pass} bestanden, ${fail} fehlgeschlagen`);
process.exit(fail ? 1 : 0);

71
tools/vapid-keys.py Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Erzeugt das VAPID-Schlüsselpaar für Web Push.
python3 tools/vapid-keys.py
VAPID ("Voluntary Application Server Identification") ist die Art, wie
sich dieser Server gegenüber den Push-Diensten der Browserhersteller
ausweist. Das Schlüsselpaar gehört dir - es ist kein Konto bei Google
oder Apple nötig und es fließen keine Daten an Dritte, außer der
verschlüsselten Nachricht selbst.
Der öffentliche Schlüssel geht an den Browser, der private bleibt auf dem
Server. Wird der private Schlüssel getauscht, verlieren alle bestehenden
Anmeldungen ihre Gültigkeit und müssen erneuert werden - deshalb einmal
erzeugen und dann in Ruhe lassen.
Läuft ohne Zusatzpakete: `cryptography` ist bereits eine Abhängigkeit
des Backends.
"""
import base64
import sys
try:
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
except ImportError:
print(
"Das Paket 'cryptography' fehlt. Entweder im Container ausführen:\n"
" docker compose exec api python3 tools/vapid-keys.py\n"
"oder lokal installieren: pip install cryptography",
file=sys.stderr,
)
raise SystemExit(1)
def b64(data: bytes) -> str:
"""base64url ohne Auffüllzeichen - so verlangen es Web Push und JWT."""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def main() -> int:
key = ec.generate_private_key(ec.SECP256R1())
# Privater Schlüssel: die 32 Byte des Skalars, nicht das PEM-Format.
# py_vapid nimmt diese Form direkt an.
private_value = key.private_numbers().private_value
private_bytes = private_value.to_bytes(32, "big")
# Öffentlicher Schlüssel: unkomprimierter Punkt, 65 Byte (0x04 + X + Y).
# Genau diese Form erwartet der Browser als applicationServerKey.
public_bytes = key.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
print("# In die .env übernehmen. Vorhandene Zeilen ERSETZEN, nicht")
print("# ergänzen: Bei doppelten Einträgen nimmt Docker Compose den")
print("# letzten - und das ist oft die leere Vorlagenzeile.\n")
print(f"VAPID_PRIVATE_KEY={b64(private_bytes)}")
print(f"VAPID_PUBLIC_KEY={b64(public_bytes)}")
print("# Kontaktadresse für die Push-Dienste - bei Problemen melden sie sich dort.")
print("VAPID_SUBJECT=mailto:admin@example.de")
print()
print("# Der private Schlüssel gehört NICHT ins Versionsverwaltungssystem.")
print("# Ein Wechsel entwertet alle bestehenden Anmeldungen.")
return 0
if __name__ == "__main__":
raise SystemExit(main())