Erste Produktivversion
This commit is contained in:
43
tools/check-all.sh
Normal file
43
tools/check-all.sh
Normal 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
52
tools/check-env.sh
Normal 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
243
tools/check-js.mjs
Normal 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
139
tools/check-nginx.py
Normal 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
88
tools/check-routes.py
Normal 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
134
tools/check-schema.py
Normal 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
210
tools/test-barcode.mjs
Normal 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
71
tools/vapid-keys.py
Normal 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())
|
||||
Reference in New Issue
Block a user