moderation preview of pictures and audio files
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user