moderation preview of pictures and audio files
Sonarqube Scanner / Prepare environment (push) Successful in 9s
Sonarqube Scanner / Build and test (push) Successful in 26s
Sonarqube Scanner / SonarQube analysis (push) Successful in 1m11s
Sonarqube Scanner / Deploy to Server (push) Successful in 8s

This commit is contained in:
Florian Zumpe
2026-10-03 15:48:04 +02:00
parent 94793b530d
commit 4b8bdcad5f
7 changed files with 209 additions and 8 deletions
+1
View File
@@ -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
+68
View File
@@ -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.
+19
View File
@@ -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`).
+22
View File
@@ -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;
}
+31
View File
@@ -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:
+6 -1
View File
@@ -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
+62 -7
View File
@@ -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)