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

This commit is contained in:
Florian Zumpe
2026-10-18 17:24:41 +02:00
parent 31768ae907
commit 0e4cdfbd54
18 changed files with 400 additions and 49 deletions
+25 -9
View File
@@ -1,15 +1,16 @@
# 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
In „Strecken bearbeiten“ öffnet der kleine Gruppenbutton zwischen Stift und
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
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
Danach zeigt die App fünf Wichtel als Gruppencode. Andere öffnen in der
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
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.
## Arbeiten und Einladungen
@@ -22,8 +23,12 @@ funktionieren ebenfalls. „Mitmachen“ öffnet den gemeinsamen Entwurf.
- 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.
- Der Einladungscode gilt 30 Minuten. Anschließend erzeugt die Website automatisch
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.
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
@@ -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
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
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
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.
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.
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
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
sie entfernen. Sie enthalten nur die betroffene Station und neu ausgewählte
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
View File
@@ -874,7 +874,7 @@ Siehe [Gruppenbearbeitung.md](Gruppenbearbeitung.md). Alle Gruppenantworten sind
| --- | --- |
| 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/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 |
| 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
den bisherigen POST angelegt und vor den weiteren Dateioperationen gesperrt.
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.
+18 -3
View File
@@ -1,7 +1,7 @@
# Agent-Hinweise zur Gruppenbearbeitung
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:
[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.
- 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
- Einladungscodes haben fünf Stellen mit den vorhandenen neun Symbolen.
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.
- Schutz auch auf den ursprünglichen /api/routes/...-Adressen für alle Gruppen-
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
entfernter Stationen sowie verteilte Sperren. Eine nachfolgende Aufnahme
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.
+1 -1
View File
@@ -20,7 +20,7 @@ von links nach rechts.
| 🎃.png | Gnom mit Kürbis | Symbol zeigt einen Halloween-Kürbis |
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.
## Klasse
+5 -5
View File
@@ -595,23 +595,23 @@
.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-share-pictures { display: flex; align-items: center; justify-content: center; gap: 1rem; margin: 1rem 0; }
.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::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-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 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-wheel-option img { width: 100%; max-width: 48px; height: 48px; object-fit: contain; pointer-events: none; }
.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-actions { max-width: min(18rem, 52vw); flex-wrap: wrap; justify-content: flex-end; }
.group-actions .editor-secondary-button { font-size: .85rem; }
+14 -2
View File
@@ -336,7 +336,7 @@
<h3>5. Deine eigenen Strecken</h3>
<p>Neue Strecken bleiben zuerst auf deinem Gerät. Gespeichert werden der Weg, die Orte der Stationen, Texte sowie ausgewählte Bilder und Tonaufnahmen.</p>
<p>Deine Entwürfe bleiben erhalten, wenn alter Fortschritt gelöscht wird oder du „Meinen Fortschritt merken“ ausschaltest. Sie bleiben auf diesem Gerät, bis du sie löschst oder die gespeicherten Daten von Wegwichtel in deinem Internetbrowser entfernst. Nur wenn du „Zur Prüfung senden“ 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>
<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>
@@ -579,13 +579,25 @@
<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/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">
<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>
<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>
<button id="group-join" type="button" data-i18n="groups.join">Mitmachen</button>
</section>
+9
View File
@@ -1652,6 +1652,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (shareInProgress) return;
shareInProgress = true;
try {
if (ns.Groups.confirmShare && !await ns.Groups.confirmShare(routeName(route))) return;
let sourceId = routeId(route);
if (routeSource(route) === 'local') {
if (!globalThis.confirm(ns.I18n.t('groups.upload-confirm'))) return;
@@ -1712,6 +1713,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
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']) {
byId(ids.dialog)?.addEventListener(eventName, event => {
if (pointSaveInProgress && eventName === 'cancel') { event.preventDefault(); return; }
+22 -1
View File
@@ -5,6 +5,9 @@ globalThis.Wegwichtel ??= {};
let database;
let current = null;
let generation = 0;
let activity = 0;
let sentActivity = 0;
let touching = false;
const key = (routeId, poiId) => `${routeId}:${poiId}`;
async function store(mode, operation) {
if (!database) database = new Promise((resolve, reject) => {
@@ -45,6 +48,7 @@ globalThis.Wegwichtel ??= {};
method: 'POST', credentials: 'same-origin', cache: 'no-store',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
});
if (response.status === 204) return {};
const payload = await response.json();
if (!response.ok) {
const error = new Error(t(response.status === 423 ? 'lost' : 'unavailable'));
@@ -158,6 +162,23 @@ globalThis.Wegwichtel ??= {};
if (savingGeneration !== generation) return false;
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) {
const match = /^\/routes\/(\d+)\/pois\/(\d+)(?:\/|$)/.exec(path);
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');
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 }; },
changed: () => document.dispatchEvent(new Event('wegwichtel:point-drafts-changed')) });
}(globalThis.Wegwichtel));
+44 -8
View File
@@ -8,7 +8,7 @@ globalThis.Wegwichtel ??= {};
entry: 'group-entry', invitation: 'group-invitation', code: 'group-code', join: 'group-join',
edit: 'group-edit', expiry: 'group-expiry'
};
const codeLength = 8;
const codeLength = 5;
const wheelStep = 52;
const revisions = new Map();
let selection = [];
@@ -43,6 +43,7 @@ globalThis.Wegwichtel ??= {};
function errorMessage(error) {
if (error.reason === 'group-limit') return t('error.limit');
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 === 429) return t('error.rate');
if (error.status === 404) return t('error.code');
@@ -167,6 +168,19 @@ globalThis.Wegwichtel ??= {};
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) {
element(ids.dialog).close();
await ns.EditorUi.openGroup(group.route);
@@ -244,18 +258,34 @@ globalThis.Wegwichtel ??= {};
function shareButton(action) {
const result = document.createElement('button');
result.type = 'button';
result.className = 'editor-icon-button group-pair';
result.className = 'editor-icon-button';
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);
}
result.setAttribute('aria-haspopup', 'dialog');
result.textContent = '👥';
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) {
const id = /^\/routes\/(\d+)(?:\/|$)/.exec(path)?.[1];
return id && revisions.has(id) ? { 'If-Match': String(revisions.get(id)) } : {};
@@ -268,6 +298,12 @@ globalThis.Wegwichtel ??= {};
}
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(); });
element(ids.dialog).addEventListener('cancel', () => { dialogGeneration += 1; });
element(ids.dialog).addEventListener('close', () => { dialogGeneration += 1; });
@@ -292,7 +328,7 @@ globalThis.Wegwichtel ??= {};
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 });
else init();
}(globalThis.Wegwichtel));
+4 -1
View File
@@ -134,12 +134,15 @@ globalThis.Wegwichtel ??= {};
"ready": "Alles ist bereit."
},
"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",
"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.",
"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",
"join": "Mitmachen",
"invite-help": "Zeige diesen Code den anderen. Damit können sie deinen gemeinsamen Entwurf öffnen und bearbeiten. Das Original bleibt erhalten.",
+4 -1
View File
@@ -134,12 +134,15 @@ globalThis.Wegwichtel ??= {};
"ready": "Everything is ready."
},
"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",
"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.",
"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",
"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.",
+3
View File
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
import { offlineManifest } from './src/services/offline-manifest.js';
import { setAppImageCopyright } from './src/services/copyright.js';
import { config, ensureRuntimeDirectories } from './src/config.js';
import { startCollaborationMaintenance } from './src/services/collaboration.js';
import { openDatabase } from './src/database.js';
import { createApiRouter } from './src/routes/api.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));
ensureRuntimeDirectories();
const db = openDatabase();
const stopCollaborationMaintenance = startCollaborationMaintenance(db);
const app = express();
const publicDirectory = path.join(__dirname, 'public');
@@ -54,6 +56,7 @@ const server = app.listen(config.port, config.host, () => {
function shutdown(signal) {
console.log(`${signal}: Server wird beendet.`);
server.close(() => {
stopCollaborationMaintenance();
db.close();
process.exit(0);
});
+13 -2
View File
@@ -1,7 +1,7 @@
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';
export function createGroupsRouter(db) {
@@ -16,12 +16,23 @@ export function createGroupsRouter(db) {
if (!group) throw new HttpError(404, 'Gruppe nicht gefunden.');
member(db, req, group);
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) {
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('/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.post('/', (req, res) => res.status(201).json(createGroup(db, req, res)));
router.post('/join', (req, res) => res.json({ group: joinGroup(db, req, res) }));
+43 -4
View File
@@ -1,5 +1,5 @@
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 path from 'node:path';
import { config } from '../config.js';
@@ -9,10 +9,11 @@ import { createRoute, getRoute, getRouteGpxFile, createPoi } from './routes-serv
import { routeDirectory, relativeStoragePath, resolveStoredFile } from './storage.js';
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';
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 now = () => Math.floor(Date.now() / 1000);
const joinFailures = new Map();
@@ -71,7 +72,9 @@ function issueCode(db, groupId) {
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 };
const invitation = { code, expiresAt };
invitations.set(groupId, invitation);
return invitation;
}
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) {
const group = owner(db, req, id);
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);
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("DELETE FROM collaboration_members WHERE group_id = ? AND role = 'member'").run(id);
});
invitations.delete(id);
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) {
const origin = req.headers.origin;
if (!origin) return;
+33 -1
View File
@@ -7,6 +7,7 @@ import { HttpError } from '../middleware/errors.js';
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
const running = new Set();
export const POINT_IDLE_MS = 60 * 60 * 1000;
const key = (routeId, poiId) => `${Number(routeId)}:${Number(poiId)}`;
function filename(routeId, poiId) {
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`);
}
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; }
}
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,
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);
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' });
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) {
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 });
@@ -86,6 +117,7 @@ export function guardPoint(db, group, account, poiId, req, res) {
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))) {
touchPoint(group, account, poiId, req.headers['x-poi-lock']);
res.setHeader('X-Poi-Version', pointVersion(db, group.route_id, poiId));
res.setHeader('X-Poi-Id', String(poiId));
}
+60 -2
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import nodeCrypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
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 { 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 { groupRouteGuard, GROUP_SYMBOLS, SESSION_COOKIE, maintainCollaboration, startCollaborationMaintenance } = 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>';
@@ -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 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.equal(created.code.length, 5);
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/);
@@ -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 });
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);
});
+22 -1
View File
@@ -41,7 +41,8 @@ async function harness(records = new Map()) {
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 };
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 payload = { token: 'our-secret', version: controls.version, mediaVersion: controls.mediaVersion, poi: structuredClone(poi) };
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(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
View File
@@ -12,7 +12,7 @@ class Element extends EventTarget {
replaceChildren(...nodes) { this.children = nodes; }
setAttribute(name, value) { this.attributes.set(name, value); }
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')); }
}
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) {
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'];
const nodes = new Map(ids.map(id => [id, new Element()]));
const document = new EventTarget();
@@ -48,7 +49,7 @@ async function harness(loading = false) {
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 };
else value = { group: group(), code: Array(5).fill('🐦'), expiresAt: 1800000000 };
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'));
assert.equal(h.nodes.get('group-dialog').open, true);
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;
assert.equal(viewport.attributes.get('role'), 'spinbutton');
key(up, 'Enter');
@@ -86,7 +87,7 @@ test('group code wheels support arrows, keyboard wrapping, scrolling and joining
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.length, 5);
assert.equal(body.code[0], '🌼');
assert.equal(body.creator_id, 'a'.repeat(64));
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);
});
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();
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-code').children.length, 5);
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);
assert.equal(shareButton.textContent, '👥');
assert.equal(shareButton.attributes.get('aria-haspopup'), 'dialog');
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;
@@ -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();
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();
});