allow update for uploaded routes and implement creator id for routes
This commit is contained in:
@@ -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
@@ -3,9 +3,12 @@
|
||||
## Stationen und Weg getrennt speichern
|
||||
|
||||
- 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.
|
||||
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.
|
||||
Beim Löschen des Entwurfs endet die Aufzeichnung ebenfalls.
|
||||
- Tatsächliche Positionen stehen als `trk/trkseg/trkpt` mit Zeitstempel und,
|
||||
|
||||
+14
-13
@@ -1,19 +1,20 @@
|
||||
# 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.
|
||||
Umgesetzt: dauerhafte, zufällige 256-Bit-Gerätekennung im getrennten Local Storage,
|
||||
`creator_id` in lokalen Entwürfen und Server-Strecken, Anzeige im Moderationsskript,
|
||||
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
|
||||
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.
|
||||
- Authentifizierte Eigentumszuordnung und Einladungen für Mitbearbeiter entwickeln.
|
||||
Eine frei übermittelte Gerätekennung allein beweist kein Eigentum.
|
||||
- Wiederherstellung nach verlorenen Browserdaten und bewussten Gerätewechsel planen.
|
||||
- Gleichzeitige Änderungen und Versionskonflikte erkennen; der derzeitige lokale
|
||||
Entwurf wird beim Einsenden als vollständiger Stand behandelt.
|
||||
- Idempotente POSTs auch bei verlorenem HTTP-Erfolg und Gesamtübertragungen mit
|
||||
mehreren Dateien absichern.
|
||||
|
||||
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`).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS routes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
creator_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
school_name TEXT NOT NULL DEFAULT '',
|
||||
|
||||
@@ -336,6 +336,7 @@
|
||||
<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>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>
|
||||
<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>
|
||||
|
||||
@@ -799,6 +799,7 @@
|
||||
},
|
||||
|
||||
async initialize(context) {
|
||||
ns.LocalRoutes?.creatorId?.();
|
||||
bindEvents();
|
||||
await ns.Api.health();
|
||||
context.markStep('server', 'done');
|
||||
|
||||
+85
-50
@@ -202,6 +202,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function showListView() {
|
||||
ns.RouteRecorder?.endEditing();
|
||||
setDialogTitle('Streckenverwaltung');
|
||||
setHidden(byId(ids.routeListView), false);
|
||||
setHidden(byId(ids.routeDetailView), true);
|
||||
@@ -469,6 +470,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
ns.RouteRecorder?.endEditing();
|
||||
const dialog = byId(ids.dialog);
|
||||
if (dialog?.open && typeof dialog.close === 'function') dialog.close();
|
||||
else if (dialog?.open) dialog.removeAttribute('open');
|
||||
@@ -507,7 +509,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
if (state.currentSource === 'local') {
|
||||
const uploaded = Boolean(route?.serverRouteId);
|
||||
setHidden(uploadButton, false);
|
||||
setDisabled(uploadButton, uploaded);
|
||||
setDisabled(uploadButton, false);
|
||||
if (uploadButton) uploadButton.textContent = uploaded ? ns.I18n.t("editor.submitted") : ns.I18n.t("editor.submit");
|
||||
byId(ids.routeActivated).textContent = uploaded
|
||||
? ns.I18n.t("editor.submitted-hint")
|
||||
@@ -761,6 +763,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
async function loadRoute(route) {
|
||||
const id = routeId(route);
|
||||
if (!id) return;
|
||||
if (ns.RouteRecorder?.routeId && (routeSource(route) !== 'local' || ns.RouteRecorder.routeId !== id)) {
|
||||
ns.RouteRecorder.endEditing();
|
||||
}
|
||||
setStatus(ns.I18n.t("editor.route.loading"));
|
||||
state.currentSource = routeSource(route);
|
||||
|
||||
@@ -773,6 +778,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
gpx: routePackage.gpx,
|
||||
_source: 'local'
|
||||
};
|
||||
if (byId(ids.dialog)?.open) ns.RouteRecorder?.beginEditing(id);
|
||||
state.pois = [...routePackage.pois].sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||
} else {
|
||||
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
|
||||
@@ -789,6 +795,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function newRoute() {
|
||||
ns.RouteRecorder?.endEditing();
|
||||
state.currentSource = 'local';
|
||||
state.currentRoute = {
|
||||
id: '',
|
||||
@@ -839,7 +846,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
});
|
||||
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: '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;
|
||||
await loadRoutes();
|
||||
await loadRoute({ id: saved.id, _source: 'local' });
|
||||
@@ -884,6 +891,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const data = new FormData();
|
||||
data.append('name', routePackage.route?.name || ns.I18n.t("editor.untitled-route"));
|
||||
data.append('description', routePackage.route?.description || '');
|
||||
data.append('creator_id', routePackage.route?.creator_id || ns.LocalRoutes.creatorId());
|
||||
const uploadGpx = ns.LocalRoutes.gpxForUpload(routePackage);
|
||||
const gpx = new Blob([uploadGpx], { type: 'application/gpx+xml' });
|
||||
data.append('gpx', gpx, safeGpxFilename(routePackage.route?.name));
|
||||
@@ -898,72 +906,89 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function uploadStoredMedia(path, fieldName, media, fallbackName) {
|
||||
async function uploadStoredMedia(path, fieldName, media, fallbackName, method = 'POST') {
|
||||
const blob = await storedMediaBlob(media);
|
||||
if (!blob) return;
|
||||
const data = new FormData();
|
||||
data.append(fieldName, blob, media.name || fallbackName);
|
||||
await sendForm(path, 'POST', 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`);
|
||||
}
|
||||
if (fieldName === 'picture') data.append('caption', media.caption || '');
|
||||
return sendForm(path, method, data);
|
||||
}
|
||||
|
||||
async function createServerRouteFromLocal(routePackage) {
|
||||
let createdRouteId = null;
|
||||
try {
|
||||
const checkpoint = () => {
|
||||
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 serverRoute = payload.route || payload;
|
||||
createdRouteId = routeId(serverRoute);
|
||||
if (!createdRouteId) throw new Error(ns.I18n.t("editor.error.send-route"));
|
||||
|
||||
const orderedPois = [...(routePackage.pois || [])]
|
||||
.sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||
for (const [index, poi] of orderedPois.entries()) {
|
||||
const poiTitle = poi.title || ns.I18n.t("editor.station-number", { value1: index + 1 });
|
||||
const poiPayload = await sendJson(`/routes/${encodeURIComponent(createdRouteId)}/pois`, 'POST', {
|
||||
description: poi.description || '',
|
||||
lat: poi.lat,
|
||||
lon: poi.lon,
|
||||
sequence: index,
|
||||
title: poiTitle,
|
||||
id = routeId(payload.route || payload);
|
||||
if (!id) throw new Error(ns.I18n.t("editor.error.send-route"));
|
||||
routePackage.route.serverRouteId = id;
|
||||
checkpoint();
|
||||
}
|
||||
const orderedPois = [...(routePackage.pois || [])]
|
||||
.sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||
for (const [index, poi] of orderedPois.entries()) {
|
||||
const base = `/routes/${encodeURIComponent(id)}/pois`;
|
||||
const payload = await sendJson(poi.serverPoiId ? `${base}/${poi.serverPoiId}` : base,
|
||||
poi.serverPoiId ? 'PUT' : 'POST', {
|
||||
description: poi.description || '', lat: poi.lat, lon: poi.lon, sequence: index,
|
||||
title: poi.title || ns.I18n.t("editor.station-number", { value1: index + 1 }),
|
||||
triggerRadiusM: poi.triggerRadiusM || 25
|
||||
});
|
||||
const serverPoi = poiPayload.poi || poiPayload;
|
||||
const serverPoiId = poiId(serverPoi);
|
||||
if (!serverPoiId) throw new Error(ns.I18n.t("editor.error.send-station", { value1: poiTitle }));
|
||||
await uploadLocalPoiMedia(createdRouteId, serverPoiId, poi);
|
||||
poi.serverPoiId = poiId(payload.poi || payload);
|
||||
if (!poi.serverPoiId) throw new Error(ns.I18n.t("editor.error.send-station", { value1: poi.title || '' }));
|
||||
checkpoint();
|
||||
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;
|
||||
} catch (error) {
|
||||
if (createdRouteId) {
|
||||
try {
|
||||
await sendDelete(`/routes/${encodeURIComponent(createdRouteId)}`);
|
||||
} catch {
|
||||
// Der ursprüngliche Uploadfehler ist wichtiger als ein fehlgeschlagener Rollback.
|
||||
for (const [imageIndex, image] of (poi.images || []).entries()) {
|
||||
const path = image.serverPictureId ? `${mediaBase}/pictures/${image.serverPictureId}` : `${mediaBase}/pictures`;
|
||||
const picture = await uploadStoredMedia(path, 'picture', image, `bild-${imageIndex + 1}.bin`,
|
||||
image.serverPictureId ? 'PUT' : 'POST');
|
||||
image.serverPictureId = (picture.picture || picture).id;
|
||||
if (!image.serverPictureId) throw new Error(ns.I18n.t("editor.error.send-file", { value1: image.name || '' }));
|
||||
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() {
|
||||
if (state.currentSource !== 'local') throw new Error(ns.I18n.t("editor.error.submit-own-draft"));
|
||||
const values = routeFromForm();
|
||||
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);
|
||||
setDisabled(uploadButton, true);
|
||||
try {
|
||||
@@ -989,7 +1014,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
await loadRoutes();
|
||||
setStatus(ns.I18n.t("editor.submit-success"));
|
||||
} 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() {
|
||||
for (const eventName of ['cancel', 'close']) {
|
||||
byId(ids.dialog)?.addEventListener(eventName, () => ns.RouteRecorder?.endEditing());
|
||||
}
|
||||
activate(byId(ids.button), () => guarded(openEditor));
|
||||
activate(byId(ids.close), closeEditor);
|
||||
activate(byId(ids.newRoute), newRoute);
|
||||
|
||||
@@ -19,6 +19,31 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
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) {
|
||||
return `${STORAGE_PREFIX}.${encodeURIComponent(String(id))}.${suffix}`;
|
||||
}
|
||||
@@ -499,6 +524,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const inputRoute = routePackage.route || {};
|
||||
const route = {
|
||||
id,
|
||||
creator_id: inputRoute.creator_id || creatorId(),
|
||||
name: String(inputRoute.name || ''),
|
||||
description: String(inputRoute.description || ''),
|
||||
createdAt: inputRoute.createdAt || now,
|
||||
@@ -724,6 +750,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
ns.LocalRoutes = Object.freeze({
|
||||
STORAGE_PREFIX,
|
||||
creatorId,
|
||||
forgetCreatorId,
|
||||
appendTrackPoint,
|
||||
recordedPositions,
|
||||
replaceRecordedPositions,
|
||||
|
||||
@@ -3,6 +3,7 @@ globalThis.Wegwichtel ??= {};
|
||||
'use strict';
|
||||
const INTERVAL_MS = 30000;
|
||||
let id = null;
|
||||
let editingId = null;
|
||||
let status = 'idle';
|
||||
let timer = null;
|
||||
let generation = 0;
|
||||
@@ -79,9 +80,21 @@ globalThis.Wegwichtel ??= {};
|
||||
function start(routeId) {
|
||||
lastPosition = null;
|
||||
id = String(routeId);
|
||||
editingId = id;
|
||||
resume();
|
||||
}
|
||||
|
||||
function beginEditing(routeId) {
|
||||
if (editingId === String(routeId)) return;
|
||||
stop();
|
||||
start(routeId);
|
||||
}
|
||||
|
||||
function endEditing() {
|
||||
editingId = null;
|
||||
stop();
|
||||
}
|
||||
|
||||
function addPositionField(position = {}, index = null) {
|
||||
const container = document.getElementById('editor-recorded-positions');
|
||||
if (!container) return;
|
||||
@@ -132,6 +145,7 @@ globalThis.Wegwichtel ??= {};
|
||||
}
|
||||
|
||||
function bind() {
|
||||
globalThis.addEventListener?.('pagehide', endEditing);
|
||||
const activate = (elementId, action) => {
|
||||
const element = document.getElementById(elementId);
|
||||
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 });
|
||||
else bind();
|
||||
}(globalThis.Wegwichtel));
|
||||
|
||||
@@ -105,7 +105,6 @@ globalThis.Wegwichtel ??= {};
|
||||
"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.",
|
||||
"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.",
|
||||
"save-route-first": "Speichere zuerst die Strecke.",
|
||||
"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-stations": "Noch keine Stationen angelegt.",
|
||||
"loading-routes": "Strecken werden geladen …",
|
||||
"submitted": "Schon zur Prüfung gesendet",
|
||||
"submitted": "Erneut 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.",
|
||||
"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.",
|
||||
@@ -220,7 +219,9 @@ globalThis.Wegwichtel ??= {};
|
||||
"missing": "{value1}: Diese Information fehlt noch."
|
||||
},
|
||||
"local": {
|
||||
"confirm-forget-identity": "Gerätekennung wirklich löschen? Dieses Gerät bekommt eine neue Kennung. Die Zuordnung zu bisherigen Strecken bleibt bestehen.",
|
||||
"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.",
|
||||
"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.",
|
||||
|
||||
@@ -105,7 +105,6 @@ globalThis.Wegwichtel ??= {};
|
||||
"send-route": "The trail 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.",
|
||||
"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.",
|
||||
"save-route-first": "Save the trail first.",
|
||||
"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-stations": "There are no stops yet.",
|
||||
"loading-routes": "Loading trails …",
|
||||
"submitted": "Already sent for review",
|
||||
"submitted": "Send changes 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.",
|
||||
"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.",
|
||||
@@ -220,7 +219,9 @@ globalThis.Wegwichtel ??= {};
|
||||
"missing": "{value1}: This information still needs to be added."
|
||||
},
|
||||
"local": {
|
||||
"confirm-forget-identity": "Delete the device ID? This device will get a new ID. Existing routes will keep their original creator ID.",
|
||||
"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.",
|
||||
"media-storage": "Pictures and recordings cannot be saved on this device right now.",
|
||||
"media-read": "Saved pictures and recordings could not be opened.",
|
||||
|
||||
@@ -50,6 +50,7 @@ function rowToRoute(row, includeDeleted = false, parsed = null) {
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
creator_id: row.creator_id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
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.');
|
||||
let routeId;
|
||||
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 name = String(fields.name || parsed.name || '').trim();
|
||||
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 metrics = parsed.metrics;
|
||||
const result = db.prepare(`INSERT INTO routes
|
||||
(slug, name, description, school_name, gpx_path, min_lat, min_lon, max_lat, max_lon)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
(slug, name, description, school_name, gpx_path, min_lat, min_lon, max_lat, max_lon, creator_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
slug,
|
||||
name,
|
||||
fields.description || '',
|
||||
@@ -187,7 +192,8 @@ export function createRoute(db, fields, gpxFile) {
|
||||
metrics.minLat,
|
||||
metrics.minLon,
|
||||
metrics.maxLat,
|
||||
metrics.maxLon
|
||||
metrics.maxLon,
|
||||
creatorId
|
||||
);
|
||||
const id = Number(result.lastInsertRowid);
|
||||
return id;
|
||||
@@ -207,13 +213,20 @@ export function createRoute(db, fields, gpxFile) {
|
||||
export function updateRoute(db, id, fields, gpxFile) {
|
||||
const existing = db.prepare("SELECT * FROM routes WHERE id = ? AND status = 'active'").get(id);
|
||||
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;
|
||||
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(
|
||||
fields.name ?? existing.name,
|
||||
fields.description ?? existing.description,
|
||||
fields.schoolName ?? existing.school_name,
|
||||
existing.creator_id || fields.creator_id || null,
|
||||
id
|
||||
);
|
||||
});
|
||||
|
||||
+64
-5
@@ -188,6 +188,7 @@ function submitEvent() {
|
||||
}
|
||||
|
||||
function buildEditorContext() {
|
||||
const controls = { failPicture: false, approved: false, recording: null };
|
||||
const ids = [
|
||||
'editor-add-poi',
|
||||
'editor-button',
|
||||
@@ -254,13 +255,22 @@ function buildEditorContext() {
|
||||
async function fetch(url, options = {}) {
|
||||
const method = options.method || 'GET';
|
||||
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:')) {
|
||||
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' && 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 (/^\/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/1' && method === 'GET') return jsonResponse({ route: serverRoute });
|
||||
if (url === '/api/routes/1' && method === 'DELETE') return jsonResponse({ deleted: true });
|
||||
@@ -280,6 +290,7 @@ function buildEditorContext() {
|
||||
const mediaBlobs = new Map();
|
||||
const storedMediaCalls = [];
|
||||
const LocalRoutes = {
|
||||
creatorId: () => "a".repeat(64),
|
||||
createPoiId: () => 9001,
|
||||
createRouteId: () => 'local-new',
|
||||
emptyGpx: name => `<gpx><metadata><name>${name}</name></metadata><trk><trkseg></trkseg></trk></gpx>`,
|
||||
@@ -354,17 +365,25 @@ function buildEditorContext() {
|
||||
Wegwichtel: {
|
||||
App: { currentPosition: () => ({ lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }) },
|
||||
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
|
||||
}
|
||||
};
|
||||
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 () => {
|
||||
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
||||
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);
|
||||
|
||||
@@ -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.match(elements.get('editor-route-activated').textContent, /Dieser Entwurf bleibt auf deinem Gerät/);
|
||||
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').disabled, false);
|
||||
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');
|
||||
assert.equal(local.route.serverRouteId, '22');
|
||||
assert.ok(local.route.uploadedAt);
|
||||
assert.equal(elements.get('editor-route-upload').disabled, true);
|
||||
assert.equal(elements.get('editor-route-upload').textContent, 'Schon zur Prüfung gesendet');
|
||||
assert.equal(elements.get('editor-route-upload').disabled, false);
|
||||
assert.equal(elements.get('editor-route-upload').textContent, 'Erneut zur Prüfung senden');
|
||||
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 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();
|
||||
local = packages.get('local-new');
|
||||
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);
|
||||
elements.get('editor-poi-delete').dispatchEvent(pointerEvent());
|
||||
await tick();
|
||||
await tick();
|
||||
local = packages.get('local-new');
|
||||
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();
|
||||
assert.equal(controls.recording, null);
|
||||
assert.equal(elements.get('editor-dialog').open, false);
|
||||
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import { webcrypto } from 'node:crypto';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
const GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1';
|
||||
@@ -322,7 +323,7 @@ class FakeFileReader extends EventTarget {
|
||||
async function loadLocalRoutes(options = {}) {
|
||||
const sourcePath = path.join(root, 'public/js/local-routes.js');
|
||||
const source = await fs.readFile(sourcePath, 'utf8');
|
||||
const localStorage = new FakeStorage();
|
||||
const localStorage = options.localStorage || new FakeStorage();
|
||||
const indexedDB = new FakeIndexedDB();
|
||||
const document = new EventTarget();
|
||||
const events = [];
|
||||
@@ -336,7 +337,7 @@ async function loadLocalRoutes(options = {}) {
|
||||
EventTarget,
|
||||
FileReader: FakeFileReader,
|
||||
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,
|
||||
globalThis: null,
|
||||
indexedDB,
|
||||
@@ -348,7 +349,7 @@ async function loadLocalRoutes(options = {}) {
|
||||
context.globalThis = context;
|
||||
await loadI18n(context);
|
||||
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"?>
|
||||
@@ -700,3 +701,32 @@ test('multiple Stationen with large local media keep Local Storage metadata smal
|
||||
assert.doesNotMatch(metadata, /data:image/);
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -214,6 +214,37 @@ assert result['ok'] is True
|
||||
assert.doesNotMatch(downloadedGpx.toString(), /<trkpt /);
|
||||
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`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'Ohne Empfang', lat: null, lon: null })
|
||||
|
||||
@@ -57,6 +57,14 @@ class ModerationTests(unittest.TestCase):
|
||||
self.assertIsNone(self.activation())
|
||||
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):
|
||||
for flag in ["--enable", "--enanble"]:
|
||||
self.assertEqual(self.run_cli([flag, "1"]), 0)
|
||||
|
||||
@@ -47,7 +47,7 @@ function activate(node, type = 'pointerup', key) {
|
||||
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();
|
||||
recorder.start('draft'); await tick();
|
||||
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'));
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ const EXPECTED_SCHEMA = Object.freeze({
|
||||
routes: {
|
||||
id: { type: 'INTEGER', pk: 1 },
|
||||
slug: { type: 'TEXT', notnull: 1 },
|
||||
creator_id: { type: 'TEXT', notnull: 0 },
|
||||
name: { type: 'TEXT', notnull: 1 },
|
||||
status: { type: 'TEXT', notnull: 1 },
|
||||
gpx_path: { type: 'TEXT', notnull: 1 },
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
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:
|
||||
[MODERATION-MEDIEN.md](../../MODERATION-MEDIEN.md) beschrieben. Gerätekennung und
|
||||
erneutes Einsenden stehen in [CREATOR-ID.md](../../CREATOR-ID.md); noch offene
|
||||
Berechtigungen in [TODO-MODERATION.md](../../TODO-MODERATION.md):
|
||||
|
||||
```bash
|
||||
python3 tools/python/moderate_routes.py
|
||||
|
||||
@@ -105,14 +105,14 @@ def show_pois(database, identifier, review_url, active):
|
||||
|
||||
|
||||
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:
|
||||
print("Keine Strecken vorhanden.")
|
||||
return
|
||||
print("ID\tStatus\tFreigegeben am (UTC)\tName")
|
||||
print("ID\tStatus\tFreigegeben am (UTC)\tName\tErsteller-ID")
|
||||
for row in rows:
|
||||
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")
|
||||
print()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user