moderation preview of pictures and audio files
This commit is contained in:
@@ -3,6 +3,7 @@ PORT=47145
|
|||||||
DATA_DIR=./data
|
DATA_DIR=./data
|
||||||
STORAGE_DIR=./storage
|
STORAGE_DIR=./storage
|
||||||
GPX_CLUSTER_METERS=20
|
GPX_CLUSTER_METERS=20
|
||||||
|
MODERATION_REVIEW_URL=https://wegwichtel.de/moderation/api
|
||||||
MAX_UPLOAD_MB=50
|
MAX_UPLOAD_MB=50
|
||||||
DEFAULT_ROUTE_RADIUS_KM=25
|
DEFAULT_ROUTE_RADIUS_KM=25
|
||||||
DEFAULT_POI_TRIGGER_METERS=80
|
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.assertEqual(self.run_cli([]), 0)
|
||||||
self.assertIn("Keine Strecken", self.out.getvalue())
|
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):
|
def test_invalid_arguments(self):
|
||||||
for args in [["-e", "0"], ["-d", "abc"], ["-e", "1", "-d", "1"], ["-e"]]:
|
for args in [["-e", "0"], ["-d", "abc"], ["-e", "1", "-d", "1"], ["-e"]]:
|
||||||
with redirect_stderr(self.error), self.assertRaises(SystemExit) as result:
|
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
|
`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,
|
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
|
```bash
|
||||||
python3 tools/python/moderate_routes.py
|
python3 tools/python/moderate_routes.py
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import re
|
|||||||
import shlex
|
import shlex
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
def database_path(project_root):
|
def environment_settings(project_root):
|
||||||
"""Liest die .env; bereits gesetzte Umgebungsvariablen haben Vorrang."""
|
"""Liest die .env, ohne die Prozessumgebung zu verändern."""
|
||||||
settings = {}
|
settings = {}
|
||||||
env_file = project_root / ".env"
|
env_file = project_root / ".env"
|
||||||
try:
|
try:
|
||||||
@@ -32,6 +33,12 @@ def database_path(project_root):
|
|||||||
settings[key.strip()] = " ".join(shlex.split(value, comments=True))
|
settings[key.strip()] = " ".join(shlex.split(value, comments=True))
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
raise ValueError(f"Ungültige .env-Anführungszeichen in Zeile {number}.") from 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"
|
data_dir = os.environ.get("DATA_DIR", settings.get("DATA_DIR", "")) or "data"
|
||||||
return (project_root / data_dir / "wegwichtel.sqlite").resolve()
|
return (project_root / data_dir / "wegwichtel.sqlite").resolve()
|
||||||
|
|
||||||
@@ -53,15 +60,61 @@ def arguments(argv):
|
|||||||
return parser.parse_args(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()
|
rows = database.execute("SELECT id, name, status, activated_at FROM routes ORDER BY id").fetchall()
|
||||||
if not rows:
|
if not rows:
|
||||||
print("Keine Strecken vorhanden.")
|
print("Keine Strecken vorhanden.")
|
||||||
return
|
return
|
||||||
print("ID\tStatus\tFreigegeben am (UTC)\tName")
|
print("ID\tStatus\tFreigegeben am (UTC)\tName")
|
||||||
for row in rows:
|
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}')
|
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):
|
def moderate(database, identifier, enable):
|
||||||
@@ -84,12 +137,14 @@ def main(argv=None, project_root=None):
|
|||||||
args = arguments(argv)
|
args = arguments(argv)
|
||||||
try:
|
try:
|
||||||
path = database_path(project_root or PROJECT_ROOT)
|
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)
|
database = sqlite3.connect(f"{path.as_uri()}?mode={mode}", uri=True, timeout=10)
|
||||||
try:
|
try:
|
||||||
database.row_factory = sqlite3.Row
|
database.row_factory = sqlite3.Row
|
||||||
if mode == "ro":
|
if listing:
|
||||||
list_routes(database)
|
list_routes(database, review_url)
|
||||||
else:
|
else:
|
||||||
identifier = args.enable if args.enable is not None else args.disable
|
identifier = args.enable if args.enable is not None else args.disable
|
||||||
moderate(database, identifier, args.enable is not None)
|
moderate(database, identifier, args.enable is not None)
|
||||||
|
|||||||
Reference in New Issue
Block a user