allow update for uploaded routes and implement creator id for routes
Sonarqube Scanner / Prepare environment (push) Successful in 8s
Sonarqube Scanner / Build and test (push) Successful in 21s
Sonarqube Scanner / SonarQube analysis (push) Failing after 1m3s
Sonarqube Scanner / Deploy to Server (push) Successful in 6s

This commit is contained in:
Florian Zumpe
2026-10-03 16:43:08 +02:00
parent 4b8bdcad5f
commit 0db91660b3
20 changed files with 423 additions and 91 deletions
+80
View File
@@ -0,0 +1,80 @@
# Erstellerkennung und erneutes Einsenden
Beim App-Start entsteht ein kryptografischer Zufallswert mit 256 Bit über
`crypto.getRandomValues()`. Er liegt als 64-stellige Hexadezimalzeichenfolge
unter `wegwichtel.creator-id.v1` im Local Storage. Keine Zeitzone, Geräteangaben
oder Informationen über den Internetanbieter werden gesammelt. Ein zusätzlicher
Hash würde die Eindeutigkeit des Zufallswerts nicht erhöhen.
Neue Entwürfe bekommen `route.creator_id`; alte lokale Entwürfe bekommen sie
beim nächsten Speichern. Beim Einsenden landet sie in `routes.creator_id`.
Die API liefert das Feld, die Moderations-CLI zeigt die Ersteller-ID. Bereits
zugeordnete Server-Strecken behalten ihren Ersteller bei Änderungen. Strecken
älterer Versionen können beim ersten erneuten Einsenden zugeordnet werden.
## Speicherung und Löschen
Das Ausschalten des Offline-Speichers, Löschen von Fortschritt, Entwürfen oder
Medien löscht die Gerätekennung nicht. Es gibt keine automatische Bereinigung
dieses getrennten Speicherschlüssels. Die gezielte Entwicklerfunktion
`Wegwichtel.LocalRoutes.forgetCreatorId()` entfernt die Kennung ausschließlich
nach ausdrücklich bestätigter Internetbrowser-Abfrage. Es wird kein zusätzlicher
Löschbutton in die Kinderoberfläche eingebaut. Nach bestätigtem Löschen entsteht
beim nächsten Zugriff eine neue Kennung; vorhandene Strecken behalten ihre alte
Erstellerzuordnung. Die App kann das Löschen sämtlicher Website-Daten in den
Einstellungen des Internetbrowsers nicht verhindern. Kopierte Browserdaten
kopieren auch die Identität; das ist keine Hardware-ID.
## Aktualisieren einer eingesandten Strecke
Der lokale Entwurf bleibt erhalten und merkt sich `serverRouteId`,
`serverPoiId`, `serverPictureId` und den Audio-Status. Erneutes Einsenden nutzt
PUT für bestehende Daten und POST für neue Stationen/Bilder. Entfernte lokale
Stationen und Medien werden bei einer erfolgreichen erneuten Übertragung auch
auf dem Server entfernt. Bestätigte Server-IDs werden nach jedem Schritt
lokal gespeichert, damit ein späterer Fehler nicht zum Neuanlegen der Strecke
führt. Der Entwurf bleibt nach Fehlern erneut einsendbar.
Freigegebene Strecken bleiben unveränderlich. Die Prüfung erfolgt vor der
Aktualisierung und zusätzlich in den vorhandenen Server-Schreibprüfungen.
Bei älteren Entwürfen fehlen noch die gespeicherten Stations-/Bild-IDs: Beim
ersten erneuten Einsenden werden neue Stationen samt Medien angelegt und die
alten Stationen anschließend entfernt. Die Strecken-ID bleibt gleich. Deshalb
können sich bei dieser einmaligen Übernahme die Stations-IDs ändern.
Die Übertragung mehrerer Dateien ist keine gemeinsame Transaktion. Bei einem
Abbruch können einzelne Änderungen bereits auf dem Server sein. Bei einem
verlorenen HTTP-Erfolg nach serverseitigem POST ist zusätzlich eine spätere
Idempotenzlösung erforderlich. Parallele Bearbeitung ist noch nicht freigegeben;
die aktuelle Übertragung behandelt den lokalen Entwurf als vollständigen Stand.
## Datenbank und Betrieb
Das Schema und die Startprüfung enthalten die nullable Spalte
`routes.creator_id`. Neue Datenbanken enthalten sie direkt. Es gibt keine
automatische Migration einer vorhandenen Datenbank; `npm run init-db` allein
fügt einer schon bestehenden Tabelle keine Spalte hinzu. Bestehende Strecken
dürfen für diese Änderung nicht gelöscht werden. Für die vorhandene Datenbank
ist vor dem Neustart folgende gezielte Schemaergänzung nötig:
```sql
ALTER TABLE routes ADD COLUMN creator_id TEXT;
```
Die Anwendung vorher anhalten und die Datenbank sichern. Den tatsächlichen
Datenbankpfad aus `DATA_DIR` in `.env` verwenden. Die Anweisung nur einmal
anwenden und anschließend `npm run test:runtime-db` ausführen, dann die App
starten. Nicht die Datenbank oder Streckenverzeichnisse löschen.
## Berechtigungen
`creator_id` ist Metadatum, kein Passwort und kein Eigentumsnachweis. Eine frei
übermittelte Kennung gewährt keine Berechtigung. Bestehende Zugriffsregeln bleiben
wirksam. Abgesicherte Eigentumszuordnung, Einladungen und gemeinsame Bearbeitung
stehen in `TODO-MODERATION.md` weiterhin aus.
Die GPS-Aufzeichnung läuft nur innerhalb einer Bearbeitungssitzung. Schließen,
Abbrechen, Rückkehr zur Liste, Streckenwechsel und Verlassen der Seite beenden
sie; ein Neustart übernimmt keinen aktiven Aufzeichnungszustand. Beim erneuten
Öffnen eines lokalen Entwurfs beginnt eine neue Sitzung. Einzelheiten stehen in
`GPX-Aufzeichnung.md`.
+5 -2
View File
@@ -3,9 +3,12 @@
## Stationen und Weg getrennt speichern ## Stationen und Weg getrennt speichern
- Stationen mit Koordinaten stehen als `wpt` unmittelbar unter `gpx`. - Stationen mit Koordinaten stehen als `wpt` unmittelbar unter `gpx`.
- Beim ersten Speichern eines neuen Entwurfs startet eine unabhängige Aufzeichnung: - Beim ersten Speichern eines neuen Entwurfs startet während der Bearbeitung eine Aufzeichnung:
sofort eine Position, danach alle 30 Sekunden eine neue GPS-Abfrage. sofort eine Position, danach alle 30 Sekunden eine neue GPS-Abfrage.
Das Schließen des Bearbeitungsfensters beendet die Aufzeichnung nicht. Schließen, Abbrechen, Rückkehr zur Streckenliste oder Wechsel der Strecke
beendet die Aufzeichnung. Nach einem Neustart ist sie inaktiv. Beim erneuten
Öffnen eines lokalen Entwurfs zur Bearbeitung beginnt eine neue Sitzung.
Speichern innerhalb derselben Sitzung hebt Pause oder bewusstes Stoppen nicht auf.
- Die sichtbaren Schaltflächen erlauben Pausieren, Fortsetzen und Beenden. - Die sichtbaren Schaltflächen erlauben Pausieren, Fortsetzen und Beenden.
Beim Löschen des Entwurfs endet die Aufzeichnung ebenfalls. Beim Löschen des Entwurfs endet die Aufzeichnung ebenfalls.
- Tatsächliche Positionen stehen als `trk/trkseg/trkpt` mit Zeitstempel und, - Tatsächliche Positionen stehen als `trk/trkseg/trkpt` mit Zeitstempel und,
+14 -13
View File
@@ -1,19 +1,20 @@
# Moderation: Ersteller und Gerätekennung # Moderation: Ersteller und Gerätekennung
Noch nicht umgesetzt: eine dauerhafte Owner-ID pro App auf einem Gerät und eine Umgesetzt: dauerhafte, zufällige 256-Bit-Gerätekennung im getrennten Local Storage,
Zuordnung des Erstellers zu eingereichten Strecken. Das GPX-Attribut `creator_id` in lokalen Entwürfen und Server-Strecken, Anzeige im Moderationsskript,
`creator="Wegwichtel"` ist ausschließlich die Kennung des erzeugenden Programms. gezieltes Löschen nur nach ausdrücklicher Bestätigung und erneutes Einsenden noch
nicht freigegebener Strecken. Beschreibung: `CREATOR-ID.md`.
Das GPX-Attribut `creator="Wegwichtel"` bezeichnet weiterhin nur das Programm.
Sobald diese Funktion eingebaut wird: Noch offen für gemeinsame Bearbeitung:
- Die Owner-ID beim Start einmal mit einem kryptografisch sicheren Zufallswert - Authentifizierte Eigentumszuordnung und Einladungen für Mitbearbeiter entwickeln.
erzeugen und dauerhaft auf dem Gerät speichern. Löschen der Browserdaten Eine frei übermittelte Gerätekennung allein beweist kein Eigentum.
muss ausdrücklich als Verlust dieser Identität berücksichtigt werden. - Wiederherstellung nach verlorenen Browserdaten und bewussten Gerätewechsel planen.
- Bei der Einreichung die Erstellerzuordnung serverseitig übernehmen und - Gleichzeitige Änderungen und Versionskonflikte erkennen; der derzeitige lokale
in der CLI-Streckenübersicht von `tools/python/moderate_routes.py` anzeigen. Entwurf wird beim Einsenden als vollständiger Stand behandelt.
- Zwischen Anzeigename, Gerätekennung und authentifizierter Berechtigung - Idempotente POSTs auch bei verlorenem HTTP-Erfolg und Gesamtübertragungen mit
unterscheiden. Eine frei übermittelte Geräte-ID allein beweist kein Eigentum. mehreren Dateien absichern.
- Tests für Start, Neustart, Einreichung und Anzeige der Kennung ergänzen.
Die Owner-ID ersetzt keine Anmeldung. Der Moderationszugang wird separat Die Ersteller-ID ersetzt keine Anmeldung. Der Moderationszugang ist separat
über Nginx und eine Passwortdatei geschützt (siehe `MODERATION-MEDIEN.md`). über Nginx und eine Passwortdatei geschützt (siehe `MODERATION-MEDIEN.md`).
+1
View File
@@ -1,6 +1,7 @@
CREATE TABLE IF NOT EXISTS routes ( CREATE TABLE IF NOT EXISTS routes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE,
creator_id TEXT,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
school_name TEXT NOT NULL DEFAULT '', school_name TEXT NOT NULL DEFAULT '',
+1
View File
@@ -336,6 +336,7 @@
<h3>5. Deine eigenen Strecken</h3> <h3>5. Deine eigenen Strecken</h3>
<p>Neue Strecken bleiben zuerst auf deinem Gerät. Gespeichert werden der Weg, die Orte der Stationen, Texte sowie ausgewählte Bilder und Tonaufnahmen.</p> <p>Neue Strecken bleiben zuerst auf deinem Gerät. Gespeichert werden der Weg, die Orte der Stationen, Texte sowie ausgewählte Bilder und Tonaufnahmen.</p>
<p>Deine Entwürfe bleiben erhalten, wenn alter Fortschritt gelöscht wird oder du „Meinen Fortschritt merken“ ausschaltest. Sie bleiben auf diesem Gerät, bis du sie löschst oder die gespeicherten Daten von Wegwichtel in deinem Internetbrowser entfernst. Nur wenn du „Zur Prüfung senden“ wählst, wird deine Strecke an die Website gesendet.</p> <p>Deine Entwürfe bleiben erhalten, wenn alter Fortschritt gelöscht wird oder du „Meinen Fortschritt merken“ ausschaltest. Sie bleiben auf diesem Gerät, bis du sie löschst oder die gespeicherten Daten von Wegwichtel in deinem Internetbrowser entfernst. Nur wenn du „Zur Prüfung senden“ wählst, wird deine Strecke an die Website gesendet.</p>
<p>Beim ersten Start wird eine zufällige Gerätekennung gespeichert. Sie wird beim Einsenden mit deiner Strecke verknüpft. Dafür werden keine Angaben über dein Gerät oder deinen Internetanbieter gesammelt. Beim Löschen von Entwürfen oder gespeicherten App-Daten bleibt die Kennung erhalten. Wenn du alle Website-Daten im Internetbrowser löschst, geht auch die Kennung verloren.</p>
<h3>6. Werbung und gespeicherte Einstellungen</h3> <h3>6. Werbung und gespeicherte Einstellungen</h3>
<p>Wegwichtel verwendet keine Cookies. Es zählt keine Besuche für Werbung und verfolgt nicht, welche anderen Seiten du besuchst. Auf deinem Gerät wird gespeichert, ob du „Meinen Fortschritt merken“ eingeschaltet hast.</p> <p>Wegwichtel verwendet keine Cookies. Es zählt keine Besuche für Werbung und verfolgt nicht, welche anderen Seiten du besuchst. Auf deinem Gerät wird gespeichert, ob du „Meinen Fortschritt merken“ eingeschaltet hast.</p>
<h3>7. Inhalte von Schülerinnen und Schülern</h3> <h3>7. Inhalte von Schülerinnen und Schülern</h3>
+1
View File
@@ -799,6 +799,7 @@
}, },
async initialize(context) { async initialize(context) {
ns.LocalRoutes?.creatorId?.();
bindEvents(); bindEvents();
await ns.Api.health(); await ns.Api.health();
context.markStep('server', 'done'); context.markStep('server', 'done');
+82 -47
View File
@@ -202,6 +202,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function showListView() { function showListView() {
ns.RouteRecorder?.endEditing();
setDialogTitle('Streckenverwaltung'); setDialogTitle('Streckenverwaltung');
setHidden(byId(ids.routeListView), false); setHidden(byId(ids.routeListView), false);
setHidden(byId(ids.routeDetailView), true); setHidden(byId(ids.routeDetailView), true);
@@ -469,6 +470,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function closeEditor() { function closeEditor() {
ns.RouteRecorder?.endEditing();
const dialog = byId(ids.dialog); const dialog = byId(ids.dialog);
if (dialog?.open && typeof dialog.close === 'function') dialog.close(); if (dialog?.open && typeof dialog.close === 'function') dialog.close();
else if (dialog?.open) dialog.removeAttribute('open'); else if (dialog?.open) dialog.removeAttribute('open');
@@ -507,7 +509,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (state.currentSource === 'local') { if (state.currentSource === 'local') {
const uploaded = Boolean(route?.serverRouteId); const uploaded = Boolean(route?.serverRouteId);
setHidden(uploadButton, false); setHidden(uploadButton, false);
setDisabled(uploadButton, uploaded); setDisabled(uploadButton, false);
if (uploadButton) uploadButton.textContent = uploaded ? ns.I18n.t("editor.submitted") : ns.I18n.t("editor.submit"); if (uploadButton) uploadButton.textContent = uploaded ? ns.I18n.t("editor.submitted") : ns.I18n.t("editor.submit");
byId(ids.routeActivated).textContent = uploaded byId(ids.routeActivated).textContent = uploaded
? ns.I18n.t("editor.submitted-hint") ? ns.I18n.t("editor.submitted-hint")
@@ -761,6 +763,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function loadRoute(route) { async function loadRoute(route) {
const id = routeId(route); const id = routeId(route);
if (!id) return; if (!id) return;
if (ns.RouteRecorder?.routeId && (routeSource(route) !== 'local' || ns.RouteRecorder.routeId !== id)) {
ns.RouteRecorder.endEditing();
}
setStatus(ns.I18n.t("editor.route.loading")); setStatus(ns.I18n.t("editor.route.loading"));
state.currentSource = routeSource(route); state.currentSource = routeSource(route);
@@ -773,6 +778,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
gpx: routePackage.gpx, gpx: routePackage.gpx,
_source: 'local' _source: 'local'
}; };
if (byId(ids.dialog)?.open) ns.RouteRecorder?.beginEditing(id);
state.pois = [...routePackage.pois].sort((left, right) => Number(left.sequence) - Number(right.sequence)); state.pois = [...routePackage.pois].sort((left, right) => Number(left.sequence) - Number(right.sequence));
} else { } else {
const payload = await readJson(`/routes/${encodeURIComponent(id)}`); const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
@@ -789,6 +795,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function newRoute() { function newRoute() {
ns.RouteRecorder?.endEditing();
state.currentSource = 'local'; state.currentSource = 'local';
state.currentRoute = { state.currentRoute = {
id: '', id: '',
@@ -839,7 +846,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}); });
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' }; state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
state.currentSource = 'local'; state.currentSource = 'local';
if (isNew) ns.RouteRecorder?.start(saved.id); if (isNew && byId(ids.dialog)?.open) ns.RouteRecorder?.beginEditing(saved.id);
state.pois = saved.pois; state.pois = saved.pois;
await loadRoutes(); await loadRoutes();
await loadRoute({ id: saved.id, _source: 'local' }); await loadRoute({ id: saved.id, _source: 'local' });
@@ -884,6 +891,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const data = new FormData(); const data = new FormData();
data.append('name', routePackage.route?.name || ns.I18n.t("editor.untitled-route")); data.append('name', routePackage.route?.name || ns.I18n.t("editor.untitled-route"));
data.append('description', routePackage.route?.description || ''); data.append('description', routePackage.route?.description || '');
data.append('creator_id', routePackage.route?.creator_id || ns.LocalRoutes.creatorId());
const uploadGpx = ns.LocalRoutes.gpxForUpload(routePackage); const uploadGpx = ns.LocalRoutes.gpxForUpload(routePackage);
const gpx = new Blob([uploadGpx], { type: 'application/gpx+xml' }); const gpx = new Blob([uploadGpx], { type: 'application/gpx+xml' });
data.append('gpx', gpx, safeGpxFilename(routePackage.route?.name)); data.append('gpx', gpx, safeGpxFilename(routePackage.route?.name));
@@ -898,72 +906,89 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return blob; return blob;
} }
async function uploadStoredMedia(path, fieldName, media, fallbackName) { async function uploadStoredMedia(path, fieldName, media, fallbackName, method = 'POST') {
const blob = await storedMediaBlob(media); const blob = await storedMediaBlob(media);
if (!blob) return;
const data = new FormData(); const data = new FormData();
data.append(fieldName, blob, media.name || fallbackName); data.append(fieldName, blob, media.name || fallbackName);
await sendForm(path, 'POST', data); if (fieldName === 'picture') data.append('caption', media.caption || '');
} return sendForm(path, method, data);
async function uploadLocalPoiMedia(serverRouteId, serverPoiId, poi) {
const basePath = `/routes/${encodeURIComponent(serverRouteId)}/pois/${encodeURIComponent(serverPoiId)}`;
if (poi.audio) {
await uploadStoredMedia(`${basePath}/audio`, 'audio', poi.audio, 'audio.bin');
}
for (const [index, image] of (poi.images || []).entries()) {
if (!image) continue;
await uploadStoredMedia(`${basePath}/pictures`, 'picture', image, `bild-${index + 1}.bin`);
}
} }
async function createServerRouteFromLocal(routePackage) { async function createServerRouteFromLocal(routePackage) {
let createdRouteId = null; const checkpoint = () => {
try { const latest = ns.LocalRoutes.get(routePackage.id);
// GPS samples may arrive while media requests are pending.
if (latest) routePackage.gpx = latest.gpx;
return ns.LocalRoutes.save(routePackage);
};
let id = routePackage.route.serverRouteId;
let previousPois = [];
if (id) {
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
const existing = payload.route || payload;
if (existing.activatedAt || existing.activated_at) {
throw new Error(ns.I18n.t("editor.read-only-hint"));
}
previousPois = existing.pois || [];
await sendForm(`/routes/${encodeURIComponent(id)}`, 'PUT', localRouteUploadForm(routePackage));
} else {
const payload = await sendForm('/routes', 'POST', localRouteUploadForm(routePackage)); const payload = await sendForm('/routes', 'POST', localRouteUploadForm(routePackage));
const serverRoute = payload.route || payload; id = routeId(payload.route || payload);
createdRouteId = routeId(serverRoute); if (!id) throw new Error(ns.I18n.t("editor.error.send-route"));
if (!createdRouteId) throw new Error(ns.I18n.t("editor.error.send-route")); routePackage.route.serverRouteId = id;
checkpoint();
}
const orderedPois = [...(routePackage.pois || [])] const orderedPois = [...(routePackage.pois || [])]
.sort((left, right) => Number(left.sequence) - Number(right.sequence)); .sort((left, right) => Number(left.sequence) - Number(right.sequence));
for (const [index, poi] of orderedPois.entries()) { for (const [index, poi] of orderedPois.entries()) {
const poiTitle = poi.title || ns.I18n.t("editor.station-number", { value1: index + 1 }); const base = `/routes/${encodeURIComponent(id)}/pois`;
const poiPayload = await sendJson(`/routes/${encodeURIComponent(createdRouteId)}/pois`, 'POST', { const payload = await sendJson(poi.serverPoiId ? `${base}/${poi.serverPoiId}` : base,
description: poi.description || '', poi.serverPoiId ? 'PUT' : 'POST', {
lat: poi.lat, description: poi.description || '', lat: poi.lat, lon: poi.lon, sequence: index,
lon: poi.lon, title: poi.title || ns.I18n.t("editor.station-number", { value1: index + 1 }),
sequence: index,
title: poiTitle,
triggerRadiusM: poi.triggerRadiusM || 25 triggerRadiusM: poi.triggerRadiusM || 25
}); });
const serverPoi = poiPayload.poi || poiPayload; poi.serverPoiId = poiId(payload.poi || payload);
const serverPoiId = poiId(serverPoi); if (!poi.serverPoiId) throw new Error(ns.I18n.t("editor.error.send-station", { value1: poi.title || '' }));
if (!serverPoiId) throw new Error(ns.I18n.t("editor.error.send-station", { value1: poiTitle })); checkpoint();
await uploadLocalPoiMedia(createdRouteId, serverPoiId, poi); const mediaBase = `${base}/${poi.serverPoiId}`;
const oldPoi = previousPois.find(old => String(old.id) === String(poi.serverPoiId));
if (poi.audio) {
await uploadStoredMedia(`${mediaBase}/audio`, 'audio', poi.audio, 'audio.bin',
oldPoi?.audioUrl || poi.serverAudio ? 'PUT' : 'POST');
poi.serverAudio = true;
checkpoint();
} else if (oldPoi?.audioUrl) {
await sendDelete(`${mediaBase}/audio`);
poi.serverAudio = false;
checkpoint();
} }
return createdRouteId; for (const [imageIndex, image] of (poi.images || []).entries()) {
} catch (error) { const path = image.serverPictureId ? `${mediaBase}/pictures/${image.serverPictureId}` : `${mediaBase}/pictures`;
if (createdRouteId) { const picture = await uploadStoredMedia(path, 'picture', image, `bild-${imageIndex + 1}.bin`,
try { image.serverPictureId ? 'PUT' : 'POST');
await sendDelete(`/routes/${encodeURIComponent(createdRouteId)}`); image.serverPictureId = (picture.picture || picture).id;
} catch { if (!image.serverPictureId) throw new Error(ns.I18n.t("editor.error.send-file", { value1: image.name || '' }));
// Der ursprüngliche Uploadfehler ist wichtiger als ein fehlgeschlagener Rollback. checkpoint();
}
for (const oldImage of oldPoi?.images || []) {
if (!(poi.images || []).some(image => String(image.serverPictureId) === String(oldImage.id))) {
await sendDelete(`${mediaBase}/pictures/${oldImage.id}`);
} }
} }
throw error;
} }
for (const oldPoi of previousPois) {
if (!orderedPois.some(poi => String(poi.serverPoiId) === String(oldPoi.id))) {
await sendDelete(`/routes/${encodeURIComponent(id)}/pois/${oldPoi.id}`);
}
}
return id;
} }
async function uploadLocalRoute() { async function uploadLocalRoute() {
if (state.currentSource !== 'local') throw new Error(ns.I18n.t("editor.error.submit-own-draft")); if (state.currentSource !== 'local') throw new Error(ns.I18n.t("editor.error.submit-own-draft"));
const values = routeFromForm(); const values = routeFromForm();
if (!values.name) throw new Error(ns.I18n.t("editor.error.route-name")); if (!values.name) throw new Error(ns.I18n.t("editor.error.route-name"));
if (state.currentRoute?.serverRouteId) {
setStatus(ns.I18n.t("editor.error.already-submitted"));
return;
}
const uploadButton = byId(ids.routeUpload); const uploadButton = byId(ids.routeUpload);
setDisabled(uploadButton, true); setDisabled(uploadButton, true);
try { try {
@@ -989,7 +1014,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
await loadRoutes(); await loadRoutes();
setStatus(ns.I18n.t("editor.submit-success")); setStatus(ns.I18n.t("editor.submit-success"));
} finally { } finally {
if (!state.currentRoute?.serverRouteId) setDisabled(uploadButton, false); // Keep acknowledged server IDs even if a later media request failed.
const latest = ns.LocalRoutes.get(routeId(state.currentRoute));
if (latest) {
state.currentRoute = { ...latest.route, id: latest.id, gpx: latest.gpx, _source: 'local' };
state.pois = latest.pois;
fillRouteForm(state.currentRoute);
}
setDisabled(uploadButton, false);
} }
} }
@@ -1392,6 +1424,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function bind() { function bind() {
for (const eventName of ['cancel', 'close']) {
byId(ids.dialog)?.addEventListener(eventName, () => ns.RouteRecorder?.endEditing());
}
activate(byId(ids.button), () => guarded(openEditor)); activate(byId(ids.button), () => guarded(openEditor));
activate(byId(ids.close), closeEditor); activate(byId(ids.close), closeEditor);
activate(byId(ids.newRoute), newRoute); activate(byId(ids.newRoute), newRoute);
+28
View File
@@ -19,6 +19,31 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return target; return target;
} }
// Separate from app/route storage: ordinary cleanup must preserve this identity.
const CREATOR_KEY = 'wegwichtel.creator-id.v1';
function creatorId() {
const target = storage();
const existing = target.getItem(CREATOR_KEY);
if (existing) {
if (!/^[a-f0-9]{64}$/.test(existing)) throw new Error(ns.I18n.t("local.error.identity"));
return existing;
}
if (typeof globalThis.crypto?.getRandomValues !== 'function') {
throw new Error(ns.I18n.t("local.error.identity"));
}
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(32));
const value = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
target.setItem(CREATOR_KEY, value);
return value;
}
function forgetCreatorId() {
if (globalThis.confirm?.(ns.I18n.t("local.confirm-forget-identity")) !== true) return false;
storage().removeItem(CREATOR_KEY);
return true;
}
function routeKey(id, suffix) { function routeKey(id, suffix) {
return `${STORAGE_PREFIX}.${encodeURIComponent(String(id))}.${suffix}`; return `${STORAGE_PREFIX}.${encodeURIComponent(String(id))}.${suffix}`;
} }
@@ -499,6 +524,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const inputRoute = routePackage.route || {}; const inputRoute = routePackage.route || {};
const route = { const route = {
id, id,
creator_id: inputRoute.creator_id || creatorId(),
name: String(inputRoute.name || ''), name: String(inputRoute.name || ''),
description: String(inputRoute.description || ''), description: String(inputRoute.description || ''),
createdAt: inputRoute.createdAt || now, createdAt: inputRoute.createdAt || now,
@@ -724,6 +750,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
ns.LocalRoutes = Object.freeze({ ns.LocalRoutes = Object.freeze({
STORAGE_PREFIX, STORAGE_PREFIX,
creatorId,
forgetCreatorId,
appendTrackPoint, appendTrackPoint,
recordedPositions, recordedPositions,
replaceRecordedPositions, replaceRecordedPositions,
+15 -1
View File
@@ -3,6 +3,7 @@ globalThis.Wegwichtel ??= {};
'use strict'; 'use strict';
const INTERVAL_MS = 30000; const INTERVAL_MS = 30000;
let id = null; let id = null;
let editingId = null;
let status = 'idle'; let status = 'idle';
let timer = null; let timer = null;
let generation = 0; let generation = 0;
@@ -79,9 +80,21 @@ globalThis.Wegwichtel ??= {};
function start(routeId) { function start(routeId) {
lastPosition = null; lastPosition = null;
id = String(routeId); id = String(routeId);
editingId = id;
resume(); resume();
} }
function beginEditing(routeId) {
if (editingId === String(routeId)) return;
stop();
start(routeId);
}
function endEditing() {
editingId = null;
stop();
}
function addPositionField(position = {}, index = null) { function addPositionField(position = {}, index = null) {
const container = document.getElementById('editor-recorded-positions'); const container = document.getElementById('editor-recorded-positions');
if (!container) return; if (!container) return;
@@ -132,6 +145,7 @@ globalThis.Wegwichtel ??= {};
} }
function bind() { function bind() {
globalThis.addEventListener?.('pagehide', endEditing);
const activate = (elementId, action) => { const activate = (elementId, action) => {
const element = document.getElementById(elementId); const element = document.getElementById(elementId);
element?.addEventListener('pointerup', event => { element?.addEventListener('pointerup', event => {
@@ -153,7 +167,7 @@ globalThis.Wegwichtel ??= {};
}); });
} }
ns.RouteRecorder = Object.freeze({ start, pause, resume, stop, renderPoints, editGpx, get status() { return status; }, get routeId() { return id; }, get position() { return lastPosition; } }); ns.RouteRecorder = Object.freeze({ start, pause, resume, stop, beginEditing, endEditing, renderPoints, editGpx, get status() { return status; }, get routeId() { return id; }, get position() { return lastPosition; } });
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind, { once: true }); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind, { once: true });
else bind(); else bind();
}(globalThis.Wegwichtel)); }(globalThis.Wegwichtel));
+4 -3
View File
@@ -105,7 +105,6 @@ globalThis.Wegwichtel ??= {};
"send-route": "Die Strecke konnte nicht gesendet werden. Bitte versuche es noch einmal.", "send-route": "Die Strecke konnte nicht gesendet werden. Bitte versuche es noch einmal.",
"send-station": "Die Station {value1} konnte nicht gesendet werden. Bitte versuche es noch einmal.", "send-station": "Die Station {value1} konnte nicht gesendet werden. Bitte versuche es noch einmal.",
"submit-own-draft": "Du kannst nur deine eigenen Entwürfe zur Prüfung senden.", "submit-own-draft": "Du kannst nur deine eigenen Entwürfe zur Prüfung senden.",
"already-submitted": "Diese Strecke wurde schon zur Prüfung gesendet.",
"location-required": "Für eine neue Station brauchen wir deinen Standort. Erlaube den Standort und versuche es noch einmal.", "location-required": "Für eine neue Station brauchen wir deinen Standort. Erlaube den Standort und versuche es noch einmal.",
"save-route-first": "Speichere zuerst die Strecke.", "save-route-first": "Speichere zuerst die Strecke.",
"station-location": "Der Ort dieser Station fehlt. Erlaube deinen Standort und versuche es noch einmal.", "station-location": "Der Ort dieser Station fehlt. Erlaube deinen Standort und versuche es noch einmal.",
@@ -131,9 +130,9 @@ globalThis.Wegwichtel ??= {};
"no-draft": "Noch kein eigener Entwurf vorhanden.", "no-draft": "Noch kein eigener Entwurf vorhanden.",
"no-stations": "Noch keine Stationen angelegt.", "no-stations": "Noch keine Stationen angelegt.",
"loading-routes": "Strecken werden geladen …", "loading-routes": "Strecken werden geladen …",
"submitted": "Schon zur Prüfung gesendet", "submitted": "Erneut zur Prüfung senden",
"submit": "Zur Prüfung senden", "submit": "Zur Prüfung senden",
"submitted-hint": "Diese Strecke wurde zur Prüfung gesendet. Andere können sie erst nach der Freigabe sehen.", "submitted-hint": "Du kannst Änderungen erneut zur Prüfung senden, solange die Strecke noch nicht freigegeben wurde.",
"draft-hint": "Dieser Entwurf bleibt auf deinem Gerät. Mit „Zur Prüfung senden“ reichst du ihn zur Freigabe ein.", "draft-hint": "Dieser Entwurf bleibt auf deinem Gerät. Mit „Zur Prüfung senden“ reichst du ihn zur Freigabe ein.",
"read-only-hint": "Diese Strecke ist für alle freigegeben und kann hier nicht mehr verändert werden.", "read-only-hint": "Diese Strecke ist für alle freigegeben und kann hier nicht mehr verändert werden.",
"unpublished-hint": "Diese Strecke wurde noch nicht für alle freigegeben.", "unpublished-hint": "Diese Strecke wurde noch nicht für alle freigegeben.",
@@ -220,7 +219,9 @@ globalThis.Wegwichtel ??= {};
"missing": "{value1}: Diese Information fehlt noch." "missing": "{value1}: Diese Information fehlt noch."
}, },
"local": { "local": {
"confirm-forget-identity": "Gerätekennung wirklich löschen? Dieses Gerät bekommt eine neue Kennung. Die Zuordnung zu bisherigen Strecken bleibt bestehen.",
"error": { "error": {
"identity": "Die Gerätekennung konnte nicht gespeichert werden. Erlaube das Speichern für diese Seite und versuche es erneut.",
"route-storage": "Deine Strecke kann auf diesem Gerät gerade nicht gespeichert werden.", "route-storage": "Deine Strecke kann auf diesem Gerät gerade nicht gespeichert werden.",
"media-storage": "Bilder und Ton können auf diesem Gerät gerade nicht gespeichert werden.", "media-storage": "Bilder und Ton können auf diesem Gerät gerade nicht gespeichert werden.",
"media-read": "Gespeicherte Bilder und Ton konnten nicht geöffnet werden.", "media-read": "Gespeicherte Bilder und Ton konnten nicht geöffnet werden.",
+4 -3
View File
@@ -105,7 +105,6 @@ globalThis.Wegwichtel ??= {};
"send-route": "The trail could not be sent. Please try again.", "send-route": "The trail could not be sent. Please try again.",
"send-station": "The stop {value1} could not be sent. Please try again.", "send-station": "The stop {value1} could not be sent. Please try again.",
"submit-own-draft": "You can only send your own drafts for review.", "submit-own-draft": "You can only send your own drafts for review.",
"already-submitted": "This trail has already been sent for review.",
"location-required": "We need your location to add a new stop. Allow location access and try again.", "location-required": "We need your location to add a new stop. Allow location access and try again.",
"save-route-first": "Save the trail first.", "save-route-first": "Save the trail first.",
"station-location": "This stop has no location. Allow location access and try again.", "station-location": "This stop has no location. Allow location access and try again.",
@@ -131,9 +130,9 @@ globalThis.Wegwichtel ??= {};
"no-draft": "There are no drafts yet.", "no-draft": "There are no drafts yet.",
"no-stations": "There are no stops yet.", "no-stations": "There are no stops yet.",
"loading-routes": "Loading trails …", "loading-routes": "Loading trails …",
"submitted": "Already sent for review", "submitted": "Send changes for review",
"submit": "Send for review", "submit": "Send for review",
"submitted-hint": "This trail has been sent for review. Others can see it once it has been approved.", "submitted-hint": "You can send changes for review until the route has been approved.",
"draft-hint": "This draft stays on your device. Choose “Send for review” to submit it for approval.", "draft-hint": "This draft stays on your device. Choose “Send for review” to submit it for approval.",
"read-only-hint": "This trail is available to everyone and can no longer be changed here.", "read-only-hint": "This trail is available to everyone and can no longer be changed here.",
"unpublished-hint": "This trail has not been made available to everyone yet.", "unpublished-hint": "This trail has not been made available to everyone yet.",
@@ -220,7 +219,9 @@ globalThis.Wegwichtel ??= {};
"missing": "{value1}: This information still needs to be added." "missing": "{value1}: This information still needs to be added."
}, },
"local": { "local": {
"confirm-forget-identity": "Delete the device ID? This device will get a new ID. Existing routes will keep their original creator ID.",
"error": { "error": {
"identity": "The device ID could not be saved. Allow this site to store data and try again.",
"route-storage": "Your trail cannot be saved on this device right now.", "route-storage": "Your trail cannot be saved on this device right now.",
"media-storage": "Pictures and recordings cannot be saved on this device right now.", "media-storage": "Pictures and recordings cannot be saved on this device right now.",
"media-read": "Saved pictures and recordings could not be opened.", "media-read": "Saved pictures and recordings could not be opened.",
+17 -4
View File
@@ -50,6 +50,7 @@ function rowToRoute(row, includeDeleted = false, parsed = null) {
return { return {
id: row.id, id: row.id,
slug: row.slug, slug: row.slug,
creator_id: row.creator_id,
name: row.name, name: row.name,
description: row.description, description: row.description,
schoolName: row.school_name, schoolName: row.school_name,
@@ -170,6 +171,10 @@ export function createRoute(db, fields, gpxFile) {
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.'); if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
let routeId; let routeId;
try { try {
const creatorId = fields.creator_id || null;
if (creatorId !== null && (typeof creatorId !== 'string' || !/^[a-f0-9]{64}$/.test(creatorId))) {
throw new HttpError(400, 'Ungültige Erstellerkennung.');
}
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8')); const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
const name = String(fields.name || parsed.name || '').trim(); const name = String(fields.name || parsed.name || '').trim();
if (!name) throw new HttpError(400, 'Ein Streckenname ist erforderlich.'); if (!name) throw new HttpError(400, 'Ein Streckenname ist erforderlich.');
@@ -177,8 +182,8 @@ export function createRoute(db, fields, gpxFile) {
const slug = uniqueSlug(db, fields.slug || name); const slug = uniqueSlug(db, fields.slug || name);
const metrics = parsed.metrics; const metrics = parsed.metrics;
const result = db.prepare(`INSERT INTO routes const result = db.prepare(`INSERT INTO routes
(slug, name, description, school_name, gpx_path, min_lat, min_lon, max_lat, max_lon) (slug, name, description, school_name, gpx_path, min_lat, min_lon, max_lat, max_lon, creator_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
slug, slug,
name, name,
fields.description || '', fields.description || '',
@@ -187,7 +192,8 @@ export function createRoute(db, fields, gpxFile) {
metrics.minLat, metrics.minLat,
metrics.minLon, metrics.minLon,
metrics.maxLat, metrics.maxLat,
metrics.maxLon metrics.maxLon,
creatorId
); );
const id = Number(result.lastInsertRowid); const id = Number(result.lastInsertRowid);
return id; return id;
@@ -207,13 +213,20 @@ export function createRoute(db, fields, gpxFile) {
export function updateRoute(db, id, fields, gpxFile) { export function updateRoute(db, id, fields, gpxFile) {
const existing = db.prepare("SELECT * FROM routes WHERE id = ? AND status = 'active'").get(id); const existing = db.prepare("SELECT * FROM routes WHERE id = ? AND status = 'active'").get(id);
if (!existing) throw new HttpError(404, 'Strecke nicht gefunden.'); if (!existing) throw new HttpError(404, 'Strecke nicht gefunden.');
if (fields.creator_id != null && (typeof fields.creator_id !== 'string' || !/^[a-f0-9]{64}$/.test(fields.creator_id))) {
throw new HttpError(400, 'Ungültige Erstellerkennung.');
}
if (existing.creator_id && fields.creator_id != null && fields.creator_id !== existing.creator_id) {
throw new HttpError(409, 'Die Erstellerzuordnung kann nicht geändert werden.');
}
const parsed = gpxFile ? parseGpx(fs.readFileSync(gpxFile.path, 'utf8')) : null; const parsed = gpxFile ? parseGpx(fs.readFileSync(gpxFile.path, 'utf8')) : null;
transaction(db, () => { transaction(db, () => {
db.prepare(`UPDATE routes SET name = ?, description = ?, school_name = ?, updated_at = CURRENT_TIMESTAMP db.prepare(`UPDATE routes SET name = ?, description = ?, school_name = ?, creator_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`).run( WHERE id = ?`).run(
fields.name ?? existing.name, fields.name ?? existing.name,
fields.description ?? existing.description, fields.description ?? existing.description,
fields.schoolName ?? existing.school_name, fields.schoolName ?? existing.school_name,
existing.creator_id || fields.creator_id || null,
id id
); );
}); });
+64 -5
View File
@@ -188,6 +188,7 @@ function submitEvent() {
} }
function buildEditorContext() { function buildEditorContext() {
const controls = { failPicture: false, approved: false, recording: null };
const ids = [ const ids = [
'editor-add-poi', 'editor-add-poi',
'editor-button', 'editor-button',
@@ -254,13 +255,22 @@ function buildEditorContext() {
async function fetch(url, options = {}) { async function fetch(url, options = {}) {
const method = options.method || 'GET'; const method = options.method || 'GET';
calls.push({ body: options.body, method, url }); calls.push({ body: options.body, method, url });
if (controls.failPicture && url.endsWith('/pictures/24') && method === 'PUT') {
controls.failPicture = false;
return jsonResponse({ message: 'Testfehler' }, false, 500);
}
if (String(url).startsWith('data:')) { if (String(url).startsWith('data:')) {
return { ok: true, status: 200, blob: async () => new Blob(['media'], { type: 'application/octet-stream' }) }; return { ok: true, status: 200, blob: async () => new Blob(['media'], { type: 'application/octet-stream' }) };
} }
if (url === '/api/routes?activatedOnly=true' && method === 'GET') return jsonResponse({ routes: [serverRoute] }); if (url === '/api/routes?activatedOnly=true' && method === 'GET') return jsonResponse({ routes: [serverRoute] });
if (url === '/api/routes' && method === 'POST') return jsonResponse({ id: 22, name: 'Neue lokale Strecke', pois: [] }, true, 201); if (url === '/api/routes' && method === 'POST') return jsonResponse({ id: 22, name: 'Neue lokale Strecke', pois: [] }, true, 201);
if (url === '/api/routes/22/pois' && method === 'POST') return jsonResponse({ id: 23, title: 'Aussicht' }, true, 201); if (url === '/api/routes/22/pois' && method === 'POST') return jsonResponse({ id: 23, title: 'Aussicht' }, true, 201);
if (/^\/api\/routes\/22\/pois\/23\/(audio|pictures)$/.test(url) && method === 'POST') return jsonResponse({ ok: true }, true, 201); if (/^\/api\/routes\/22\/pois\/23\/(audio|pictures)(?:\/24)?$/.test(url) && (method === 'POST' || method === 'PUT')) return jsonResponse({ id: 24 }, true, 201);
if (url === '/api/routes/22' && method === 'GET') return jsonResponse({ id: 22, activatedAt: controls.approved ? '2026-10-03' : null, pois: [{ id: 23, audioUrl: '/audio', images: [{ id: 24 }] }] });
if (url === '/api/routes/22' && method === 'PUT') return jsonResponse({ id: 22 });
if (url === '/api/routes/22/pois/23' && method === 'PUT') return jsonResponse({ id: 23 });
if (/^\/api\/routes\/22\/pois\/23(?:\/audio|\/pictures\/24)?$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
if (url === '/api/routes/22' && method === 'DELETE') return jsonResponse({ deleted: true }); if (url === '/api/routes/22' && method === 'DELETE') return jsonResponse({ deleted: true });
if (url === '/api/routes/1' && method === 'GET') return jsonResponse({ route: serverRoute }); if (url === '/api/routes/1' && method === 'GET') return jsonResponse({ route: serverRoute });
if (url === '/api/routes/1' && method === 'DELETE') return jsonResponse({ deleted: true }); if (url === '/api/routes/1' && method === 'DELETE') return jsonResponse({ deleted: true });
@@ -280,6 +290,7 @@ function buildEditorContext() {
const mediaBlobs = new Map(); const mediaBlobs = new Map();
const storedMediaCalls = []; const storedMediaCalls = [];
const LocalRoutes = { const LocalRoutes = {
creatorId: () => "a".repeat(64),
createPoiId: () => 9001, createPoiId: () => 9001,
createRouteId: () => 'local-new', createRouteId: () => 'local-new',
emptyGpx: name => `<gpx><metadata><name>${name}</name></metadata><trk><trkseg></trkseg></trk></gpx>`, emptyGpx: name => `<gpx><metadata><name>${name}</name></metadata><trk><trkseg></trkseg></trk></gpx>`,
@@ -354,17 +365,25 @@ function buildEditorContext() {
Wegwichtel: { Wegwichtel: {
App: { currentPosition: () => ({ lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }) }, App: { currentPosition: () => ({ lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }) },
Config: { apiBase: '/api/' }, Config: { apiBase: '/api/' },
RouteRecorder: {
beginEditing(id) { controls.recording = id; },
endEditing() { controls.recording = null; },
renderPoints() {},
editGpx: gpx => gpx,
get routeId() { return controls.recording; },
get position() { return { lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }; }
},
LocalRoutes LocalRoutes
} }
}; };
context.globalThis = context; context.globalThis = context;
return { calls, context, elements, packages, saves, storedMediaCalls }; return { calls, context, elements, packages, saves, storedMediaCalls, controls };
} }
test('public editor workflow saves new route and GPX Station locally without POSTing a route', async () => { test('public editor workflow saves new route and GPX Station locally without POSTing a route', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js'); const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8'); const source = await fs.readFile(sourcePath, 'utf8');
const { calls, context, elements, packages, saves } = buildEditorContext(); const { calls, context, elements, packages, saves, controls } = buildEditorContext();
await loadI18n(context); await loadI18n(context);
@@ -390,6 +409,7 @@ test('public editor workflow saves new route and GPX Station locally without POS
assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false); assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false);
assert.match(elements.get('editor-route-activated').textContent, /Dieser Entwurf bleibt auf deinem Gerät/); assert.match(elements.get('editor-route-activated').textContent, /Dieser Entwurf bleibt auf deinem Gerät/);
assert.equal(elements.get('editor-status').textContent, 'Gespeichert.'); assert.equal(elements.get('editor-status').textContent, 'Gespeichert.');
assert.equal(controls.recording, 'local-new');
assert.equal(elements.get('editor-route-upload').hidden, false); assert.equal(elements.get('editor-route-upload').hidden, false);
assert.equal(elements.get('editor-route-upload').disabled, false); assert.equal(elements.get('editor-route-upload').disabled, false);
assert.equal(elements.get('editor-route-gpx-group').hidden, true); assert.equal(elements.get('editor-route-gpx-group').hidden, true);
@@ -452,9 +472,35 @@ test('public editor workflow saves new route and GPX Station locally without POS
local = packages.get('local-new'); local = packages.get('local-new');
assert.equal(local.route.serverRouteId, '22'); assert.equal(local.route.serverRouteId, '22');
assert.ok(local.route.uploadedAt); assert.ok(local.route.uploadedAt);
assert.equal(elements.get('editor-route-upload').disabled, true); assert.equal(elements.get('editor-route-upload').disabled, false);
assert.equal(elements.get('editor-route-upload').textContent, 'Schon zur Prüfung gesendet'); assert.equal(elements.get('editor-route-upload').textContent, 'Erneut zur Prüfung senden');
assert.equal(elements.get('editor-status').textContent, 'Zur Prüfung gesendet.'); assert.equal(elements.get('editor-status').textContent, 'Zur Prüfung gesendet.');
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
for (let i = 0; i < 10; i += 1) await tick();
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes').length, 1);
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes/22/pois').length, 1);
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/pictures').length, 1);
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/22'));
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/22/pois/23/audio'));
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/22/pois/23/pictures/24'));
controls.failPicture = true;
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
for (let i = 0; i < 10; i += 1) await tick();
assert.equal(packages.get('local-new').pois[0].serverPictureId, undefined);
assert.equal(packages.get('local-new').pois[0].images[0].serverPictureId, 24);
assert.equal(packages.get('local-new').route.serverRouteId, '22');
assert.equal(elements.get('editor-route-upload').disabled, false);
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
for (let i = 0; i < 10; i += 1) await tick();
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes').length, 1);
controls.approved = true;
const writesBeforeApproval = calls.filter(call => call.method !== 'GET').length;
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
for (let i = 0; i < 10; i += 1) await tick();
assert.equal(calls.filter(call => call.method !== 'GET').length, writesBeforeApproval);
controls.approved = false;
const poiList = elements.get('editor-poi-list'); const poiList = elements.get('editor-poi-list');
const poiItem = poiList.children.find(child => child.dataset.poiId); const poiItem = poiList.children.find(child => child.dataset.poiId);
@@ -481,15 +527,28 @@ test('public editor workflow saves new route and GPX Station locally without POS
await tick(); await tick();
local = packages.get('local-new'); local = packages.get('local-new');
assert.equal(local.pois[0].audio, null); assert.equal(local.pois[0].audio, null);
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
for (let i = 0; i < 10; i += 1) await tick();
assert.ok(calls.some(call => call.method === 'DELETE' && call.url === '/api/routes/22/pois/23/audio'));
assert.ok(calls.some(call => call.method === 'DELETE' && call.url === '/api/routes/22/pois/23/pictures/24'));
const currentList = elements.get('editor-poi-list');
const currentItem = currentList.children.find(child => child.dataset.poiId);
currentList.dispatchEvent(pointerEvent(currentItem.children[1].children[2]));
assert.equal(elements.get('editor-poi-delete').hidden, false); assert.equal(elements.get('editor-poi-delete').hidden, false);
elements.get('editor-poi-delete').dispatchEvent(pointerEvent()); elements.get('editor-poi-delete').dispatchEvent(pointerEvent());
await tick(); await tick();
await tick(); await tick();
local = packages.get('local-new'); local = packages.get('local-new');
assert.equal(local.pois.length, 0); assert.equal(local.pois.length, 0);
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
for (let i = 0; i < 10; i += 1) await tick();
assert.ok(calls.some(call => call.method === 'DELETE' && call.url === '/api/routes/22/pois/23'));
context.Wegwichtel.EditorUi.close(); context.Wegwichtel.EditorUi.close();
assert.equal(controls.recording, null);
assert.equal(elements.get('editor-dialog').open, false); assert.equal(elements.get('editor-dialog').open, false);
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false'); assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
}); });
+33 -3
View File
@@ -4,6 +4,7 @@ import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import vm from 'node:vm'; import vm from 'node:vm';
import { webcrypto } from 'node:crypto';
const root = path.resolve(import.meta.dirname, '..'); const root = path.resolve(import.meta.dirname, '..');
const GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1'; const GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1';
@@ -322,7 +323,7 @@ class FakeFileReader extends EventTarget {
async function loadLocalRoutes(options = {}) { async function loadLocalRoutes(options = {}) {
const sourcePath = path.join(root, 'public/js/local-routes.js'); const sourcePath = path.join(root, 'public/js/local-routes.js');
const source = await fs.readFile(sourcePath, 'utf8'); const source = await fs.readFile(sourcePath, 'utf8');
const localStorage = new FakeStorage(); const localStorage = options.localStorage || new FakeStorage();
const indexedDB = new FakeIndexedDB(); const indexedDB = new FakeIndexedDB();
const document = new EventTarget(); const document = new EventTarget();
const events = []; const events = [];
@@ -336,7 +337,7 @@ async function loadLocalRoutes(options = {}) {
EventTarget, EventTarget,
FileReader: FakeFileReader, FileReader: FakeFileReader,
XMLSerializer: FakeXMLSerializer, XMLSerializer: FakeXMLSerializer,
crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555' }, crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555', getRandomValues: bytes => webcrypto.getRandomValues(bytes) },
document, document,
globalThis: null, globalThis: null,
indexedDB, indexedDB,
@@ -348,7 +349,7 @@ async function loadLocalRoutes(options = {}) {
context.globalThis = context; context.globalThis = context;
await loadI18n(context); await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath }); vm.runInNewContext(source, context, { filename: sourcePath });
return { api: context.Wegwichtel.LocalRoutes, events, indexedDB, localStorage }; return { api: context.Wegwichtel.LocalRoutes, events, indexedDB, localStorage, context };
} }
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?> const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
@@ -700,3 +701,32 @@ test('multiple Stationen with large local media keep Local Storage metadata smal
assert.doesNotMatch(metadata, /data:image/); assert.doesNotMatch(metadata, /data:image/);
assert.equal(api.get(routeId).pois.length, 2); assert.equal(api.get(routeId).pois.length, 2);
}); });
test('256-bit creator identity survives reload, route deletion and requires explicit confirmation to reset', async () => {
const first = await loadLocalRoutes();
const identity = first.api.creatorId();
assert.match(identity, /^[a-f0-9]{64}$/);
const second = await loadLocalRoutes({ localStorage: first.localStorage });
assert.equal(second.api.creatorId(), identity);
const saved = second.api.save({ id: 'identity-test', route: { name: 'Test' }, pois: [] });
assert.equal(saved.route.creator_id, identity);
await second.api.remove('identity-test');
assert.equal(second.api.creatorId(), identity);
second.context.confirm = () => false;
assert.equal(second.api.forgetCreatorId(), false);
assert.equal(second.api.creatorId(), identity);
second.context.confirm = () => true;
assert.equal(second.api.forgetCreatorId(), true);
assert.notEqual(second.api.creatorId(), identity);
const other = await loadLocalRoutes();
assert.notEqual(other.api.creatorId(), identity);
});
test('creator identity never falls back to insecure randomness or replaces corrupt stored identity', async () => {
const { api, localStorage } = await loadLocalRoutes({ crypto: {} });
assert.throws(() => api.creatorId(), /Gerätekennung/);
localStorage.setItem('wegwichtel.creator-id.v1', 'broken');
assert.throws(() => api.creatorId(), /Gerätekennung/);
assert.equal(localStorage.getItem('wegwichtel.creator-id.v1'), 'broken');
});
+31
View File
@@ -214,6 +214,37 @@ assert result['ok'] is True
assert.doesNotMatch(downloadedGpx.toString(), /<trkpt /); assert.doesNotMatch(downloadedGpx.toString(), /<trkpt /);
assert.deepEqual(parseGpx(downloadedGpx.toString()).points, parseGpx(gpx.toString()).points); assert.deepEqual(parseGpx(downloadedGpx.toString()).points, parseGpx(gpx.toString()).points);
assert.equal(route.creator_id, null);
const creatorId = 'a'.repeat(64);
const { body: assigned } = await requestJson(`${baseUrl}/api/routes/${route.id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ creator_id: creatorId })
});
assert.equal(assigned.creator_id, creatorId);
await requestJson(`${baseUrl}/api/routes/${route.id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ creator_id: 'b'.repeat(64) })
}, 409);
await requestJson(`${baseUrl}/api/routes/${route.id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ creator_id: 'bad' })
}, 400);
const { body: preserved } = await requestJson(`${baseUrl}/api/routes/${route.id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: 'Ersteller bleibt erhalten' })
});
assert.equal(preserved.creator_id, creatorId);
const createWithIdentity = new FormData();
createWithIdentity.append('name', 'Ersteller-Test');
createWithIdentity.append('creator_id', creatorId);
createWithIdentity.append('gpx', new Blob([gpx], { type: 'application/gpx+xml' }), 'route.gpx');
const { body: ownedRoute } = await requestJson(`${baseUrl}/api/routes`, { method: 'POST', body: createWithIdentity }, 201);
assert.equal(ownedRoute.creator_id, creatorId);
const invalidIdentity = new FormData();
invalidIdentity.append('creator_id', 'invalid');
invalidIdentity.append('gpx', new Blob([gpx], { type: 'application/gpx+xml' }), 'route.gpx');
await requestJson(`${baseUrl}/api/routes`, { method: 'POST', body: invalidIdentity }, 400);
const { body: pendingPoi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, { const { body: pendingPoi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Ohne Empfang', lat: null, lon: null }) body: JSON.stringify({ title: 'Ohne Empfang', lat: null, lon: null })
+8
View File
@@ -57,6 +57,14 @@ class ModerationTests(unittest.TestCase):
self.assertIsNone(self.activation()) self.assertIsNone(self.activation())
self.assertIsNone(self.activation(2)) self.assertIsNone(self.activation(2))
def test_creator_id_is_shown_in_listing(self):
identity = 'a' * 64
with sqlite3.connect(self.path) as database:
database.execute('UPDATE routes SET creator_id=? WHERE id=1', (identity,))
self.assertEqual(self.run_cli([]), 0)
self.assertIn('Ersteller-ID', self.out.getvalue())
self.assertIn(identity, self.out.getvalue())
def test_aliases(self): def test_aliases(self):
for flag in ["--enable", "--enanble"]: for flag in ["--enable", "--enanble"]:
self.assertEqual(self.run_cli([flag, "1"]), 0) self.assertEqual(self.run_cli([flag, "1"]), 0)
+24 -1
View File
@@ -47,7 +47,7 @@ function activate(node, type = 'pointerup', key) {
node.dispatchEvent(event); node.dispatchEvent(event);
} }
test('recording persists every 30 seconds independently of the editor, pauses and finishes', async () => { test('recording persists every 30 seconds during an editing session, pauses and finishes', async () => {
const { recorder, packages, intervals, nodes } = await setup(); const { recorder, packages, intervals, nodes } = await setup();
recorder.start('draft'); await tick(); recorder.start('draft'); await tick();
assert.equal(recorder.status, 'recording'); assert.equal(recorder.status, 'recording');
@@ -103,3 +103,26 @@ test('missing recorded locations can be filled manually while newer samples rema
activate(nodes.get('editor-add-position')); activate(nodes.get('editor-add-position'));
assert.equal(JSON.parse(recorder.editGpx('[]', 'Entwurf')).length, 1); assert.equal(JSON.parse(recorder.editGpx('[]', 'Entwurf')).length, 1);
}); });
test('ending editing stops sampling, reopening permits recording and a fresh app remains inactive', async () => {
const { recorder, intervals, packages } = await setup();
assert.equal(recorder.status, 'idle');
recorder.beginEditing('draft');
await tick();
assert.equal(intervals.size, 1);
recorder.pause();
recorder.beginEditing('draft');
assert.equal(recorder.status, 'paused');
recorder.endEditing();
assert.equal(recorder.status, 'idle');
assert.equal(recorder.routeId, null);
assert.equal(intervals.size, 0);
assert.equal(JSON.parse(packages.get('draft').gpx).length, 1);
recorder.beginEditing('draft');
await tick();
assert.equal(recorder.status, 'recording');
const fresh = await setup();
assert.equal(fresh.recorder.status, 'idle');
assert.equal(fresh.intervals.size, 0);
});
+1
View File
@@ -15,6 +15,7 @@ const EXPECTED_SCHEMA = Object.freeze({
routes: { routes: {
id: { type: 'INTEGER', pk: 1 }, id: { type: 'INTEGER', pk: 1 },
slug: { type: 'TEXT', notnull: 1 }, slug: { type: 'TEXT', notnull: 1 },
creator_id: { type: 'TEXT', notnull: 0 },
name: { type: 'TEXT', notnull: 1 }, name: { type: 'TEXT', notnull: 1 },
status: { type: 'TEXT', notnull: 1 }, status: { type: 'TEXT', notnull: 1 },
gpx_path: { type: 'TEXT', notnull: 1 }, gpx_path: { type: 'TEXT', notnull: 1 },
+4 -3
View File
@@ -21,12 +21,13 @@ 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. Darunter stehen die Stationen tabellarisch Name, Status, Freigabezeitpunkt und Ersteller-ID (`creator_id`) an. Darunter stehen die Stationen tabellarisch
mit ID, Platz, Beschreibung, Erstelldatum und dauerhaften Links zu Bildern und Tonaufnahmen. mit ID, Platz, Beschreibung, Erstelldatum und dauerhaften Links zu Bildern und Tonaufnahmen.
Die Anzeige öffnet die Datenbank ausschließlich lesend. Einrichtung und Die Anzeige öffnet die Datenbank ausschließlich lesend. Einrichtung und
passwortgeschützter Webzugang sind in passwortgeschützter Webzugang sind in
[MODERATION-MEDIEN.md](../../MODERATION-MEDIEN.md) beschrieben; die spätere [MODERATION-MEDIEN.md](../../MODERATION-MEDIEN.md) beschrieben. Gerätekennung und
Ersteller-Zuordnung ist in [TODO-MODERATION.md](../../TODO-MODERATION.md) vorgemerkt: erneutes Einsenden stehen in [CREATOR-ID.md](../../CREATOR-ID.md); noch offene
Berechtigungen in [TODO-MODERATION.md](../../TODO-MODERATION.md):
```bash ```bash
python3 tools/python/moderate_routes.py python3 tools/python/moderate_routes.py
+3 -3
View File
@@ -105,14 +105,14 @@ def show_pois(database, identifier, review_url, active):
def list_routes(database, review_url=""): 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, creator_id 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\tErsteller-ID")
for row in rows: for row in rows:
name = table_text(row["name"]) 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}\t{row["creator_id"] or "–"}')
show_pois(database, row["id"], review_url, row["status"] == "active") show_pois(database, row["id"], review_url, row["status"] == "active")
print() print()