group editor implemented
Sonarqube Scanner / Prepare environment (push) Successful in 6s
Sonarqube Scanner / Build and test (push) Successful in 27s
Sonarqube Scanner / Deploy to Server (push) Failing after 7s
Sonarqube Scanner / SonarQube analysis (push) Failing after 1m14s

This commit is contained in:
Florian Zumpe
2026-10-18 16:33:12 +02:00
parent 9c4d5f6324
commit 31768ae907
28 changed files with 2361 additions and 55 deletions
+25
View File
@@ -65,3 +65,28 @@ CREATE INDEX IF NOT EXISTS idx_routes_status ON routes(status);
CREATE INDEX IF NOT EXISTS idx_routes_activation ON routes(status, activated_at); CREATE INDEX IF NOT EXISTS idx_routes_activation ON routes(status, activated_at);
CREATE INDEX IF NOT EXISTS idx_pois_route ON pois(route_id, sequence); CREATE INDEX IF NOT EXISTS idx_pois_route ON pois(route_id, sequence);
CREATE INDEX IF NOT EXISTS idx_poi_images_poi ON poi_images(poi_id, sequence); CREATE INDEX IF NOT EXISTS idx_poi_images_poi ON poi_images(poi_id, sequence);
CREATE TABLE IF NOT EXISTS collaboration_sessions (
token_hash TEXT PRIMARY KEY NOT NULL,
creator_id TEXT NOT NULL,
expires_at INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS collaboration_groups (
id TEXT PRIMARY KEY NOT NULL,
route_id INTEGER NOT NULL UNIQUE REFERENCES routes(id),
source_route_id INTEGER REFERENCES routes(id),
owner_creator_id TEXT NOT NULL,
code_hash TEXT UNIQUE,
code_expires_at INTEGER NOT NULL,
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
revision INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;
CREATE TABLE IF NOT EXISTS collaboration_members (
group_id TEXT NOT NULL REFERENCES collaboration_groups(id) ON DELETE CASCADE,
session_hash TEXT NOT NULL REFERENCES collaboration_sessions(token_hash) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
PRIMARY KEY (group_id, session_hash)
) STRICT;
+143
View File
@@ -0,0 +1,143 @@
# Gemeinsam bearbeiten
In „Strecken bearbeiten“ trägt jede Strecke eine Schaltfläche mit zwei Wichteln:
links der Vogel, rechts der Stein. „Gemeinsam bearbeiten“ legt einen gemeinsamen
Entwurf auf der Website an. Das Original bleibt unverändert. Bei lokalen Entwürfen
fragt die App vorher, ob Weg, Stationen, Bilder und Ton übertragen werden dürfen.
Ein bereits eingesandter eigener Entwurf wird dabei zuerst aktualisiert.
Danach zeigt die App acht Wichtel als Gruppencode. Andere öffnen in der
Streckenverwaltung „Gruppencode eingeben“ und schieben die Bilder in den acht
Drehrädern nach oben oder unten. Es zählen die Wichtel in der Mitte, von links
nach rechts, zuerst in der oberen Reihe. Pfeiltasten und die Pfeilschaltflächen
funktionieren ebenfalls. „Mitmachen“ öffnet den gemeinsamen Entwurf.
## Arbeiten und Einladungen
- Gemeinsame Entwürfe erscheinen im Bearbeitungsfenster in einer eigenen Liste.
- Änderungen an Strecke, Stationen, Bildern und Ton werden über die bestehenden
Editor-Funktionen direkt auf der Website gespeichert. Internet ist erforderlich.
- „Änderungen holen“ lädt den aktuellen Stand. Die App fragt vorher, weil dabei
noch nicht gespeicherte Eingaben ersetzt werden.
- Wenn jemand zwischenzeitlich gespeichert hat, weist die App den veralteten
Speicherversuch zurück. Die Eingaben bleiben stehen und können vor dem Laden
des neuen Stands gesichert werden. Es gibt noch keine automatische Zusammenführung.
- Der Einladungscode gilt eine Stunde. „Neuen Code anzeigen“ ersetzt ihn; bereits
beigetretene Personen behalten ihren Zugang. Nur die eröffnende Person kann das.
- „Gruppe schließen“ entfernt den Zugang der anderen Personen und den Code.
Die eröffnende Person behält ihren Entwurf und kann wieder einen Code erzeugen.
- Es sind höchstens 50 Zugänge je Gruppe und zwölf gleichzeitig offene Gruppen
je eröffnendem Zugang vorgesehen. Fehlerhafte Codeversuche werden begrenzt.
Der Zugang wird mit einem geschützten Sitzungscookie für höchstens 30 Tage
aufbewahrt. Es wird erst beim Öffnen einer Gruppe oder beim Beitritt angelegt,
nicht beim bloßen Aufruf der App. Nach Ablauf oder Löschen der Browserdaten muss
man erneut eingeladen werden. Eine Wiederherstellung des eröffnenden Zugangs
nach Verlust dieses Cookies ist noch nicht enthalten. Die Gerätekennung allein
reicht dafür nicht. Wer den Code kennt, kann während seiner Gültigkeit beitreten;
Codes deshalb nur mit den gewünschten Personen teilen.
## Berechtigungen und Freigabe
Der gemeinsame Entwurf besitzt eine eigene Strecken-ID und eine Erstellerkennung.
Die eröffnende Person hat zusätzlich die Gruppenverwaltung. Mitbearbeiter ändern
diese Zuordnung nicht. Die Gerätekennung ist Metadatum; der tatsächliche Zugang
wird anhand einer geheimen Sitzung und ihrer Gruppenmitgliedschaft geprüft.
Gemeinsame Entwürfe erscheinen nicht in den öffentlichen Streckenlisten. Ihre
Strecken-, Bild-, Ton- und Wegdateien sind vor der Veröffentlichung nur mit einem
berechtigten Gruppenzugang abrufbar. Veröffentlichen erfolgt weiterhin durch die
vorhandene serverseitige Moderation. Eine Freigabe macht die Kopie öffentlich
und sperrt weitere Änderungen wie bei anderen freigegebenen Strecken.
Das reine Beenden der Gruppe veröffentlicht oder löscht den Entwurf nicht.
Nicht freigegebene Gruppenmedien benötigen auch bei der Prüfung einen
Gruppenzugang; der bisherige Nginx-Passwortschutz allein erteilt keine zusätzliche
Gruppenmitgliedschaft. Betreiber können auf dem Server die Dateien prüfen oder
sich während der gültigen Einladung beteiligen. Bereits geladene Inhalte können
beim Schließen einer Gruppe nicht aus fremden Geräten zurückgerufen werden.
## Einspielen auf dem Server
Nach dem Kopieren der Dateien und vor der Startprüfung bzw. dem Neustart:
```bash
npm run init-db
npm run test:runtime-db
systemctl --user restart wegwichtel.service
```
`init-db` legt die drei neuen STRICT-Tabellen an. Es verändert keine bestehenden
Streckentabellen und löscht keine Strecken. Eine Neuerzeugung oder das Löschen
der vorhandenen Datenbank ist hierfür nicht erforderlich.
Die normale Nginx-Weiterleitung an die App muss auch `/api/groups` erreichen.
HTTPS sowie die vorhandenen Header `Host` und `X-Forwarded-Proto` beibehalten.
Der Zugriffsschutz in der App muss alle Geräte auf dieselbe SQLite-Datenbank
und denselben App-Prozess führen. Die zusätzliche Schreibsperre für laufende
Anfragen gilt pro Prozess; ein verteilter Mehrprozessbetrieb benötigt dafür eine
gemeinsame Sperrverwaltung. Der vorhandene systemd-Betrieb mit einem Prozess ist
unterstützt. `X-Real-IP` wird zur Begrenzung falscher Codeversuche nur bei einem
lokalen Reverse Proxy ausgewertet und muss dort mit der tatsächlichen Client-IP
überschrieben werden.
## Stationen sperren und Änderungen aufbewahren
Beim Öffnen einer gemeinsamen Station übernimmt das Gerät ihre Bearbeitung.
Ist sie bereits geöffnet, fragt die App, ob die Bearbeitung übernommen werden
soll. Erst die Bestätigung ersetzt die bisherige Sperre. Das andere Gerät darf
danach nicht mehr speichern. Zwei Fenster desselben Geräts benötigen ebenfalls
eine bestätigte Übernahme, damit sie einander nicht unbemerkt überschreiben.
Beim Verlassen der Station wird ihre eigene Sperre entfernt. Bleibt nach einem
Abbruch oder ohne Verbindung eine Sperre zurück, kann sie bestätigt übernommen
werden. Während eine Speicherung läuft, ist keine Übernahme möglich.
Vor dem Senden sichert die App Texte, ausgewählte Bild- und Tondateien sowie
vorgemerkte Entfernungen auf diesem Gerät. Wird die Speicherung abgewiesen oder
unterbrochen, sind diese unter „Noch nicht gesendete Änderungen“ erreichbar,
auch ohne Verbindung und nach einem Neustart des Internetbrowsers. Das gilt auch
für die vorgemerkte Entfernung einer Station. „Verwerfen“ entfernt diesen
zurückgehaltenen Stand nach einer Rückfrage. Die Speicherung benötigt freien
Gerätespeicher; bei einem Speicherfehler bleibt das Formular offen und die App
behauptet nicht, dass die Sicherung gelungen sei. Bereits bestätigte einzelne
Dateiübertragungen werden aus der Warteliste genommen.
Zum erneuten Senden die Änderungen öffnen und „Speichern“ wählen. Die App
beansprucht die Station erneut, gegebenenfalls nach einer Übernahme-Rückfrage.
Sie vergleicht den Stand beim ursprünglichen Öffnen mit dem aktuellen Inhalt.
Geänderte Texte, Koordinaten, Reihenfolge sowie geänderte Bild- und Tondateien
lösen einen Vergleich aus. Unterschiedliche Dateiinhalte werden auch bei gleicher
Adresse und gleicher Dateianzahl erkannt. Eine Tabelle zeigt die ursprünglichen,
eigenen und aktuellen Angaben; aktuelle Aufnahmen können geprüft werden.
„Meine Änderungen senden“ übernimmt die eigenen Angaben und vorgemerkten
Dateiaktionen. „Auf diesem Gerät behalten“ sendet nichts. Ein weiterer Vergleich
direkt vor dem Schreiben verhindert ein Überschreiben nach dieser Rückfrage.
Es gibt kein unbeaufsichtigtes erneutes Senden oder automatisches Zusammenführen.
Wenn eine Station entfernt wurde oder der Gruppenzugang fehlt, bleibt der Entwurf
auf dem Gerät einsehbar; er wird nicht automatisch als neue Station angelegt.
### Hinweise zum Betrieb
Sperrdateien liegen unter `storage/active/routes/<Strecke>/locks/<Station>.json`
(bzw. dem konfigurierten `STORAGE_DIR`). Sie enthalten `creator_id`, den Hash der
Gruppensitzung, einen zufälligen Bearbeitungsschlüssel und den Erstellzeitpunkt.
Nur die passende Kennung **und** die berechtigte Sitzung mit dem richtigen
Bearbeitungsschlüssel dürfen schreiben oder ihre Sperre entfernen. Die Kennung
wird aus der bereits eingerichteten Sitzung übernommen, nicht aus einem frei
angegebenen Anfragefeld. Dateien werden atomar ersetzt und mit Modus 0600
angelegt. Dieser Ordner darf nicht unmittelbar durch Nginx ausgeliefert werden.
Schließen der Gruppe entfernt ihre Sperren; laufende Schreibanfragen müssen
zunächst fertig werden. Erfolgreiches Entfernen einer Station entfernt auch
ihre Sperrdatei. Zurückgebliebene Sperren werden nicht zeitgesteuert gelöscht.
Stationsänderungen prüfen zusätzlich einen SHA-256-Abdruck aus Stationsangaben
und den tatsächlichen Bild-/Tondateien. Andere Stationen können deshalb unabhängig
vom alten Versionszähler der ganzen Strecke gespeichert werden. Änderungen der
Streckenangaben selbst verwenden weiter `If-Match`. Sperrübernahmen und Dateischreiben
sind im vorhandenen Einprozessbetrieb synchronisiert. Mehrprozessbetrieb ist
weiterhin nicht unterstützt.
Die Sicherungen liegen in IndexedDB (`wegwichtel.group-point-drafts.v1`) und zählen
zur vorhandenen Anzeige des belegten Gerätespeichers. Browserdaten-Löschung kann
sie entfernen. Sie enthalten nur die betroffene Station und neu ausgewählte
Dateien; vorhandene Aufnahmen werden hierfür nicht gesammelt heruntergeladen.
+3 -1
View File
@@ -215,7 +215,7 @@ Die Oberfläche enthält rechts oben einen global verfügbaren Informationsbutto
Die enthaltenen Rechtstexte sind technische Vorlagen. Vor einer öffentlichen Bereitstellung müssen insbesondere Betreiber, ladungsfähige Anschrift, Kontakt, Hostinganbieter, Protokollierungsdauer, Datenschutzkontakt und zuständige Aufsicht ergänzt und rechtlich geprüft werden. Eine Checkliste steht in [`PUBLICATION-CHECKLIST.md`](PUBLICATION-CHECKLIST.md). Die enthaltenen Rechtstexte sind technische Vorlagen. Vor einer öffentlichen Bereitstellung müssen insbesondere Betreiber, ladungsfähige Anschrift, Kontakt, Hostinganbieter, Protokollierungsdauer, Datenschutzkontakt und zuständige Aufsicht ergänzt und rechtlich geprüft werden. Eine Checkliste steht in [`PUBLICATION-CHECKLIST.md`](PUBLICATION-CHECKLIST.md).
Wegwichtel setzt selbst keine Cookies und bindet keine Analyse-, Werbe- oder Trackingdienste ein. Der Startbildschirm weist darauf hin. Die freiwillige Speicherung des Streckenfortschritts wird getrennt gesteuert: Wegwichtel verwendet keine Werbe- oder Tracking-Cookies und bindet keine Analyse-, Werbe- oder Trackingdienste ein. Für die ausdrücklich gestartete Gruppenbearbeitung wird ein geschütztes Sitzungscookie mit höchstens 30 Tagen Laufzeit verwendet. Der Startbildschirm weist darauf hin. Die freiwillige Speicherung des Streckenfortschritts wird getrennt gesteuert:
- `wegwichtel.privacy.v1` speichert die gewählte Einstellung, - `wegwichtel.privacy.v1` speichert die gewählte Einstellung,
- `wegwichtel.route-progress.v1` enthält bei aktivierter Funktion ein JSON-Objekt mit aktueller Route, Routenstatus, besuchten und angesehenen Stationen, abgespielten Audioeinträgen und Zeitstempeln, - `wegwichtel.route-progress.v1` enthält bei aktivierter Funktion ein JSON-Objekt mit aktueller Route, Routenstatus, besuchten und angesehenen Stationen, abgespielten Audioeinträgen und Zeitstempeln,
@@ -224,3 +224,5 @@ Wegwichtel setzt selbst keine Cookies und bindet keine Analyse-, Werbe- oder Tra
- ohne aktivierte Speicherung bleiben alle Routen- und Stationsdaten ausschließlich im Arbeitsspeicher des geöffneten Browserfensters. - ohne aktivierte Speicherung bleiben alle Routen- und Stationsdaten ausschließlich im Arbeitsspeicher des geöffneten Browserfensters.
Der Initialisierungsbildschirm bleibt nach erfolgreichem Laden sichtbar. Erst eine bewusste Pointer- oder Tastaturaktion öffnet die eigentliche Anwendung. Das Informationsfenster und die Datenschutzeinstellungen können bereits auf dem Startbildschirm verwendet werden, ohne die Anwendung weiterzuschalten. Der Initialisierungsbildschirm bleibt nach erfolgreichem Laden sichtbar. Erst eine bewusste Pointer- oder Tastaturaktion öffnet die eigentliche Anwendung. Das Informationsfenster und die Datenschutzeinstellungen können bereits auf dem Startbildschirm verwendet werden, ohne die Anwendung weiterzuschalten.
Gemeinsame Entwürfe und Wichtelcodes: [Gruppenbearbeitung.md](Gruppenbearbeitung.md).
+47
View File
@@ -864,3 +864,50 @@ Die Dateinamen und relativen Pfade unter `storage/` bleiben ausschließlich inte
Unter `tools/python/` liegen interaktive Skripte für Anlegen, Ändern, Erweitern, Wiederherstellen und Löschen von Routen, POIs, Bildern und Audio. Sie verwenden ausschließlich die Python-Standardbibliothek. Fehlende Parameter werden abgefragt. Antwortet ein vorgeschalteter Webserver mit HTTP 401, fragt die gemeinsame Request-Schicht Benutzername und Passwort ab und wiederholt den ursprünglichen Request. Unter `tools/python/` liegen interaktive Skripte für Anlegen, Ändern, Erweitern, Wiederherstellen und Löschen von Routen, POIs, Bildern und Audio. Sie verwenden ausschließlich die Python-Standardbibliothek. Fehlende Parameter werden abgefragt. Antwortet ein vorgeschalteter Webserver mit HTTP 401, fragt die gemeinsame Request-Schicht Benutzername und Passwort ab und wiederholt den ursprünglichen Request.
Details und Aufrufbeispiele stehen in [`Python-Werkzeuge.md`](Python-Werkzeuge.md). Details und Aufrufbeispiele stehen in [`Python-Werkzeuge.md`](Python-Werkzeuge.md).
## Gruppenbearbeitung
Siehe [Gruppenbearbeitung.md](Gruppenbearbeitung.md). Alle Gruppenantworten sind
`no-store`. POST/DELETE prüfen einen übermittelten Origin gegen den Host.
| Methode und Pfad | Zweck |
| --- | --- |
| GET /api/groups | Gruppen der Sitzung auflisten; ohne Sitzung leere Liste |
| POST /api/groups | Mit routeId und creator_id eine unabhängige Kopie öffnen |
| POST /api/groups/join | Mit code (Array aus acht Unicode-Symbolen) und creator_id beitreten |
| POST /api/groups/:id/code | Als Besitzer neuen Einladungscode erzeugen |
| DELETE /api/groups/:id | Als Besitzer Einladung und Mitbearbeiterzugänge widerrufen |
Der Server setzt `wegwichtel_group_session` als geschütztes Cookie auf `/api`.
Nur Hashes werden gespeichert. Die creator_id allein erteilt keine Gruppenrechte.
Gruppenantworten enthalten id, route, role, active und revision; nur das Erzeugen
oder Erneuern liefert den Klartextcode und expiresAt (Unixsekunden).
Die bisherigen `/api/routes/:id/...`-Endpunkte prüfen auch den Gruppenzugang,
einschließlich GPX, Bilder und Audio. Unveröffentlichte Gruppenkopien sind nicht
öffentlich gelistet. GET liefert `X-Group-Route` und `X-Group-Revision`; Änderungen
müssen die geladene Revision als `If-Match` senden. Überlappende oder veraltete
Änderungen ergeben 409. Erfolgreiche Änderungen erhöhen die Revision. API-Clients
müssen Cookies beibehalten; Gruppenmedien können damit auch als normale Bild- und
Audio-Ressourcen abgerufen werden. Freigegebene Strecken bleiben schreibgeschützt.
### Stationssperren für gemeinsame Entwürfe
`POST /api/groups/routes/:routeId/pois/:poiId/lock` verwendet die berechtigte
Gruppensitzung. `{}` beansprucht eine freie Station. Antwort: `token`, `version`,
`mediaVersion`, `poi`. Eine vorhandene Bearbeitung ergibt 423 mit
`details.code=point-locked` und `details.replace`. Nach einer bestätigten Rückfrage
mit `{ "replace": "<angezeigter Schlüssel>" }` erneut senden. Eine inzwischen
veränderte Sperre braucht eine neue Bestätigung. `{ "token": "<eigener Schlüssel>" }`
aktualisiert den eigenen Inhaltsstand; `{ "release": true, "token": "…" }` gibt
nur die eigene Sperre frei. Fremde creator_id-Angaben werden nicht übernommen.
Laufende Schreibanfragen ergeben `point-busy` (423), ein verlorener Zugang zur
Bearbeitung `point-lost` (423), ein veränderter Stationsinhalt `point-changed` (409).
Für Schreibzugriffe auf `/api/routes/:id/pois/:poiId` und dessen Bild-/Tonpfade
sind `X-Poi-Lock: <token>` und `If-Poi-Match: <version>` erforderlich. Anstelle
von If-Match wird dort der Stationsinhalt geprüft. Erfolgreiche Antworten liefern
`X-Poi-Id` und `X-Poi-Version`, solange die Station existiert. Diese Version bei
mehreren Dateioperationen jeweils weiterverwenden. Eine neue Station wird über
den bisherigen POST angelegt und vor den weiteren Dateioperationen gesperrt.
Die Versionsabdrücke berücksichtigen Dateibytes, nicht bloß URLs oder Metadaten.
+5
View File
@@ -173,3 +173,8 @@ Schulleitung, Schulträger und Schulamt nicht als Betreiber der Website führen.
Die konkrete Nutzungsfreigabe für die Schule bleibt gesondert festzulegen. Die konkrete Nutzungsfreigabe für die Schule bleibt gesondert festzulegen.
Kontakt: info@wegwichtel.de. Die Anschrift ist noch zu ergänzen. Kontakt: info@wegwichtel.de. Die Anschrift ist noch zu ergänzen.
Rechte fremder Urheber und bestehende Lizenzen bleiben davon unberührt. Rechte fremder Urheber und bestehende Lizenzen bleiben davon unberührt.
## Gemeinsame Entwürfe
Vor Änderungen an Gruppenrechten und Code-Eingabe zusätzlich
`docs/agents/GRUPPENBEARBEITUNG.md` lesen und bei Übergaben ausdrücklich nennen.
+79
View File
@@ -0,0 +1,79 @@
# Agent-Hinweise zur Gruppenbearbeitung
Implementierter erster Stand: gemeinsame serverseitige Kopien von eigenen und
veröffentlichten Strecken, acht Unicode-Wichtel im Einladungscode, Drehrad-Eingabe,
Sitzungen, Rollen owner/member, Codewechsel und Widerruf. Bedienung und Einspielen:
[Gruppenbearbeitung.md](../Gruppenbearbeitung.md).
## Beizubehalten
- Originale bleiben unverändert; keine Schreibrechte auf freigegebene Strecken
öffnen. Neue gemeinsame Kopien brauchen erneut die normale Moderationsfreigabe.
- creator_id bleibt Ersteller-Metadatum. Eigentums-/Mitgliedsrechte werden durch
zufällige Sitzungsgeheimnisse geprüft. Niemals Rechte anhand einer frei
übermittelten creator_id oder eines von einem Client behaupteten Rollenfelds vergeben.
- Sitzungsgeheimnisse nur als Hash serverseitig und als HttpOnly-/SameSite-Strict-
Cookie auf dem Client speichern; bei HTTPS zusätzlich Secure. Nicht ausgeben.
- Einladungscodes haben acht der vorhandenen neun Symbole, werden mit randomInt
gewählt und nur gehasht gespeichert. Gültigkeit eine Stunde; falsche Versuche
sind begrenzt. Variationselektoren ändern die Unicode-Zuordnung nicht.
- Schutz auch auf den ursprünglichen /api/routes/...-Adressen für alle Gruppen-
Dateien durchsetzen. Anders geschriebene IDs (führende Nullen, Prozentkodierung,
Exponentialschreibweise, Groß-/Kleinschreibung der Pfade) dürfen ihn nicht umgehen.
- Gemeinsame Entwürfe aus öffentlichen Listen ausschließen. Änderungen verwenden
die geladene Gruppenrevision als If-Match. Hintergrundlisten dürfen diese
Bearbeitungsrevision nicht auf einen neueren Stand setzen. Konflikte zeigen,
ohne ungespeicherte Eingaben zu ersetzen. Schreibanfragen bis zum Abschluss sperren.
- Codewechsel entfernt nur die alte Einladung, Gruppenschließung entzieht auch
bestehenden Mitbearbeitern Rechte. Besitzer behalten Zugriff. Gemeinsame
Strecken nicht über die normalen Delete-/Restore-Endpunkte entfernen/wiederherstellen.
- Gruppenzugang benötigt Internet. Lokale Entwürfe weiter sofort anzeigen und
ihre Dateien erst bei Bedarf bzw. beim Übertragen laden.
- Rechtliche Hinweise zu Sitzungs-Cookies und serverseitigen Gruppenentwürfen
statisch auf Deutsch halten; Bedienung vollständig in Deutsch und Englisch.
## Weitere Aufgaben
- Wiederherstellung des eröffnenden Zugangs nach Cookie-/Geräteverlust und eine
ausdrücklich autorisierte Eigentumsübertragung entwickeln.
- Einzelne Mitglieder verwalten und gezielt entfernen; beigetretene Sitzungen
sind derzeit nur gemeinsam über „Gruppe schließen“ widerrufbar.
- Automatisches Zusammenführen bzw. feinere Konflikte und transaktionale
Mehrdatei-Übertragungen evaluieren. Bereits erfolgreiche Teiländerungen können
bei einem späteren Fehler bestehen bleiben. Neue Routen-POSTs sind noch nicht
gegen verlorene Erfolgsantworten idempotent.
- Expliziten unabhängigen Moderationszugang für private Gruppenmedien und einen
Ablauf zum Anfordern ihrer Veröffentlichung ergänzen; keinen Rechte-Bypass
anhand frei setzbarer Header oder beliebiger Basic-Auth-Header einführen.
- Aufbewahrung und Bereinigung verwaister Gruppen, Sitzungen und Kopien sowie
weitergehende Quoten und verteilte Sperren für einen Mehrprozessbetrieb planen.
- Der Gruppen-Code ist eine Einladung, kein persönlicher Identitätsnachweis.
Rechteprüfung für einzelne Inhalte bleibt eine getrennte offene Aufgabe.
## Stationssperren und zurückgehaltene Änderungen
- `src/services/point-locks.js` verwaltet atomare JSON-Sperrdateien pro Station
mit creator_id, Sitzungshash und zufälligem Bearbeitungsschlüssel. Rechte nicht
allein anhand der creator_id prüfen. Übernahmen brauchen eine bestätigte,
vergleichende Ersetzung des gerade angezeigten Schlüssels; während laufender
Speicherung weder freigeben noch übernehmen oder die Gruppe schließen.
- Alle Stations-Schreibpfade einschließlich Bilder/Ton, Entfernung und Umordnung
durch `guardPoint` schützen, auch bei alternativen URL-Schreibweisen.
- `X-Poi-Lock` und `If-Poi-Match` sind Pflicht. Der Inhaltsabdruck berücksichtigt
auch tatsächliche Dateibytes. Antwortheader `X-Poi-Version`/`X-Poi-Id` nach
erfolgreichen Teiländerungen aktualisieren. Stations-Schreibzugriffe verwenden
diese Prüfung anstelle des streckenweiten If-Match, damit verschiedene
Stationen unabhängig bearbeitbar sind. Das Einprozessmodell bleibt erforderlich.
- `public/js/group-points.js` sichert Entwürfe inklusive File/Blob und
Entfernungsvormerkungen vor dem Senden in IndexedDB. Transaktionsabschluss
abwarten; bei Quotenfehlern niemals erfolgreiche Sicherung behaupten.
- Entwürfe auch ohne Netz bzw. nach verlorenem Zugang anzeigen. Erst nach
erneutem Sperrerwerb und Inhaltsvergleich senden. Bei Abweichungen Vergleich
mit ausdrücklicher Bestätigung; keine automatische Wiederholung/Überschreibung.
- Datei-Entfernungen bei Gruppen zunächst vormerken. Bereits quittierte
Übertragungen aus der Warteliste nehmen. Die ursprüngliche Vergleichsbasis
behalten, bis sämtliche Änderungen erfolgreich übertragen wurden.
- Weiter offen: atomare Mehrdatei-Übertragungen/Idempotenz bei verlorenen
Erfolgsantworten, gezieltes Zusammenführen einzelner Felder und Auflösung
entfernter Stationen sowie verteilte Sperren. Eine nachfolgende Aufnahme
kann nicht ohne Einschränkungen zurückgerufen werden.
+13 -5
View File
@@ -6,13 +6,21 @@ gezieltes Löschen nur nach ausdrücklicher Bestätigung und erneutes Einsenden
nicht freigegebener Strecken. Beschreibung: [CREATOR-ID.md](../CREATOR-ID.md). nicht freigegebener Strecken. Beschreibung: [CREATOR-ID.md](../CREATOR-ID.md).
Das GPX-Attribut `creator="Wegwichtel"` bezeichnet weiterhin nur das Programm. Das GPX-Attribut `creator="Wegwichtel"` bezeichnet weiterhin nur das Programm.
Noch offen für gemeinsame Bearbeitung: Erster Gruppenstand umgesetzt: gemeinsame Kopien, Wichtelcodes, geschützte
Sitzungen, owner/member-Rollen und Revisionsprüfung. Details und Grenzen:
[GRUPPENBEARBEITUNG.md](GRUPPENBEARBEITUNG.md).
- Authentifizierte Eigentumszuordnung und Einladungen für Mitbearbeiter entwickeln. Noch offen für den weiteren Ausbau:
Eine frei übermittelte Gerätekennung allein beweist kein Eigentum.
- Geräteübergreifende Anmeldung, Wiederherstellung und gezielte Mitgliederverwaltung
ergänzen. Gruppenrechte und Einladungen bestehen bereits auf Sitzungsbasis;
eine frei übermittelte Gerätekennung beweist weiterhin kein Eigentum.
- Wiederherstellung nach verlorenen Browserdaten und bewussten Gerätewechsel planen. - Wiederherstellung nach verlorenen Browserdaten und bewussten Gerätewechsel planen.
- Gleichzeitige Änderungen und Versionskonflikte erkennen; der derzeitige lokale - Automatisches Zusammenführen und feinere Konfliktauflösung ergänzen. Gemeinsame
Entwurf wird beim Einsenden als vollständiger Stand behandelt. Entwürfe erkennen inzwischen veraltete Revisionen und sperren überlappende Writes.
Stationssperren, bestätigte Übernahme und lokale Rückhaltung einschließlich
Inhaltsvergleich beim erneuten Senden sind umgesetzt.
Lokale Wieder-Einreichungen behandeln den Entwurf weiterhin als vollständigen Stand.
- Idempotente POSTs auch bei verlorenem HTTP-Erfolg und Gesamtübertragungen mit - Idempotente POSTs auch bei verlorenem HTTP-Erfolg und Gesamtübertragungen mit
mehreren Dateien absichern. mehreren Dateien absichern.
+7 -8
View File
@@ -1,8 +1,8 @@
# Platzhalter für die neun Gnom-Motive # Wichtel für Gruppencodes
Die PNGs sind vollständig transparente Platzhalter mit 256 × 256 Pixeln. Die neun PNG-Dateien werden für Einladungen und die senkrechten Code-Drehräder
Sie können direkt durch die echten Bilder ersetzt werden; der Dateiname muss verwendet. Ihre Unicode-Dateinamen bleiben unverändert. Bedienung und Grenzen:
beibehalten werden. Die Klasse leitet den Bildpfad direkt vom Unicode-Symbol ab. [Gruppenbearbeitung.md](Gruppenbearbeitung.md).
Die Reihenfolge entspricht der Vorlage: oben von links nach rechts, danach unten Die Reihenfolge entspricht der Vorlage: oben von links nach rechts, danach unten
von links nach rechts. von links nach rechts.
@@ -20,13 +20,12 @@ von links nach rechts.
| 🎃.png | Gnom mit Kürbis | Symbol zeigt einen Halloween-Kürbis | | 🎃.png | Gnom mit Kürbis | Symbol zeigt einen Halloween-Kürbis |
Alle Bilder liegen unter `public/images/group-symbols/`. Alle Bilder liegen unter `public/images/group-symbols/`.
Neun Motive sind vorbereitet; die spätere Gruppencode-Funktion wird hier noch nicht Die Gruppenfunktion verwendet acht Symbole je Code. Bilder werden dafür nicht
implementiert. `index.html` und die bestehenden Ansichten bleiben unverändert. umbenannt oder neu erzeugt.
## Klasse ## Klasse
`public/js/group-symbols.js` registriert `Wegwichtel.GroupSymbols`, sobald das Script `public/js/group-symbols.js` registriert `Wegwichtel.GroupSymbols`, beim Laden der App. Die Initialisierung des Namensraums steht als
später ausdrücklich geladen wird. Die Initialisierung des Namensraums steht als
eigene Anweisung vor der Registrierung (Korrektur für Sonar javascript:S1121). eigene Anweisung vor der Registrierung (Korrektur für Sonar javascript:S1121).
```js ```js
+5
View File
@@ -57,3 +57,8 @@ Bei einem Sprachschalter müssen die aktuellen Ansichten zusätzlich neu gerende
werden. Fehlende einzelne Übersetzungen und unbekannte Sprachen fallen auf werden. Fehlende einzelne Übersetzungen und unbekannte Sprachen fallen auf
Deutsch zurück. Ein fehlender deutscher Textschlüssel wirft einen internen Deutsch zurück. Ein fehlender deutscher Textschlüssel wirft einen internen
Fehler, damit fehlerhafte Zuordnungen nicht unbemerkt bleiben. Fehler, damit fehlerhafte Zuordnungen nicht unbemerkt bleiben.
Gruppen verwenden nach dem Öffnen oder Beitritt ein HttpOnly-/SameSite-Strict-
Sitzungscookie, bei HTTPS mit Secure, Laufzeit höchstens 30 Tage. Die Datenbank
hält nur den Hash sowie creator_id und Mitgliedschaften. Die deaktivierte
Option für optionale Cookies betrifft keine notwendigen Gruppensitzungen.
+31
View File
@@ -593,3 +593,34 @@
.current-location-button { min-width: 44px; min-height: 44px; } .current-location-button { min-width: 44px; min-height: 44px; }
.current-location-button img { width: 24px; height: 24px; vertical-align: middle; } .current-location-button img { width: 24px; height: 24px; vertical-align: middle; }
/* Shared drafts and vertically scrolling Wichtel-code wheels. */
.group-pair { display: inline-flex; align-items: center; justify-content: center; width: 4.5rem; }
.group-pair img { width: 1.8rem; height: 2.3rem; object-fit: contain; pointer-events: none; }
.group-dialog { width: min(35rem, calc(100% - 2rem)); max-height: 90dvh; box-sizing: border-box; overflow-y: auto; padding: 1.25rem; border: 1px solid #c8d3ca; border-radius: 1.25rem; background: #f8faf5; color: #1f3022; }
.group-dialog::backdrop { background: rgba(20, 40, 25, .5); }
.group-dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.group-dialog-header h2 { margin: 0; font-size: 1.2rem; }
.group-dialog button { min-width: 44px; min-height: 44px; border: 1px solid #c8d3ca; border-radius: .85rem; background: white; color: #24462b; touch-action: manipulation; }
.group-dialog button:focus-visible, .group-wheel-viewport:focus-visible { outline: 3px solid #1e6fba; outline-offset: 2px; }
.group-wheels { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .8rem; padding: 0; border: 0; margin: 1rem 0; }
.group-code-fieldset { border: 0; padding: 0; min-width: 0; }
.group-code-fieldset legend { margin-bottom: .5rem; font-weight: 600; }
.group-wheel { display: grid; gap: .3rem; min-width: 0; }
.group-wheel-viewport { height: 156px; padding: 52px 0; box-sizing: border-box; overflow-y: auto; overscroll-behavior: contain; scroll-snap-type: y mandatory; scrollbar-width: none; border-radius: .85rem; background: linear-gradient(to bottom, #edf2eb 0 33.33%, #dcebdc 33.33% 66.66%, #edf2eb 66.66%); }
.group-wheel-viewport::-webkit-scrollbar { display: none; }
.group-wheel-option { height: 52px; display: grid; place-items: center; scroll-snap-align: center; cursor: pointer; }
.group-wheel-option img { width: 48px; height: 48px; object-fit: contain; pointer-events: none; }
.group-code { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .75rem; padding: 1rem; border: 1px solid #c8d3ca; border-radius: 1rem; background: #e7f1e9; }
.group-code img { width: 100%; height: 64px; object-fit: contain; }
.group-actions { max-width: min(18rem, 52vw); flex-wrap: wrap; justify-content: flex-end; }
.group-actions .editor-secondary-button { font-size: .85rem; }
.group-status { min-height: 1.5rem; color: #9b2c2c; }
@media (prefers-reduced-motion: reduce) { .group-wheel-viewport { scroll-behavior: auto; } }
.point-compare-dialog { width: min(56rem, calc(100vw - 2rem)); }
.point-compare-content { overflow-x: auto; max-height: 55vh; overflow-y: auto; }
.point-compare-content table { width: 100%; border-collapse: collapse; }
.point-compare-content th, .point-compare-content td { padding: .6rem; border: 1px solid #c9d1ca; text-align: start; vertical-align: top; overflow-wrap: anywhere; white-space: pre-wrap; min-width: 6rem; }
.point-compare-content img { max-width: 10rem; max-height: 10rem; object-fit: contain; }
.point-compare-content audio { max-width: 100%; }
+42 -3
View File
@@ -180,6 +180,7 @@
</section> </section>
<div class="route-control-actions"> <div class="route-control-actions">
<button id="editor-button" class="editor-button" type="button" aria-label="Strecken bearbeiten" title="Strecken bearbeiten" aria-haspopup="dialog" aria-expanded="false" aria-controls="editor-dialog" data-i18n-title="editor.open" data-i18n-aria-label="editor.open" data-i18n="editor.open">Strecken bearbeiten</button> <button id="editor-button" class="editor-button" type="button" aria-label="Strecken bearbeiten" title="Strecken bearbeiten" aria-haspopup="dialog" aria-expanded="false" aria-controls="editor-dialog" data-i18n-title="editor.open" data-i18n-aria-label="editor.open" data-i18n="editor.open">Strecken bearbeiten</button>
<button id="group-join-open" class="editor-secondary-button" type="button" data-i18n="groups.join-open">Gruppencode eingeben</button>
<button id="route-control-new" class="editor-primary-button" type="button" data-i18n="editor.new-route">Neue Strecke</button> <button id="route-control-new" class="editor-primary-button" type="button" data-i18n="editor.new-route">Neue Strecke</button>
</div> </div>
</nav> </nav>
@@ -334,10 +335,11 @@
</label> </label>
<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“ oder „Gemeinsam bearbeiten“ wählst, wird deine Strecke an die Website gesendet.</p>
<p>Änderungen an gemeinsamen Stationen werden vor dem Senden zusätzlich auf diesem Gerät gesichert. Dazu gehören deine Eingaben, neu ausgewählte Bilder und Tonaufnahmen sowie vorgemerkte Entfernungen. Noch nicht gesendete Änderungen bleiben bis zum erfolgreichen Senden oder bewussten Verwerfen erhalten. Das Löschen der Website-Daten im Internetbrowser kann diese Sicherungen entfernen. Auf der Website wird während der Bearbeitung eine Sperre mit deiner Gerätekennung und deinem Gruppenzugang gespeichert, damit sich Änderungen nicht unbemerkt überschreiben.</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> <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 Werbe- oder Tracking-Cookies. Für die Gruppenbearbeitung wird nach dem Öffnen einer Gruppe oder dem Beitritt ein geschütztes Zugangscookie für höchstens 30 Tage gespeichert. Es merkt sich, welche gemeinsamen Entwürfe du bearbeiten darfst. Beim Öffnen einer Gruppe oder beim Beitritt werden außerdem deine Gerätekennung und deine Gruppenmitgliedschaft auf der Website gespeichert. Gemeinsame Entwürfe und ihre Bilder und Tonaufnahmen werden auf der Website gespeichert und sind für die Gruppenmitglieder zugänglich. 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>
<p>Bilder, Texte und Tonaufnahmen dürfen nur geteilt werden, wenn das erlaubt ist. Wenn Kinder auf Bildern oder in Aufnahmen erkannt werden können, müssen die Regeln der Schule beachtet werden. Auch die Eltern oder anderen Sorgeberechtigten müssen die nötige Erlaubnis geben.</p> <p>Bilder, Texte und Tonaufnahmen dürfen nur geteilt werden, wenn das erlaubt ist. Wenn Kinder auf Bildern oder in Aufnahmen erkannt werden können, müssen die Regeln der Schule beachtet werden. Auch die Eltern oder anderen Sorgeberechtigten müssen die nötige Erlaubnis geben.</p>
<h3>8. Eingesandte Bilder und Tonaufnahmen</h3> <h3>8. Eingesandte Bilder und Tonaufnahmen</h3>
@@ -427,7 +429,7 @@
</ul> </ul>
<p data-i18n="storage-help.permissions.optional">Du kannst diese Erlaubnisse ablehnen. Dann fehlen die jeweiligen Funktionen. Strecken und Stationen kannst du weiterhin selbst auswählen.</p> <p data-i18n="storage-help.permissions.optional">Du kannst diese Erlaubnisse ablehnen. Dann fehlen die jeweiligen Funktionen. Strecken und Stationen kannst du weiterhin selbst auswählen.</p>
<h3 data-i18n="storage-help.providers-title">Werbung und andere Anbieter</h3> <h3 data-i18n="storage-help.providers-title">Werbung und andere Anbieter</h3>
<p data-i18n="storage-help.providers-data">Wegwichtel zeigt keine Werbung und verfolgt nicht, welche anderen Seiten du besuchst. Die App lädt ihre eigenen Programme und Bilder von Wegwichtel. Es werden keine Cookies verwendet.</p> <p data-i18n="storage-help.providers-data">Wegwichtel zeigt keine Werbung und verfolgt nicht, welche anderen Seiten du besuchst. Die App lädt ihre eigenen Programme und Bilder von Wegwichtel. Für gemeinsame Entwürfe merkt sich dein Internetbrowser deinen Zugang für höchstens 30 Tage mit einem Cookie.</p>
</article> </article>
</div> </div>
</div> </div>
@@ -459,6 +461,13 @@
</section> </section>
<p class="editor-muted" data-i18n="editor.routes-hint">Hier findest du Strecken für alle und deine eigenen Entwürfe.</p> <p class="editor-muted" data-i18n="editor.routes-hint">Hier findest du Strecken für alle und deine eigenen Entwürfe.</p>
<ul id="editor-route-list" class="editor-route-list" aria-live="polite"></ul> <ul id="editor-route-list" class="editor-route-list" aria-live="polite"></ul>
<h3 data-i18n="groups.title">Gemeinsame Entwürfe</h3>
<p class="editor-muted" data-i18n="groups.online">Gemeinsam bearbeiten braucht eine Internetverbindung.</p>
<button id="editor-group-join" class="editor-secondary-button" type="button" data-i18n="groups.join-open">Gruppencode eingeben</button>
<h4 data-i18n="point-locks.pending-title">Noch nicht gesendete Änderungen</h4>
<p class="editor-muted" data-i18n="point-locks.pending-help">Diese Änderungen bleiben auf diesem Gerät. Öffne sie später und tippe auf Speichern. Vor dem Senden prüft Wegwichtel, ob die Station inzwischen geändert wurde.</p>
<ul id="editor-point-drafts" class="editor-route-list" aria-live="polite"></ul>
<ul id="editor-group-list" class="editor-route-list" aria-live="polite"></ul>
</section> </section>
<section id="editor-route-detail-view" class="editor-view" aria-labelledby="editor-route-detail-title" hidden> <section id="editor-route-detail-view" class="editor-view" aria-labelledby="editor-route-detail-title" hidden>
<div class="editor-toolbar editor-toolbar-back"> <div class="editor-toolbar editor-toolbar-back">
@@ -570,6 +579,36 @@
<script src="js/user-messages.js?v=0.12.14"></script> <script src="js/user-messages.js?v=0.12.14"></script>
<script src="js/local-routes.js?v=0.12.14"></script> <script src="js/local-routes.js?v=0.12.14"></script>
<script src="js/route-recorder.js?v=0.12.14"></script> <script src="js/route-recorder.js?v=0.12.14"></script>
<dialog id="group-dialog" class="group-dialog" aria-labelledby="group-title">
<header class="group-dialog-header">
<h2 id="group-title" data-i18n="groups.share">Gemeinsam bearbeiten</h2>
<button id="group-close" type="button" aria-label="Schließen" data-i18n-aria-label="info.close">×</button>
</header>
<section id="group-entry">
<p data-i18n="groups.wheel-help">Schiebe die Wichtel nach oben oder unten. Die Wichtel in der Mitte ergeben den Code. Lies von links nach rechts, zuerst die obere Reihe.</p>
<fieldset class="group-code-fieldset"><legend data-i18n="groups.code-label">Gruppencode</legend><div id="group-wheels" class="group-wheels"></div></fieldset>
<button id="group-join" type="button" data-i18n="groups.join">Mitmachen</button>
</section>
<section id="group-invitation" hidden>
<p data-i18n="groups.invite-help">Zeige diesen Code den anderen. Damit können sie deinen gemeinsamen Entwurf öffnen und bearbeiten. Das Original bleibt erhalten.</p>
<div id="group-code" class="group-code" aria-label="Gruppencode" data-i18n-aria-label="groups.code-label"></div>
<p id="group-expiry"></p>
<button id="group-edit" type="button" data-i18n="groups.edit">Entwurf bearbeiten</button>
</section>
<p id="group-status" class="group-status" role="status"></p>
</dialog>
<dialog id="point-compare-dialog" class="group-dialog point-compare-dialog" aria-labelledby="point-compare-title">
<h2 id="point-compare-title" data-i18n="point-locks.compare-title">Die Station wurde geändert</h2>
<p data-i18n="point-locks.compare-help">Vergleiche deine Änderungen mit dem neuen Stand. Wenn du deine Änderungen sendest, ersetzen deine Angaben den aktuellen Stand. Prüfe auch Bilder und Tonaufnahmen. Bei gleicher Anzahl können andere Dateien dahinterstecken.</p>
<div id="point-compare-content" class="point-compare-content"></div>
<div class="editor-form-actions">
<button id="point-compare-cancel" type="button" data-i18n="point-locks.keep-device">Auf diesem Gerät behalten</button>
<button id="point-compare-accept" type="button" data-i18n="point-locks.use-mine">Meine Änderungen senden</button>
</div>
</dialog>
<script src="js/group-points.js?v=0.12.14"></script>
<script src="js/group-symbols.js?v=0.12.14"></script>
<script src="js/groups.js?v=0.12.14"></script>
<script src="js/editor-ui.js?v=0.12.14"></script> <script src="js/editor-ui.js?v=0.12.14"></script>
<script src="js/privacy-storage.js?v=0.12.14"></script> <script src="js/privacy-storage.js?v=0.12.14"></script>
<script src="js/legal-ui.js?v=0.12.14"></script> <script src="js/legal-ui.js?v=0.12.14"></script>
+209 -20
View File
@@ -88,6 +88,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const state = { const state = {
currentPoi: null, currentPoi: null,
groupDeletions: [],
groupDeletePoint: false,
currentRoute: null, currentRoute: null,
currentSource: null, currentSource: null,
pendingAudioFile: null, pendingAudioFile: null,
@@ -212,6 +214,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function showListView() { function showListView() {
void ns.GroupPoints?.release();
releasePoiPreview(); releasePoiPreview();
ns.RouteRecorder?.endEditing(); ns.RouteRecorder?.endEditing();
setDialogTitle('Streckenverwaltung'); setDialogTitle('Streckenverwaltung');
@@ -221,6 +224,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function showRouteView() { function showRouteView() {
void ns.GroupPoints?.release();
releasePoiPreview(); releasePoiPreview();
setDialogTitle(routeDialogTitle()); setDialogTitle(routeDialogTitle());
setHidden(byId(ids.routeListView), true); setHidden(byId(ids.routeListView), true);
@@ -276,7 +280,18 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
async function handleResponse(response) { async function handleResponse(response) {
if (response.ok) return parseJson(response); if ([401, 403].includes(response.status) && state.currentSource === 'group') {
throw new Error(ns.I18n.t('groups.error.access'));
}
if (response.status === 423) throw new Error(ns.I18n.t('point-locks.lost'));
if (response.status === 409 && response.headers?.get('X-Group-Route')) {
throw new Error(ns.I18n.t('groups.error.conflict'));
}
if (response.ok) {
ns.Groups?.observe?.(response);
ns.GroupPoints?.observe?.(response);
return parseJson(response);
}
const payload = await parseJson(response); const payload = await parseJson(response);
const error = new Error(payload.message || ns.I18n.t("editor.error.action")); const error = new Error(payload.message || ns.I18n.t("editor.error.action"));
error.status = response.status; error.status = response.status;
@@ -286,7 +301,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function readJson(path) { async function readJson(path) {
const response = await fetch(apiUrl(path), { const response = await fetch(apiUrl(path), {
credentials: 'same-origin', credentials: 'same-origin',
headers: { Accept: 'application/json' } headers: { ...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path), Accept: 'application/json' }
}); });
return handleResponse(response); return handleResponse(response);
} }
@@ -296,6 +311,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
body: JSON.stringify(body), body: JSON.stringify(body),
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: {
...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path),
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
@@ -308,7 +324,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const response = await fetch(apiUrl(path), { const response = await fetch(apiUrl(path), {
body: formData, body: formData,
credentials: 'same-origin', credentials: 'same-origin',
headers: { Accept: 'application/json' }, headers: { ...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path), Accept: 'application/json' },
method method
}); });
return handleResponse(response); return handleResponse(response);
@@ -317,7 +333,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function sendDelete(path) { async function sendDelete(path) {
const response = await fetch(apiUrl(path), { const response = await fetch(apiUrl(path), {
credentials: 'same-origin', credentials: 'same-origin',
headers: { Accept: 'application/json' }, headers: { ...ns.Groups?.headersFor?.(path), ...ns.GroupPoints?.headersFor?.(path), Accept: 'application/json' },
method: 'DELETE' method: 'DELETE'
}); });
return handleResponse(response); return handleResponse(response);
@@ -375,6 +391,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = classes.iconRow; actions.className = classes.iconRow;
actions.appendChild(iconButton(ns.I18n.t("editor.edit-route"), '✎', 'edit-route')); actions.appendChild(iconButton(ns.I18n.t("editor.edit-route"), '✎', 'edit-route'));
if (ns.Groups?.shareButton) actions.appendChild(ns.Groups.shareButton('share-route'));
if (routeSource(route) === 'local') { if (routeSource(route) === 'local') {
actions.appendChild(iconButton(ns.I18n.t("editor.delete-route"), '×', 'delete-route', false, true)); actions.appendChild(iconButton(ns.I18n.t("editor.delete-route"), '×', 'delete-route', false, true));
} }
@@ -453,6 +470,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
let routeListGeneration = 0; let routeListGeneration = 0;
let shareInProgress = false;
async function loadRoutes() { async function loadRoutes() {
const requestGeneration = ++routeListGeneration; const requestGeneration = ++routeListGeneration;
@@ -518,6 +536,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
setDialogExpanded(true); setDialogExpanded(true);
showListView(); showListView();
await guarded(loadRoutes); await guarded(loadRoutes);
void ns.Groups?.refresh?.();
} }
async function openNewLocalRoute() { async function openNewLocalRoute() {
@@ -526,6 +545,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function closeEditor() { function closeEditor() {
if (pointSaveInProgress) return;
void ns.GroupPoints?.release();
releasePoiPreview(); releasePoiPreview();
ns.RouteRecorder?.endEditing(); ns.RouteRecorder?.endEditing();
const dialog = byId(ids.dialog); const dialog = byId(ids.dialog);
@@ -837,7 +858,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} else { } else {
const payload = await readJson(`/routes/${encodeURIComponent(id)}`); const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
const detail = payload.route || payload; const detail = payload.route || payload;
state.currentRoute = { ...detail, _source: 'server' }; state.currentRoute = { ...detail, _source: state.currentSource };
state.pois = poiArray(detail); state.pois = poiArray(detail);
} }
@@ -871,6 +892,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const file = byId(ids.routeGpx)?.files?.[0]; const file = byId(ids.routeGpx)?.files?.[0];
data.append('name', values.name); data.append('name', values.name);
data.append('description', values.description); data.append('description', values.description);
data.append('creator_id', state.currentRoute.creator_id || ns.LocalRoutes.creatorId());
if (file) data.append('gpx', file); if (file) data.append('gpx', file);
return data; return data;
} }
@@ -911,21 +933,22 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const id = routeId(state.currentRoute); const id = routeId(state.currentRoute);
if (!id) throw new Error(ns.I18n.t("editor.error.create-locally")); if (!id) throw new Error(ns.I18n.t("editor.error.create-locally"));
const file = byId(ids.routeGpx)?.files?.[0]; const file = byId(ids.routeGpx)?.files?.[0];
values = { ...values, creator_id: state.currentRoute.creator_id || ns.LocalRoutes.creatorId() };
const path = `/routes/${encodeURIComponent(id)}`; const path = `/routes/${encodeURIComponent(id)}`;
const payload = file const payload = file
? await sendForm(path, 'PUT', formDataFromRoute(values)) ? await sendForm(path, 'PUT', formDataFromRoute(values))
: await sendJson(path, 'PUT', values); : await sendJson(path, 'PUT', values);
const saved = payload.route || payload; const saved = payload.route || payload;
state.currentRoute = { ...saved, _source: 'server' }; state.currentRoute = { ...saved, _source: state.currentSource };
await loadRoutes(); await loadRoutes();
await loadRoute({ id, _source: 'server' }); await loadRoute({ id, _source: state.currentSource });
setStatus(ns.I18n.t("editor.saved")); setStatus(ns.I18n.t("editor.saved"));
} }
async function saveRoute() { async function saveRoute() {
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.currentSource === 'server') await saveServerRoute(values); if (state.currentSource !== 'local') await saveServerRoute(values);
else await saveLocalRoute(values); else await saveLocalRoute(values);
} }
@@ -1134,6 +1157,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function newPoi() { function newPoi() {
void ns.GroupPoints?.release();
releasePoiPreview(); releasePoiPreview();
const position = currentPosition(); const position = currentPosition();
const lat = position?.lat ?? null; const lat = position?.lat ?? null;
@@ -1141,6 +1165,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const accuracy = Number(position?.accuracy); const accuracy = Number(position?.accuracy);
state.currentPoi = null; state.currentPoi = null;
state.groupDeletions = [];
state.groupDeletePoint = false;
const identifier = state.currentSource === 'local' ? nextLocalPoiId() : null; const identifier = state.currentSource === 'local' ? nextLocalPoiId() : null;
fillPoiForm({ fillPoiForm({
...(identifier == null ? {} : { id: identifier }), ...(identifier == null ? {} : { id: identifier }),
@@ -1155,11 +1181,30 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function editPoi(poi) { async function editPoi(poi) {
if (!poi) return; if (!poi) return;
releasePoiPreview(); releasePoiPreview();
let draft;
if (state.currentSource === 'group' && ns.GroupPoints) {
const opened = await ns.GroupPoints.begin(routeId(state.currentRoute), poi);
if (!opened) return;
poi = opened.poi;
draft = opened.draft;
}
state.groupDeletions = draft?.media?.deletions || [];
state.groupDeletePoint = Boolean(draft?.media?.deletePoint);
state.currentPoi = poi; state.currentPoi = poi;
fillPoiForm(poi); fillPoiForm(draft ? { ...poi, ...draft.values } : poi);
if (draft) {
state.pendingAudioFile = draft.media?.audio || null;
state.pendingImages = (draft.media?.images || []).map(file => ({ file, key: `pending-image-${ns.LocalRoutes.createPoiId()}`, previewUrl: objectUrlFor(file) }));
const hiddenImages = state.groupDeletions.map(item => item.path);
state.currentPoi = { ...poi, images: (poi.images || []).filter(image => !hiddenImages.includes(`pictures/${image.id}`)),
...(hiddenImages.includes('audio') ? { audioUrl: null } : {}) };
renderPoiMediaPreview();
updatePoiMediaStatus();
setStatus(ns.I18n.t(state.groupDeletePoint ? 'point-locks.delete-restored' : 'point-locks.restored'));
}
byId(ids.poiFormTitle).textContent = ns.I18n.t("editor.edit-station"); byId(ids.poiFormTitle).textContent = ns.I18n.t("editor.edit-station");
showPoiView(); showPoiView();
if (state.currentSource !== 'local') return; if (state.currentSource !== 'local') return true;
const requestGeneration = poiMediaGeneration; const requestGeneration = poiMediaGeneration;
const hydrated = await ns.LocalRoutes.getPoiWithMedia(routeId(state.currentRoute), poi.id, { requestPermission: true }); const hydrated = await ns.LocalRoutes.getPoiWithMedia(routeId(state.currentRoute), poi.id, { requestPermission: true });
if (requestGeneration !== poiMediaGeneration || state.currentPoi !== poi) { if (requestGeneration !== poiMediaGeneration || state.currentPoi !== poi) {
@@ -1278,16 +1323,62 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (state.pendingAudioFile) { if (state.pendingAudioFile) {
const method = existingAudioUrl(state.currentPoi) ? 'PUT' : 'POST'; const method = existingAudioUrl(state.currentPoi) ? 'PUT' : 'POST';
await uploadOne(`${basePath}/audio`, 'audio', state.pendingAudioFile, method); await uploadOne(`${basePath}/audio`, 'audio', state.pendingAudioFile, method);
if (state.currentSource === 'group') { state.pendingAudioFile = null; await rememberGroupPoint(poiFromForm(), currentPoiId); }
} }
for (const pending of state.pendingImages) { for (const pending of [...state.pendingImages]) {
await uploadOne(`${basePath}/pictures`, 'picture', pending.file); await uploadOne(`${basePath}/pictures`, 'picture', pending.file);
if (state.currentSource === 'group') {
state.pendingImages = state.pendingImages.filter(item => item !== pending);
releasePendingPreview(pending);
await rememberGroupPoint(poiFromForm(), currentPoiId);
}
}
}
async function rememberGroupPoint(values, identifier = byId(ids.poiId).value || 'new') {
return ns.GroupPoints.remember(state.currentRoute, identifier, values, {
audio: state.pendingAudioFile, images: state.pendingImages.map(item => item.file), deletions: state.groupDeletions, deletePoint: state.groupDeletePoint
});
}
async function prepareGroupPoint(values, currentPoiId) {
const draft = await rememberGroupPoint(values);
if (!currentPoiId) return true;
try {
const prepared = await ns.GroupPoints.prepare(draft);
if (prepared) {
state.groupDeletions = draft.media.deletions;
state.currentPoi = prepared.poi;
return true;
}
} catch { /* The complete intent was persisted before trying to acquire the point. */ }
setStatus(ns.I18n.t('point-locks.saved-device'));
return false;
}
async function saveGroupPointActions(currentRouteId, currentPoiId, saved, values) {
const savedPoiId = poiId(saved) || currentPoiId;
if (!currentPoiId) {
byId(ids.poiId).value = savedPoiId;
state.currentPoi = saved;
// Persist the acknowledged ID before any further network request.
await rememberGroupPoint(values, savedPoiId);
await ns.GroupPoints.remove(currentRouteId, 'new');
await ns.GroupPoints.acceptNew(currentRouteId, saved);
}
for (const deletion of [...state.groupDeletions]) {
await sendDelete(`/routes/${currentRouteId}/pois/${savedPoiId}/${deletion.path}`);
state.groupDeletions = state.groupDeletions.filter(item => item !== deletion);
await rememberGroupPoint(values, savedPoiId);
} }
} }
async function saveServerPoi(values) { async function saveServerPoi(values) {
if (state.currentSource === 'group' && state.groupDeletePoint) return deleteCurrentPoi();
const currentRouteId = routeId(state.currentRoute); const currentRouteId = routeId(state.currentRoute);
if (!currentRouteId) throw new Error(ns.I18n.t("editor.error.save-route-first")); if (!currentRouteId) throw new Error(ns.I18n.t("editor.error.save-route-first"));
const currentPoiId = byId(ids.poiId).value; const currentPoiId = byId(ids.poiId).value;
if (state.currentSource === 'group' && ns.GroupPoints && !await prepareGroupPoint(values, currentPoiId)) return;
const path = currentPoiId const path = currentPoiId
? `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}` ? `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`
: `/routes/${encodeURIComponent(currentRouteId)}/pois`; : `/routes/${encodeURIComponent(currentRouteId)}/pois`;
@@ -1295,16 +1386,34 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const payload = await sendJson(path, method, values); const payload = await sendJson(path, method, values);
const saved = payload.poi || payload; const saved = payload.poi || payload;
const savedPoiId = poiId(saved) || currentPoiId; const savedPoiId = poiId(saved) || currentPoiId;
if (state.currentSource === 'group' && ns.GroupPoints) await saveGroupPointActions(currentRouteId, currentPoiId, saved, values);
if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId); if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
if (state.currentSource === 'group' && ns.GroupPoints) await ns.GroupPoints.remove(currentRouteId, savedPoiId);
setStatus(ns.I18n.t("editor.station-saved")); setStatus(ns.I18n.t("editor.station-saved"));
await loadRoute({ id: currentRouteId, _source: 'server' }); await loadRoute({ id: currentRouteId, _source: state.currentSource });
} }
let pointSaveInProgress = false;
async function savePoi() { async function savePoi() {
if (pointSaveInProgress) return;
pointSaveInProgress = true;
byId(ids.dialog).inert = true;
try { await savePoiContents(); } finally { pointSaveInProgress = false; byId(ids.dialog).inert = false; }
}
async function savePoiContents() {
const values = poiFromForm(); const values = poiFromForm();
if (!values.title) throw new Error(ns.I18n.t("editor.error.station-name")); if (!values.title) throw new Error(ns.I18n.t("editor.error.station-name"));
if (state.currentSource === 'local') await saveLocalPoi(values); if (state.currentSource === 'local') await saveLocalPoi(values);
else await saveServerPoi(values); else {
try { await saveServerPoi(values); }
catch (error) {
if (state.currentSource !== 'group' || !ns.GroupPoints) throw error;
// Retain all unacknowledged files and intents, even when the lock changed during upload.
await rememberGroupPoint(values);
setStatus(ns.I18n.t('point-locks.saved-device'));
}
}
} }
function updateCurrentPoi(updatedPoi) { function updateCurrentPoi(updatedPoi) {
@@ -1364,13 +1473,15 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const updated = { ...state.currentPoi }; const updated = { ...state.currentPoi };
if (kind === 'audio') { if (kind === 'audio') {
await sendDelete(`${basePath}/audio`); if (state.currentSource === 'group') state.groupDeletions.push({ path: 'audio' });
else await sendDelete(`${basePath}/audio`);
updated.audio = null; updated.audio = null;
updated.audioUrl = null; updated.audioUrl = null;
updated.audio_path = null; updated.audio_path = null;
} else { } else {
if (!mediaId) throw new Error(ns.I18n.t("editor.error.remove-image")); if (!mediaId) throw new Error(ns.I18n.t("editor.error.remove-image"));
await sendDelete(`${basePath}/pictures/${encodeURIComponent(mediaId)}`); if (state.currentSource === 'group') state.groupDeletions.push({ path: `pictures/${encodeURIComponent(mediaId)}` });
else await sendDelete(`${basePath}/pictures/${encodeURIComponent(mediaId)}`);
updated.images = (state.currentPoi.images || []).filter(image => text(image.id) !== text(mediaId)); updated.images = (state.currentPoi.images || []).filter(image => text(image.id) !== text(mediaId));
} }
@@ -1378,7 +1489,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
state.selectedMediaKey = null; state.selectedMediaKey = null;
renderPoiMediaPreview(); renderPoiMediaPreview();
updatePoiMediaStatus(); updatePoiMediaStatus();
setStatus(kind === 'audio' ? ns.I18n.t("editor.audio.removed") : ns.I18n.t("editor.image-removed")); if (state.currentSource === 'group') {
await rememberGroupPoint(poiFromForm());
setStatus(ns.I18n.t('point-locks.deletion-staged'));
} else setStatus(kind === 'audio' ? ns.I18n.t("editor.audio.removed") : ns.I18n.t("editor.image-removed"));
} }
async function removeMedia(control) { async function removeMedia(control) {
@@ -1427,8 +1541,16 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return; return;
} }
await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`); if (state.currentSource === 'group' && ns.GroupPoints) {
await loadRoute({ id: currentRouteId, _source: 'server' }); state.groupDeletePoint = true;
const draft = await rememberGroupPoint(poiFromForm());
try {
if (!await ns.GroupPoints.prepare(draft)) { setStatus(ns.I18n.t('point-locks.saved-device')); return; }
await sendDelete(`/routes/${currentRouteId}/pois/${currentPoiId}`);
await ns.GroupPoints.remove(currentRouteId, currentPoiId);
} catch { setStatus(ns.I18n.t('point-locks.saved-device')); return; }
} else await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`);
await loadRoute({ id: currentRouteId, _source: state.currentSource });
setStatus(ns.I18n.t("editor.station-removed")); setStatus(ns.I18n.t("editor.station-removed"));
} }
@@ -1447,6 +1569,25 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
guarded(savePoiOrder); guarded(savePoiOrder);
} }
async function saveGroupPoiOrder(currentRouteId) {
let deferred = false;
for (const [index, poi] of state.pois.entries()) {
if (await ns.GroupPoints.read(currentRouteId, poi.id)) { deferred = true; continue; }
const values = { ...poi, sequence: index };
try {
const opened = await ns.GroupPoints.begin(currentRouteId, poi);
await ns.GroupPoints.remember(state.currentRoute, poi.id, { ...(opened?.poi || values), sequence: index }, { images: [], audio: null, deletions: [] });
if (!opened) { deferred = true; continue; }
await sendJson(`/routes/${currentRouteId}/pois/${poi.id}`, 'PUT', { sequence: index, sortOrder: index });
await ns.GroupPoints.remove(currentRouteId, poi.id);
} catch {
await ns.GroupPoints.remember(state.currentRoute, poi.id, values, { images: [], audio: null, deletions: [] });
deferred = true;
} finally { await ns.GroupPoints.release(); }
}
setStatus(ns.I18n.t(deferred ? 'point-locks.saved-device' : 'editor.order.saved'));
}
async function savePoiOrder() { async function savePoiOrder() {
const currentRouteId = routeId(state.currentRoute); const currentRouteId = routeId(state.currentRoute);
if (!currentRouteId) return; if (!currentRouteId) return;
@@ -1455,6 +1596,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
setStatus(ns.I18n.t("editor.station-order-saved")); setStatus(ns.I18n.t("editor.station-order-saved"));
return; return;
} }
if (state.currentSource === 'group' && ns.GroupPoints) return saveGroupPoiOrder(currentRouteId);
const jobs = state.pois.map((poi, index) => { const jobs = state.pois.map((poi, index) => {
const currentPoiId = poiId(poi); const currentPoiId = poiId(poi);
if (!currentPoiId) return null; if (!currentPoiId) return null;
@@ -1506,6 +1648,21 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const route = routeForItem(event.target); const route = routeForItem(event.target);
if (!route) return; if (!route) return;
if (action === 'share-route') {
if (shareInProgress) return;
shareInProgress = true;
try {
let sourceId = routeId(route);
if (routeSource(route) === 'local') {
if (!globalThis.confirm(ns.I18n.t('groups.upload-confirm'))) return;
await loadRoute(route);
await uploadLocalRoute();
sourceId = state.currentRoute.serverRouteId;
}
await ns.Groups.share(sourceId);
} finally { shareInProgress = false; }
return;
}
if (action === 'edit-route') { if (action === 'edit-route') {
await loadRoute(route); await loadRoute(route);
return; return;
@@ -1517,6 +1674,12 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function deletePoiFromList(poi) { async function deletePoiFromList(poi) {
if (!poi) return; if (!poi) return;
if (state.currentSource === 'group') {
if (!await editPoi(poi)) return;
state.groupDeletePoint = true;
await savePoi();
return;
}
state.currentPoi = poi; state.currentPoi = poi;
await deleteCurrentPoi(); await deleteCurrentPoi();
} }
@@ -1550,8 +1713,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function bind() { function bind() {
for (const eventName of ['cancel', 'close']) { for (const eventName of ['cancel', 'close']) {
byId(ids.dialog)?.addEventListener(eventName, () => { byId(ids.dialog)?.addEventListener(eventName, event => {
if (pointSaveInProgress && eventName === 'cancel') { event.preventDefault(); return; }
releasePoiPreview(); releasePoiPreview();
void ns.GroupPoints?.release();
ns.RouteRecorder?.endEditing(); ns.RouteRecorder?.endEditing();
}); });
} }
@@ -1567,7 +1732,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
byId(ids.poiAudio)?.addEventListener('change', handleAudioSelection); byId(ids.poiAudio)?.addEventListener('change', handleAudioSelection);
byId(ids.poiImages)?.addEventListener('change', () => guarded(handleImageSelection)); byId(ids.poiImages)?.addEventListener('change', () => guarded(handleImageSelection));
activate(byId(ids.poiMediaPreview), event => guarded(() => mediaPreviewAction(event))); activate(byId(ids.poiMediaPreview), event => guarded(() => mediaPreviewAction(event)));
activate(byId(ids.poiDelete), () => guarded(deleteCurrentPoi)); activate(byId(ids.poiDelete), () => {
if (state.currentSource === 'group') { state.groupDeletePoint = true; void guarded(savePoi); }
else void guarded(deleteCurrentPoi);
});
activate(byId(ids.poiCancel), showRouteView); activate(byId(ids.poiCancel), showRouteView);
activate(byId(ids.routeList), event => guarded(() => routeListAction(event))); activate(byId(ids.routeList), event => guarded(() => routeListAction(event)));
activate(byId(ids.poiList), event => guarded(() => poiListAction(event))); activate(byId(ids.poiList), event => guarded(() => poiListAction(event)));
@@ -1580,6 +1748,27 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
close: closeEditor, close: closeEditor,
open: openEditor, open: openEditor,
openNewLocalRoute, openNewLocalRoute,
openGroup: async route => {
await openEditor();
await loadRoute({ ...route, _source: 'group' });
},
openPendingPoint: async draft => {
await openEditor();
try { await loadRoute({ ...draft.route, _source: 'group' }); }
catch {
state.currentSource = 'group';
state.currentRoute = { ...draft.route, _source: 'group' };
state.pois = draft.basePoi ? [draft.basePoi] : [];
fillRouteForm(state.currentRoute); renderPoiList(); showRouteView();
}
if (draft.poiId === 'new') {
newPoi();
fillPoiForm(draft.values);
state.pendingAudioFile = draft.media?.audio || null;
state.pendingImages = (draft.media?.images || []).map(file => ({ file, key: `pending-image-${ns.LocalRoutes.createPoiId()}`, previewUrl: objectUrlFor(file) }));
renderPoiMediaPreview(); updatePoiMediaStatus();
} else await editPoi(draft.basePoi || { id: draft.poiId });
},
refresh: loadRoutes refresh: loadRoutes
}); });
} }
+173
View File
@@ -0,0 +1,173 @@
globalThis.Wegwichtel ??= {};
(function (ns) {
'use strict';
const t = (key, vars) => ns.I18n.t(`point-locks.${key}`, vars);
let database;
let current = null;
let generation = 0;
const key = (routeId, poiId) => `${routeId}:${poiId}`;
async function store(mode, operation) {
if (!database) database = new Promise((resolve, reject) => {
const request = indexedDB.open('wegwichtel.group-point-drafts.v1', 1);
request.onupgradeneeded = () => request.result.createObjectStore('drafts', { keyPath: 'key' });
request.onsuccess = () => resolve(request.result);
request.onerror = () => { database = null; reject(request.error); };
request.onblocked = () => { database = null; reject(new Error(t('storage-error'))); };
});
const db = await database;
return new Promise((resolve, reject) => {
const transaction = db.transaction('drafts', mode);
const request = operation(transaction.objectStore('drafts'));
transaction.oncomplete = () => resolve(request.result);
transaction.onabort = () => reject(transaction.error || new Error(t('storage-error')));
transaction.onerror = () => reject(transaction.error || new Error(t('storage-error')));
});
}
const read = (routeId, poiId) => store('readonly', target => target.get(key(routeId, poiId)));
const list = () => store('readonly', target => target.getAll());
async function remove(routeId, poiId) {
await store('readwrite', target => target.delete(key(routeId, poiId)));
document.dispatchEvent(new Event('wegwichtel:point-drafts-changed'));
}
async function remember(route, poiId, values, media) {
const previous = await read(route.id, poiId);
const draft = { key: key(route.id, poiId), route: { id: route.id, name: route.name, description: route.description, creator_id: route.creator_id },
poiId, values, media, baseVersion: previous?.baseVersion || current?.version || null,
baseMediaVersion: previous?.baseMediaVersion || current?.mediaVersion || null,
basePoi: previous?.basePoi || current?.poi || { ...values, id: poiId }, savedAt: new Date().toISOString() };
try { await store('readwrite', target => target.put(draft)); }
catch { throw new Error(t('storage-error')); }
document.dispatchEvent(new Event('wegwichtel:point-drafts-changed'));
return draft;
}
async function request(routeId, poiId, body) {
const response = await fetch(`${(ns.Config?.apiBase || '/api').replace(/\/$/, '')}/groups/routes/${routeId}/pois/${poiId}/lock`, {
method: 'POST', credentials: 'same-origin', cache: 'no-store',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
});
const payload = await response.json();
if (!response.ok) {
const error = new Error(t(response.status === 423 ? 'lost' : 'unavailable'));
error.status = response.status;
error.details = payload.details;
throw error;
}
return payload;
}
async function acquire(routeId, poiId, token = null) {
try { return await request(routeId, poiId, { token }); }
catch (error) {
if (error.details?.code !== 'point-locked') throw error;
if (!globalThis.confirm(t('takeover'))) return null;
// Compare-and-swap: a second takeover while the question was open needs a new confirmation.
return request(routeId, poiId, { replace: error.details.replace });
}
}
async function release() {
generation += 1;
const previous = current;
current = null;
if (!previous?.token) return;
try { await request(previous.routeId, previous.poiId, { release: true, token: previous.token }); }
catch { /* Another editor may own the point now; never remove their lock. */ }
}
async function begin(routeId, poi) {
await release();
const openedGeneration = generation;
const draft = await read(routeId, poi.id);
let result;
try { result = await acquire(routeId, poi.id); }
catch (error) {
if (openedGeneration !== generation) return null;
if (!draft) throw error;
if (!globalThis.confirm(t('offline-restore'))) return null;
current = { routeId, poiId: poi.id, token: null, version: draft.baseVersion, mediaVersion: draft.baseMediaVersion, poi: draft.basePoi };
return { poi: draft.basePoi, draft };
}
if (!result) return null;
if (openedGeneration !== generation) {
await request(routeId, poi.id, { release: true, token: result.token }).catch(() => {}); return null;
}
current = { ...result, routeId, poiId: poi.id };
if (draft && !globalThis.confirm(t('restore'))) { await release(); return null; }
// Keep the old baseline until the saved draft has been compared with the server.
if (draft) current = { ...current, version: draft.baseVersion, mediaVersion: draft.baseMediaVersion, poi: draft.basePoi };
return { poi: result.poi, draft };
}
function compare(draft, remote) {
return new Promise(resolve => {
const dialog = document.getElementById('point-compare-dialog');
const content = document.getElementById('point-compare-content');
const table = document.createElement('table');
const header = document.createElement('tr');
for (const label of ['field', 'before', 'mine', 'theirs']) {
const cell = document.createElement('th'); cell.textContent = t(label); header.appendChild(cell);
}
table.appendChild(header);
for (const field of ['title', 'description', 'lat', 'lon', 'triggerRadiusM', 'sequence']) {
const row = document.createElement('tr');
for (const value of [t(`fields.${field === 'triggerRadiusM' ? 'trigger-radius' : field}`), draft.basePoi?.[field], draft.values[field], remote.poi[field]]) {
const cell = document.createElement('td'); cell.textContent = String(value ?? '—'); row.appendChild(cell);
}
table.appendChild(row);
}
const summary = document.createElement('p');
const media = poi => t('media-count', { images: poi?.images?.length || 0, audio: poi?.audioUrl ? 1 : 0 });
const files = [...(draft.media?.images || []).map(file => file.name), draft.media?.audio?.name].filter(Boolean).join(', ');
summary.textContent = t('media-compare', { before: media(draft.basePoi), theirs: media(remote.poi), files: files || '—', removed: draft.media?.deletions?.length || 0 });
const finish = accepted => {
dialog.removeEventListener('close', cancel);
document.getElementById('point-compare-accept').removeEventListener('click', accept);
document.getElementById('point-compare-cancel').removeEventListener('click', cancel);
dialog.close(); resolve(accepted);
};
const accept = () => finish(true);
const cancel = () => finish(false);
document.getElementById('point-compare-accept').addEventListener('click', accept);
document.getElementById('point-compare-cancel').addEventListener('click', cancel);
dialog.addEventListener('close', cancel);
const fileStatus = document.createElement('p');
fileStatus.textContent = t(draft.baseMediaVersion === remote.mediaVersion ? 'files-unchanged' : 'files-changed');
const previews = document.createElement('div');
for (const image of remote.poi.images || []) {
const preview = document.createElement('img'); preview.src = image.url; preview.alt = image.caption || ''; previews.appendChild(preview);
}
if (remote.poi.audioUrl) {
const audio = document.createElement('audio'); audio.controls = true; audio.preload = 'none'; audio.src = remote.poi.audioUrl; previews.appendChild(audio);
}
const warning = document.createElement('p');
warning.textContent = draft.media?.deletePoint ? t('delete-warning') : '';
content.replaceChildren(table, summary, fileStatus, previews, warning);
dialog.showModal();
});
}
async function prepare(draft) {
const savingGeneration = generation;
const remote = await acquire(draft.route.id, draft.poiId, current?.token);
if (!remote) return false;
if (savingGeneration !== generation) {
await request(draft.route.id, draft.poiId, { release: true, token: remote.token }).catch(() => {}); return false;
}
current = { ...remote, routeId: draft.route.id, poiId: draft.poiId };
if (draft.baseVersion !== remote.version && !await compare(draft, remote)) {
await release(); return false;
}
// Deleting an already absent file is a no-op after an explicit content comparison.
draft.media.deletions = (draft.media.deletions || []).filter(item => item.path === 'audio'
? Boolean(remote.poi.audioUrl) : remote.poi.images.some(image => item.path === `pictures/${image.id}`));
if (savingGeneration !== generation) return false;
return remote;
}
function headersFor(path) {
const match = /^\/routes\/(\d+)\/pois\/(\d+)(?:\/|$)/.exec(path);
if (!match || !current || String(current.routeId) !== match[1] || String(current.poiId) !== match[2]) return {};
return { 'X-Poi-Lock': current.token || '', 'If-Poi-Match': current.version || '' };
}
function observe(response) {
const version = response.headers?.get('X-Poi-Version');
if (current && String(current.poiId) === response.headers?.get('X-Poi-Id') && version) current.version = version;
}
ns.GroupPoints = Object.freeze({ begin, release, remember, read, list, remove, prepare, headersFor, observe,
acceptNew: async (routeId, poi) => { current = { ...await acquire(routeId, poi.id), routeId, poiId: poi.id }; },
changed: () => document.dispatchEvent(new Event('wegwichtel:point-drafts-changed')) });
}(globalThis.Wegwichtel));
+298
View File
@@ -0,0 +1,298 @@
globalThis.Wegwichtel ??= {};
(function (ns) {
'use strict';
const ids = {
joinOpen: 'group-join-open', editorJoin: 'editor-group-join', list: 'editor-group-list',
dialog: 'group-dialog', close: 'group-close', status: 'group-status', wheels: 'group-wheels',
entry: 'group-entry', invitation: 'group-invitation', code: 'group-code', join: 'group-join',
edit: 'group-edit', expiry: 'group-expiry'
};
const codeLength = 8;
const wheelStep = 52;
const revisions = new Map();
let selection = [];
let currentGroup = null;
let pending = false;
let dialogGeneration = 0;
const element = id => document.getElementById(id);
const t = (key, variables) => ns.I18n.t(`groups.${key}`, variables);
function activate(button, action) {
button.addEventListener('pointerup', event => {
if (event.isPrimary === false || (event.button && event.button !== 0)) return;
event.preventDefault();
void action();
});
button.addEventListener('keydown', event => {
if (!['Enter', ' '].includes(event.key)) return;
event.preventDefault();
void action();
});
}
function button(label, action) {
const result = document.createElement('button');
result.type = 'button';
result.className = 'editor-secondary-button';
result.textContent = label;
activate(result, action);
return result;
}
function errorMessage(error) {
if (error.reason === 'group-limit') return t('error.limit');
if (error.reason === 'group-full') return t('error.full');
if (error.status === 423) return ns.I18n.t('point-locks.busy');
if (error.status === 429) return t('error.rate');
if (error.status === 404) return t('error.code');
if (error.status === 401 || error.status === 403) return t('error.access');
if (error.status === 409) return t('error.conflict');
return t('error.connection');
}
async function request(path, method = 'GET', body = null) {
const base = (ns.Config?.apiBase || '/api').replace(/\/$/, '');
const response = await fetch(`${base}/groups${path}`, {
method, credentials: 'same-origin', cache: 'no-store',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {})
});
if (!response.ok) {
const error = new Error(t('error.connection'));
error.status = response.status;
if (typeof response.json === 'function') {
try { error.reason = (await response.json()).details?.code; } catch { /* Use the translated generic error. */ }
}
throw error;
}
return response.json();
}
async function guarded(action) {
if (pending) return;
pending = true;
element(ids.status).textContent = '';
element(ids.join).disabled = true;
try { await action(); } catch (error) {
element(ids.status).textContent = errorMessage(error);
if (!element(ids.dialog).open) {
element(ids.entry).hidden = true;
element(ids.invitation).hidden = true;
element(ids.dialog).showModal();
}
}
finally { pending = false; element(ids.join).disabled = false; }
}
function picture(symbol) {
const image = document.createElement('img');
image.src = symbol.imageUrl;
image.alt = symbol.label;
image.width = 48;
image.height = 48;
image.draggable = false;
return image;
}
function makeWheel(position) {
const motifs = ns.GroupSymbols.list();
const column = document.createElement('div');
column.className = 'group-wheel';
const viewport = document.createElement('div');
viewport.className = 'group-wheel-viewport';
viewport.tabIndex = 0;
viewport.setAttribute('role', 'spinbutton');
viewport.setAttribute('aria-label', t('position', { number: position + 1 }));
viewport.setAttribute('aria-valuemin', '1');
viewport.setAttribute('aria-valuemax', String(motifs.length));
const paint = index => {
selection[position] = index;
viewport.setAttribute('aria-valuenow', String(index + 1));
viewport.setAttribute('aria-valuetext', motifs[index].label);
};
const choose = index => {
const next = (index + motifs.length) % motifs.length;
paint(next);
viewport.scrollTo({ top: next * wheelStep, behavior: globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
};
const up = button('↑', () => choose(selection[position] - 1));
up.setAttribute('aria-label', t('previous', { number: position + 1 }));
const down = button('↓', () => choose(selection[position] + 1));
down.setAttribute('aria-label', t('next', { number: position + 1 }));
for (const [index, motif] of motifs.entries()) {
const option = document.createElement('div');
option.className = 'group-wheel-option';
option.setAttribute('aria-hidden', 'true');
const image = picture(motif);
image.alt = '';
option.appendChild(image);
activate(option, () => choose(index));
viewport.appendChild(option);
}
viewport.addEventListener('scroll', () => paint(Math.max(0, Math.min(motifs.length - 1, Math.round(viewport.scrollTop / wheelStep)))));
viewport.addEventListener('keydown', event => {
const changes = { ArrowUp: -1, ArrowDown: 1 };
if (Object.hasOwn(changes, event.key)) {
event.preventDefault(); choose(selection[position] + changes[event.key]);
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault(); choose(event.key === 'Home' ? 0 : motifs.length - 1);
}
});
paint(0);
column.append(up, viewport, down);
return column;
}
function openEntry() {
dialogGeneration += 1;
selection = Array(codeLength).fill(0);
element(ids.status).textContent = '';
element(ids.entry).hidden = false;
element(ids.invitation).hidden = true;
const wheels = element(ids.wheels);
wheels.replaceChildren(...selection.map((_, index) => makeWheel(index)));
if (!element(ids.dialog).open) element(ids.dialog).showModal();
}
function showInvitation(payload) {
currentGroup = payload.group;
element(ids.status).textContent = '';
element(ids.entry).hidden = true;
element(ids.invitation).hidden = false;
const motifs = payload.code.map(value => ns.GroupSymbols.get(value));
element(ids.code).replaceChildren(...motifs.map(picture));
const time = new Date(payload.expiresAt * 1000).toLocaleTimeString(ns.I18n.language, { hour: '2-digit', minute: '2-digit' });
element(ids.expiry).textContent = t('expires', { time });
if (!element(ids.dialog).open) element(ids.dialog).showModal();
}
async function edit(group) {
element(ids.dialog).close();
await ns.EditorUi.openGroup(group.route);
}
async function renderPending() {
const list = element('editor-point-drafts');
if (!list || !ns.GroupPoints) return;
try {
const drafts = await ns.GroupPoints.list();
list.replaceChildren();
for (const draft of drafts) {
const item = document.createElement('li'); item.className = 'editor-route-item';
const name = document.createElement('span');
name.textContent = `${draft.route.name}: ${draft.values.title}`;
const actions = document.createElement('div'); actions.className = 'editor-icon-row';
actions.appendChild(button(ns.I18n.t('point-locks.open-draft'), () => guarded(() => ns.EditorUi.openPendingPoint(draft))));
actions.appendChild(button(ns.I18n.t('point-locks.discard'), () => guarded(async () => {
if (globalThis.confirm(ns.I18n.t('point-locks.discard-confirm'))) await ns.GroupPoints.remove(draft.route.id, draft.poiId);
})));
item.append(name, actions); list.appendChild(item);
}
} catch { /* Storage errors are reported when attempting to save; never claim a draft was saved. */ }
}
async function refresh() {
await renderPending();
try {
const payload = await request('');
const list = element(ids.list);
if (!list) return;
list.replaceChildren();
for (const group of payload.groups) {
const item = document.createElement('li');
item.className = 'editor-route-item';
const name = document.createElement('strong');
name.textContent = group.route.name;
const actions = document.createElement('div');
actions.className = 'editor-icon-row group-actions';
actions.appendChild(button(t('edit'), () => guarded(async () => { await edit(group); })));
actions.appendChild(button(t('reload'), () => guarded(async () => {
if (globalThis.confirm(t('reload-confirm'))) await edit(group);
})));
if (group.role === 'owner') {
actions.appendChild(button(t('new-code'), () => guarded(async () => {
if (!globalThis.confirm(t('new-code-confirm'))) return;
showInvitation(await request(`/${group.id}/code`, 'POST', {}));
await refresh();
})));
if (group.active) actions.appendChild(button(t('close-sharing'), () => guarded(async () => {
if (!globalThis.confirm(t('close-confirm'))) return;
await request(`/${group.id}`, 'DELETE');
await refresh();
})));
}
item.append(name, actions);
list.appendChild(item);
}
if (!payload.groups.length) {
const empty = document.createElement('li');
empty.textContent = t('none');
list.appendChild(empty);
}
} catch { /* Existing drafts and forms stay usable without a connection. */ }
}
async function share(routeId) {
try {
const payload = await request('', 'POST', { routeId, creator_id: ns.LocalRoutes.creatorId() });
const editor = document.getElementById('editor-dialog');
if (!editor || editor.open) showInvitation(payload);
await refresh();
} catch (error) { throw new Error(errorMessage(error)); }
}
function shareButton(action) {
const result = document.createElement('button');
result.type = 'button';
result.className = 'editor-icon-button group-pair';
result.dataset.editorAction = action;
result.title = t('share');
result.setAttribute('aria-label', t('share'));
for (const value of ['🐦', '🪨']) {
const image = picture(ns.GroupSymbols.get(value));
image.alt = '';
result.appendChild(image);
}
return result;
}
function headersFor(path) {
const id = /^\/routes\/(\d+)(?:\/|$)/.exec(path)?.[1];
return id && revisions.has(id) ? { 'If-Match': String(revisions.get(id)) } : {};
}
function observe(response) {
const routeId = response.headers?.get('X-Group-Route');
const revision = response.headers?.get('X-Group-Revision');
if (routeId && revision !== null && /^\d+$/.test(revision)) revisions.set(routeId, Number(revision));
}
function init() {
document.addEventListener('wegwichtel:point-drafts-changed', () => { void renderPending(); void ns.StorageUsage?.refresh(); });
element(ids.dialog).addEventListener('cancel', () => { dialogGeneration += 1; });
element(ids.dialog).addEventListener('close', () => { dialogGeneration += 1; });
for (const id of [ids.joinOpen, ids.editorJoin]) activate(element(id), openEntry);
activate(element(ids.close), () => element(ids.dialog).close());
activate(element(ids.join), () => guarded(async () => {
const generation = dialogGeneration;
const motifs = ns.GroupSymbols.list();
let payload;
try {
payload = await request('/join', 'POST', {
code: selection.map(index => motifs[index].unicode), creator_id: ns.LocalRoutes.creatorId()
});
} catch (error) {
if (!element(ids.dialog).open || generation !== dialogGeneration) return;
throw error;
}
if (!element(ids.dialog).open || generation !== dialogGeneration) return;
await edit(payload.group);
await refresh();
}));
activate(element(ids.edit), () => guarded(() => edit(currentGroup)));
}
ns.Groups = Object.freeze({ share, shareButton, refresh, headersFor, observe });
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
else init();
}(globalThis.Wegwichtel));
+72 -1
View File
@@ -6,6 +6,45 @@ globalThis.Wegwichtel ??= {};
static language = 'de'; static language = 'de';
static messages = Object.freeze( static messages = Object.freeze(
{ {
"point-locks": {
"busy": "Diese Station wird gerade gespeichert. Warte kurz und versuche es erneut.",
"pending-title": "Noch nicht gesendete Änderungen",
"pending-help": "Diese Änderungen bleiben auf diesem Gerät. Öffne sie später und tippe auf Speichern. Vor dem Senden prüft Wegwichtel, ob die Station inzwischen geändert wurde.",
"open-draft": "Änderungen öffnen",
"discard": "Verwerfen",
"discard-confirm": "Diese ungesendeten Änderungen und die dazu ausgewählten Dateien von diesem Gerät entfernen?",
"takeover": "Jemand bearbeitet diese Station bereits. Möchtest du die Bearbeitung übernehmen? Die andere Person kann dann nicht mehr speichern.",
"lost": "Jemand anderes bearbeitet diese Station. Sichere deine Änderungen auf diesem Gerät und versuche es später erneut.",
"unavailable": "Die Station kann gerade nicht geöffnet werden. Prüfe deine Verbindung und deinen Gruppenzugang.",
"storage-error": "Die Änderungen konnten nicht auf diesem Gerät gesichert werden. Lass dieses Fenster offen und schaffe Platz auf deinem Gerät.",
"offline-restore": "Die Station ist gerade nicht erreichbar. Möchtest du deine gesicherten Änderungen auf diesem Gerät öffnen?",
"restore": "Für diese Station liegen ungesendete Änderungen auf diesem Gerät. Möchtest du sie öffnen?",
"restored": "Deine ungesendeten Änderungen sind geöffnet. Tippe auf Speichern, um sie zu prüfen und zu senden.",
"saved-device": "Die Änderungen sind auf diesem Gerät gesichert und noch nicht vollständig gesendet. Öffne sie später unter „Noch nicht gesendete Änderungen“ oder tippe erneut auf Speichern.",
"deletion-staged": "Die Entfernung ist vorgemerkt. Tippe auf Speichern, um sie zu senden.",
"delete-restored": "Die Entfernung dieser Station ist vorgemerkt. Speichern prüft den aktuellen Stand und sendet die Entfernung.",
"compare-title": "Die Station wurde geändert",
"compare-help": "Vergleiche deine Änderungen mit dem neuen Stand. Wenn du deine Änderungen sendest, ersetzen deine Angaben den aktuellen Stand. Prüfe auch Bilder und Tonaufnahmen. Bei gleicher Anzahl können andere Dateien dahinterstecken.",
"field": "Angabe",
"before": "Beim Öffnen",
"mine": "Deine Änderungen",
"theirs": "Jetzt online",
"keep-device": "Auf diesem Gerät behalten",
"use-mine": "Meine Änderungen senden",
"media-count": "{images} Bilder, {audio} Tonaufnahmen",
"media-compare": "Beim Öffnen: {before}. Jetzt online: {theirs}. Von dir hinzugefügt: {files}. Vorgemerkte Entfernungen: {removed}.",
"files-changed": "Bilder oder Tonaufnahmen wurden geändert. Prüfe den aktuellen Stand unten.",
"files-unchanged": "Die vorhandenen Bild- und Tondateien sind unverändert.",
"delete-warning": "Du hast die Entfernung der gesamten Station vorgemerkt.",
"fields": {
"title": "Name",
"description": "Beschreibung",
"lat": "Breitengrad",
"lon": "Längengrad",
"trigger-radius": "Abstand zum Öffnen",
"sequence": "Reihenfolge"
}
},
"app": { "app": {
"no-tracking": "Hier gibt es keine Werbung. Wir verfolgen nicht, welche anderen Seiten du besuchst.", "no-tracking": "Hier gibt es keine Werbung. Wir verfolgen nicht, welche anderen Seiten du besuchst.",
"resume-hint": "Du kannst dort weitermachen, wo du aufgehört hast.", "resume-hint": "Du kannst dort weitermachen, wo du aufgehört hast.",
@@ -94,6 +133,38 @@ globalThis.Wegwichtel ??= {};
"cleanup-progress": "Alten Fortschritt löschen ({value1} / {value2})", "cleanup-progress": "Alten Fortschritt löschen ({value1} / {value2})",
"ready": "Alles ist bereit." "ready": "Alles ist bereit."
}, },
"groups": {
"title": "Gemeinsame Entwürfe",
"upload-confirm": "Dein Entwurf mit Bildern und Ton wird dafür an die Website gesendet und als gemeinsamer Entwurf kopiert. Fortfahren?",
"share": "Gemeinsam bearbeiten",
"join-open": "Gruppencode eingeben",
"online": "Gemeinsam bearbeiten braucht eine Internetverbindung.",
"wheel-help": "Schiebe die Wichtel nach oben oder unten. Die Wichtel in der Mitte ergeben den Code. Lies von links nach rechts, zuerst die obere Reihe.",
"code-label": "Gruppencode",
"join": "Mitmachen",
"invite-help": "Zeige diesen Code den anderen. Damit können sie deinen gemeinsamen Entwurf öffnen und bearbeiten. Das Original bleibt erhalten.",
"edit": "Entwurf bearbeiten",
"reload": "Änderungen holen",
"reload-confirm": "Ungespeicherte Eingaben werden ersetzt. Möchtest du den aktuellen gemeinsamen Stand holen?",
"new-code": "Neuen Code anzeigen",
"new-code-confirm": "Der bisherige Code funktioniert danach nicht mehr. Bereits beigetretene Personen bleiben dabei. Neuen Code erstellen?",
"close-sharing": "Gruppe schließen",
"close-confirm": "Die anderen können diesen Entwurf danach nicht mehr öffnen oder bearbeiten. Du behältst den Zugriff. Gruppe schließen?",
"expires": "Der Code gilt bis {time}.",
"position": "Wichtel {number}",
"previous": "Vorheriger Wichtel an Stelle {number}",
"next": "Nächster Wichtel an Stelle {number}",
"none": "Du bist noch in keiner Gruppe.",
"error": {
"limit": "Schließe erst eine deiner Gruppen, bevor du eine weitere öffnest.",
"full": "Diese Gruppe ist bereits voll. Bitte um einen Code für eine andere Gruppe.",
"rate": "Bitte warte eine Minute und versuche es dann erneut.",
"code": "Der Code stimmt nicht oder ist abgelaufen. Prüfe die Wichtel noch einmal.",
"access": "Diese Gruppe ist für dich nicht geöffnet. Bitte die Person, die sie geöffnet hat, um einen neuen Code.",
"conflict": "Jemand hat den Entwurf inzwischen geändert. Deine Eingaben bleiben hier stehen. Hole die Änderungen, bevor du weiter speicherst.",
"connection": "Das hat nicht geklappt. Prüfe deine Internetverbindung und versuche es erneut."
}
},
"editor": { "editor": {
"error": { "error": {
"add-station": "Zu dieser Strecke kann gerade keine weitere Station hinzugefügt werden.", "add-station": "Zu dieser Strecke kann gerade keine weitere Station hinzugefügt werden.",
@@ -436,7 +507,7 @@ globalThis.Wegwichtel ??= {};
"optional": "Du kannst diese Erlaubnisse ablehnen. Dann fehlen die jeweiligen Funktionen. Strecken und Stationen kannst du weiterhin selbst auswählen." "optional": "Du kannst diese Erlaubnisse ablehnen. Dann fehlen die jeweiligen Funktionen. Strecken und Stationen kannst du weiterhin selbst auswählen."
}, },
"providers-title": "Werbung und andere Anbieter", "providers-title": "Werbung und andere Anbieter",
"providers-data": "Wegwichtel zeigt keine Werbung und verfolgt nicht, welche anderen Seiten du besuchst. Die App lädt ihre eigenen Programme und Bilder von Wegwichtel. Es werden keine Cookies verwendet." "providers-data": "Wegwichtel zeigt keine Werbung und verfolgt nicht, welche anderen Seiten du besuchst. Die App lädt ihre eigenen Programme und Bilder von Wegwichtel. Für gemeinsame Entwürfe merkt sich dein Internetbrowser deinen Zugang für höchstens 30 Tage mit einem Cookie."
}, },
"media": { "media": {
"audio": { "audio": {
+72 -1
View File
@@ -6,6 +6,45 @@ globalThis.Wegwichtel ??= {};
static language = 'en'; static language = 'en';
static messages = Object.freeze( static messages = Object.freeze(
{ {
"point-locks": {
"busy": "This stop is being saved. Wait a moment and try again.",
"pending-title": "Changes waiting to be sent",
"pending-help": "These changes stay on this device. Open them later and choose Save. Before sending, Wegwichtel checks whether the stop has changed.",
"open-draft": "Open changes",
"discard": "Discard",
"discard-confirm": "Remove these unsent changes and the selected files from this device?",
"takeover": "Someone is already editing this stop. Do you want to take over? They will no longer be able to save.",
"lost": "Someone else is editing this stop. Keep your changes on this device and try again later.",
"unavailable": "This stop cannot be opened right now. Check your connection and group access.",
"storage-error": "Your changes could not be saved on this device. Keep this window open and free up some space.",
"offline-restore": "This stop cannot be reached right now. Would you like to open the changes saved on this device?",
"restore": "This device has unsent changes for this stop. Would you like to open them?",
"restored": "Your unsent changes are open. Choose Save to check and send them.",
"saved-device": "Your changes are saved on this device and have not all been sent. Open them later under “Changes waiting to be sent”, or choose Save again.",
"deletion-staged": "The removal is ready. Choose Save to send it.",
"delete-restored": "This stop is marked for removal. Save checks the latest version and sends the removal.",
"compare-title": "This stop has changed",
"compare-help": "Compare your changes with the latest version. Sending your changes replaces the current details with yours. Check pictures and recordings too: the same number can contain different files.",
"field": "Detail",
"before": "When opened",
"mine": "Your changes",
"theirs": "Online now",
"keep-device": "Keep on this device",
"use-mine": "Send my changes",
"media-count": "{images} pictures, {audio} recordings",
"media-compare": "When opened: {before}. Online now: {theirs}. Added by you: {files}. Planned removals: {removed}.",
"files-changed": "Pictures or recordings have changed. Check the current files below.",
"files-unchanged": "The existing picture and audio files are unchanged.",
"delete-warning": "You have marked the entire stop for removal.",
"fields": {
"title": "Name",
"description": "Description",
"lat": "Latitude",
"lon": "Longitude",
"trigger-radius": "Opening distance",
"sequence": "Order"
}
},
"app": { "app": {
"no-tracking": "There are no adverts here. We do not track which other websites you visit.", "no-tracking": "There are no adverts here. We do not track which other websites you visit.",
"resume-hint": "You can carry on where you left off.", "resume-hint": "You can carry on where you left off.",
@@ -94,6 +133,38 @@ globalThis.Wegwichtel ??= {};
"cleanup-progress": "Removing old progress ({value1} / {value2})", "cleanup-progress": "Removing old progress ({value1} / {value2})",
"ready": "Everything is ready." "ready": "Everything is ready."
}, },
"groups": {
"title": "Shared drafts",
"upload-confirm": "Your draft, including pictures and recordings, will be sent to the website and copied into a shared draft. Continue?",
"share": "Edit together",
"join-open": "Enter group code",
"online": "You need an internet connection to edit together.",
"wheel-help": "Slide the gnomes up or down. The gnomes in the middle make the code. Read from left to right, starting with the top row.",
"code-label": "Group code",
"join": "Join in",
"invite-help": "Show this code to the others so they can open and edit your shared draft. The original stays as it is.",
"edit": "Edit draft",
"reload": "Get changes",
"reload-confirm": "Your unsaved changes will be replaced. Would you like to get the latest shared version?",
"new-code": "Show a new code",
"new-code-confirm": "The old code will stop working. People who have already joined will keep their access. Create a new code?",
"close-sharing": "Close group",
"close-confirm": "The others will no longer be able to open or edit this draft. You will keep your access. Close the group?",
"expires": "This code works until {time}.",
"position": "Gnome {number}",
"previous": "Previous gnome in position {number}",
"next": "Next gnome in position {number}",
"none": "You have not joined a group yet.",
"error": {
"limit": "Close one of your groups before opening another.",
"full": "This group is full. Ask for a code for another group.",
"rate": "Please wait a minute and try again.",
"code": "The code is wrong or has expired. Check the gnomes again.",
"access": "This group is not open to you. Ask the person who opened it for a new code.",
"conflict": "Someone else has changed the draft. Your entries are still here. Get the changes before saving again.",
"connection": "That did not work. Check your internet connection and try again."
}
},
"editor": { "editor": {
"error": { "error": {
"add-station": "Another stop cannot be added to this trail right now.", "add-station": "Another stop cannot be added to this trail right now.",
@@ -436,7 +507,7 @@ globalThis.Wegwichtel ??= {};
"optional": "You can refuse these permissions. The matching features will then be unavailable. You can still choose trails and stops yourself." "optional": "You can refuse these permissions. The matching features will then be unavailable. You can still choose trails and stops yourself."
}, },
"providers-title": "Adverts and other providers", "providers-title": "Adverts and other providers",
"providers-data": "Wegwichtel does not show adverts or track which other websites you visit. The app loads its own programs and pictures from Wegwichtel. No cookies are used." "providers-data": "Wegwichtel does not show adverts or track which other websites you visit. The app loads its own programs and pictures from Wegwichtel. For shared drafts, a cookie remembers your access for up to 30 days."
}, },
"media": { "media": {
"audio": { "audio": {
+22 -9
View File
@@ -1,4 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { createGroupsRouter } from './groups.js';
import { groupRouteGuard } from '../services/collaboration.js';
import { removeUpload } from '../services/storage.js';
import { inspectUpload, upload } from '../middleware/upload.js'; import { inspectUpload, upload } from '../middleware/upload.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { UPLOAD_COPYRIGHT_HEADERS } from '../services/copyright.js'; import { UPLOAD_COPYRIGHT_HEADERS } from '../services/copyright.js';
@@ -32,6 +35,13 @@ function sendFileResource(res, file, contentType = null, headers = {}) {
}); });
} }
function validatePointWrite(req, res, next) {
// Recheck after asynchronous uploads: a disconnected client may have lost its lock.
try { req.validatePointWrite?.(); }
catch (error) { removeUpload(req.file); throw error; }
next();
}
export function createApiRouter(db) { export function createApiRouter(db) {
const api = Router(); const api = Router();
const requireWritableRoute = paramName => (req, res, next) => { const requireWritableRoute = paramName => (req, res, next) => {
@@ -50,6 +60,9 @@ export function createApiRouter(db) {
next(); next();
}); });
api.use('/groups', createGroupsRouter(db));
api.use(groupRouteGuard(db));
api.get('/health', (req, res) => { api.get('/health', (req, res) => {
const sqliteVersion = db.prepare('SELECT sqlite_version() AS version').get().version; const sqliteVersion = db.prepare('SELECT sqlite_version() AS version').get().version;
res.json({ res.json({
@@ -102,14 +115,14 @@ export function createApiRouter(db) {
res.json(appendRoute(db, Number(req.params.id), req.file)); res.json(appendRoute(db, Number(req.params.id), req.file));
}); });
api.post('/routes/:id/pois', requireWritableRoute('id'), upload.none(), (req, res) => { api.post('/routes/:id/pois', requireWritableRoute('id'), upload.none(), validatePointWrite, (req, res) => {
const poi = createPoi(db, Number(req.params.id), req.body); const poi = createPoi(db, Number(req.params.id), req.body);
res.status(201) res.status(201)
.location(`/api/routes/${req.params.id}/pois/${poi.id}`) .location(`/api/routes/${req.params.id}/pois/${poi.id}`)
.json(poi); .json(poi);
}); });
api.put('/routes/:routeId/pois/:poiId', requireWritableRoute('routeId'), upload.none(), (req, res) => { api.put('/routes/:routeId/pois/:poiId', requireWritableRoute('routeId'), upload.none(), validatePointWrite, (req, res) => {
res.json(updatePoi( res.json(updatePoi(
db, db,
Number(req.params.routeId), Number(req.params.routeId),
@@ -118,7 +131,7 @@ export function createApiRouter(db) {
)); ));
}); });
api.delete('/routes/:routeId/pois/:poiId', requireRouteDeleteAllowed('routeId'), (req, res) => { api.delete('/routes/:routeId/pois/:poiId', requireRouteDeleteAllowed('routeId'), validatePointWrite, (req, res) => {
res.json(deletePoi(db, Number(req.params.routeId), Number(req.params.poiId))); res.json(deletePoi(db, Number(req.params.routeId), Number(req.params.poiId)));
}); });
@@ -138,7 +151,7 @@ export function createApiRouter(db) {
sendFileResource(res, file, null, UPLOAD_COPYRIGHT_HEADERS); sendFileResource(res, file, null, UPLOAD_COPYRIGHT_HEADERS);
}); });
api.post('/routes/:routeId/pois/:poiId/pictures', requireWritableRoute('routeId'), upload.single('picture'), inspectUpload('picture', 'picture', { allowJson: true }), (req, res) => { api.post('/routes/:routeId/pois/:poiId/pictures', requireWritableRoute('routeId'), upload.single('picture'), inspectUpload('picture', 'picture', { allowJson: true }), validatePointWrite, (req, res) => {
const routeId = Number(req.params.routeId); const routeId = Number(req.params.routeId);
const poiId = Number(req.params.poiId); const poiId = Number(req.params.poiId);
const picture = createPicture(db, routeId, poiId, req.body, req.file); const picture = createPicture(db, routeId, poiId, req.body, req.file);
@@ -147,7 +160,7 @@ export function createApiRouter(db) {
.json(picture); .json(picture);
}); });
api.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', requireWritableRoute('routeId'), upload.single('picture'), inspectUpload('picture', 'picture', { allowJson: true }), (req, res) => { api.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', requireWritableRoute('routeId'), upload.single('picture'), inspectUpload('picture', 'picture', { allowJson: true }), validatePointWrite, (req, res) => {
res.json(updatePicture( res.json(updatePicture(
db, db,
Number(req.params.routeId), Number(req.params.routeId),
@@ -158,7 +171,7 @@ export function createApiRouter(db) {
)); ));
}); });
api.delete('/routes/:routeId/pois/:poiId/pictures/:pictureId', requireRouteDeleteAllowed('routeId'), (req, res) => { api.delete('/routes/:routeId/pois/:poiId/pictures/:pictureId', requireRouteDeleteAllowed('routeId'), validatePointWrite, (req, res) => {
res.json(deletePicture( res.json(deletePicture(
db, db,
Number(req.params.routeId), Number(req.params.routeId),
@@ -177,7 +190,7 @@ export function createApiRouter(db) {
sendFileResource(res, getAudioFile(db, routeId, poiId)); sendFileResource(res, getAudioFile(db, routeId, poiId));
}); });
api.post('/routes/:routeId/pois/:poiId/audio', requireWritableRoute('routeId'), upload.single('audio'), inspectUpload('audio', 'audio', { allowJson: true }), (req, res) => { api.post('/routes/:routeId/pois/:poiId/audio', requireWritableRoute('routeId'), upload.single('audio'), inspectUpload('audio', 'audio', { allowJson: true }), validatePointWrite, (req, res) => {
const routeId = Number(req.params.routeId); const routeId = Number(req.params.routeId);
const poiId = Number(req.params.poiId); const poiId = Number(req.params.poiId);
const audio = createAudio(db, routeId, poiId, req.file); const audio = createAudio(db, routeId, poiId, req.file);
@@ -186,11 +199,11 @@ export function createApiRouter(db) {
.json(audio); .json(audio);
}); });
api.put('/routes/:routeId/pois/:poiId/audio', requireWritableRoute('routeId'), upload.single('audio'), inspectUpload('audio', 'audio', { allowJson: true }), (req, res) => { api.put('/routes/:routeId/pois/:poiId/audio', requireWritableRoute('routeId'), upload.single('audio'), inspectUpload('audio', 'audio', { allowJson: true }), validatePointWrite, (req, res) => {
res.json(updateAudio(db, Number(req.params.routeId), Number(req.params.poiId), req.file)); res.json(updateAudio(db, Number(req.params.routeId), Number(req.params.poiId), req.file));
}); });
api.delete('/routes/:routeId/pois/:poiId/audio', requireRouteDeleteAllowed('routeId'), (req, res) => { api.delete('/routes/:routeId/pois/:poiId/audio', requireRouteDeleteAllowed('routeId'), validatePointWrite, (req, res) => {
res.json(deleteAudio(db, Number(req.params.routeId), Number(req.params.poiId))); res.json(deleteAudio(db, Number(req.params.routeId), Number(req.params.poiId)));
}); });
+31
View File
@@ -0,0 +1,31 @@
import { Router } from 'express';
import { createGroup, joinGroup, listGroups, renewCode, closeGroup, verifyOrigin, session, member } from '../services/collaboration.js';
import { acquirePoint, releasePoint } from '../services/point-locks.js';
import { HttpError } from '../middleware/errors.js';
export function createGroupsRouter(db) {
const router = Router();
router.use((req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
if (!['GET', 'HEAD'].includes(req.method)) verifyOrigin(req);
next();
});
router.post('/routes/:routeId/pois/:poiId/lock', (req, res) => {
const group = db.prepare('SELECT * FROM collaboration_groups WHERE route_id = ?').get(Number(req.params.routeId));
if (!group) throw new HttpError(404, 'Gruppe nicht gefunden.');
member(db, req, group);
const account = session(db, req);
if (req.body?.release) {
releasePoint(group, account, req.params.poiId, req.body.token);
return res.json({ released: true });
}
return res.json(acquirePoint(db, group, account, req.params.poiId, req.body));
});
router.get('/', (req, res) => res.json({ groups: listGroups(db, req) }));
router.post('/', (req, res) => res.status(201).json(createGroup(db, req, res)));
router.post('/join', (req, res) => res.json({ group: joinGroup(db, req, res) }));
router.post('/:id/code', (req, res) => res.json(renewCode(db, req, req.params.id)));
router.delete('/:id', (req, res) => res.json(closeGroup(db, req, req.params.id)));
return router;
}
+256
View File
@@ -0,0 +1,256 @@
import crypto from 'node:crypto';
import { guardPoint, clearPointLocks } from './point-locks.js';
import fs from 'node:fs';
import path from 'node:path';
import { config } from '../config.js';
import { transaction } from '../database.js';
import { HttpError } from '../middleware/errors.js';
import { createRoute, getRoute, getRouteGpxFile, createPoi } from './routes-service.js';
import { routeDirectory, relativeStoragePath, resolveStoredFile } from './storage.js';
export const GROUP_SYMBOLS = Object.freeze(['🪵', '🪨', '🏮', '🌼', '🚬', '🐦', '🍄', '🍓', '🎃']);
export const GROUP_CODE_LENGTH = 8;
export const SESSION_COOKIE = 'wegwichtel_group_session';
const SESSION_SECONDS = 30 * 24 * 60 * 60;
const CODE_SECONDS = 60 * 60;
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
const now = () => Math.floor(Date.now() / 1000);
const joinFailures = new Map();
function cookieToken(req) {
const entry = String(req.headers.cookie || '').split(';').map(value => value.trim())
.find(value => value.startsWith(`${SESSION_COOKIE}=`));
const token = entry?.slice(SESSION_COOKIE.length + 1);
return /^[a-f0-9]{64}$/.test(token || '') ? token : null;
}
export function session(db, req, res = null, creatorId = null) {
const token = cookieToken(req);
const existing = token && db.prepare('SELECT * FROM collaboration_sessions WHERE token_hash = ? AND expires_at > ?')
.get(hash(token), now());
if (existing) return existing;
if (!res) throw new HttpError(401, 'Bitte gib den Gruppencode erneut ein.');
if (!/^[a-f0-9]{64}$/.test(creatorId || '')) throw new HttpError(400, 'Die Gerätekennung fehlt.');
const secret = crypto.randomBytes(32).toString('hex');
const tokenHash = hash(secret);
db.prepare('DELETE FROM collaboration_sessions WHERE expires_at <= ?').run(now());
db.prepare('INSERT INTO collaboration_sessions (token_hash, creator_id, expires_at) VALUES (?, ?, ?)')
.run(tokenHash, creatorId, now() + SESSION_SECONDS);
res.cookie(SESSION_COOKIE, secret, {
httpOnly: true, sameSite: 'strict', path: '/api', maxAge: SESSION_SECONDS * 1000,
secure: req.secure || req.headers['x-forwarded-proto'] === 'https'
});
return { token_hash: tokenHash, creator_id: creatorId };
}
export function member(db, req, group) {
const account = session(db, req);
const result = db.prepare('SELECT role FROM collaboration_members WHERE group_id = ? AND session_hash = ?')
.get(group.id, account.token_hash);
if (!result || (!group.active && result.role !== 'owner')) throw new HttpError(403, 'Diese Gruppe ist für dich nicht geöffnet.');
return result;
}
export function groupById(db, id) {
const group = db.prepare('SELECT * FROM collaboration_groups WHERE id = ?').get(id);
if (!group) throw new HttpError(404, 'Gruppe nicht gefunden.');
return group;
}
export function owner(db, req, id) {
const group = groupById(db, id);
if (member(db, req, group).role !== 'owner') throw new HttpError(403, 'Nur die Person, die die Gruppe geöffnet hat, kann das tun.');
return group;
}
function issueCode(db, groupId) {
for (let attempt = 0; attempt < 10; attempt += 1) {
const code = Array.from({ length: GROUP_CODE_LENGTH }, () => GROUP_SYMBOLS[crypto.randomInt(GROUP_SYMBOLS.length)]);
const digest = hash(code.join(''));
if (db.prepare('SELECT 1 FROM collaboration_groups WHERE code_hash = ?').get(digest)) continue;
const expiresAt = now() + CODE_SECONDS;
db.prepare('UPDATE collaboration_groups SET code_hash = ?, code_expires_at = ?, active = 1 WHERE id = ?')
.run(digest, expiresAt, groupId);
return { code, expiresAt };
}
throw new HttpError(503, 'Bitte versuche es gleich noch einmal.');
}
function copyFile(relative, routeId, folder) {
const destination = path.join(routeDirectory(routeId), folder, `${crypto.randomUUID()}${path.extname(relative)}`);
fs.copyFileSync(resolveStoredFile(relative), destination);
return relativeStoragePath(destination);
}
function copyStations(db, sourceRouteId, targetRouteId) {
const pois = db.prepare('SELECT * FROM pois WHERE route_id = ? ORDER BY sequence, id').all(sourceRouteId);
for (const poi of pois) {
const copied = createPoi(db, targetRouteId, {
title: poi.title, description: poi.description, lat: poi.lat, lon: poi.lon,
sequence: poi.sequence, triggerRadiusM: poi.trigger_radius_m
});
if (poi.audio_path) db.prepare('UPDATE pois SET audio_path = ? WHERE id = ?')
.run(copyFile(poi.audio_path, targetRouteId, 'audio'), copied.id);
for (const image of db.prepare('SELECT * FROM poi_images WHERE poi_id = ? ORDER BY sequence, id').all(poi.id)) {
db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)')
.run(copied.id, copyFile(image.path, targetRouteId, 'images'), image.caption, image.sequence);
}
}
}
function assertGroupQuota(db, sessionHash) {
const owned = db.prepare("SELECT COUNT(*) AS total FROM collaboration_members m JOIN collaboration_groups g ON g.id = m.group_id WHERE m.session_hash = ? AND m.role = 'owner' AND g.active = 1").get(sessionHash).total;
if (owned >= 12) throw new HttpError(409, 'Schließe erst eine deiner Gruppen, bevor du eine weitere öffnest.', { code: 'group-limit' });
}
export function createGroup(db, req, res) {
const account = session(db, req, res, req.body?.creator_id);
assertGroupQuota(db, account.token_hash);
const source = getRoute(db, Number(req.body?.routeId));
const previousGroup = db.prepare('SELECT * FROM collaboration_groups WHERE route_id = ?').get(source.id);
if (previousGroup && !source.activatedAt) member(db, req, previousGroup);
if (!source.activatedAt && source.creator_id !== account.creator_id) {
throw new HttpError(403, 'Diese Strecke ist noch nicht für alle freigegeben.');
}
const upload = { path: path.join(config.storageDir, 'uploads', `${crypto.randomUUID()}.gpx`) };
fs.copyFileSync(getRouteGpxFile(db, source.id).absolutePath, upload.path);
let route;
try {
route = createRoute(db, { name: source.name, description: source.description,
schoolName: source.schoolName, creator_id: account.creator_id }, upload);
copyStations(db, source.id, route.id);
const id = crypto.randomBytes(16).toString('hex');
transaction(db, () => {
db.prepare(`INSERT INTO collaboration_groups (id, route_id, source_route_id, owner_creator_id, code_expires_at)
VALUES (?, ?, ?, ?, 0)`).run(id, route.id, source.id, account.creator_id);
db.prepare("INSERT INTO collaboration_members (group_id, session_hash, role) VALUES (?, ?, 'owner')")
.run(id, account.token_hash);
});
const invitation = issueCode(db, id);
return { group: groupSummary(db, groupById(db, id), 'owner'), ...invitation };
} catch (error) {
if (route) {
db.prepare('DELETE FROM collaboration_members WHERE group_id IN (SELECT id FROM collaboration_groups WHERE route_id = ?)').run(route.id);
db.prepare('DELETE FROM collaboration_groups WHERE route_id = ?').run(route.id);
db.prepare('DELETE FROM poi_images WHERE poi_id IN (SELECT id FROM pois WHERE route_id = ?)').run(route.id);
db.prepare('DELETE FROM pois WHERE route_id = ?').run(route.id);
db.prepare('DELETE FROM routes WHERE id = ?').run(route.id);
fs.rmSync(routeDirectory(route.id), { recursive: true, force: true });
}
fs.rmSync(upload.path, { force: true });
throw error;
}
}
export function groupSummary(db, group, role) {
return { id: group.id, route: getRoute(db, group.route_id), role,
active: Boolean(group.active), revision: group.revision, codeExpiresAt: group.code_expires_at };
}
export function listGroups(db, req) {
let account;
try { account = session(db, req); } catch (error) {
if (error.status === 401) return [];
throw error;
}
const rows = db.prepare(`SELECT g.*, m.role FROM collaboration_groups g
JOIN collaboration_members m ON m.group_id = g.id JOIN routes r ON r.id = g.route_id
WHERE m.session_hash = ? AND r.status = 'active' AND (g.active = 1 OR m.role = 'owner')`).all(account.token_hash);
return rows.map(group => groupSummary(db, group, group.role));
}
function failureKey(req) {
const localProxy = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket?.remoteAddress);
return hash(String(localProxy && req.headers['x-real-ip'] || req.socket?.remoteAddress || 'unknown'));
}
function invitationCode(body) {
const input = Array.isArray(body?.code) && body.code.length === GROUP_CODE_LENGTH ? body.code : [];
const code = input.map(value => typeof value === 'string' ? value.normalize('NFC').replaceAll(/[\uFE0E\uFE0F]/g, '') : '');
return code.length === GROUP_CODE_LENGTH && code.every(value => GROUP_SYMBOLS.includes(value)) ? code : null;
}
function failedJoin(key, attempts) {
if (joinFailures.size >= 2048) joinFailures.delete(joinFailures.keys().next().value);
joinFailures.set(key, { count: attempts?.until > now() ? attempts.count + 1 : 1, until: now() + 60 });
throw new HttpError(404, 'Der Code stimmt nicht oder ist abgelaufen.');
}
export function joinGroup(db, req, res) {
const key = failureKey(req);
const attempts = joinFailures.get(key);
if (attempts?.until > now() && attempts.count >= 20) throw new HttpError(429, 'Bitte warte eine Minute und versuche es dann erneut.');
const code = invitationCode(req.body);
const group = code && db.prepare(`SELECT g.* FROM collaboration_groups g JOIN routes r ON r.id = g.route_id
WHERE g.code_hash = ? AND g.code_expires_at > ? AND g.active = 1 AND r.status = 'active'`).get(hash(code.join('')), now());
if (!group) failedJoin(key, attempts);
const account = session(db, req, res, req.body?.creator_id);
const count = db.prepare('SELECT COUNT(*) AS total FROM collaboration_members WHERE group_id = ?').get(group.id).total;
const existing = db.prepare('SELECT role FROM collaboration_members WHERE group_id = ? AND session_hash = ?')
.get(group.id, account.token_hash);
if (!existing && count >= 50) throw new HttpError(409, 'Diese Gruppe ist bereits voll.', { code: 'group-full' });
db.prepare("INSERT OR IGNORE INTO collaboration_members (group_id, session_hash, role) VALUES (?, ?, 'member')")
.run(group.id, account.token_hash);
joinFailures.delete(key);
return groupSummary(db, group, existing?.role || 'member');
}
export function renewCode(db, req, id) {
const group = owner(db, req, id);
if (!group.active) assertGroupQuota(db, session(db, req).token_hash);
const invitation = issueCode(db, id);
return { group: groupSummary(db, groupById(db, id), 'owner'), ...invitation };
}
export function closeGroup(db, req, id) {
const group = owner(db, req, id);
clearPointLocks(group.route_id);
transaction(db, () => {
db.prepare('UPDATE collaboration_groups SET active = 0, code_hash = NULL, code_expires_at = 0 WHERE id = ?').run(id);
db.prepare("DELETE FROM collaboration_members WHERE group_id = ? AND role = 'member'").run(id);
});
return { closed: true };
}
export function verifyOrigin(req) {
const origin = req.headers.origin;
if (!origin) return;
let host;
try { host = new URL(origin).host; } catch { throw new HttpError(403, 'Diese Anfrage ist nicht erlaubt.'); }
if (host !== req.headers.host) throw new HttpError(403, 'Diese Anfrage ist nicht erlaubt.');
}
export function groupRouteGuard(db) {
const writing = new Set();
return (req, res, next) => {
let pathname;
try { pathname = decodeURIComponent(req.path); } catch { throw new HttpError(400, 'Ungültige Anfrage.'); }
const match = /^\/routes\/([^/]+)(\/.*)?$/i.exec(pathname);
if (!match) return next();
const group = db.prepare('SELECT g.*, r.activated_at FROM collaboration_groups g JOIN routes r ON r.id = g.route_id WHERE g.route_id = ?').get(Number(match[1]));
if (!group) return next();
const read = req.method === 'GET' || req.method === 'HEAD';
if (read && group.activated_at) return next();
member(db, req, group);
res.setHeader('X-Group-Route', String(group.route_id));
if (read) { res.setHeader('X-Group-Revision', String(group.revision)); return next(); }
verifyOrigin(req);
const resource = (match[2] || '/').toLowerCase();
if ((resource === '/' && req.method === 'DELETE') || /^\/restore\/?$/.test(resource)) {
throw new HttpError(403, 'Ein gemeinsamer Entwurf kann hier nicht gelöscht oder wiederhergestellt werden.');
}
const point = /^\/pois\/([^/]+)(?:\/|$)/i.exec(resource);
if (point) guardPoint(db, group, session(db, req), Number(point[1]), req, res);
if ((!point && req.headers['if-match'] !== String(group.revision)) || writing.has(group.id)) {
throw new HttpError(409, 'Jemand hat die Strecke inzwischen geändert. Lade sie neu, bevor du speicherst.');
}
writing.add(group.id);
res.setHeader('X-Group-Revision', String(group.revision + 1));
res.once('finish', () => {
if (res.statusCode >= 200 && res.statusCode < 300) db.prepare('UPDATE collaboration_groups SET revision = revision + 1 WHERE id = ?').run(group.id);
writing.delete(group.id);
});
res.once('close', () => writing.delete(group.id));
next();
};
}
+98
View File
@@ -0,0 +1,98 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { routeDirectory, resolveStoredFile } from './storage.js';
import { getPoi } from './routes-service.js';
import { HttpError } from '../middleware/errors.js';
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
const running = new Set();
const key = (routeId, poiId) => `${Number(routeId)}:${Number(poiId)}`;
function filename(routeId, poiId) {
if (![routeId, poiId].every(value => Number.isSafeInteger(Number(value)) && Number(value) > 0)) {
throw new HttpError(400, 'Ungültige Station.');
}
return path.join(routeDirectory(Number(routeId)), 'locks', `${Number(poiId)}.json`);
}
function read(routeId, poiId) {
try { return JSON.parse(fs.readFileSync(filename(routeId, poiId), 'utf8')); }
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
}
function write(routeId, poiId, lock) {
const target = filename(routeId, poiId);
fs.mkdirSync(path.dirname(target), { recursive: true });
const temporary = `${target}.${crypto.randomUUID()}.tmp`;
try {
fs.writeFileSync(temporary, JSON.stringify(lock), { mode: 0o600, flag: 'wx' });
fs.renameSync(temporary, target);
} finally { fs.rmSync(temporary, { force: true }); }
}
export function pointSnapshot(db, routeId, poiId) {
const poi = getPoi(db, routeId, poiId);
const audio = db.prepare('SELECT audio_path FROM pois WHERE id = ?').get(Number(poiId));
const images = db.prepare('SELECT path FROM poi_images WHERE poi_id = ? ORDER BY sequence, id').all(Number(poiId));
const content = relative => relative ? digest(fs.readFileSync(resolveStoredFile(relative))) : null;
const mediaVersion = digest(JSON.stringify([content(audio.audio_path), images.map(image => content(image.path)), poi.images]));
return { version: digest(JSON.stringify([poi, mediaVersion])), mediaVersion };
}
export const pointVersion = (db, routeId, poiId) => pointSnapshot(db, routeId, poiId).version;
function sameOwner(lock, account) {
return lock?.creator_id === account.creator_id && lock?.session_hash === account.token_hash;
}
export function acquirePoint(db, group, account, poiId, body) {
const route = db.prepare('SELECT status, activated_at FROM routes WHERE id = ?').get(group.route_id);
if (route?.status !== 'active' || route.activated_at) throw new HttpError(409, 'Diese Strecke kann nicht mehr bearbeitet werden.');
const poi = getPoi(db, group.route_id, Number(poiId));
const existing = read(group.route_id, poi.id);
if (running.has(key(group.route_id, poi.id))) throw new HttpError(423, 'Diese Station wird gerade gespeichert.', { code: 'point-busy' });
// Even two tabs on the same device need a confirmed takeover of a different editing token.
const resume = sameOwner(existing, account) && body?.token === existing.token;
if (existing && !resume && body?.replace !== existing.token) {
throw new HttpError(423, 'Diese Station wird bereits bearbeitet.', { code: 'point-locked', replace: existing.token });
}
const lock = resume ? existing : { creator_id: account.creator_id, session_hash: account.token_hash,
token: crypto.randomBytes(32).toString('hex'), created_at: new Date().toISOString() };
write(group.route_id, poi.id, lock);
return { token: lock.token, ...pointSnapshot(db, group.route_id, poi.id), poi };
}
export function releasePoint(group, account, poiId, token) {
const existing = read(group.route_id, poiId);
if (!existing) return;
if (!sameOwner(existing, account) || existing.token !== token) throw new HttpError(423, 'Die Bearbeitung wurde übernommen.', { code: 'point-lost' });
if (running.has(key(group.route_id, poiId))) throw new HttpError(423, 'Diese Station wird gerade gespeichert.', { code: 'point-busy' });
fs.rmSync(filename(group.route_id, poiId), { force: true });
}
export function clearPointLocks(routeId) {
if ([...running].some(id => id.startsWith(`${Number(routeId)}:`))) throw new HttpError(423, 'Eine Station wird gerade gespeichert.', { code: 'point-busy' });
fs.rmSync(path.join(routeDirectory(routeId), 'locks'), { recursive: true, force: true });
}
function assertPointWrite(db, group, account, poiId, req) {
const route = db.prepare('SELECT status, activated_at FROM routes WHERE id = ?').get(group.route_id);
if (route?.status !== 'active' || route.activated_at) throw new HttpError(409, 'Diese Strecke kann nicht mehr bearbeitet werden.');
const lock = read(group.route_id, poiId);
if (!sameOwner(lock, account) || lock.token !== req.headers['x-poi-lock']) {
throw new HttpError(423, 'Die Bearbeitung wurde übernommen. Sichere deine Änderungen auf diesem Gerät.', { code: 'point-lost' });
}
if (req.headers['if-poi-match'] !== pointVersion(db, group.route_id, poiId)) {
throw new HttpError(409, 'Die Station wurde inzwischen geändert.', { code: 'point-changed' });
}
}
export function guardPoint(db, group, account, poiId, req, res) {
req.validatePointWrite = () => assertPointWrite(db, group, account, poiId, req);
req.validatePointWrite();
const id = key(group.route_id, poiId);
if (running.has(id)) throw new HttpError(423, 'Diese Station wird gerade gespeichert.', { code: 'point-busy' });
running.add(id);
const json = res.json;
res.json = function (payload) {
if (res.statusCode >= 200 && res.statusCode < 300 && db.prepare('SELECT 1 FROM pois WHERE id = ?').get(Number(poiId))) {
res.setHeader('X-Poi-Version', pointVersion(db, group.route_id, poiId));
res.setHeader('X-Poi-Id', String(poiId));
}
if (res.statusCode >= 200 && res.statusCode < 300 && !db.prepare('SELECT 1 FROM pois WHERE id = ?').get(Number(poiId))) fs.rmSync(filename(group.route_id, poiId), { force: true });
return json.call(this, payload);
};
const release = () => running.delete(id);
res.once('finish', release);
res.once('close', release);
}
+5 -4
View File
@@ -129,11 +129,12 @@ export function listRoutes(db, {
includeDeleted = false, includeDeleted = false,
publishedOnly = false publishedOnly = false
}) { }) {
let where = ''; let where = `WHERE NOT EXISTS (SELECT 1 FROM collaboration_groups g
WHERE g.route_id = routes.id AND routes.activated_at IS NULL)`;
if (!includeDeleted) { if (!includeDeleted) {
where = publishedOnly where += publishedOnly
? "WHERE status = 'active' AND activated_at IS NOT NULL" ? " AND status = 'active' AND activated_at IS NOT NULL"
: "WHERE status = 'active'"; : " AND status = 'active'";
} }
const rows = db.prepare(`SELECT * FROM routes ${where} ORDER BY name COLLATE NOCASE`).all(); const rows = db.prepare(`SELECT * FROM routes ${where} ORDER BY name COLLATE NOCASE`).all();
const origin = Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null; const origin = Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null;
+240
View File
@@ -0,0 +1,240 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { EventEmitter } from 'node:events';
const runtime = fs.mkdtempSync(path.join(os.tmpdir(), 'wegwichtel-groups-'));
process.env.DATA_DIR = path.join(runtime, 'data');
process.env.STORAGE_DIR = path.join(runtime, 'storage');
const { config, ensureRuntimeDirectories } = await import('../src/config.js');
const { openDatabase } = await import('../src/database.js');
const { createRoute, createPoi } = await import('../src/services/routes-service.js');
const { createPicture, createAudio } = await import('../src/services/media-service.js');
const { resolveStoredFile } = await import('../src/services/storage.js');
const { createApiRouter } = await import('../src/routes/api.js');
const { notFoundHandler, errorHandler } = await import('../src/middleware/errors.js');
const { groupRouteGuard, GROUP_SYMBOLS, SESSION_COOKIE } = await import('../src/services/collaboration.js');
const express = (await import('express')).default;
const gpx = '<gpx><metadata><name>Waldweg</name></metadata><trk><trkseg><trkpt lat="50" lon="10"/><trkpt lat="50.01" lon="10.01"/></trkseg></trk></gpx>';
async function fixture(t) {
ensureRuntimeDirectories();
const db = openDatabase(path.join(config.dataDir, `${crypto.randomUUID()}.sqlite`));
const upload = { path: path.join(config.storageDir, 'uploads', `${crypto.randomUUID()}.gpx`) };
fs.writeFileSync(upload.path, gpx);
const source = createRoute(db, { name: 'Waldweg', creator_id: 'a'.repeat(64) }, upload);
const poi = createPoi(db, source.id, { title: 'Start', description: 'Blumen', lat: 50, lon: 10 });
for (const [kind, mime, extension] of [['picture', 'image/png', 'png'], ['audio', 'audio/mpeg', 'mp3']]) {
const file = { path: path.join(config.storageDir, 'uploads', crypto.randomUUID()), detectedMime: mime, detectedExtension: extension };
fs.writeFileSync(file.path, kind);
if (kind === 'picture') createPicture(db, source.id, poi.id, { caption: 'Wiese' }, file);
else createAudio(db, source.id, poi.id, file);
}
db.prepare('UPDATE routes SET activated_at = ? WHERE id = ?').run('2026-10-18', source.id);
const app = express();
app.use(express.json());
app.use('/api', createApiRouter(db));
app.use(notFoundHandler);
app.use(errorHandler);
const server = await new Promise(resolve => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
async function request(client, endpoint, method = 'GET', body = null, status = 200, headers = {}) {
const response = await fetch(base + '/api' + endpoint, { method,
headers: { ...(client.cookie ? { Cookie: client.cookie } : {}), 'Content-Type': 'application/json', ...headers },
...(body ? { body: JSON.stringify(body) } : {}) });
const cookie = response.headers.get('set-cookie');
if (cookie) client.cookie = cookie.split(';')[0];
const value = response.headers.get('content-type')?.includes('application/json') ? await response.json() : await response.text();
assert.equal(response.status, status, JSON.stringify(value));
return { value, response };
}
t.after(async () => { await new Promise(resolve => server.close(resolve)); db.close(); fs.rmSync(config.storageDir, { recursive: true, force: true }); });
return { db, source, poi, request, base };
}
test('shared draft clones a published route and protects its contents and ownership', async t => {
const { db, source, request } = await fixture(t);
const host = {}, guest = {}, outsider = {};
const { value: created, response } = await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201);
assert.equal(created.code.length, 8);
assert.ok(created.code.every(value => GROUP_SYMBOLS.includes(value)));
assert.match(response.headers.get('set-cookie'), /HttpOnly/);
assert.match(response.headers.get('set-cookie'), /SameSite=Strict/);
assert.match(host.cookie, new RegExp(`^${SESSION_COOKIE}=`));
const route = created.group.route;
assert.notEqual(route.id, source.id);
assert.equal(route.activatedAt, null);
assert.equal(route.creator_id, 'a'.repeat(64));
assert.equal(route.pois[0].description, 'Blumen');
assert.equal(route.pois[0].images[0].caption, 'Wiese');
const oldImage = db.prepare('SELECT path FROM poi_images WHERE poi_id = ?').get(source.pois?.[0]?.id || 1);
const image = db.prepare('SELECT path FROM poi_images WHERE poi_id = ?').get(route.pois[0].id);
assert.notEqual(image.path, oldImage.path);
assert.equal(fs.readFileSync(resolveStoredFile(image.path), 'utf8'), 'picture');
const audio = db.prepare('SELECT audio_path FROM pois WHERE id = ?').get(route.pois[0].id);
assert.equal(fs.readFileSync(resolveStoredFile(audio.audio_path), 'utf8'), 'audio');
const list = (await request(outsider, '/routes')).value.routes;
assert.equal(list.some(value => value.id === route.id), false);
await request(outsider, `/routes/${route.id}`, 'GET', null, 401);
for (const spelling of [`0${route.id}`, `${route.id}.0`, `${route.id}e0`, `0x${route.id.toString(16)}`, `%${String(route.id).charCodeAt(0).toString(16)}`]) {
await request(outsider, `/routes/${spelling}`, 'GET', null, 401);
await request(outsider, `/routes/${spelling}`, 'PUT', { name: 'Bypass' }, 401);
}
await request(outsider, `/Routes/${route.id}`, 'GET', null, 401);
await request(outsider, route.pois[0].images[0].url.replace('/api', ''), 'GET', null, 401);
const joined = (await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'a'.repeat(64) })).value.group;
assert.equal(joined.role, 'member');
await request(guest, `/groups/${created.group.id}`, 'DELETE', null, 403);
const loaded = await request(guest, `/routes/${route.id}`);
assert.equal(loaded.response.headers.get('x-group-revision'), '0');
await request(guest, `/routes/${route.id}`, 'PUT', { name: 'Changed' }, 409);
await request(guest, `/routes/${route.id}`, 'PUT', { name: 'Changed' }, 200, { 'If-Match': '0' });
await request(host, `/routes/${route.id}`, 'PUT', { name: 'Lost update' }, 409, { 'If-Match': '0' });
assert.equal(db.prepare('SELECT name FROM routes WHERE id = ?').get(route.id).name, 'Changed');
assert.equal(db.prepare('SELECT name FROM routes WHERE id = ?').get(source.id).name, 'Waldweg');
await request(guest, `/routes/${route.id}`, 'DELETE', null, 403, { 'If-Match': '1' });
await request(guest, `/routes/0${route.id}`, 'DELETE', null, 403, { 'If-Match': '1' });
await request(guest, `/routes/${route.id}/restore`, 'POST', {}, 403, { 'If-Match': '1' });
const renewed = (await request(host, `/groups/${created.group.id}/code`, 'POST', {})).value;
await request(outsider, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) }, 404);
await request(outsider, '/groups/join', 'POST', { code: renewed.code, creator_id: 'b'.repeat(64) });
await request(host, `/groups/${created.group.id}`, 'DELETE');
await request(guest, `/routes/${route.id}`, 'GET', null, 403);
assert.equal((await request(guest, '/groups')).value.groups.length, 0);
await request(host, `/routes/${route.id}`);
const reopened = (await request(host, `/groups/${created.group.id}/code`, 'POST', {})).value;
assert.equal(reopened.group.active, true);
await request(guest, '/groups/join', 'POST', { code: reopened.code, creator_id: 'a'.repeat(64) });
assert.equal((await request(guest, '/groups')).value.groups.length, 1);
});
test('invitation expiry, request origin, invalid IDs and expired sessions are enforced', async t => {
const { db, source, request } = await fixture(t);
const host = {}, guest = {};
await request(host, '/groups', 'POST', { routeId: source.id }, 400);
await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'https://other.test' });
await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'null' });
await request(host, '/groups', 'POST', { routeId: 900, creator_id: 'a'.repeat(64) }, 404);
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
await request(host, `/groups/${'a'.repeat(32)}/code`, 'POST', {}, 404);
db.prepare('UPDATE collaboration_groups SET code_expires_at = 0 WHERE id = ?').run(created.group.id);
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) }, 404);
await request(guest, '/groups/join', 'POST', { code: ['invalid'] }, 404);
await request(host, `/routes/${created.group.route.id}`, 'PUT', { name: 'Cross site' }, 403,
{ Origin: 'https://other.test', 'If-Match': '0' });
db.prepare('UPDATE routes SET activated_at = ? WHERE id = ?').run('2026-10-18', created.group.route.id);
await request(guest, `/routes/${created.group.route.id}`);
await request(host, `/routes/${created.group.route.id}`, 'PUT', { name: 'Published change' }, 409, { 'If-Match': '0' });
db.prepare('UPDATE collaboration_sessions SET expires_at = 0').run();
assert.equal((await request(host, '/groups')).value.groups.length, 0);
await request(host, `/groups/${created.group.id}/code`, 'POST', {}, 401);
});
test('overlapping writes are locked until completion and revision changes once', async t => {
const { db, source, request } = await fixture(t);
const host = {};
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
const guard = groupRouteGuard(db);
const req = { method: 'PUT', path: `/routes/${created.group.route.id}`, headers: { cookie: host.cookie, 'if-match': '0' } };
const res = new EventEmitter(); res.setHeader = () => {}; res.statusCode = 200;
let reached = false;
guard(req, res, () => { reached = true; });
assert.equal(reached, true);
assert.throws(() => guard(req, res, () => {}), error => error.status === 409);
res.emit('finish');
assert.equal(db.prepare('SELECT revision FROM collaboration_groups').get().revision, 1);
res.emit('close');
});
test('group copying rolls back a failed media copy and rate limits invalid invitations', async t => {
const { db, source, request } = await fixture(t);
const image = db.prepare('SELECT path FROM poi_images LIMIT 1').get();
fs.rmSync(resolveStoredFile(image.path));
await request({}, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 404);
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM routes').get().total, 1);
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM collaboration_groups').get().total, 0);
const client = {};
for (let attempt = 0; attempt < 20; attempt += 1) {
await request(client, '/groups/join', 'POST', { code: [] }, 404, { 'X-Real-IP': '192.0.2.99' });
}
await request(client, '/groups/join', 'POST', { code: [] }, 429, { 'X-Real-IP': '192.0.2.99' });
});
test('point lock files bind the device and session, require confirmed takeover and reject stale content', async t => {
const { db, source, request } = await fixture(t);
const host = {}, guest = {}, outsider = {};
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) });
const route = created.group.route, poi = route.pois[0];
const endpoint = `/groups/routes/${route.id}/pois/${poi.id}/lock`;
const target = `/routes/${route.id}/pois/${poi.id}`;
await request(outsider, endpoint, 'POST', { creator_id: 'a'.repeat(64) }, 401);
await request(guest, target, 'PUT', { title: 'Not locked' }, 423, { 'If-Match': '0' });
const lock = (await request(host, endpoint, 'POST', { creator_id: 'b'.repeat(64) })).value;
const lockfile = path.join(config.storageDir, 'active', 'routes', String(route.id), 'locks', `${poi.id}.json`);
assert.equal(JSON.parse(fs.readFileSync(lockfile)).creator_id, 'a'.repeat(64));
assert.equal(fs.statSync(lockfile).mode & 0o777, 0o600);
const conflict = (await request(guest, endpoint, 'POST', {}, 423)).value;
assert.equal(conflict.details.code, 'point-locked');
await request(guest, target, 'PUT', { title: 'Spoofed creator' }, 423,
{ 'X-Poi-Lock': lock.token, 'If-Poi-Match': lock.version, 'X-Creator-Id': 'a'.repeat(64) });
await request(guest, endpoint, 'POST', { replace: 'outdated' }, 423);
const takeover = (await request(guest, endpoint, 'POST', { replace: conflict.details.replace })).value;
assert.equal(JSON.parse(fs.readFileSync(lockfile)).creator_id, 'b'.repeat(64));
await request(host, target, 'PUT', { title: 'Lost edit' }, 423, { 'X-Poi-Lock': lock.token, 'If-Poi-Match': lock.version });
await request(host, endpoint, 'POST', { release: true, token: lock.token }, 423);
await request(host, `/Routes/0${route.id}/Pois/${poi.id}`, 'DELETE', {}, 423);
const saved = await request(guest, target, 'PUT', { title: 'Guest edit' }, 200,
{ 'X-Poi-Lock': takeover.token, 'If-Poi-Match': takeover.version });
assert.ok(saved.response.headers.get('x-poi-version'));
assert.notEqual(saved.response.headers.get('x-poi-version'), takeover.version);
await request(guest, target, 'PUT', { title: 'Stale edit' }, 409,
{ 'X-Poi-Lock': takeover.token, 'If-Poi-Match': takeover.version });
const fresh = (await request(guest, endpoint, 'POST', { token: takeover.token })).value;
// Same URL and same file count, but different bytes must invalidate the comparison.
const audio = db.prepare('SELECT audio_path FROM pois WHERE id = ?').get(poi.id);
fs.writeFileSync(resolveStoredFile(audio.audio_path), 'changed audio bytes');
await request(guest, `${target}/audio`, 'DELETE', null, 409,
{ 'X-Poi-Lock': fresh.token, 'If-Poi-Match': fresh.version });
const latest = (await request(guest, endpoint, 'POST', { token: fresh.token })).value;
assert.notEqual(latest.mediaVersion, fresh.mediaVersion);
const removed = await request(guest, `${target}/audio`, 'DELETE', null, 200,
{ 'X-Poi-Lock': latest.token, 'If-Poi-Match': latest.version });
assert.ok(removed.response.headers.get('x-poi-version'));
await request(guest, endpoint, 'POST', { release: true, token: latest.token });
assert.equal(fs.existsSync(lockfile), false);
await request(guest, endpoint, 'POST', { release: true, token: latest.token });
await request(host, endpoint, 'POST', {});
await request(host, `/groups/${created.group.id}`, 'DELETE');
assert.equal(fs.existsSync(lockfile), false);
});
test('different station edits do not conflict through the route revision, and in-flight writes prevent takeover', async t => {
const { db, source, request } = await fixture(t);
const host = {}, guest = {};
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) });
const route = created.group.route;
const second = createPoi(db, route.id, { title: 'Second stop' });
const lockPath = poiId => `/groups/routes/${route.id}/pois/${poiId}/lock`;
const first = route.pois[0];
const a = (await request(host, lockPath(first.id), 'POST', {})).value;
const b = (await request(guest, lockPath(second.id), 'POST', {})).value;
await request(host, `/routes/${route.id}/pois/${first.id}`, 'PUT', { title: 'A' }, 200,
{ 'X-Poi-Lock': a.token, 'If-Poi-Match': a.version, 'If-Match': '0' });
await request(guest, `/routes/${route.id}/pois/${second.id}`, 'PUT', { title: 'B' }, 200,
{ 'X-Poi-Lock': b.token, 'If-Poi-Match': b.version, 'If-Match': '0' });
const fresh = (await request(host, lockPath(first.id), 'POST', { token: a.token })).value;
const req = { method: 'PUT', path: `/routes/${route.id}/pois/${first.id}`, headers: {
cookie: host.cookie, 'x-poi-lock': fresh.token, 'if-poi-match': fresh.version } };
const res = new EventEmitter(); res.setHeader = () => {}; res.json = () => {}; res.statusCode = 200;
groupRouteGuard(db)(req, res, () => {});
await request(guest, lockPath(first.id), 'POST', { replace: fresh.token }, 423);
await request(host, lockPath(first.id), 'POST', { release: true, token: fresh.token }, 423);
await request(host, `/groups/${created.group.id}`, 'DELETE', {}, 423);
res.emit('close');
await request(guest, lockPath(first.id), 'POST', { replace: fresh.token });
assert.throws(() => req.validatePointWrite(), error => error.status === 423);
});
+141 -1
View File
@@ -826,7 +826,7 @@ test('editing an activated server route uses PUT and never writes activation sta
const update = calls.find(call => call.method === 'PUT' && call.url === '/api/routes/1'); const update = calls.find(call => call.method === 'PUT' && call.url === '/api/routes/1');
assert.ok(update); assert.ok(update);
const body = JSON.parse(update.body); const body = JSON.parse(update.body);
assert.deepEqual(Object.keys(body).sort(), ['description', 'name']); assert.deepEqual(Object.keys(body).sort(), ['creator_id', 'description', 'name']);
assert.equal('activated_at' in body, false); assert.equal('activated_at' in body, false);
assert.equal('activatedAt' in body, false); assert.equal('activatedAt' in body, false);
assert.equal(elements.get('editor-status').textContent, 'Gespeichert.'); assert.equal(elements.get('editor-status').textContent, 'Gespeichert.');
@@ -963,3 +963,143 @@ test('late station previews are released after the editor closes without replaci
assert.equal(released.length, 1); assert.equal(released.length, 1);
assert.equal(elements.get('editor-poi-title').value, 'Noch nicht gespeichert'); assert.equal(elements.get('editor-poi-title').value, 'Noch nicht gespeichert');
}); });
test('shared drafts use guarded server saves, preserve the creator and retain unsaved input on conflicts', async () => {
const h = buildEditorContext();
const shared = { id: 44, name: 'Gemeinsamer Waldweg', description: '', creator_id: 'b'.repeat(64), pois: [] };
let conflict = false;
let refreshed = 0;
const originalFetch = h.context.fetch;
h.context.Wegwichtel.Groups = {
headersFor: path => path.startsWith('/routes/44') ? { 'If-Match': '3' } : {},
observe() {}, refresh() { refreshed += 1; }
};
h.context.fetch = async (url, options = {}) => {
if (url === '/api/routes/44') {
h.calls.push({ url, ...options, method: options.method || 'GET' });
if (options.method === 'PUT') {
if (conflict) return { ...jsonResponse({ message: 'Conflict' }, false, 409), headers: { get: () => '44' } };
Object.assign(shared, JSON.parse(options.body));
}
return jsonResponse(shared);
}
return originalFetch(url, options);
};
await loadI18n(h.context);
const messages = path.join(root, 'public/js/user-messages.js');
vm.runInNewContext(await fs.readFile(messages, 'utf8'), h.context, { filename: messages });
const filename = path.join(root, 'public/js/editor-ui.js');
vm.runInNewContext(await fs.readFile(filename, 'utf8'), h.context, { filename });
await h.context.Wegwichtel.EditorUi.openGroup(shared);
assert.equal(h.controls.recording, null);
assert.ok(refreshed > 0);
h.elements.get('editor-route-name').value = 'Gemeinsam geändert';
h.elements.get('editor-route-form').dispatchEvent(submitEvent());
for (let i = 0; i < 6; i += 1) await tick();
const saved = h.calls.find(call => call.method === 'PUT' && call.url === '/api/routes/44');
assert.equal(saved.headers['If-Match'], '3');
assert.equal(JSON.parse(saved.body).creator_id, 'b'.repeat(64));
assert.equal(h.packages.has('44'), false);
assert.equal(shared.name, 'Gemeinsam geändert');
conflict = true;
h.elements.get('editor-route-name').value = 'Noch nicht gespeichert';
h.elements.get('editor-route-form').dispatchEvent(submitEvent());
for (let i = 0; i < 6; i += 1) await tick();
assert.equal(shared.name, 'Gemeinsam geändert');
assert.equal(h.elements.get('editor-route-name').value, 'Noch nicht gespeichert');
assert.match(h.elements.get('editor-status').textContent, /inzwischen geändert/);
});
test('sharing a local draft asks before sending and opens a separate shared copy', async () => {
const h = buildEditorContext();
const sharedIds = [];
h.context.confirm = () => false;
h.context.Wegwichtel.Groups = {
shareButton(action) { const button = new FakeElement('', 'button'); button.dataset.editorAction = action; return button; },
async share(id) { sharedIds.push(id); }
};
await loadI18n(h.context);
const filename = path.join(root, 'public/js/editor-ui.js');
vm.runInNewContext(await fs.readFile(filename, 'utf8'), h.context, { filename });
await h.context.Wegwichtel.EditorUi.open();
const list = h.elements.get('editor-route-list');
const item = list.children.find(child => child.dataset.routeId === 'local-existing');
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
for (let i = 0; i < 4; i += 1) await tick();
assert.equal(h.calls.some(call => call.method === 'POST'), false);
h.context.confirm = () => true;
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
for (let i = 0; i < 14; i += 1) await tick();
assert.deepEqual(sharedIds, ['22']);
assert.equal(h.packages.get('local-existing').route.serverRouteId, '22');
assert.ok(h.calls.some(call => call.method === 'POST' && call.url === '/api/routes'));
});
test('shared station saves persist files before sending, retain lost-lock changes and release locks on exit', async () => {
const h = buildEditorContext();
const poi = { id: 8, title: 'Wiese', description: 'Blumen', lat: 50, lon: 10, sequence: 0, images: [], audioUrl: null };
const route = { id: 44, name: 'Gruppenweg', creator_id: 'a'.repeat(64), pois: [poi] };
const events = [], drafts = new Map();
let allowed = false;
h.context.Wegwichtel.GroupPoints = {
async begin() { events.push('lock'); return { poi }; },
async release() { events.push('release'); },
async read(id, poiId) { return drafts.get(String(poiId)); },
async remember(route, poiId, values, media) { const draft = { route, poiId, values, media }; events.push('backup'); drafts.set(String(poiId), structuredClone(draft)); return draft; },
async prepare() { events.push('prepare'); return allowed ? { poi } : false; },
async remove(id, poiId) { events.push('remove'); drafts.delete(String(poiId)); },
headersFor: () => ({ 'X-Poi-Lock': 'secret', 'If-Poi-Match': 'v1' }), observe() {},
async acceptNew() {}
};
const original = h.context.fetch;
h.context.fetch = async (url, options = {}) => {
if (url.startsWith('/api/routes/44')) {
h.calls.push({ url, ...options, method: options.method || 'GET' });
if (url === '/api/routes/44') return jsonResponse(route);
if (options.method === 'PUT') { events.push('send'); Object.assign(poi, JSON.parse(options.body)); return jsonResponse({ poi }); }
if (options.method === 'DELETE') { events.push('delete'); return jsonResponse({ deleted: true }); }
}
return original(url, options);
};
await loadI18n(h.context);
vm.runInNewContext(await fs.readFile('public/js/editor-ui.js', 'utf8'), h.context, { filename: path.resolve('public/js/editor-ui.js') });
await h.context.Wegwichtel.EditorUi.openGroup(route);
const list = h.elements.get('editor-poi-list');
list.dispatchEvent(pointerEvent(list.children[0].children[1].children[2])); await tick();
h.elements.get('editor-poi-title').value = 'Meine Blumen';
const file = new Blob(['photo'], { type: 'image/png' }); file.name = 'blume.png';
h.elements.get('editor-poi-images').files = [file];
h.elements.get('editor-poi-images').dispatchEvent(new Event('change')); await tick();
h.elements.get('editor-poi-form').dispatchEvent(submitEvent()); await tick();
assert.equal(events.includes('send'), false);
assert.equal(drafts.get('8').values.title, 'Meine Blumen');
assert.equal(drafts.get('8').media.images.length, 1);
assert.match(h.elements.get('editor-status').textContent, /auf diesem Gerät gesichert/);
// Remove the selected picture, then retry the same text edit with the lock held.
await h.context.Wegwichtel.EditorUi.openPendingPoint({ route, poiId: 8, basePoi: poi, values: poi, media: {} });
allowed = true;
h.elements.get('editor-poi-title').value = 'Gemeinsam gespeichert';
h.elements.get('editor-poi-form').dispatchEvent(submitEvent());
h.elements.get('editor-poi-form').dispatchEvent(submitEvent());
for (let i = 0; i < 6; i += 1) await tick();
assert.equal(events.filter(event => event === 'send').length, 1);
assert.ok(events.indexOf('backup') < events.indexOf('send'));
assert.equal(drafts.size, 0);
assert.equal(poi.title, 'Gemeinsam gespeichert');
const saved = h.calls.find(call => call.method === 'PUT');
assert.equal(saved.headers['X-Poi-Lock'], 'secret');
allowed = false;
const latestList = h.elements.get('editor-poi-list');
latestList.dispatchEvent(pointerEvent(latestList.children[0].children[1].children[3]));
for (let i = 0; i < 4; i += 1) await tick();
assert.equal(drafts.get('8').media.deletePoint, true);
assert.equal(events.includes('delete'), false);
allowed = true;
h.elements.get('editor-poi-form').dispatchEvent(submitEvent());
for (let i = 0; i < 5; i += 1) await tick();
assert.equal(events.includes('delete'), true);
assert.equal(drafts.size, 0);
h.context.Wegwichtel.EditorUi.close();
assert.equal(events.at(-1), 'release');
});
+133
View File
@@ -0,0 +1,133 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import vm from 'node:vm';
import path from 'node:path';
import { loadI18n } from '../test-support/i18n.js';
class Element extends EventTarget {
constructor() { super(); this.children = []; this.open = false; }
appendChild(child) { this.children.push(child); }
replaceChildren(...children) { this.children = children; }
showModal() { this.open = true; }
close() { this.open = false; this.dispatchEvent(new Event('close')); }
}
const tick = async () => { for (let i = 0; i < 4; i += 1) await new Promise(resolve => setTimeout(resolve, 0)); };
async function harness(records = new Map()) {
const nodes = new Map(['point-compare-dialog','point-compare-content','point-compare-accept','point-compare-cancel'].map(id => [id, new Element()]));
const document = new EventTarget(); document.createElement = () => new Element(); document.getElementById = id => nodes.get(id);
const calls = [], controls = { accept: true, offline: false, locked: false, abort: false, version: 'v1', mediaVersion: 'm1' };
const poi = { id: 8, title: 'Start', description: 'Wiese', lat: 50, lon: 10, sequence: 0, triggerRadiusM: 25,
audioUrl: '/api/routes/7/pois/8/audio', images: [{ id: 1, url: '/image.png', caption: 'Wiese' }] };
const database = { createObjectStore() {}, transaction() {
const transaction = { error: new Error('quota'), objectStore() {
const operation = (kind, value) => {
const request = {};
queueMicrotask(() => {
if (controls.abort) { transaction.onabort?.(); return; }
if (kind === 'put') records.set(value.key, structuredClone(value));
if (kind === 'delete') records.delete(value);
request.result = kind === 'get' ? structuredClone(records.get(value)) : kind === 'all' ? structuredClone([...records.values()]) : value;
transaction.oncomplete();
});
return request;
};
return { get: id => operation('get', id), getAll: () => operation('all'), put: value => operation('put', value), delete: id => operation('delete', id) };
}}; return transaction;
}};
const context = { Event, File, Blob, document, indexedDB: { open() { const request = { result: database }; queueMicrotask(() => { request.onupgradeneeded(); request.onsuccess(); }); return request; } },
confirm: () => controls.accept, Wegwichtel: {},
fetch: async (url, options) => {
const body = JSON.parse(options.body); calls.push({ url, body });
if (controls.offline) throw new TypeError('offline');
let status = 200, payload;
if (body.release) payload = { released: true };
else if (controls.locked && !body.replace) { status = 423; payload = { details: { code: 'point-locked', replace: 'old-secret' } }; }
else payload = { token: 'our-secret', version: controls.version, mediaVersion: controls.mediaVersion, poi: structuredClone(poi) };
return { ok: status === 200, status, json: async () => payload };
}};
context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(await fs.readFile('public/js/group-points.js', 'utf8'), context, { filename: path.resolve('public/js/group-points.js') });
return { api: context.Wegwichtel.GroupPoints, nodes, context, controls, calls, poi, records };
}
const media = () => ({ images: [new File(['photo'], 'blume.png', { type: 'image/png' })], audio: new File(['sound'], 'vogel.mp3'), deletions: [{ path: 'pictures/1' }] });
test('point takeover requires confirmation, preserves baseline and binds writes to the editing token', async () => {
const h = await harness();
h.controls.locked = true; h.controls.accept = false;
assert.equal(await h.api.begin(7, h.poi), null);
assert.equal(h.calls.length, 1);
h.controls.accept = true;
await h.api.begin(7, h.poi);
assert.equal(h.calls.at(-1).body.replace, 'old-secret');
assert.equal(h.api.headersFor('/routes/7/pois/8/audio')['X-Poi-Lock'], 'our-secret');
assert.equal(Object.keys(h.api.headersFor('/routes/9/pois/8')).length, 0);
h.api.observe({ headers: new Headers({ 'X-Poi-Version': 'v2', 'X-Poi-Id': '8' }) });
assert.equal(h.api.headersFor('/routes/7/pois/8')['If-Poi-Match'], 'v2');
await h.api.release();
assert.equal(h.calls.at(-1).body.token, 'our-secret');
assert.equal(Object.keys(h.api.headersFor('/routes/7/pois/8')).length, 0);
});
test('unsent text, blobs, removals and baseline survive reopening; transaction abort is never reported as saved', async () => {
const h = await harness();
await h.api.begin(7, h.poi);
await h.api.remember({ id: 7, name: 'Waldweg' }, 8, { ...h.poi, title: 'Mein Titel' }, media());
const next = await harness(h.records);
const draft = (await next.api.list())[0];
assert.equal(draft.baseVersion, 'v1');
assert.equal(draft.values.title, 'Mein Titel');
assert.equal(await draft.media.images[0].text(), 'photo');
assert.equal(await draft.media.audio.text(), 'sound');
const opened = await next.api.begin(7, next.poi);
assert.equal(opened.draft.values.title, 'Mein Titel');
next.controls.abort = true;
await assert.rejects(next.api.remember({ id: 7 }, 8, {}, {}));
assert.equal(h.records.size, 1);
next.controls.abort = false;
await next.api.remove(7, 8);
assert.equal((await next.api.list()).length, 0);
});
test('retry compares all fields and media, retains cancelled work and only uses the latest version after consent', async () => {
const h = await harness();
await h.api.begin(7, h.poi);
const draft = await h.api.remember({ id: 7, name: 'Waldweg' }, 8, { ...h.poi, title: 'Mein Titel' }, media());
assert.ok(await h.api.prepare(draft));
h.controls.version = 'v2'; h.controls.mediaVersion = 'm2'; h.poi.description = 'Geändert';
const rejected = h.api.prepare(draft); await tick();
assert.equal(h.nodes.get('point-compare-dialog').open, true);
const table = h.nodes.get('point-compare-content').children[0];
assert.equal(table.children[1].children[2].textContent, 'Mein Titel');
assert.equal(table.children[2].children[3].textContent, 'Geändert');
assert.match(h.nodes.get('point-compare-content').children[2].textContent, /wurden geändert/);
h.nodes.get('point-compare-cancel').dispatchEvent(new Event('click'));
assert.equal(await rejected, false);
assert.equal((await h.api.list()).length, 1);
const accepted = h.api.prepare(draft); await tick();
h.nodes.get('point-compare-accept').dispatchEvent(new Event('click'));
assert.ok(await accepted);
assert.equal(h.api.headersFor('/routes/7/pois/8')['If-Poi-Match'], 'v2');
});
test('offline drafts remain editable without claiming a server lock and a late lock does not reopen a closed editor', async () => {
const h = await harness();
await h.api.begin(7, h.poi);
await h.api.remember({ id: 7 }, 8, h.poi, media());
await h.api.release(); h.controls.offline = true;
assert.ok((await h.api.begin(7, h.poi)).draft);
assert.equal(h.api.headersFor('/routes/7/pois/8')['X-Poi-Lock'], '');
await assert.rejects(h.api.prepare((await h.api.list())[0]));
h.controls.offline = false;
let resolve;
const original = h.context.fetch;
h.context.fetch = (url, options) => {
if (JSON.parse(options.body).release) return original(url, options);
return new Promise(finish => { resolve = () => finish({ ok: true, json: async () => ({ token: 'late', version: 'v1', poi: h.poi }) }); });
};
const opening = h.api.begin(7, h.poi); await tick();
await h.api.release(); resolve();
assert.equal(await opening, null);
assert.equal(Object.keys(h.api.headersFor('/routes/7/pois/8')).length, 0);
});
+188
View File
@@ -0,0 +1,188 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import vm from 'node:vm';
import { loadI18n } from '../test-support/i18n.js';
class Element extends EventTarget {
constructor() { super(); this.children = []; this.attributes = new Map(); this.dataset = {}; this.scrollTop = 0; this.open = false; }
appendChild(node) { this.children.push(node); return node; }
append(...nodes) { nodes.forEach(node => this.appendChild(node)); }
replaceChildren(...nodes) { this.children = nodes; }
setAttribute(name, value) { this.attributes.set(name, value); }
showModal() { this.open = true; }
close() { this.open = false; }
scrollTo({ top }) { this.scrollTop = top; this.dispatchEvent(new Event('scroll')); }
}
const tick = async () => { for (let i = 0; i < 4; i += 1) await new Promise(resolve => setTimeout(resolve, 0)); };
function pointer(node, properties = {}) {
const event = new Event('pointerup', { cancelable: true });
for (const [key, value] of Object.entries(properties)) Object.defineProperty(event, key, { value });
node.dispatchEvent(event);
}
function key(node, value) {
const event = new Event('keydown', { cancelable: true });
Object.defineProperty(event, 'key', { value }); node.dispatchEvent(event);
}
async function harness(loading = false) {
const ids = ['group-join-open', 'editor-group-join', 'editor-group-list', 'group-dialog', 'group-close',
'group-status', 'group-wheels', 'group-entry', 'group-invitation', 'group-code', 'group-join', 'group-edit', 'group-expiry'];
const nodes = new Map(ids.map(id => [id, new Element()]));
const document = new EventTarget();
document.readyState = loading ? 'loading' : 'complete';
document.getElementById = id => nodes.get(id);
document.createElement = () => new Element();
const calls = [], edits = [];
const controls = { error: 0, offline: false, confirm: true, active: true, role: 'owner' };
const group = () => ({ id: 'group-id', route: { id: 22, name: 'Waldweg' }, revision: 0, role: controls.role, active: controls.active });
const context = { Event, document, Date, Map, Set, Headers, console, globalThis: null,
confirm: () => controls.confirm, matchMedia: () => ({ matches: false }),
Wegwichtel: { Config: { apiBase: '/api/' }, LocalRoutes: { creatorId: () => 'a'.repeat(64) },
EditorUi: { async openGroup(route) { edits.push(route.id); } } },
fetch: async (url, options) => {
calls.push({ url, ...options });
if (controls.offline) throw new TypeError('Failed to fetch');
if (controls.error) return { ok: false, status: controls.error };
let value;
if (options.method === 'GET') value = { groups: [group()] };
else if (options.method === 'DELETE') { controls.active = false; value = { closed: true }; }
else if (url.endsWith('/join')) value = { group: group() };
else value = { group: group(), code: Array(8).fill('🐦'), expiresAt: 1800000000 };
return { ok: true, json: async () => value };
}
};
context.globalThis = context;
await loadI18n(context);
for (const module of ['group-symbols', 'groups']) {
const filename = path.resolve('public/js', module + '.js');
vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename });
}
return { nodes, context, calls, controls, edits, groups: context.Wegwichtel.Groups };
}
test('group code wheels support arrows, keyboard wrapping, scrolling and joining with ordered Unicode', async () => {
const h = await harness(true);
h.context.document.dispatchEvent(new Event('DOMContentLoaded'));
pointer(h.nodes.get('group-join-open'), { isPrimary: false });
assert.equal(h.nodes.get('group-dialog').open, false);
pointer(h.nodes.get('group-join-open'));
assert.equal(h.nodes.get('group-dialog').open, true);
const columns = h.nodes.get('group-wheels').children;
assert.equal(columns.length, 8);
const [up, viewport, down] = columns[0].children;
assert.equal(viewport.attributes.get('role'), 'spinbutton');
key(up, 'Enter');
assert.equal(viewport.attributes.get('aria-valuenow'), '9');
pointer(down);
assert.equal(viewport.attributes.get('aria-valuenow'), '1');
key(viewport, 'End'); key(viewport, 'ArrowUp');
assert.equal(viewport.attributes.get('aria-valuenow'), '8');
key(viewport, 'Home'); key(viewport, 'ArrowDown');
pointer(viewport.children[5]);
assert.equal(viewport.attributes.get('aria-valuetext'), 'Vogel');
viewport.scrollTop = 3 * 52; viewport.dispatchEvent(new Event('scroll'));
key(h.nodes.get('group-join'), ' ');
await tick();
const joined = h.calls.find(call => call.url.endsWith('/join'));
const body = JSON.parse(joined.body);
assert.equal(body.code.length, 8);
assert.equal(body.code[0], '🌼');
assert.equal(body.creator_id, 'a'.repeat(64));
assert.deepEqual(h.edits, [22]);
assert.equal(h.nodes.get('group-dialog').open, false);
pointer(h.nodes.get('editor-group-join'));
pointer(h.nodes.get('group-close'));
assert.equal(h.nodes.get('group-dialog').open, false);
});
test('sharing displays eight motifs and the owner can renew or close access with confirmation', async () => {
const h = await harness();
await h.groups.share(7);
assert.equal(JSON.parse(h.calls[0].body).routeId, 7);
assert.equal(h.nodes.get('group-code').children.length, 8);
assert.equal(h.nodes.get('group-entry').hidden, true);
assert.match(h.nodes.get('group-expiry').textContent, /Code gilt/);
const shareButton = h.groups.shareButton('share-route');
assert.equal(shareButton.dataset.editorAction, 'share-route');
assert.equal(shareButton.children.length, 2);
pointer(h.nodes.get('group-edit')); await tick();
assert.deepEqual(h.edits, [22]);
let actions = h.nodes.get('editor-group-list').children[0].children[1].children;
h.controls.confirm = false;
pointer(actions[2]); await tick();
assert.equal(h.calls.some(call => call.url.endsWith('/code')), false);
h.controls.confirm = true;
pointer(actions[2]); await tick();
assert.ok(h.calls.some(call => call.url.endsWith('/code')));
actions = h.nodes.get('editor-group-list').children[0].children[1].children;
pointer(actions[1]); await tick();
assert.equal(h.edits.length, 2);
pointer(actions[3]); await tick();
assert.ok(h.calls.some(call => call.method === 'DELETE'));
h.controls.role = 'member'; await h.groups.refresh();
assert.equal(h.nodes.get('editor-group-list').children[0].children[1].children.length, 2);
});
test('code failures are localized and group list refresh never advances an editing revision', async () => {
const h = await harness();
assert.equal(h.groups.headersFor('/routes/22')['If-Match'], undefined);
h.groups.observe({ headers: new Headers({ 'X-Group-Route': '22', 'X-Group-Revision': '3' }) });
assert.equal(h.groups.headersFor('/routes/22/pois/1')['If-Match'], '3');
await h.groups.refresh();
assert.equal(h.groups.headersFor('/routes/22')['If-Match'], '3');
assert.equal(h.groups.headersFor('/routes/23')['If-Match'], undefined);
h.groups.observe({ headers: new Headers({ 'X-Group-Route': '22', 'X-Group-Revision': 'invalid' }) });
assert.equal(h.groups.headersFor('/routes/22')['If-Match'], '3');
pointer(h.nodes.get('group-join-open'));
for (const [status, text] of [[404, /Code stimmt/], [401, /nicht geöffnet/], [403, /nicht geöffnet/], [409, /inzwischen geändert/], [429, /eine Minute/], [500, /Internetverbindung/]]) {
h.controls.error = status;
pointer(h.nodes.get('group-join')); await tick();
assert.match(h.nodes.get('group-status').textContent, text);
assert.equal(h.nodes.get('group-join').disabled, false);
}
h.controls.error = 0; h.controls.offline = true;
h.context.Wegwichtel.I18n.setLanguage('en', null);
pointer(h.nodes.get('group-join')); await tick();
assert.match(h.nodes.get('group-status').textContent, /internet connection/);
await h.groups.refresh();
});
test('a late join response does not reopen a cancelled dialog or editor', async () => {
const h = await harness();
const original = h.context.fetch;
let release;
h.context.fetch = async (url, options) => {
if (url.endsWith('/join')) await new Promise(resolve => { release = resolve; });
return original(url, options);
};
pointer(h.nodes.get('group-join-open'));
pointer(h.nodes.get('group-join'));
await tick();
pointer(h.nodes.get('group-close'));
release();
await tick();
assert.equal(h.nodes.get('group-dialog').open, false);
assert.deepEqual(h.edits, []);
});
test('unsent station changes are listed before network access and discarded only after confirmation', async () => {
const h = await harness();
const list = new Element(); h.nodes.set('editor-point-drafts', list);
const drafts = [{ route: { id: 7, name: 'Waldweg' }, poiId: 8, values: { title: 'Blumen' } }];
const opened = [];
h.context.Wegwichtel.EditorUi.openPendingPoint = async draft => opened.push(draft.poiId);
h.context.Wegwichtel.GroupPoints = { async list() { return drafts; }, async remove() { drafts.splice(0); } };
h.controls.offline = true;
await h.groups.refresh();
assert.equal(list.children.length, 1);
pointer(list.children[0].children[1].children[0]); await tick();
assert.deepEqual(opened, [8]);
h.controls.confirm = false;
pointer(list.children[0].children[1].children[1]); await tick();
assert.equal(drafts.length, 1);
h.controls.confirm = true;
pointer(list.children[0].children[1].children[1]); await tick();
h.context.document.dispatchEvent(new Event('wegwichtel:point-drafts-changed')); await tick();
assert.equal(list.children.length, 0);
});
+1 -1
View File
@@ -17,7 +17,7 @@ test('start screen exposes cookie status, optional local progress and deliberate
const loader = await read('public/js/bootstrap-loader.js'); const loader = await read('public/js/bootstrap-loader.js');
const storage = await read('public/js/privacy-storage.js'); const storage = await read('public/js/privacy-storage.js');
assert.match(html, /Wegwichtel verwendet keine Cookies/); assert.match(html, /Wegwichtel verwendet keine Werbe- oder Tracking-Cookies/);
assert.match(html, /class="cookie-toggle"[^>]*disabled/); assert.match(html, /class="cookie-toggle"[^>]*disabled/);
assert.match(html, /class="progress-storage-toggle"/); assert.match(html, /class="progress-storage-toggle"/);
assert.match(html, /role="switch"[^>]*aria-checked="false"/); assert.match(html, /role="switch"[^>]*aria-checked="false"/);
+1 -1
View File
@@ -34,7 +34,7 @@ test('every static HTML id is referenced by client JavaScript or accessible HTML
test('every static HTML class is used by CSS or authored JavaScript', async () => { test('every static HTML class is used by CSS or authored JavaScript', async () => {
const html = await read('public/index.html'); const html = await read('public/index.html');
const css = `${await read('public/css/bootstrap.css')}\n${await read('public/css/app.css')}`; const css = `${await read('public/css/bootstrap.css')}\n${await read('public/css/app.css')}\n${await read('public/css/editor.css')}`;
const scripts = (await clientSources()).join('\n'); const scripts = (await clientSources()).join('\n');
const classes = [...html.matchAll(/\bclass="([^"]+)"/g)] const classes = [...html.matchAll(/\bclass="([^"]+)"/g)]
.flatMap(match => match[1].split(/\s+/)) .flatMap(match => match[1].split(/\s+/))
+21
View File
@@ -29,6 +29,27 @@ const EXPECTED_SCHEMA = Object.freeze({
activated_at: { type: 'TEXT' }, activated_at: { type: 'TEXT' },
trash_path: { type: 'TEXT' } trash_path: { type: 'TEXT' }
}, },
collaboration_sessions: {
token_hash: { type: 'TEXT', notnull: 1, pk: 1 },
creator_id: { type: 'TEXT', notnull: 1 },
expires_at: { type: 'INTEGER', notnull: 1 }
},
collaboration_groups: {
id: { type: 'TEXT', notnull: 1, pk: 1 },
route_id: { type: 'INTEGER', notnull: 1 },
source_route_id: { type: 'INTEGER' },
owner_creator_id: { type: 'TEXT', notnull: 1 },
code_hash: { type: 'TEXT' },
code_expires_at: { type: 'INTEGER', notnull: 1 },
active: { type: 'INTEGER', notnull: 1 },
revision: { type: 'INTEGER', notnull: 1 },
created_at: { type: 'TEXT', notnull: 1 }
},
collaboration_members: {
group_id: { type: 'TEXT', notnull: 1, pk: 1 },
session_hash: { type: 'TEXT', notnull: 1, pk: 2 },
role: { type: 'TEXT', notnull: 1 }
},
pois: { pois: {
id: { type: 'INTEGER', pk: 1 }, id: { type: 'INTEGER', pk: 1 },
route_id: { type: 'INTEGER', notnull: 1 }, route_id: { type: 'INTEGER', notnull: 1 },