diff --git a/.env.example b/.env.example index 7a3a0e5..1eada0d 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ PORT=47145 DATA_DIR=./data STORAGE_DIR=./storage GPX_CLUSTER_METERS=20 +MODERATION_REVIEW_URL=https://wegwichtel.de/moderation/api MAX_UPLOAD_MB=50 DEFAULT_ROUTE_RADIUS_KM=25 DEFAULT_POI_TRIGGER_METERS=80 diff --git a/MODERATION-MEDIEN.md b/MODERATION-MEDIEN.md new file mode 100644 index 0000000..fc414fc --- /dev/null +++ b/MODERATION-MEDIEN.md @@ -0,0 +1,68 @@ +# Stationen und Medien prüfen + +`npm run moderate` zeigt unter jeder Strecke eine Tabelle mit Stations-ID, +Platz (ab 1, sortiert nach `sequence, id`), Beschreibung und Erstelldatum. +Bei leerer Beschreibung erscheint der Titel. Zeilenumbrüche werden nur für +die Tabellenanzeige zusammengefasst. Die Datenbank wird lesend geöffnet. + +## Dauerhafte Prüflinks + +In `.env` ergänzen: + +```dotenv +MODERATION_REVIEW_URL=https://wegwichtel.de/moderation/api +``` + +Die Tabelle zeigt für jedes Bild und jede Tonaufnahme einen eigenen Link. +Gelöschte Strecken erhalten keine Links. Ohne diese Einstellung bleibt die +Linkspalte leer. Zugangsdaten gehören nicht in URLs oder `.env`. +Es gibt keine Einmal-Tokens, keine zusätzliche Datenbanktabelle und kein PHP. +Eine Datenbank-Neuinitialisierung ist für diese Änderung nicht nötig. + +## Anmeldung mit Nginx und htpasswd + +`nginx/moderation-review.location.conf` in den bestehenden HTTPS-server-Block +aufnehmen. Der geschützte Pfad `/moderation/api/` nutzt die vorhandene Node-API. +GET und HEAD sind erlaubt; schreibende Methoden sind gesperrt. +Nginx prüft Benutzername und Passwort und leitet die Zugangsdaten nicht an Node weiter. +Die Vorlage verweigert HTTP; die Anmeldung muss über HTTPS erfolgen. + +Auf Debian/Ubuntu beispielsweise: + +```bash +sudo apt install apache2-utils +sudo htpasswd -c /etc/nginx/wegwichtel-moderation.htpasswd moderator +sudo chown root:www-data /etc/nginx/wegwichtel-moderation.htpasswd +sudo chmod 640 /etc/nginx/wegwichtel-moderation.htpasswd +sudo nginx -t +sudo systemctl reload nginx +``` + +`www-data` gegebenenfalls an den Nginx-Benutzer anpassen. `-c` nur beim ersten +Anlegen benutzen, damit bestehende Benutzer nicht überschrieben werden. +Beim Öffnen eines Prüflinks erscheint die Anmeldeabfrage des Internetbrowsers. +Bilder und Audio nutzen die bestehenden Datei-Endpunkte einschließlich Range-Requests. +Die Vorlage ersetzt nicht die vorhandene Server-Konfiguration. + +Wichtig für den Umfang: Die Authentifizierung schützt den Moderationspfad. +Die bisherigen `/api/`-Endpunkte bleiben entsprechend dem bisherigen Verhalten +zugänglich, auch für noch nicht freigegebene aktive Strecken. Das entspricht +bereits online verfügbaren Daten; es ist keine Zugriffssperre für unveröffentlichte +Inhalte. Eine solche Sperre müsste separat für die öffentliche API umgesetzt werden. + +## Falls das vorherige Archiv bereits eingebaut wurde + +Die Dateien `tools/php/moderation-review.php` und +`test/php/test_moderation_review.py` sowie den alten PHP-Location-Block entfernen. +Den npm-Eintrag `test:php` entfernen und `MODERATION_TOKEN_TTL_SECONDS` aus `.env` +löschen. Eine bereits angelegte Tabelle `moderation_review_tokens` wird nicht +mehr benutzt; sie kann bestehen bleiben. Die neuen Dateien ersetzen keine +Datenbank und löschen keine Strecken. + +## Tests + +Python-Tests prüfen Tabellenreihenfolge, stabile Bild-/Audiolinks, gelöschte +Strecken, URL-Konfiguration und dass die Anzeige die SQLite-Datei nicht verändert. +Nginx muss auf dem Zielserver mit `nginx -t` und anschließend über HTTP geprüft +werden: ohne oder mit falschem Passwort 401, mit gültigem Passwort Dateiabruf; +schreibende Methoden dürfen keine Änderung auslösen. diff --git a/TODO-MODERATION.md b/TODO-MODERATION.md new file mode 100644 index 0000000..33552fe --- /dev/null +++ b/TODO-MODERATION.md @@ -0,0 +1,19 @@ +# Moderation: Ersteller und Gerätekennung + +Noch nicht umgesetzt: eine dauerhafte Owner-ID pro App auf einem Gerät und eine +Zuordnung des Erstellers zu eingereichten Strecken. Das GPX-Attribut +`creator="Wegwichtel"` ist ausschließlich die Kennung des erzeugenden Programms. + +Sobald diese Funktion eingebaut wird: + +- Die Owner-ID beim Start einmal mit einem kryptografisch sicheren Zufallswert + erzeugen und dauerhaft auf dem Gerät speichern. Löschen der Browserdaten + muss ausdrücklich als Verlust dieser Identität berücksichtigt werden. +- Bei der Einreichung die Erstellerzuordnung serverseitig übernehmen und + in der CLI-Streckenübersicht von `tools/python/moderate_routes.py` anzeigen. +- Zwischen Anzeigename, Gerätekennung und authentifizierter Berechtigung + unterscheiden. Eine frei übermittelte Geräte-ID allein beweist kein Eigentum. +- Tests für Start, Neustart, Einreichung und Anzeige der Kennung ergänzen. + +Die Owner-ID ersetzt keine Anmeldung. Der Moderationszugang wird separat +über Nginx und eine Passwortdatei geschützt (siehe `MODERATION-MEDIEN.md`). diff --git a/nginx/moderation-review.location.conf b/nginx/moderation-review.location.conf new file mode 100644 index 0000000..5ada19d --- /dev/null +++ b/nginx/moderation-review.location.conf @@ -0,0 +1,22 @@ +# In den bestehenden HTTPS-server-Block aufnehmen. +# Passwortdatei außerhalb des Projekt-/Webverzeichnisses ablegen. +location ^~ /moderation/api/ { + if ($scheme != https) { return 403; } + auth_basic "Wegwichtel Moderation"; + auth_basic_user_file /etc/nginx/wegwichtel-moderation.htpasswd; + limit_except GET { deny all; } + + proxy_pass http://127.0.0.1:47145/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization ""; + proxy_cache off; + proxy_hide_header Cache-Control; + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "no-referrer" always; +} diff --git a/test/python/test_moderate_routes.py b/test/python/test_moderate_routes.py index 63c2cc4..296904a 100644 --- a/test/python/test_moderate_routes.py +++ b/test/python/test_moderate_routes.py @@ -114,6 +114,37 @@ class ModerationTests(unittest.TestCase): self.assertEqual(self.run_cli([]), 0) self.assertIn("Keine Strecken", self.out.getvalue()) + def test_station_table_has_ordered_media_links_without_database_writes(self): + with sqlite3.connect(self.path) as database: + database.execute("INSERT INTO pois (route_id, title, description, sequence, audio_path) VALUES (1, 'Ton', 'Erste\nStation', 9, 'active/routes/1/audio/a.mp3')") + database.execute("INSERT INTO pois (route_id, title, description, sequence) VALUES (1, 'Bild', 'Zweite', 3)") + database.execute("INSERT INTO poi_images (poi_id, path) VALUES (2, 'active/routes/1/images/a.png')") + database.execute("INSERT INTO pois (route_id, title, sequence) VALUES (1, 'Ohne Medien', 12)") + database.execute("INSERT INTO pois (route_id, title, audio_path) VALUES (2, 'Gelöscht', 'trash/routes/2/audio/a.mp3')") + (self.root / '.env').write_text('MODERATION_REVIEW_URL=https://wegwichtel.de/moderation/api/\n') + before = self.path.read_bytes() + self.assertEqual(self.run_cli([]), 0) + output = self.out.getvalue() + self.assertIn('Beschreibung', output) + self.assertIn('Erste Station', output) + self.assertRegex(output, r'2\s+\| 1\s+\| Zweite') + self.assertIn('https://wegwichtel.de/moderation/api/routes/1/pois/2/pictures/1', output) + self.assertIn('https://wegwichtel.de/moderation/api/routes/1/pois/1/audio', output) + self.assertNotIn('/routes/2/pois/', output) + self.assertEqual(self.path.read_bytes(), before) + self.out.seek(0) + self.out.truncate() + self.assertEqual(self.run_cli([]), 0) + self.assertEqual(output, self.out.getvalue()) + + def test_invalid_review_configuration(self): + for setting in ['MODERATION_REVIEW_URL=http://example.com/review', + 'MODERATION_REVIEW_URL=https://example.com/review?token=x', + 'MODERATION_REVIEW_URL=https://user:password@example.com/review', + 'MODERATION_REVIEW_URL="https://example.com/review#fragment"']: + (self.root / '.env').write_text(setting) + self.assertEqual(self.run_cli([]), 1) + def test_invalid_arguments(self): for args in [["-e", "0"], ["-d", "abc"], ["-e", "1", "-d", "1"], ["-e"]]: with redirect_stderr(self.error), self.assertRaises(SystemExit) as result: diff --git a/tools/python/README.md b/tools/python/README.md index 2e509e7..90d44d7 100644 --- a/tools/python/README.md +++ b/tools/python/README.md @@ -21,7 +21,12 @@ Die API-Basisadresse ist standardmäßig `https://wegwichtel.de`. Alle Skripte u `moderate_routes.py` greift direkt auf die bestehende SQLite-Datenbank zu und benötigt keine API-Verbindung. Ohne Parameter zeigt es alle Strecken mit ID, -Name, Status und Freigabezeitpunkt an: +Name, Status und Freigabezeitpunkt an. Darunter stehen die Stationen tabellarisch +mit ID, Platz, Beschreibung, Erstelldatum und dauerhaften Links zu Bildern und Tonaufnahmen. +Die Anzeige öffnet die Datenbank ausschließlich lesend. Einrichtung und +passwortgeschützter Webzugang sind in +[MODERATION-MEDIEN.md](../../MODERATION-MEDIEN.md) beschrieben; die spätere +Ersteller-Zuordnung ist in [TODO-MODERATION.md](../../TODO-MODERATION.md) vorgemerkt: ```bash python3 tools/python/moderate_routes.py diff --git a/tools/python/moderate_routes.py b/tools/python/moderate_routes.py index 44002d3..66da489 100644 --- a/tools/python/moderate_routes.py +++ b/tools/python/moderate_routes.py @@ -9,12 +9,13 @@ import re import shlex import sqlite3 import sys +from urllib.parse import urlsplit PROJECT_ROOT = Path(__file__).resolve().parents[2] -def database_path(project_root): - """Liest die .env; bereits gesetzte Umgebungsvariablen haben Vorrang.""" +def environment_settings(project_root): + """Liest die .env, ohne die Prozessumgebung zu verändern.""" settings = {} env_file = project_root / ".env" try: @@ -32,6 +33,12 @@ def database_path(project_root): settings[key.strip()] = " ".join(shlex.split(value, comments=True)) except ValueError as error: raise ValueError(f"Ungültige .env-Anführungszeichen in Zeile {number}.") from error + return settings + + +def database_path(project_root): + """Bereits gesetzte Umgebungsvariablen haben Vorrang vor der .env.""" + settings = environment_settings(project_root) data_dir = os.environ.get("DATA_DIR", settings.get("DATA_DIR", "")) or "data" return (project_root / data_dir / "wegwichtel.sqlite").resolve() @@ -53,15 +60,61 @@ def arguments(argv): return parser.parse_args(argv) -def list_routes(database): +def review_settings(project_root): + settings = environment_settings(project_root) + url = os.environ.get("MODERATION_REVIEW_URL", settings.get("MODERATION_REVIEW_URL", "")) + parsed = urlsplit(url) + if url and (parsed.scheme != "https" or not parsed.netloc or parsed.fragment or parsed.query + or parsed.username or parsed.password): + raise ValueError("MODERATION_REVIEW_URL muss eine HTTPS-Adresse ohne Zugangsdaten, Query oder Fragment sein.") + return url.rstrip("/") + + +def table_text(value): + plain = re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', str(value or "")) + return " ".join(plain.split()) + + +def print_table(headers, rows): + cells = [[table_text(value) for value in row] for row in [headers, *rows]] + widths = [max(len(row[index]) for row in cells) for index in range(len(headers))] + for row in cells: + print(" | ".join(value.ljust(width) for value, width in zip(row, widths))) + + +def show_pois(database, identifier, review_url, active): + rows = database.execute("""SELECT id, sequence, title, description, created_at, audio_path + FROM pois WHERE route_id=? ORDER BY sequence, id""", (identifier,)).fetchall() + print("Stationen:") + if not rows: + print("Keine Stationen vorhanden.") + return + details = [] + for number, poi in enumerate(rows, 1): + access = [] + if review_url and active: + base = f"{review_url}/routes/{identifier}/pois/{poi['id']}" + images = database.execute("SELECT id FROM poi_images WHERE poi_id=? ORDER BY sequence, id", + (poi["id"],)).fetchall() + access.extend(f"{base}/pictures/{image['id']}" for image in images) + if poi["audio_path"]: + access.append(f"{base}/audio") + description = poi["description"] or poi["title"] + details.append([poi["id"], number, description, poi["created_at"] or "–", " ; ".join(access) or "–"]) + print_table(["ID", "Platz", "Beschreibung", "Erstellt am (UTC)", "Bilder / Ton (Anmeldung)"], details) + + +def list_routes(database, review_url=""): rows = database.execute("SELECT id, name, status, activated_at FROM routes ORDER BY id").fetchall() if not rows: print("Keine Strecken vorhanden.") return print("ID\tStatus\tFreigegeben am (UTC)\tName") for row in rows: - name = row["name"].replace("\n", " ").replace("\r", " ").replace("\t", " ") + name = table_text(row["name"]) print(f'{row["id"]}\t{row["status"]}\t{row["activated_at"] or "nicht freigegeben"}\t{name}') + show_pois(database, row["id"], review_url, row["status"] == "active") + print() def moderate(database, identifier, enable): @@ -84,12 +137,14 @@ def main(argv=None, project_root=None): args = arguments(argv) try: path = database_path(project_root or PROJECT_ROOT) - mode = "ro" if args.enable is None and args.disable is None else "rw" + listing = args.enable is None and args.disable is None + review_url = review_settings(project_root or PROJECT_ROOT) if listing else "" + mode = "ro" if listing else "rw" database = sqlite3.connect(f"{path.as_uri()}?mode={mode}", uri=True, timeout=10) try: database.row_factory = sqlite3.Row - if mode == "ro": - list_routes(database) + if listing: + list_routes(database, review_url) else: identifier = args.enable if args.enable is not None else args.disable moderate(database, identifier, args.enable is not None)