group code popup
This commit is contained in:
@@ -1,15 +1,16 @@
|
|||||||
# Gemeinsam bearbeiten
|
# Gemeinsam bearbeiten
|
||||||
|
|
||||||
In „Strecken bearbeiten“ trägt jede Strecke eine Schaltfläche mit zwei Wichteln:
|
In „Strecken bearbeiten“ öffnet der kleine Gruppenbutton zwischen Stift und
|
||||||
links der Vogel, rechts der Stein. „Gemeinsam bearbeiten“ legt einen gemeinsamen
|
Löschen einen Dialog. Dort sind die beiden Wichtel groß zu sehen: links der Vogel,
|
||||||
|
rechts der Stein. Erst „Gemeinsamen Entwurf öffnen“ legt einen gemeinsamen
|
||||||
Entwurf auf der Website an. Das Original bleibt unverändert. Bei lokalen Entwürfen
|
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.
|
fragt die App vorher, ob Weg, Stationen, Bilder und Ton übertragen werden dürfen.
|
||||||
Ein bereits eingesandter eigener Entwurf wird dabei zuerst aktualisiert.
|
Ein bereits eingesandter eigener Entwurf wird dabei zuerst aktualisiert.
|
||||||
|
|
||||||
Danach zeigt die App acht Wichtel als Gruppencode. Andere öffnen in der
|
Danach zeigt die App fünf Wichtel als Gruppencode. Andere öffnen in der
|
||||||
Streckenverwaltung „Gruppencode eingeben“ und schieben die Bilder in den acht
|
Streckenverwaltung „Gruppencode eingeben“ und schieben die Bilder in den fünf
|
||||||
Drehrädern nach oben oder unten. Es zählen die Wichtel in der Mitte, von links
|
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
|
nach rechts. Wiederholungen sind erlaubt. Pfeiltasten und die Pfeilschaltflächen
|
||||||
funktionieren ebenfalls. „Mitmachen“ öffnet den gemeinsamen Entwurf.
|
funktionieren ebenfalls. „Mitmachen“ öffnet den gemeinsamen Entwurf.
|
||||||
|
|
||||||
## Arbeiten und Einladungen
|
## Arbeiten und Einladungen
|
||||||
@@ -22,8 +23,12 @@ funktionieren ebenfalls. „Mitmachen“ öffnet den gemeinsamen Entwurf.
|
|||||||
- Wenn jemand zwischenzeitlich gespeichert hat, weist die App den veralteten
|
- Wenn jemand zwischenzeitlich gespeichert hat, weist die App den veralteten
|
||||||
Speicherversuch zurück. Die Eingaben bleiben stehen und können vor dem Laden
|
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.
|
des neuen Stands gesichert werden. Es gibt noch keine automatische Zusammenführung.
|
||||||
- Der Einladungscode gilt eine Stunde. „Neuen Code anzeigen“ ersetzt ihn; bereits
|
- Der Einladungscode gilt 30 Minuten. Anschließend erzeugt die Website automatisch
|
||||||
beigetretene Personen behalten ihren Zugang. Nur die eröffnende Person kann das.
|
einen neuen Code, sobald keine Station mehr gesperrt ist. Ein abgelaufener Code
|
||||||
|
erlaubt auch während einer Bearbeitung keinen neuen Beitritt. Bereits beigetretene
|
||||||
|
Personen behalten ihren Zugang. Das geöffnete Codefenster prüft alle 30 Sekunden
|
||||||
|
auf einen neuen Code. Die eröffnende Person kann mit „Neuen Code anzeigen“ auch
|
||||||
|
früher wechseln; während einer Stationsbearbeitung wartet auch dieser Wechsel.
|
||||||
- „Gruppe schließen“ entfernt den Zugang der anderen Personen und den Code.
|
- „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.
|
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
|
- Es sind höchstens 50 Zugänge je Gruppe und zwölf gleichzeitig offene Gruppen
|
||||||
@@ -88,7 +93,10 @@ 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
|
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
|
danach nicht mehr speichern. Zwei Fenster desselben Geräts benötigen ebenfalls
|
||||||
eine bestätigte Übernahme, damit sie einander nicht unbemerkt überschreiben.
|
eine bestätigte Übernahme, damit sie einander nicht unbemerkt überschreiben.
|
||||||
Beim Verlassen der Station wird ihre eigene Sperre entfernt. Bleibt nach einem
|
Beim Verlassen der Station wird ihre eigene Sperre entfernt. Eingaben sowie
|
||||||
|
Bedienaktionen in der Station melden bei bestehender Verbindung spätestens alle
|
||||||
|
30 Sekunden Aktivität. Das bloße Offenlassen des Fensters oder Abfragen des
|
||||||
|
Sperrstatus verlängert die Sperre nicht. Bleibt nach einem
|
||||||
Abbruch oder ohne Verbindung eine Sperre zurück, kann sie bestätigt übernommen
|
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.
|
werden. Während eine Speicherung läuft, ist keine Übernahme möglich.
|
||||||
|
|
||||||
@@ -128,7 +136,9 @@ angegebenen Anfragefeld. Dateien werden atomar ersetzt und mit Modus 0600
|
|||||||
angelegt. Dieser Ordner darf nicht unmittelbar durch Nginx ausgeliefert werden.
|
angelegt. Dieser Ordner darf nicht unmittelbar durch Nginx ausgeliefert werden.
|
||||||
Schließen der Gruppe entfernt ihre Sperren; laufende Schreibanfragen müssen
|
Schließen der Gruppe entfernt ihre Sperren; laufende Schreibanfragen müssen
|
||||||
zunächst fertig werden. Erfolgreiches Entfernen einer Station entfernt auch
|
zunächst fertig werden. Erfolgreiches Entfernen einer Station entfernt auch
|
||||||
ihre Sperrdatei. Zurückgebliebene Sperren werden nicht zeitgesteuert gelöscht.
|
ihre Sperrdatei. Sperren ohne gemeldete Aktivität werden nach 60 Minuten entfernt. Die Wartung
|
||||||
|
läuft beim App-Start und anschließend jede Minute; API-Zugriffe prüfen die Frist
|
||||||
|
zusätzlich unmittelbar.
|
||||||
|
|
||||||
Stationsänderungen prüfen zusätzlich einen SHA-256-Abdruck aus Stationsangaben
|
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
|
und den tatsächlichen Bild-/Tondateien. Andere Stationen können deshalb unabhängig
|
||||||
@@ -141,3 +151,9 @@ Die Sicherungen liegen in IndexedDB (`wegwichtel.group-point-drafts.v1`) und zä
|
|||||||
zur vorhandenen Anzeige des belegten Gerätespeichers. Browserdaten-Löschung kann
|
zur vorhandenen Anzeige des belegten Gerätespeichers. Browserdaten-Löschung kann
|
||||||
sie entfernen. Sie enthalten nur die betroffene Station und neu ausgewählte
|
sie entfernen. Sie enthalten nur die betroffene Station und neu ausgewählte
|
||||||
Dateien; vorhandene Aufnahmen werden hierfür nicht gesammelt heruntergeladen.
|
Dateien; vorhandene Aufnahmen werden hierfür nicht gesammelt heruntergeladen.
|
||||||
|
|
||||||
|
Einladungen werden dauerhaft weiterhin nur als Hash gespeichert. Den neu erzeugten
|
||||||
|
Klartext hält der App-Prozess im Arbeitsspeicher für die berechtigte Anzeige vor.
|
||||||
|
Nach einem App-Neustart bleibt der zuletzt gültige Code weiterhin bis zu seinem
|
||||||
|
Ablauf verwendbar. Falls er auf dem Gerät nicht mehr angezeigt wird, kann die
|
||||||
|
eröffnende Person bei freien Stationen einen neuen Code anfordern.
|
||||||
|
|||||||
+25
-1
@@ -874,7 +874,7 @@ Siehe [Gruppenbearbeitung.md](Gruppenbearbeitung.md). Alle Gruppenantworten sind
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| GET /api/groups | Gruppen der Sitzung auflisten; ohne Sitzung leere Liste |
|
| 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 | 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/join | Mit code (Array aus fünf Unicode-Symbolen; Wiederholungen erlaubt) und creator_id beitreten |
|
||||||
| POST /api/groups/:id/code | Als Besitzer neuen Einladungscode erzeugen |
|
| POST /api/groups/:id/code | Als Besitzer neuen Einladungscode erzeugen |
|
||||||
| DELETE /api/groups/:id | Als Besitzer Einladung und Mitbearbeiterzugänge widerrufen |
|
| DELETE /api/groups/:id | Als Besitzer Einladung und Mitbearbeiterzugänge widerrufen |
|
||||||
|
|
||||||
@@ -911,3 +911,27 @@ von If-Match wird dort der Stationsinhalt geprüft. Erfolgreiche Antworten liefe
|
|||||||
mehreren Dateioperationen jeweils weiterverwenden. Eine neue Station wird über
|
mehreren Dateioperationen jeweils weiterverwenden. Eine neue Station wird über
|
||||||
den bisherigen POST angelegt und vor den weiteren Dateioperationen gesperrt.
|
den bisherigen POST angelegt und vor den weiteren Dateioperationen gesperrt.
|
||||||
Die Versionsabdrücke berücksichtigen Dateibytes, nicht bloß URLs oder Metadaten.
|
Die Versionsabdrücke berücksichtigen Dateibytes, nicht bloß URLs oder Metadaten.
|
||||||
|
|
||||||
|
### Fristen, Aktivität und Status
|
||||||
|
|
||||||
|
`GET /api/groups/routes/:routeId/pois/:poiId/lock` prüft eine Station innerhalb
|
||||||
|
der berechtigten Gruppe und liefert ohne Antwortkörper **204** (frei) oder
|
||||||
|
**423** (gesperrt). Nicht vorhandene Strecken/Stationen ergeben **404**, fehlende
|
||||||
|
Anmeldung/Berechtigung weiterhin **401/403**. Die Abfrage verlängert keine Sperre.
|
||||||
|
|
||||||
|
`POST` auf denselben Pfad mit `{ "heartbeat": true, "token": "<eigener Schlüssel>" }`
|
||||||
|
verlängert nur die eigene noch gültige Sperre und liefert **204**. Abgelaufene
|
||||||
|
oder fremde Sperren ergeben **423**. Die App meldet nur tatsächliche Interaktion
|
||||||
|
mit der Station, gebündelt alle 30 Sekunden. Nach 60 Minuten ohne Aktivität wird
|
||||||
|
die Datei entfernt. Eine Wartung beim Start und jede Minute räumt sie auch ohne
|
||||||
|
weitere Client-Anfragen auf. Laufende Schreibanfragen bleiben bis zu ihrem
|
||||||
|
Abschluss geschützt.
|
||||||
|
|
||||||
|
`GET /api/groups/:id/invitation` ist ausschließlich für die eröffnende Sitzung.
|
||||||
|
Antwort: `group`, `busy`, `expired`, `expiresAt` und, wenn im Prozess verfügbar,
|
||||||
|
`code`. Nach 30 Minuten werden Codes automatisch ersetzt, sobald alle Stationen
|
||||||
|
frei sind. Während einer Stationssperre wird auch manuelles Erneuern mit **423**
|
||||||
|
(`group-editing`) zurückgewiesen. Der bisherige Code läuft trotzdem ab;
|
||||||
|
bestehende Mitglieder bleiben berechtigt. Die Wartung und dieser Abruf führen
|
||||||
|
die Erneuerung aus. Klartext-Codes stehen nur im Prozessspeicher, dauerhaft wird
|
||||||
|
ausschließlich ihr Hash gespeichert.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Agent-Hinweise zur Gruppenbearbeitung
|
# Agent-Hinweise zur Gruppenbearbeitung
|
||||||
|
|
||||||
Implementierter erster Stand: gemeinsame serverseitige Kopien von eigenen und
|
Implementierter erster Stand: gemeinsame serverseitige Kopien von eigenen und
|
||||||
veröffentlichten Strecken, acht Unicode-Wichtel im Einladungscode, Drehrad-Eingabe,
|
veröffentlichten Strecken, fünf Unicode-Wichtel im Einladungscode, Drehrad-Eingabe,
|
||||||
Sitzungen, Rollen owner/member, Codewechsel und Widerruf. Bedienung und Einspielen:
|
Sitzungen, Rollen owner/member, Codewechsel und Widerruf. Bedienung und Einspielen:
|
||||||
[Gruppenbearbeitung.md](../Gruppenbearbeitung.md).
|
[Gruppenbearbeitung.md](../Gruppenbearbeitung.md).
|
||||||
|
|
||||||
@@ -14,8 +14,10 @@ Sitzungen, Rollen owner/member, Codewechsel und Widerruf. Bedienung und Einspiel
|
|||||||
übermittelten creator_id oder eines von einem Client behaupteten Rollenfelds vergeben.
|
übermittelten creator_id oder eines von einem Client behaupteten Rollenfelds vergeben.
|
||||||
- Sitzungsgeheimnisse nur als Hash serverseitig und als HttpOnly-/SameSite-Strict-
|
- Sitzungsgeheimnisse nur als Hash serverseitig und als HttpOnly-/SameSite-Strict-
|
||||||
Cookie auf dem Client speichern; bei HTTPS zusätzlich Secure. Nicht ausgeben.
|
Cookie auf dem Client speichern; bei HTTPS zusätzlich Secure. Nicht ausgeben.
|
||||||
- Einladungscodes haben acht der vorhandenen neun Symbole, werden mit randomInt
|
- Einladungscodes haben fünf Stellen mit den vorhandenen neun Symbolen.
|
||||||
gewählt und nur gehasht gespeichert. Gültigkeit eine Stunde; falsche Versuche
|
Wiederholungen sind erlaubt; jede Stelle wird unabhängig mit randomInt
|
||||||
|
gewählt. Codes werden dauerhaft nur gehasht gespeichert; der aktuelle Klartext wird für
|
||||||
|
die Besitzeranzeige im Arbeitsspeicher des App-Prozesses gehalten. Gültigkeit 30 Minuten; falsche Versuche
|
||||||
sind begrenzt. Variationselektoren ändern die Unicode-Zuordnung nicht.
|
sind begrenzt. Variationselektoren ändern die Unicode-Zuordnung nicht.
|
||||||
- Schutz auch auf den ursprünglichen /api/routes/...-Adressen für alle Gruppen-
|
- Schutz auch auf den ursprünglichen /api/routes/...-Adressen für alle Gruppen-
|
||||||
Dateien durchsetzen. Anders geschriebene IDs (führende Nullen, Prozentkodierung,
|
Dateien durchsetzen. Anders geschriebene IDs (führende Nullen, Prozentkodierung,
|
||||||
@@ -77,3 +79,16 @@ Sitzungen, Rollen owner/member, Codewechsel und Widerruf. Bedienung und Einspiel
|
|||||||
Erfolgsantworten, gezieltes Zusammenführen einzelner Felder und Auflösung
|
Erfolgsantworten, gezieltes Zusammenführen einzelner Felder und Auflösung
|
||||||
entfernter Stationen sowie verteilte Sperren. Eine nachfolgende Aufnahme
|
entfernter Stationen sowie verteilte Sperren. Eine nachfolgende Aufnahme
|
||||||
kann nicht ohne Einschränkungen zurückgerufen werden.
|
kann nicht ohne Einschränkungen zurückgerufen werden.
|
||||||
|
|
||||||
|
- Einladungscodes nach 30 Minuten automatisch erneuern, solange keine aktive
|
||||||
|
Stationssperre vorliegt. Abgelaufene Codes erlauben keinen neuen Beitritt, auch
|
||||||
|
wenn ihre Erneuerung wegen aktiver Stationen aufgeschoben ist. Mitgliedschaften
|
||||||
|
bleiben erhalten. Wartung im Serverprozess starten und beim Beenden stoppen.
|
||||||
|
- Sperren nach 60 Minuten ohne Aktivität entfernen; updated_at bei tatsächlicher
|
||||||
|
Bearbeitung/Speicherung aktualisieren. Reine Statusabfragen und unbenutzte
|
||||||
|
Fenster dürfen die Frist nicht verlängern. Laufende Schreibanfragen nicht
|
||||||
|
wegen Zeitablauf entsperren. Zeitprüfung auch vor dem endgültigen Speichern.
|
||||||
|
- GET auf den Stations-Sperrpfad liefert 204 für frei, 423 für gesperrt, 404 für
|
||||||
|
nicht vorhanden; bestehende Gruppenrechte gelten unverändert. Heartbeats sind
|
||||||
|
POSTs mit eigenem Bearbeitungsschlüssel und dürfen weder fremde noch bereits
|
||||||
|
abgelaufene Sperren übernehmen.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ 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/`.
|
||||||
Die Gruppenfunktion verwendet acht Symbole je Code. Bilder werden dafür nicht
|
Die Gruppenfunktion verwendet fünf Symbole je Code; Wiederholungen sind erlaubt. Bilder werden dafür nicht
|
||||||
umbenannt oder neu erzeugt.
|
umbenannt oder neu erzeugt.
|
||||||
|
|
||||||
## Klasse
|
## Klasse
|
||||||
|
|||||||
@@ -595,23 +595,23 @@
|
|||||||
.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. */
|
/* Shared drafts and vertically scrolling Wichtel-code wheels. */
|
||||||
.group-pair { display: inline-flex; align-items: center; justify-content: center; width: 4.5rem; }
|
.group-share-pictures { display: flex; align-items: center; justify-content: center; gap: 1rem; margin: 1rem 0; }
|
||||||
.group-pair img { width: 1.8rem; height: 2.3rem; object-fit: contain; pointer-events: none; }
|
.group-share-pictures img { width: min(34vw, 9rem); height: 10rem; object-fit: contain; }
|
||||||
.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 { 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::backdrop { background: rgba(20, 40, 25, .5); }
|
||||||
.group-dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
.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-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 { 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-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-wheels { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .3rem; padding: 0; border: 0; margin: 1rem 0; }
|
||||||
.group-code-fieldset { border: 0; padding: 0; min-width: 0; }
|
.group-code-fieldset { border: 0; padding: 0; min-width: 0; }
|
||||||
.group-code-fieldset legend { margin-bottom: .5rem; font-weight: 600; }
|
.group-code-fieldset legend { margin-bottom: .5rem; font-weight: 600; }
|
||||||
.group-wheel { display: grid; gap: .3rem; min-width: 0; }
|
.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 { 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-viewport::-webkit-scrollbar { display: none; }
|
||||||
.group-wheel-option { height: 52px; display: grid; place-items: center; scroll-snap-align: center; cursor: pointer; }
|
.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-wheel-option img { width: 100%; max-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 { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .3rem; padding: .6rem; border: 1px solid #c8d3ca; border-radius: 1rem; background: #e7f1e9; }
|
||||||
.group-code img { width: 100%; height: 64px; object-fit: contain; }
|
.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 { max-width: min(18rem, 52vw); flex-wrap: wrap; justify-content: flex-end; }
|
||||||
.group-actions .editor-secondary-button { font-size: .85rem; }
|
.group-actions .editor-secondary-button { font-size: .85rem; }
|
||||||
|
|||||||
+14
-2
@@ -336,7 +336,7 @@
|
|||||||
<h3>5. Deine eigenen Strecken</h3>
|
<h3>5. Deine eigenen Strecken</h3>
|
||||||
<p>Neue Strecken bleiben zuerst auf deinem Gerät. Gespeichert werden der Weg, die Orte der Stationen, Texte sowie ausgewählte Bilder und Tonaufnahmen.</p>
|
<p>Neue Strecken bleiben zuerst auf deinem Gerät. Gespeichert werden der Weg, die Orte der Stationen, Texte sowie ausgewählte Bilder und Tonaufnahmen.</p>
|
||||||
<p>Deine Entwürfe bleiben erhalten, wenn alter Fortschritt gelöscht wird oder du „Meinen Fortschritt merken“ ausschaltest. Sie bleiben auf diesem Gerät, bis du sie löschst oder die gespeicherten Daten von Wegwichtel in deinem Internetbrowser entfernst. Nur wenn du „Zur Prüfung senden“ oder „Gemeinsam bearbeiten“ 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>Ä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. Nach 60 Minuten ohne gemeldete Aktivität wird diese Sperre entfernt.</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 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>
|
<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>
|
||||||
@@ -579,13 +579,25 @@
|
|||||||
<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-share-dialog" class="group-dialog" aria-labelledby="group-share-title">
|
||||||
|
<header class="group-dialog-header">
|
||||||
|
<h2 id="group-share-title" data-i18n="groups.share">Gemeinsam bearbeiten</h2>
|
||||||
|
<button id="group-share-close" type="button" data-i18n-aria-label="info.close">×</button>
|
||||||
|
</header>
|
||||||
|
<p id="group-share-route"></p>
|
||||||
|
<div id="group-share-pictures" class="group-share-pictures"></div>
|
||||||
|
<p data-i18n="groups.share-help">Mit diesen beiden Wichteln öffnest du einen gemeinsamen Entwurf. Andere können mit einem Wichtelcode mitmachen. Das Original bleibt erhalten.</p>
|
||||||
|
<div class="editor-form-actions">
|
||||||
|
<button id="group-share-start" type="button" data-i18n="groups.share-start">Gemeinsamen Entwurf öffnen</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
<dialog id="group-dialog" class="group-dialog" aria-labelledby="group-title">
|
<dialog id="group-dialog" class="group-dialog" aria-labelledby="group-title">
|
||||||
<header class="group-dialog-header">
|
<header class="group-dialog-header">
|
||||||
<h2 id="group-title" data-i18n="groups.share">Gemeinsam bearbeiten</h2>
|
<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>
|
<button id="group-close" type="button" aria-label="Schließen" data-i18n-aria-label="info.close">×</button>
|
||||||
</header>
|
</header>
|
||||||
<section id="group-entry">
|
<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>
|
<p data-i18n="groups.wheel-help">Schiebe die Wichtel nach oben oder unten. Die fünf Wichtel in der Mitte ergeben den Code. Lies von links nach rechts. Ein Wichtel darf mehrmals vorkommen.</p>
|
||||||
<fieldset class="group-code-fieldset"><legend data-i18n="groups.code-label">Gruppencode</legend><div id="group-wheels" class="group-wheels"></div></fieldset>
|
<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>
|
<button id="group-join" type="button" data-i18n="groups.join">Mitmachen</button>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1652,6 +1652,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
if (shareInProgress) return;
|
if (shareInProgress) return;
|
||||||
shareInProgress = true;
|
shareInProgress = true;
|
||||||
try {
|
try {
|
||||||
|
if (ns.Groups.confirmShare && !await ns.Groups.confirmShare(routeName(route))) return;
|
||||||
let sourceId = routeId(route);
|
let sourceId = routeId(route);
|
||||||
if (routeSource(route) === 'local') {
|
if (routeSource(route) === 'local') {
|
||||||
if (!globalThis.confirm(ns.I18n.t('groups.upload-confirm'))) return;
|
if (!globalThis.confirm(ns.I18n.t('groups.upload-confirm'))) return;
|
||||||
@@ -1712,6 +1713,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bind() {
|
function bind() {
|
||||||
|
for (const eventName of ['input', 'change', 'pointerdown', 'keydown']) {
|
||||||
|
byId(ids.poiForm)?.addEventListener(eventName, () => {
|
||||||
|
if (state.currentSource === 'group') ns.GroupPoints?.noteActivity?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('wegwichtel:point-lock-lost', () => {
|
||||||
|
if (state.currentSource === 'group') setStatus(ns.I18n.t('point-locks.lost'));
|
||||||
|
});
|
||||||
for (const eventName of ['cancel', 'close']) {
|
for (const eventName of ['cancel', 'close']) {
|
||||||
byId(ids.dialog)?.addEventListener(eventName, event => {
|
byId(ids.dialog)?.addEventListener(eventName, event => {
|
||||||
if (pointSaveInProgress && eventName === 'cancel') { event.preventDefault(); return; }
|
if (pointSaveInProgress && eventName === 'cancel') { event.preventDefault(); return; }
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ globalThis.Wegwichtel ??= {};
|
|||||||
let database;
|
let database;
|
||||||
let current = null;
|
let current = null;
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
|
let activity = 0;
|
||||||
|
let sentActivity = 0;
|
||||||
|
let touching = false;
|
||||||
const key = (routeId, poiId) => `${routeId}:${poiId}`;
|
const key = (routeId, poiId) => `${routeId}:${poiId}`;
|
||||||
async function store(mode, operation) {
|
async function store(mode, operation) {
|
||||||
if (!database) database = new Promise((resolve, reject) => {
|
if (!database) database = new Promise((resolve, reject) => {
|
||||||
@@ -45,6 +48,7 @@ globalThis.Wegwichtel ??= {};
|
|||||||
method: 'POST', credentials: 'same-origin', cache: 'no-store',
|
method: 'POST', credentials: 'same-origin', cache: 'no-store',
|
||||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
|
if (response.status === 204) return {};
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = new Error(t(response.status === 423 ? 'lost' : 'unavailable'));
|
const error = new Error(t(response.status === 423 ? 'lost' : 'unavailable'));
|
||||||
@@ -158,6 +162,23 @@ globalThis.Wegwichtel ??= {};
|
|||||||
if (savingGeneration !== generation) return false;
|
if (savingGeneration !== generation) return false;
|
||||||
return remote;
|
return remote;
|
||||||
}
|
}
|
||||||
|
function noteActivity() { activity += 1; }
|
||||||
|
async function keepActive() {
|
||||||
|
if (!current?.token || activity === sentActivity || touching) return;
|
||||||
|
const editing = current;
|
||||||
|
const observed = activity;
|
||||||
|
touching = true;
|
||||||
|
try {
|
||||||
|
await request(editing.routeId, editing.poiId, { heartbeat: true, token: editing.token });
|
||||||
|
if (editing === current) sentActivity = observed;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 423) {
|
||||||
|
sentActivity = observed;
|
||||||
|
document.dispatchEvent(new Event('wegwichtel:point-lock-lost'));
|
||||||
|
}
|
||||||
|
} finally { touching = false; }
|
||||||
|
}
|
||||||
|
globalThis.setInterval?.(() => { void keepActive(); }, 30_000);
|
||||||
function headersFor(path) {
|
function headersFor(path) {
|
||||||
const match = /^\/routes\/(\d+)\/pois\/(\d+)(?:\/|$)/.exec(path);
|
const match = /^\/routes\/(\d+)\/pois\/(\d+)(?:\/|$)/.exec(path);
|
||||||
if (!match || !current || String(current.routeId) !== match[1] || String(current.poiId) !== match[2]) return {};
|
if (!match || !current || String(current.routeId) !== match[1] || String(current.poiId) !== match[2]) return {};
|
||||||
@@ -167,7 +188,7 @@ globalThis.Wegwichtel ??= {};
|
|||||||
const version = response.headers?.get('X-Poi-Version');
|
const version = response.headers?.get('X-Poi-Version');
|
||||||
if (current && String(current.poiId) === response.headers?.get('X-Poi-Id') && version) current.version = 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,
|
ns.GroupPoints = Object.freeze({ begin, release, remember, read, list, remove, prepare, headersFor, observe, noteActivity, keepActive,
|
||||||
acceptNew: async (routeId, poi) => { current = { ...await acquire(routeId, poi.id), routeId, poiId: poi.id }; },
|
acceptNew: async (routeId, poi) => { current = { ...await acquire(routeId, poi.id), routeId, poiId: poi.id }; },
|
||||||
changed: () => document.dispatchEvent(new Event('wegwichtel:point-drafts-changed')) });
|
changed: () => document.dispatchEvent(new Event('wegwichtel:point-drafts-changed')) });
|
||||||
}(globalThis.Wegwichtel));
|
}(globalThis.Wegwichtel));
|
||||||
|
|||||||
+44
-8
@@ -8,7 +8,7 @@ globalThis.Wegwichtel ??= {};
|
|||||||
entry: 'group-entry', invitation: 'group-invitation', code: 'group-code', join: 'group-join',
|
entry: 'group-entry', invitation: 'group-invitation', code: 'group-code', join: 'group-join',
|
||||||
edit: 'group-edit', expiry: 'group-expiry'
|
edit: 'group-edit', expiry: 'group-expiry'
|
||||||
};
|
};
|
||||||
const codeLength = 8;
|
const codeLength = 5;
|
||||||
const wheelStep = 52;
|
const wheelStep = 52;
|
||||||
const revisions = new Map();
|
const revisions = new Map();
|
||||||
let selection = [];
|
let selection = [];
|
||||||
@@ -43,6 +43,7 @@ globalThis.Wegwichtel ??= {};
|
|||||||
function errorMessage(error) {
|
function errorMessage(error) {
|
||||||
if (error.reason === 'group-limit') return t('error.limit');
|
if (error.reason === 'group-limit') return t('error.limit');
|
||||||
if (error.reason === 'group-full') return t('error.full');
|
if (error.reason === 'group-full') return t('error.full');
|
||||||
|
if (error.reason === 'group-editing') return t('code-wait');
|
||||||
if (error.status === 423) return ns.I18n.t('point-locks.busy');
|
if (error.status === 423) return ns.I18n.t('point-locks.busy');
|
||||||
if (error.status === 429) return t('error.rate');
|
if (error.status === 429) return t('error.rate');
|
||||||
if (error.status === 404) return t('error.code');
|
if (error.status === 404) return t('error.code');
|
||||||
@@ -167,6 +168,19 @@ globalThis.Wegwichtel ??= {};
|
|||||||
if (!element(ids.dialog).open) element(ids.dialog).showModal();
|
if (!element(ids.dialog).open) element(ids.dialog).showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let invitationPolling = false;
|
||||||
|
async function refreshInvitation() {
|
||||||
|
const group = currentGroup;
|
||||||
|
if (!group || invitationPolling || !element(ids.dialog).open || element(ids.invitation).hidden) return;
|
||||||
|
invitationPolling = true;
|
||||||
|
try {
|
||||||
|
const payload = await request(`/${group.id}/invitation`);
|
||||||
|
if (currentGroup?.id !== group.id || !element(ids.dialog).open || element(ids.invitation).hidden) return;
|
||||||
|
if (payload.code) showInvitation(payload);
|
||||||
|
element(ids.status).textContent = payload.busy && payload.expired ? t('code-wait') : '';
|
||||||
|
} catch { /* Keep the invitation visible; expiry still applies on the server. */ }
|
||||||
|
finally { invitationPolling = false; }
|
||||||
|
}
|
||||||
async function edit(group) {
|
async function edit(group) {
|
||||||
element(ids.dialog).close();
|
element(ids.dialog).close();
|
||||||
await ns.EditorUi.openGroup(group.route);
|
await ns.EditorUi.openGroup(group.route);
|
||||||
@@ -244,18 +258,34 @@ globalThis.Wegwichtel ??= {};
|
|||||||
function shareButton(action) {
|
function shareButton(action) {
|
||||||
const result = document.createElement('button');
|
const result = document.createElement('button');
|
||||||
result.type = 'button';
|
result.type = 'button';
|
||||||
result.className = 'editor-icon-button group-pair';
|
result.className = 'editor-icon-button';
|
||||||
result.dataset.editorAction = action;
|
result.dataset.editorAction = action;
|
||||||
result.title = t('share');
|
result.title = t('share');
|
||||||
result.setAttribute('aria-label', t('share'));
|
result.setAttribute('aria-label', t('share'));
|
||||||
for (const value of ['🐦', '🪨']) {
|
result.setAttribute('aria-haspopup', 'dialog');
|
||||||
const image = picture(ns.GroupSymbols.get(value));
|
result.textContent = '👥';
|
||||||
image.alt = '';
|
|
||||||
result.appendChild(image);
|
|
||||||
}
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let shareDecision = null;
|
||||||
|
function confirmShare(name) {
|
||||||
|
if (shareDecision) return Promise.resolve(false);
|
||||||
|
const dialog = element('group-share-dialog');
|
||||||
|
element('group-share-route').textContent = name;
|
||||||
|
element('group-share-pictures').replaceChildren(...['🐦', '🪨'].map(value => picture(ns.GroupSymbols.get(value))));
|
||||||
|
return new Promise(resolve => {
|
||||||
|
shareDecision = resolve;
|
||||||
|
dialog.showModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishShare(accepted) {
|
||||||
|
const resolve = shareDecision;
|
||||||
|
shareDecision = null;
|
||||||
|
element('group-share-dialog').close();
|
||||||
|
resolve?.(accepted);
|
||||||
|
}
|
||||||
|
|
||||||
function headersFor(path) {
|
function headersFor(path) {
|
||||||
const id = /^\/routes\/(\d+)(?:\/|$)/.exec(path)?.[1];
|
const id = /^\/routes\/(\d+)(?:\/|$)/.exec(path)?.[1];
|
||||||
return id && revisions.has(id) ? { 'If-Match': String(revisions.get(id)) } : {};
|
return id && revisions.has(id) ? { 'If-Match': String(revisions.get(id)) } : {};
|
||||||
@@ -268,6 +298,12 @@ globalThis.Wegwichtel ??= {};
|
|||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
globalThis.setInterval?.(() => { void refreshInvitation(); }, 30_000);
|
||||||
|
activate(element('group-share-start'), () => finishShare(true));
|
||||||
|
activate(element('group-share-close'), () => finishShare(false));
|
||||||
|
element('group-share-dialog').addEventListener('close', () => {
|
||||||
|
const resolve = shareDecision; shareDecision = null; resolve?.(false);
|
||||||
|
});
|
||||||
document.addEventListener('wegwichtel:point-drafts-changed', () => { void renderPending(); void ns.StorageUsage?.refresh(); });
|
document.addEventListener('wegwichtel:point-drafts-changed', () => { void renderPending(); void ns.StorageUsage?.refresh(); });
|
||||||
element(ids.dialog).addEventListener('cancel', () => { dialogGeneration += 1; });
|
element(ids.dialog).addEventListener('cancel', () => { dialogGeneration += 1; });
|
||||||
element(ids.dialog).addEventListener('close', () => { dialogGeneration += 1; });
|
element(ids.dialog).addEventListener('close', () => { dialogGeneration += 1; });
|
||||||
@@ -292,7 +328,7 @@ globalThis.Wegwichtel ??= {};
|
|||||||
activate(element(ids.edit), () => guarded(() => edit(currentGroup)));
|
activate(element(ids.edit), () => guarded(() => edit(currentGroup)));
|
||||||
}
|
}
|
||||||
|
|
||||||
ns.Groups = Object.freeze({ share, shareButton, refresh, headersFor, observe });
|
ns.Groups = Object.freeze({ share, shareButton, confirmShare, refresh, refreshInvitation, headersFor, observe });
|
||||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||||
else init();
|
else init();
|
||||||
}(globalThis.Wegwichtel));
|
}(globalThis.Wegwichtel));
|
||||||
|
|||||||
@@ -134,12 +134,15 @@ globalThis.Wegwichtel ??= {};
|
|||||||
"ready": "Alles ist bereit."
|
"ready": "Alles ist bereit."
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
|
"code-wait": "In der Gruppe wird gerade eine Station bearbeitet. Ein neuer Code erscheint, sobald die Stationen wieder frei sind. Ein abgelaufener Code erlaubt keinen neuen Beitritt.",
|
||||||
|
"share-help": "Mit diesen beiden Wichteln öffnest du einen gemeinsamen Entwurf. Andere können mit einem Wichtelcode mitmachen. Das Original bleibt erhalten.",
|
||||||
|
"share-start": "Gemeinsamen Entwurf öffnen",
|
||||||
"title": "Gemeinsame Entwürfe",
|
"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?",
|
"upload-confirm": "Dein Entwurf mit Bildern und Ton wird dafür an die Website gesendet und als gemeinsamer Entwurf kopiert. Fortfahren?",
|
||||||
"share": "Gemeinsam bearbeiten",
|
"share": "Gemeinsam bearbeiten",
|
||||||
"join-open": "Gruppencode eingeben",
|
"join-open": "Gruppencode eingeben",
|
||||||
"online": "Gemeinsam bearbeiten braucht eine Internetverbindung.",
|
"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.",
|
"wheel-help": "Schiebe die Wichtel nach oben oder unten. Die fünf Wichtel in der Mitte ergeben den Code. Lies von links nach rechts. Ein Wichtel darf mehrmals vorkommen.",
|
||||||
"code-label": "Gruppencode",
|
"code-label": "Gruppencode",
|
||||||
"join": "Mitmachen",
|
"join": "Mitmachen",
|
||||||
"invite-help": "Zeige diesen Code den anderen. Damit können sie deinen gemeinsamen Entwurf öffnen und bearbeiten. Das Original bleibt erhalten.",
|
"invite-help": "Zeige diesen Code den anderen. Damit können sie deinen gemeinsamen Entwurf öffnen und bearbeiten. Das Original bleibt erhalten.",
|
||||||
|
|||||||
@@ -134,12 +134,15 @@ globalThis.Wegwichtel ??= {};
|
|||||||
"ready": "Everything is ready."
|
"ready": "Everything is ready."
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
|
"code-wait": "Someone in the group is editing a stop. A new code will appear once the stops are free. An expired code cannot be used to join.",
|
||||||
|
"share-help": "These two gnomes open a shared draft. Others can join using a gnome code. The original stays unchanged.",
|
||||||
|
"share-start": "Open a shared draft",
|
||||||
"title": "Shared drafts",
|
"title": "Shared drafts",
|
||||||
"upload-confirm": "Your draft, including pictures and recordings, will be sent to the website and copied into a shared draft. Continue?",
|
"upload-confirm": "Your draft, including pictures and recordings, will be sent to the website and copied into a shared draft. Continue?",
|
||||||
"share": "Edit together",
|
"share": "Edit together",
|
||||||
"join-open": "Enter group code",
|
"join-open": "Enter group code",
|
||||||
"online": "You need an internet connection to edit together.",
|
"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.",
|
"wheel-help": "Slide the gnomes up or down. The gnomes in the middle make the code. Read the five gnomes from left to right. The same gnome can appear more than once.",
|
||||||
"code-label": "Group code",
|
"code-label": "Group code",
|
||||||
"join": "Join in",
|
"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.",
|
"invite-help": "Show this code to the others so they can open and edit your shared draft. The original stays as it is.",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { offlineManifest } from './src/services/offline-manifest.js';
|
import { offlineManifest } from './src/services/offline-manifest.js';
|
||||||
import { setAppImageCopyright } from './src/services/copyright.js';
|
import { setAppImageCopyright } from './src/services/copyright.js';
|
||||||
import { config, ensureRuntimeDirectories } from './src/config.js';
|
import { config, ensureRuntimeDirectories } from './src/config.js';
|
||||||
|
import { startCollaborationMaintenance } from './src/services/collaboration.js';
|
||||||
import { openDatabase } from './src/database.js';
|
import { openDatabase } from './src/database.js';
|
||||||
import { createApiRouter } from './src/routes/api.js';
|
import { createApiRouter } from './src/routes/api.js';
|
||||||
import { notFoundHandler, errorHandler } from './src/middleware/errors.js';
|
import { notFoundHandler, errorHandler } from './src/middleware/errors.js';
|
||||||
@@ -11,6 +12,7 @@ import { notFoundHandler, errorHandler } from './src/middleware/errors.js';
|
|||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
ensureRuntimeDirectories();
|
ensureRuntimeDirectories();
|
||||||
const db = openDatabase();
|
const db = openDatabase();
|
||||||
|
const stopCollaborationMaintenance = startCollaborationMaintenance(db);
|
||||||
const app = express();
|
const app = express();
|
||||||
const publicDirectory = path.join(__dirname, 'public');
|
const publicDirectory = path.join(__dirname, 'public');
|
||||||
|
|
||||||
@@ -54,6 +56,7 @@ const server = app.listen(config.port, config.host, () => {
|
|||||||
function shutdown(signal) {
|
function shutdown(signal) {
|
||||||
console.log(`${signal}: Server wird beendet.`);
|
console.log(`${signal}: Server wird beendet.`);
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
|
stopCollaborationMaintenance();
|
||||||
db.close();
|
db.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-2
@@ -1,7 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { createGroup, joinGroup, listGroups, renewCode, closeGroup, verifyOrigin, session, member } from '../services/collaboration.js';
|
import { createGroup, joinGroup, listGroups, renewCode, closeGroup, verifyOrigin, session, member, invitationStatus } from '../services/collaboration.js';
|
||||||
|
|
||||||
import { acquirePoint, releasePoint } from '../services/point-locks.js';
|
import { acquirePoint, releasePoint, touchPoint, pointLocked } from '../services/point-locks.js';
|
||||||
import { HttpError } from '../middleware/errors.js';
|
import { HttpError } from '../middleware/errors.js';
|
||||||
|
|
||||||
export function createGroupsRouter(db) {
|
export function createGroupsRouter(db) {
|
||||||
@@ -16,12 +16,23 @@ export function createGroupsRouter(db) {
|
|||||||
if (!group) throw new HttpError(404, 'Gruppe nicht gefunden.');
|
if (!group) throw new HttpError(404, 'Gruppe nicht gefunden.');
|
||||||
member(db, req, group);
|
member(db, req, group);
|
||||||
const account = session(db, req);
|
const account = session(db, req);
|
||||||
|
if (req.body?.heartbeat) {
|
||||||
|
touchPoint(group, account, req.params.poiId, req.body.token);
|
||||||
|
return res.status(204).end();
|
||||||
|
}
|
||||||
if (req.body?.release) {
|
if (req.body?.release) {
|
||||||
releasePoint(group, account, req.params.poiId, req.body.token);
|
releasePoint(group, account, req.params.poiId, req.body.token);
|
||||||
return res.json({ released: true });
|
return res.json({ released: true });
|
||||||
}
|
}
|
||||||
return res.json(acquirePoint(db, group, account, req.params.poiId, req.body));
|
return res.json(acquirePoint(db, group, account, req.params.poiId, req.body));
|
||||||
});
|
});
|
||||||
|
router.get('/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);
|
||||||
|
res.status(pointLocked(db, group, req.params.poiId) ? 423 : 204).end();
|
||||||
|
});
|
||||||
|
router.get('/:id/invitation', (req, res) => res.json(invitationStatus(db, req, req.params.id)));
|
||||||
router.get('/', (req, res) => res.json({ groups: listGroups(db, req) }));
|
router.get('/', (req, res) => res.json({ groups: listGroups(db, req) }));
|
||||||
router.post('/', (req, res) => res.status(201).json(createGroup(db, req, res)));
|
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('/join', (req, res) => res.json({ group: joinGroup(db, req, res) }));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { guardPoint, clearPointLocks } from './point-locks.js';
|
import { guardPoint, clearPointLocks, hasPointLocks } from './point-locks.js';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
@@ -9,10 +9,11 @@ import { createRoute, getRoute, getRouteGpxFile, createPoi } from './routes-serv
|
|||||||
import { routeDirectory, relativeStoragePath, resolveStoredFile } from './storage.js';
|
import { routeDirectory, relativeStoragePath, resolveStoredFile } from './storage.js';
|
||||||
|
|
||||||
export const GROUP_SYMBOLS = Object.freeze(['🪵', '🪨', '🏮', '🌼', '🚬', '🐦', '🍄', '🍓', '🎃']);
|
export const GROUP_SYMBOLS = Object.freeze(['🪵', '🪨', '🏮', '🌼', '🚬', '🐦', '🍄', '🍓', '🎃']);
|
||||||
export const GROUP_CODE_LENGTH = 8;
|
export const GROUP_CODE_LENGTH = 5;
|
||||||
export const SESSION_COOKIE = 'wegwichtel_group_session';
|
export const SESSION_COOKIE = 'wegwichtel_group_session';
|
||||||
const SESSION_SECONDS = 30 * 24 * 60 * 60;
|
const SESSION_SECONDS = 30 * 24 * 60 * 60;
|
||||||
const CODE_SECONDS = 60 * 60;
|
const CODE_SECONDS = 30 * 60;
|
||||||
|
const invitations = new Map();
|
||||||
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
|
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
|
||||||
const now = () => Math.floor(Date.now() / 1000);
|
const now = () => Math.floor(Date.now() / 1000);
|
||||||
const joinFailures = new Map();
|
const joinFailures = new Map();
|
||||||
@@ -71,7 +72,9 @@ function issueCode(db, groupId) {
|
|||||||
const expiresAt = now() + CODE_SECONDS;
|
const expiresAt = now() + CODE_SECONDS;
|
||||||
db.prepare('UPDATE collaboration_groups SET code_hash = ?, code_expires_at = ?, active = 1 WHERE id = ?')
|
db.prepare('UPDATE collaboration_groups SET code_hash = ?, code_expires_at = ?, active = 1 WHERE id = ?')
|
||||||
.run(digest, expiresAt, groupId);
|
.run(digest, expiresAt, groupId);
|
||||||
return { code, expiresAt };
|
const invitation = { code, expiresAt };
|
||||||
|
invitations.set(groupId, invitation);
|
||||||
|
return invitation;
|
||||||
}
|
}
|
||||||
throw new HttpError(503, 'Bitte versuche es gleich noch einmal.');
|
throw new HttpError(503, 'Bitte versuche es gleich noch einmal.');
|
||||||
}
|
}
|
||||||
@@ -198,6 +201,7 @@ export function joinGroup(db, req, res) {
|
|||||||
export function renewCode(db, req, id) {
|
export function renewCode(db, req, id) {
|
||||||
const group = owner(db, req, id);
|
const group = owner(db, req, id);
|
||||||
if (!group.active) assertGroupQuota(db, session(db, req).token_hash);
|
if (!group.active) assertGroupQuota(db, session(db, req).token_hash);
|
||||||
|
if (hasPointLocks(group.route_id)) throw new HttpError(423, 'In dieser Gruppe wird gerade eine Station bearbeitet.', { code: 'group-editing' });
|
||||||
const invitation = issueCode(db, id);
|
const invitation = issueCode(db, id);
|
||||||
return { group: groupSummary(db, groupById(db, id), 'owner'), ...invitation };
|
return { group: groupSummary(db, groupById(db, id), 'owner'), ...invitation };
|
||||||
}
|
}
|
||||||
@@ -209,9 +213,44 @@ export function closeGroup(db, req, id) {
|
|||||||
db.prepare('UPDATE collaboration_groups SET active = 0, code_hash = NULL, code_expires_at = 0 WHERE id = ?').run(id);
|
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);
|
db.prepare("DELETE FROM collaboration_members WHERE group_id = ? AND role = 'member'").run(id);
|
||||||
});
|
});
|
||||||
|
invitations.delete(id);
|
||||||
return { closed: true };
|
return { closed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function invitationStatus(db, req, id) {
|
||||||
|
let group = owner(db, req, id);
|
||||||
|
const busy = hasPointLocks(group.route_id);
|
||||||
|
if (group.active && group.code_expires_at <= now() && !busy) {
|
||||||
|
issueCode(db, id);
|
||||||
|
group = groupById(db, id);
|
||||||
|
}
|
||||||
|
const invitation = invitations.get(id);
|
||||||
|
return { group: groupSummary(db, group, 'owner'), busy, expired: group.code_expires_at <= now(), expiresAt: group.code_expires_at,
|
||||||
|
...(invitation?.expiresAt === group.code_expires_at ? { code: invitation.code } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maintainCollaboration(db) {
|
||||||
|
const groups = db.prepare(`SELECT g.*, r.activated_at FROM collaboration_groups g JOIN routes r ON r.id = g.route_id
|
||||||
|
WHERE r.status = 'active'`).all();
|
||||||
|
for (const group of groups) {
|
||||||
|
const busy = hasPointLocks(group.route_id);
|
||||||
|
if (!group.active || group.activated_at) { invitations.delete(group.id); continue; }
|
||||||
|
const ownerExists = db.prepare(`SELECT 1 FROM collaboration_members m JOIN collaboration_sessions s ON s.token_hash = m.session_hash
|
||||||
|
WHERE m.group_id = ? AND m.role = 'owner' AND s.expires_at > ?`).get(group.id, now());
|
||||||
|
if (ownerExists && group.code_expires_at <= now() && !busy) issueCode(db, group.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startCollaborationMaintenance(db) {
|
||||||
|
const sweep = () => {
|
||||||
|
try { maintainCollaboration(db); } catch (error) { console.error('Gruppenpflege fehlgeschlagen:', error); }
|
||||||
|
};
|
||||||
|
sweep();
|
||||||
|
const timer = setInterval(sweep, 60_000);
|
||||||
|
timer.unref();
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
|
|
||||||
export function verifyOrigin(req) {
|
export function verifyOrigin(req) {
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
if (!origin) return;
|
if (!origin) return;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { HttpError } from '../middleware/errors.js';
|
|||||||
|
|
||||||
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
|
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
|
||||||
const running = new Set();
|
const running = new Set();
|
||||||
|
export const POINT_IDLE_MS = 60 * 60 * 1000;
|
||||||
const key = (routeId, poiId) => `${Number(routeId)}:${Number(poiId)}`;
|
const key = (routeId, poiId) => `${Number(routeId)}:${Number(poiId)}`;
|
||||||
function filename(routeId, poiId) {
|
function filename(routeId, poiId) {
|
||||||
if (![routeId, poiId].every(value => Number.isSafeInteger(Number(value)) && Number(value) > 0)) {
|
if (![routeId, poiId].every(value => Number.isSafeInteger(Number(value)) && Number(value) > 0)) {
|
||||||
@@ -15,7 +16,15 @@ function filename(routeId, poiId) {
|
|||||||
return path.join(routeDirectory(Number(routeId)), 'locks', `${Number(poiId)}.json`);
|
return path.join(routeDirectory(Number(routeId)), 'locks', `${Number(poiId)}.json`);
|
||||||
}
|
}
|
||||||
function read(routeId, poiId) {
|
function read(routeId, poiId) {
|
||||||
try { return JSON.parse(fs.readFileSync(filename(routeId, poiId), 'utf8')); }
|
try {
|
||||||
|
const lock = JSON.parse(fs.readFileSync(filename(routeId, poiId), 'utf8'));
|
||||||
|
const lastActivity = Date.parse(lock.updated_at || lock.created_at);
|
||||||
|
if (!running.has(key(routeId, poiId)) && (!Number.isFinite(lastActivity) || Date.now() - lastActivity >= POINT_IDLE_MS)) {
|
||||||
|
fs.rmSync(filename(routeId, poiId), { force: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return lock;
|
||||||
|
}
|
||||||
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
||||||
}
|
}
|
||||||
function write(routeId, poiId, lock) {
|
function write(routeId, poiId, lock) {
|
||||||
@@ -52,6 +61,7 @@ export function acquirePoint(db, group, account, poiId, body) {
|
|||||||
}
|
}
|
||||||
const lock = resume ? existing : { creator_id: account.creator_id, session_hash: account.token_hash,
|
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() };
|
token: crypto.randomBytes(32).toString('hex'), created_at: new Date().toISOString() };
|
||||||
|
lock.updated_at = new Date(Date.now()).toISOString();
|
||||||
write(group.route_id, poi.id, lock);
|
write(group.route_id, poi.id, lock);
|
||||||
return { token: lock.token, ...pointSnapshot(db, group.route_id, poi.id), poi };
|
return { token: lock.token, ...pointSnapshot(db, group.route_id, poi.id), poi };
|
||||||
}
|
}
|
||||||
@@ -62,6 +72,27 @@ export function releasePoint(group, account, poiId, token) {
|
|||||||
if (running.has(key(group.route_id, poiId))) throw new HttpError(423, 'Diese Station wird gerade gespeichert.', { code: 'point-busy' });
|
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 });
|
fs.rmSync(filename(group.route_id, poiId), { force: true });
|
||||||
}
|
}
|
||||||
|
export function touchPoint(group, account, poiId, token) {
|
||||||
|
const lock = read(group.route_id, poiId);
|
||||||
|
if (!sameOwner(lock, account) || lock.token !== token) throw new HttpError(423, 'Die Bearbeitung wurde übernommen.', { code: 'point-lost' });
|
||||||
|
lock.updated_at = new Date(Date.now()).toISOString();
|
||||||
|
write(group.route_id, poiId, lock);
|
||||||
|
}
|
||||||
|
export function pointLocked(db, group, poiId) {
|
||||||
|
getPoi(db, group.route_id, Number(poiId));
|
||||||
|
return Boolean(read(group.route_id, poiId));
|
||||||
|
}
|
||||||
|
export function hasPointLocks(routeId) {
|
||||||
|
const directory = path.join(routeDirectory(routeId), 'locks');
|
||||||
|
let entries;
|
||||||
|
try { entries = fs.readdirSync(directory, { withFileTypes: true }); }
|
||||||
|
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
||||||
|
let locked = false;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isFile() && /^\d+\.json$/.test(entry.name) && read(routeId, Number(entry.name.slice(0, -5)))) locked = true;
|
||||||
|
}
|
||||||
|
return locked;
|
||||||
|
}
|
||||||
export function clearPointLocks(routeId) {
|
export function clearPointLocks(routeId) {
|
||||||
if ([...running].some(id => id.startsWith(`${Number(routeId)}:`))) throw new HttpError(423, 'Eine Station wird gerade gespeichert.', { code: 'point-busy' });
|
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 });
|
fs.rmSync(path.join(routeDirectory(routeId), 'locks'), { recursive: true, force: true });
|
||||||
@@ -86,6 +117,7 @@ export function guardPoint(db, group, account, poiId, req, res) {
|
|||||||
const json = res.json;
|
const json = res.json;
|
||||||
res.json = function (payload) {
|
res.json = function (payload) {
|
||||||
if (res.statusCode >= 200 && res.statusCode < 300 && db.prepare('SELECT 1 FROM pois WHERE id = ?').get(Number(poiId))) {
|
if (res.statusCode >= 200 && res.statusCode < 300 && db.prepare('SELECT 1 FROM pois WHERE id = ?').get(Number(poiId))) {
|
||||||
|
touchPoint(group, account, poiId, req.headers['x-poi-lock']);
|
||||||
res.setHeader('X-Poi-Version', pointVersion(db, group.route_id, poiId));
|
res.setHeader('X-Poi-Version', pointVersion(db, group.route_id, poiId));
|
||||||
res.setHeader('X-Poi-Id', String(poiId));
|
res.setHeader('X-Poi-Id', String(poiId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import nodeCrypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -15,7 +16,7 @@ const { createPicture, createAudio } = await import('../src/services/media-servi
|
|||||||
const { resolveStoredFile } = await import('../src/services/storage.js');
|
const { resolveStoredFile } = await import('../src/services/storage.js');
|
||||||
const { createApiRouter } = await import('../src/routes/api.js');
|
const { createApiRouter } = await import('../src/routes/api.js');
|
||||||
const { notFoundHandler, errorHandler } = await import('../src/middleware/errors.js');
|
const { notFoundHandler, errorHandler } = await import('../src/middleware/errors.js');
|
||||||
const { groupRouteGuard, GROUP_SYMBOLS, SESSION_COOKIE } = await import('../src/services/collaboration.js');
|
const { groupRouteGuard, GROUP_SYMBOLS, SESSION_COOKIE, maintainCollaboration, startCollaborationMaintenance } = await import('../src/services/collaboration.js');
|
||||||
const express = (await import('express')).default;
|
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>';
|
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>';
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ test('shared draft clones a published route and protects its contents and owners
|
|||||||
const { db, source, request } = await fixture(t);
|
const { db, source, request } = await fixture(t);
|
||||||
const host = {}, guest = {}, outsider = {};
|
const host = {}, guest = {}, outsider = {};
|
||||||
const { value: created, response } = await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201);
|
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.equal(created.code.length, 5);
|
||||||
assert.ok(created.code.every(value => GROUP_SYMBOLS.includes(value)));
|
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'), /HttpOnly/);
|
||||||
assert.match(response.headers.get('set-cookie'), /SameSite=Strict/);
|
assert.match(response.headers.get('set-cookie'), /SameSite=Strict/);
|
||||||
@@ -238,3 +239,60 @@ test('different station edits do not conflict through the route revision, and in
|
|||||||
await request(guest, lockPath(first.id), 'POST', { replace: fresh.token });
|
await request(guest, lockPath(first.id), 'POST', { replace: fresh.token });
|
||||||
assert.throws(() => req.validatePointWrite(), error => error.status === 423);
|
assert.throws(() => req.validatePointWrite(), error => error.status === 423);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('five-symbol invitations allow repeated motifs and reject other code lengths', async t => {
|
||||||
|
const { source, request } = await fixture(t);
|
||||||
|
t.mock.method(nodeCrypto, 'randomInt', () => 5);
|
||||||
|
const host = {}, guest = {};
|
||||||
|
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||||
|
assert.deepEqual(created.code, Array(5).fill('🐦'));
|
||||||
|
await request(guest, '/groups/join', 'POST', { code: Array(4).fill('🐦'), creator_id: 'b'.repeat(64) }, 404);
|
||||||
|
await request(guest, '/groups/join', 'POST', { code: Array(6).fill('🐦'), creator_id: 'b'.repeat(64) }, 404);
|
||||||
|
const joined = (await request(guest, '/groups/join', 'POST', { code: Array(5).fill('🐦'), creator_id: 'b'.repeat(64) })).value;
|
||||||
|
assert.equal(joined.group.id, created.group.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('invitations expire after thirty minutes, wait for active locks and rotate after sixty idle minutes', async t => {
|
||||||
|
const { db, source, request } = await fixture(t);
|
||||||
|
const host = {}, guest = {};
|
||||||
|
const started = Date.now();
|
||||||
|
let clock = started;
|
||||||
|
t.mock.method(Date, 'now', () => clock);
|
||||||
|
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||||
|
assert.equal(created.expiresAt, Math.floor(started / 1000) + 1800);
|
||||||
|
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) });
|
||||||
|
const group = created.group, route = group.route, poi = route.pois[0];
|
||||||
|
const endpoint = `/groups/routes/${route.id}/pois/${poi.id}/lock`;
|
||||||
|
const lock = (await request(guest, endpoint, 'POST', {})).value;
|
||||||
|
const file = path.join(config.storageDir, 'active', 'routes', String(route.id), 'locks', `${poi.id}.json`);
|
||||||
|
const firstActivity = JSON.parse(fs.readFileSync(file)).updated_at;
|
||||||
|
clock += 31 * 60_000;
|
||||||
|
maintainCollaboration(db);
|
||||||
|
const held = (await request(host, `/groups/${group.id}/invitation`)).value;
|
||||||
|
assert.equal(held.busy, true);
|
||||||
|
assert.equal(held.expiresAt, created.expiresAt);
|
||||||
|
await request({}, '/groups/join', 'POST', { code: created.code, creator_id: 'c'.repeat(64) }, 404);
|
||||||
|
await request(guest, `/groups/${group.id}/invitation`, 'GET', null, 403);
|
||||||
|
await request(host, `/groups/${group.id}/code`, 'POST', {}, 423);
|
||||||
|
await request(host, endpoint, 'GET', null, 423);
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(file)).updated_at, firstActivity, 'status queries must not count as activity');
|
||||||
|
await request(host, endpoint, 'POST', { heartbeat: true, token: lock.token }, 423);
|
||||||
|
await request(guest, endpoint, 'POST', { heartbeat: true, token: lock.token }, 204);
|
||||||
|
assert.notEqual(JSON.parse(fs.readFileSync(file)).updated_at, firstActivity);
|
||||||
|
clock = started + 90 * 60_000;
|
||||||
|
maintainCollaboration(db);
|
||||||
|
assert.equal(fs.existsSync(file), true, 'recent real activity preserves the lock');
|
||||||
|
clock = started + 91 * 60_000;
|
||||||
|
const stop = startCollaborationMaintenance(db); stop();
|
||||||
|
assert.equal(fs.existsSync(file), false, 'sixty idle minutes remove the actual lock file');
|
||||||
|
await request(host, endpoint, 'GET', null, 204);
|
||||||
|
await request(guest, endpoint, 'POST', { heartbeat: true, token: lock.token }, 423);
|
||||||
|
const renewed = (await request(host, `/groups/${group.id}/invitation`)).value;
|
||||||
|
assert.equal(renewed.busy, false);
|
||||||
|
assert.equal(renewed.code.length, 5);
|
||||||
|
assert.equal(renewed.expiresAt, Math.floor(clock / 1000) + 1800);
|
||||||
|
await request({}, '/groups/join', 'POST', { code: renewed.code, creator_id: 'c'.repeat(64) });
|
||||||
|
await request(host, endpoint.replace(String(poi.id) + '/lock', '99999/lock'), 'GET', null, 404);
|
||||||
|
});
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ async function harness(records = new Map()) {
|
|||||||
const body = JSON.parse(options.body); calls.push({ url, body });
|
const body = JSON.parse(options.body); calls.push({ url, body });
|
||||||
if (controls.offline) throw new TypeError('offline');
|
if (controls.offline) throw new TypeError('offline');
|
||||||
let status = 200, payload;
|
let status = 200, payload;
|
||||||
if (body.release) payload = { released: true };
|
if (body.heartbeat && !controls.locked) { status = 204; payload = {}; }
|
||||||
|
else if (body.release) payload = { released: true };
|
||||||
else if (controls.locked && !body.replace) { status = 423; payload = { details: { code: 'point-locked', replace: 'old-secret' } }; }
|
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) };
|
else payload = { token: 'our-secret', version: controls.version, mediaVersion: controls.mediaVersion, poi: structuredClone(poi) };
|
||||||
return { ok: status === 200, status, json: async () => payload };
|
return { ok: status === 200, status, json: async () => payload };
|
||||||
@@ -131,3 +132,23 @@ test('offline drafts remain editable without claiming a server lock and a late l
|
|||||||
assert.equal(await opening, null);
|
assert.equal(await opening, null);
|
||||||
assert.equal(Object.keys(h.api.headersFor('/routes/7/pois/8')).length, 0);
|
assert.equal(Object.keys(h.api.headersFor('/routes/7/pois/8')).length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('only actual editor activity refreshes a point lock, while passive time and status checks do not', async () => {
|
||||||
|
const h = await harness();
|
||||||
|
await h.api.begin(7, h.poi);
|
||||||
|
await h.api.keepActive();
|
||||||
|
assert.equal(h.calls.filter(call => call.body.heartbeat).length, 0);
|
||||||
|
h.api.noteActivity(); await h.api.keepActive(); await h.api.keepActive();
|
||||||
|
assert.equal(h.calls.filter(call => call.body.heartbeat).length, 1);
|
||||||
|
assert.equal(h.api.headersFor('/routes/7/pois/8')['If-Poi-Match'], 'v1');
|
||||||
|
h.controls.offline = true;
|
||||||
|
h.api.noteActivity(); await h.api.keepActive();
|
||||||
|
h.controls.offline = false; await h.api.keepActive();
|
||||||
|
assert.equal(h.calls.filter(call => call.body.heartbeat).length, 3);
|
||||||
|
let lost = 0; h.context.document.addEventListener('wegwichtel:point-lock-lost', () => { lost += 1; });
|
||||||
|
h.controls.locked = true; h.api.noteActivity(); await h.api.keepActive();
|
||||||
|
assert.equal(lost, 1);
|
||||||
|
assert.equal(h.calls.at(-1).body.heartbeat, true);
|
||||||
|
assert.equal(h.calls.at(-1).body.replace, undefined);
|
||||||
|
});
|
||||||
|
|||||||
+55
-7
@@ -12,7 +12,7 @@ class Element extends EventTarget {
|
|||||||
replaceChildren(...nodes) { this.children = nodes; }
|
replaceChildren(...nodes) { this.children = nodes; }
|
||||||
setAttribute(name, value) { this.attributes.set(name, value); }
|
setAttribute(name, value) { this.attributes.set(name, value); }
|
||||||
showModal() { this.open = true; }
|
showModal() { this.open = true; }
|
||||||
close() { this.open = false; }
|
close() { this.open = false; this.dispatchEvent(new Event('close')); }
|
||||||
scrollTo({ top }) { this.scrollTop = top; this.dispatchEvent(new Event('scroll')); }
|
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)); };
|
const tick = async () => { for (let i = 0; i < 4; i += 1) await new Promise(resolve => setTimeout(resolve, 0)); };
|
||||||
@@ -27,6 +27,7 @@ function key(node, value) {
|
|||||||
}
|
}
|
||||||
async function harness(loading = false) {
|
async function harness(loading = false) {
|
||||||
const ids = ['group-join-open', 'editor-group-join', 'editor-group-list', 'group-dialog', 'group-close',
|
const ids = ['group-join-open', 'editor-group-join', 'editor-group-list', 'group-dialog', 'group-close',
|
||||||
|
'group-share-dialog', 'group-share-route', 'group-share-pictures', 'group-share-start', 'group-share-close',
|
||||||
'group-status', 'group-wheels', 'group-entry', 'group-invitation', 'group-code', 'group-join', 'group-edit', 'group-expiry'];
|
'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 nodes = new Map(ids.map(id => [id, new Element()]));
|
||||||
const document = new EventTarget();
|
const document = new EventTarget();
|
||||||
@@ -48,7 +49,7 @@ async function harness(loading = false) {
|
|||||||
if (options.method === 'GET') value = { groups: [group()] };
|
if (options.method === 'GET') value = { groups: [group()] };
|
||||||
else if (options.method === 'DELETE') { controls.active = false; value = { closed: true }; }
|
else if (options.method === 'DELETE') { controls.active = false; value = { closed: true }; }
|
||||||
else if (url.endsWith('/join')) value = { group: group() };
|
else if (url.endsWith('/join')) value = { group: group() };
|
||||||
else value = { group: group(), code: Array(8).fill('🐦'), expiresAt: 1800000000 };
|
else value = { group: group(), code: Array(5).fill('🐦'), expiresAt: 1800000000 };
|
||||||
return { ok: true, json: async () => value };
|
return { ok: true, json: async () => value };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -69,7 +70,7 @@ test('group code wheels support arrows, keyboard wrapping, scrolling and joining
|
|||||||
pointer(h.nodes.get('group-join-open'));
|
pointer(h.nodes.get('group-join-open'));
|
||||||
assert.equal(h.nodes.get('group-dialog').open, true);
|
assert.equal(h.nodes.get('group-dialog').open, true);
|
||||||
const columns = h.nodes.get('group-wheels').children;
|
const columns = h.nodes.get('group-wheels').children;
|
||||||
assert.equal(columns.length, 8);
|
assert.equal(columns.length, 5);
|
||||||
const [up, viewport, down] = columns[0].children;
|
const [up, viewport, down] = columns[0].children;
|
||||||
assert.equal(viewport.attributes.get('role'), 'spinbutton');
|
assert.equal(viewport.attributes.get('role'), 'spinbutton');
|
||||||
key(up, 'Enter');
|
key(up, 'Enter');
|
||||||
@@ -86,7 +87,7 @@ test('group code wheels support arrows, keyboard wrapping, scrolling and joining
|
|||||||
await tick();
|
await tick();
|
||||||
const joined = h.calls.find(call => call.url.endsWith('/join'));
|
const joined = h.calls.find(call => call.url.endsWith('/join'));
|
||||||
const body = JSON.parse(joined.body);
|
const body = JSON.parse(joined.body);
|
||||||
assert.equal(body.code.length, 8);
|
assert.equal(body.code.length, 5);
|
||||||
assert.equal(body.code[0], '🌼');
|
assert.equal(body.code[0], '🌼');
|
||||||
assert.equal(body.creator_id, 'a'.repeat(64));
|
assert.equal(body.creator_id, 'a'.repeat(64));
|
||||||
assert.deepEqual(h.edits, [22]);
|
assert.deepEqual(h.edits, [22]);
|
||||||
@@ -96,16 +97,17 @@ test('group code wheels support arrows, keyboard wrapping, scrolling and joining
|
|||||||
assert.equal(h.nodes.get('group-dialog').open, false);
|
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 () => {
|
test('sharing displays five motifs and the owner can renew or close access with confirmation', async () => {
|
||||||
const h = await harness();
|
const h = await harness();
|
||||||
await h.groups.share(7);
|
await h.groups.share(7);
|
||||||
assert.equal(JSON.parse(h.calls[0].body).routeId, 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-code').children.length, 5);
|
||||||
assert.equal(h.nodes.get('group-entry').hidden, true);
|
assert.equal(h.nodes.get('group-entry').hidden, true);
|
||||||
assert.match(h.nodes.get('group-expiry').textContent, /Code gilt/);
|
assert.match(h.nodes.get('group-expiry').textContent, /Code gilt/);
|
||||||
const shareButton = h.groups.shareButton('share-route');
|
const shareButton = h.groups.shareButton('share-route');
|
||||||
assert.equal(shareButton.dataset.editorAction, 'share-route');
|
assert.equal(shareButton.dataset.editorAction, 'share-route');
|
||||||
assert.equal(shareButton.children.length, 2);
|
assert.equal(shareButton.textContent, '👥');
|
||||||
|
assert.equal(shareButton.attributes.get('aria-haspopup'), 'dialog');
|
||||||
pointer(h.nodes.get('group-edit')); await tick();
|
pointer(h.nodes.get('group-edit')); await tick();
|
||||||
assert.deepEqual(h.edits, [22]);
|
assert.deepEqual(h.edits, [22]);
|
||||||
let actions = h.nodes.get('editor-group-list').children[0].children[1].children;
|
let actions = h.nodes.get('editor-group-list').children[0].children[1].children;
|
||||||
@@ -186,3 +188,49 @@ test('unsent station changes are listed before network access and discarded only
|
|||||||
h.context.document.dispatchEvent(new Event('wegwichtel:point-drafts-changed')); await tick();
|
h.context.document.dispatchEvent(new Event('wegwichtel:point-drafts-changed')); await tick();
|
||||||
assert.equal(list.children.length, 0);
|
assert.equal(list.children.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('compact sharing button opens a large gnome preview and requires confirmation before starting', async () => {
|
||||||
|
const h = await harness();
|
||||||
|
const decision = h.groups.confirmShare('Blumenweg');
|
||||||
|
const dialog = h.nodes.get('group-share-dialog');
|
||||||
|
assert.equal(dialog.open, true);
|
||||||
|
assert.equal(h.nodes.get('group-share-route').textContent, 'Blumenweg');
|
||||||
|
const pictures = h.nodes.get('group-share-pictures').children;
|
||||||
|
assert.equal(pictures.length, 2);
|
||||||
|
assert.match(pictures[0].src, /🐦/);
|
||||||
|
assert.match(pictures[1].src, /🪨/);
|
||||||
|
assert.equal(h.calls.length, 0);
|
||||||
|
assert.equal(await h.groups.confirmShare('Zweiter Weg'), false);
|
||||||
|
pointer(h.nodes.get('group-share-close'));
|
||||||
|
assert.equal(await decision, false);
|
||||||
|
const accepted = h.groups.confirmShare('Blumenweg');
|
||||||
|
key(h.nodes.get('group-share-start'), 'Enter');
|
||||||
|
assert.equal(await accepted, true);
|
||||||
|
assert.equal(dialog.open, false);
|
||||||
|
const dismissed = h.groups.confirmShare('Blumenweg');
|
||||||
|
dialog.close();
|
||||||
|
assert.equal(await dismissed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('the open invitation refreshes its code automatically and shows a deferred renewal during station editing', async () => {
|
||||||
|
const h = await harness();
|
||||||
|
await h.groups.share(7);
|
||||||
|
const original = h.context.fetch;
|
||||||
|
h.context.fetch = async (url, options) => {
|
||||||
|
if (!url.endsWith('/invitation')) return original(url, options);
|
||||||
|
return { ok: true, json: async () => ({ group: { id: 'group-id', route: { id: 22 }, role: 'owner' },
|
||||||
|
busy: true, expired: true, expiresAt: 1800000000 }) };
|
||||||
|
};
|
||||||
|
await h.groups.refreshInvitation();
|
||||||
|
assert.match(h.nodes.get('group-status').textContent, /Station bearbeitet/);
|
||||||
|
h.context.fetch = async () => ({ ok: true, json: async () => ({ group: { id: 'group-id', route: { id: 22 }, role: 'owner' },
|
||||||
|
busy: false, expired: false, expiresAt: 1800001800, code: Array(5).fill('🪨') }) });
|
||||||
|
await h.groups.refreshInvitation();
|
||||||
|
assert.equal(h.nodes.get('group-code').children.length, 5);
|
||||||
|
assert.match(h.nodes.get('group-code').children[0].src, /🪨/);
|
||||||
|
assert.equal(h.nodes.get('group-status').textContent, '');
|
||||||
|
h.nodes.get('group-dialog').close();
|
||||||
|
h.context.fetch = async () => { throw new Error('must not fetch a closed invitation'); };
|
||||||
|
await h.groups.refreshInvitation();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user