diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a62a7f4 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Passwort fuer den Zugang zur Seite. Der Server startet nicht ohne diesen Wert. +# In den VCH-Einstellungen setzen, nicht hier eintragen. +APP_PASSWORD=beispiel-passwort diff --git a/.gitignore b/.gitignore index 357c204..46439b9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ .DS_Store .superpowers/ +.env diff --git a/.vch/deploy.yaml b/.vch/deploy.yaml index 8f671b2..d91c8cd 100644 --- a/.vch/deploy.yaml +++ b/.vch/deploy.yaml @@ -3,8 +3,12 @@ runtime: systemd # Vite-Projekt: erst bauen, das Ergebnis landet in dist/ build: "npm ci && npm run build" -# Rein statische Seite - dist/ wird auf dem von VCH zugewiesenen Port ausgeliefert. -# Kein Server-Code noetig: Erkennung, Sortierung und Sitzung laufen vollstaendig im Browser. -start: "python3 -m http.server $PORT --directory dist" +# Passwortgeschuetzte Auslieferung von dist/ auf dem von VCH zugewiesenen Port. +# Das Passwort kommt aus der Umgebungsvariable APP_PASSWORD; fehlt sie, startet +# der Server absichtlich nicht. Kein Server-Code darueber hinaus noetig: +# Erkennung, Sortierung und Sitzung laufen vollstaendig im Browser. +start: "python3 server.py" -health: "/" +# Muss ohne Passwort erreichbar sein, sonst haelt die Ueberwachung die +# geschuetzte Anwendung faelschlich fuer ausgefallen. +health: "/healthz" diff --git a/README.md b/README.md index 9b5711c..00268ab 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,8 @@ Oberfläche laufen nur im echten Browser und werden dort manuell geprüft ## Produktion +Zum lokalen Nachschauen ohne Passwort: + ```bash npm run build npm run preview @@ -152,6 +154,37 @@ npm run preview `preview` hört auf `PORT` aus der Umgebung (Vorgabe `4173`, falls `PORT` nicht gesetzt ist). +Im Betrieb läuft stattdessen `server.py` — siehe nächster Abschnitt. + +## Passwortschutz + +Die veröffentlichte Seite ist passwortgeschützt. Geprüft wird **im Server**, +nicht im Browser: Ohne gültiges Passwort gibt `server.py` nichts heraus — +weder die Seite noch das gebündelte Skript. Ein Passwortschutz allein im +Browser wäre wirkungslos, weil das Passwort dann im ausgelieferten Skript +stünde und dort mitgelesen werden könnte. + +```bash +APP_PASSWORD=dein-passwort python3 server.py +``` + +- Das Passwort kommt ausschließlich aus der Umgebungsvariable `APP_PASSWORD` + (siehe `.env.example`). Es steht **nicht** im Quelltext. +- **Fehlt die Variable, startet der Server nicht** und meldet das im Klartext. + Das ist Absicht: Ein vergessener Eintrag soll auffallen, statt stillschweigend + eine ungeschützte Seite ins Netz zu stellen. +- Abgefragt wird per HTTP-Basic-Auth. Der Browser fragt **einmal pro Sitzung**, + nicht bei jedem Seitenaufruf — beim Sortieren wäre das sonst unbrauchbar. +- Der **Benutzername ist beliebig**; geprüft wird nur das Passwort. Beim + Vergleich wird eine zeitkonstante Prüfung verwendet. +- `/healthz` antwortet **ohne** Passwort mit `ok`. Das braucht die Überwachung + der Hosting-Umgebung, die den geschützten Dienst sonst für ausgefallen hielte. + Der Pfad gibt nichts über die Anwendung preis. + +Das Passwort wandert bei einer Basic-Auth-Anmeldung mit jeder Anfrage über die +Leitung. Das ist unbedenklich, solange die Seite über HTTPS ausgeliefert wird — +worauf du bei der Veröffentlichung achten solltest. + ## Technik - Vanilla JavaScript (ES Modules), kein Framework @@ -182,6 +215,8 @@ nicht gesetzt ist). | `src/ui/describe-spec.js` | Gemeinsame Kurzbeschreibung eines Specs (Kapazität, Rank, Geschwindigkeit, Bauform), wahlweise mit Platzhaltern für fehlende Felder; von Treffer-Rückmeldung, „Zuletzt"-Zeile und Sitzungsliste gemeinsam genutzt | | `src/main.js` | Verdrahtung aller Module zur lauffähigen App | | `src/styles.css` | Farbvariablen (Grün/Gelb/Rot der Ampel-Rückmeldung), Layout des Kamera-Vollbilds samt Zielrahmen, Stapel-Leiste und Aktionsknöpfe sowie die Overlays für Treffer-Rückmeldung, Rot-Dialog und Sitzungsliste | +| `server.py` | Auslieferung im Betrieb: statischer Server für `dist/` mit Passwortabfrage vor jeder Datei, plus passwortfreier Gesundheitspfad `/healthz`. Nur Python-Standardbibliothek, keine zusätzliche Abhängigkeit | +| `.vch/deploy.yaml` | Bau- und Startbefehl sowie Gesundheitspfad für die Hosting-Umgebung | `spec`, `pn-decoder`, `ocr-extract`, `session`, `pipeline` und `storage` sind reine Funktionen ohne Browser-Zugriff (kein `window`, `document` oder diff --git a/server.py b/server.py new file mode 100644 index 0000000..3b27936 --- /dev/null +++ b/server.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Statischer Server mit Passwortschutz fuer die RAM-Sortierhilfe. + +Liefert das gebaute Ergebnis aus dist/ aus, gibt aber ohne gueltiges Passwort +nichts heraus - weder HTML noch Skripte. Nutzt ausschliesslich die +Python-Standardbibliothek, damit keine zusaetzliche Abhaengigkeit entsteht. + +Das Passwort kommt aus der Umgebungsvariable APP_PASSWORD. Fehlt sie, startet +der Server absichtlich nicht: Ein vergessener Eintrag soll auffallen, statt +stillschweigend eine ungeschuetzte Seite ins Netz zu stellen. +""" + +import base64 +import hmac +import os +import sys +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + +SERVE_DIRECTORY = 'dist' +HEALTH_PATH = '/healthz' +REALM = 'RAM-Sortierhilfe' + + +def read_password(): + """Liest das Passwort aus der Umgebung. Fehlt es, wird der Start abgebrochen.""" + password = os.environ.get('APP_PASSWORD', '') + if not password: + sys.stderr.write( + 'FEHLER: Die Umgebungsvariable APP_PASSWORD ist nicht gesetzt.\n' + 'Der Server startet nicht, damit die Seite nicht ungeschuetzt\n' + 'erreichbar wird. Setze das Passwort in den VCH-Einstellungen.\n' + ) + sys.exit(1) + return password + + +def build_handler(password): + """Erzeugt einen Handler, der das Passwort per HTTP-Basic-Auth prueft.""" + expected = base64.b64encode(f':{password}'.encode('utf-8')) + + class ProtectedHandler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=SERVE_DIRECTORY, **kwargs) + + def is_authorised(self): + """Prueft nur das Passwort; der Benutzername darf beliebig sein.""" + header = self.headers.get('Authorization', '') + if not header.startswith('Basic '): + return False + try: + decoded = base64.b64decode(header[6:].strip(), validate=True) + except (ValueError, TypeError): + return False + _, separator, supplied = decoded.partition(b':') + if not separator: + return False + # Zeitkonstanter Vergleich, damit die Antwortdauer nichts verraet. + return hmac.compare_digest( + base64.b64encode(b':' + supplied), expected + ) + + def demand_password(self): + self.send_response(401) + self.send_header('WWW-Authenticate', f'Basic realm="{REALM}", charset="UTF-8"') + self.send_header('Content-Type', 'text/plain; charset=utf-8') + self.send_header('Content-Length', '0') + self.end_headers() + + def answer_health(self): + """Lebenszeichen fuer die Ueberwachung - ohne Passwort, ohne Inhalt.""" + body = b'ok' + self.send_response(200) + self.send_header('Content-Type', 'text/plain; charset=utf-8') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + if self.command != 'HEAD': + self.wfile.write(body) + + def guard(self): + """True, wenn die Anfrage weiterverarbeitet werden darf.""" + if self.path.split('?', 1)[0] == HEALTH_PATH: + self.answer_health() + return False + if not self.is_authorised(): + self.demand_password() + return False + return True + + def do_GET(self): + if self.guard(): + super().do_GET() + + def do_HEAD(self): + if self.guard(): + super().do_HEAD() + + return ProtectedHandler + + +def main(): + password = read_password() + port = int(os.environ.get('PORT', '8000')) + server = ThreadingHTTPServer(('0.0.0.0', port), build_handler(password)) + sys.stderr.write(f'RAM-Sortierhilfe laeuft auf Port {port}, Zugang nur mit Passwort.\n') + try: + server.serve_forever() + except KeyboardInterrupt: + server.server_close() + + +if __name__ == '__main__': + main()