diff --git a/docs/Gruppenbearbeitung.md b/docs/Gruppenbearbeitung.md index 2d7637e..0294ca2 100644 --- a/docs/Gruppenbearbeitung.md +++ b/docs/Gruppenbearbeitung.md @@ -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. diff --git a/docs/REST-API.md b/docs/REST-API.md index 560bf3e..a4e41cb 100644 --- a/docs/REST-API.md +++ b/docs/REST-API.md @@ -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": "" }` +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. diff --git a/docs/agents/GRUPPENBEARBEITUNG.md b/docs/agents/GRUPPENBEARBEITUNG.md index 6930f27..05f8389 100644 --- a/docs/agents/GRUPPENBEARBEITUNG.md +++ b/docs/agents/GRUPPENBEARBEITUNG.md @@ -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. diff --git a/docs/group-symbols.md b/docs/group-symbols.md index e563a4e..10bee42 100644 --- a/docs/group-symbols.md +++ b/docs/group-symbols.md @@ -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 diff --git a/public/css/editor.css b/public/css/editor.css index 04b9904..8463ba9 100644 --- a/public/css/editor.css +++ b/public/css/editor.css @@ -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; } diff --git a/public/index.html b/public/index.html index c78e587..0d2f52a 100644 --- a/public/index.html +++ b/public/index.html @@ -336,7 +336,7 @@

5. Deine eigenen Strecken

Neue Strecken bleiben zuerst auf deinem Gerät. Gespeichert werden der Weg, die Orte der Stationen, Texte sowie ausgewählte Bilder und Tonaufnahmen.

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.

-

Ä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.

+

Ä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.

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.

6. Werbung und gespeicherte Einstellungen

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.

@@ -579,13 +579,25 @@ + +
+

Gemeinsam bearbeiten

+ +
+

+
+

Mit diesen beiden Wichteln öffnest du einen gemeinsamen Entwurf. Andere können mit einem Wichtelcode mitmachen. Das Original bleibt erhalten.

+
+ +
+

Gemeinsam bearbeiten

-

Schiebe die Wichtel nach oben oder unten. Die Wichtel in der Mitte ergeben den Code. Lies von links nach rechts, zuerst die obere Reihe.

+

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.

Gruppencode
diff --git a/public/js/editor-ui.js b/public/js/editor-ui.js index caa3ed6..31324f2 100644 --- a/public/js/editor-ui.js +++ b/public/js/editor-ui.js @@ -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; } diff --git a/public/js/group-points.js b/public/js/group-points.js index f26deec..b0b6a35 100644 --- a/public/js/group-points.js +++ b/public/js/group-points.js @@ -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)); diff --git a/public/js/groups.js b/public/js/groups.js index 4337ec3..4e3610d 100644 --- a/public/js/groups.js +++ b/public/js/groups.js @@ -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)); diff --git a/public/js/texts-de.js b/public/js/texts-de.js index 2b75c1e..4bfd249 100644 --- a/public/js/texts-de.js +++ b/public/js/texts-de.js @@ -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.", diff --git a/public/js/texts-en.js b/public/js/texts-en.js index 181cd93..870031c 100644 --- a/public/js/texts-en.js +++ b/public/js/texts-en.js @@ -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.", diff --git a/server.js b/server.js index e785644..b688820 100644 --- a/server.js +++ b/server.js @@ -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); }); diff --git a/src/routes/groups.js b/src/routes/groups.js index 68f4bae..8f29515 100644 --- a/src/routes/groups.js +++ b/src/routes/groups.js @@ -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) })); diff --git a/src/services/collaboration.js b/src/services/collaboration.js index 4df2ae8..fc904bb 100644 --- a/src/services/collaboration.js +++ b/src/services/collaboration.js @@ -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; diff --git a/src/services/point-locks.js b/src/services/point-locks.js index 5b44d13..81a4894 100644 --- a/src/services/point-locks.js +++ b/src/services/point-locks.js @@ -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)); } diff --git a/test/collaboration.test.js b/test/collaboration.test.js index e37eeda..a51e19f 100644 --- a/test/collaboration.test.js +++ b/test/collaboration.test.js @@ -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 = 'Waldweg'; @@ -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); +}); diff --git a/test/group-points-ui.test.js b/test/group-points-ui.test.js index 40ae70a..0e03ba3 100644 --- a/test/group-points-ui.test.js +++ b/test/group-points-ui.test.js @@ -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); +}); diff --git a/test/groups-ui.test.js b/test/groups-ui.test.js index 287b41f..f493325 100644 --- a/test/groups-ui.test.js +++ b/test/groups-ui.test.js @@ -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(); +});