fix tests and change invitation mechanics
This commit is contained in:
@@ -90,3 +90,8 @@ CREATE TABLE IF NOT EXISTS collaboration_members (
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
|
||||
PRIMARY KEY (group_id, session_hash)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collaboration_invitations (
|
||||
group_id TEXT PRIMARY KEY NOT NULL REFERENCES collaboration_groups(id) ON DELETE CASCADE,
|
||||
code TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
+32
-23
@@ -1,18 +1,18 @@
|
||||
# Gemeinsam bearbeiten
|
||||
|
||||
In „Strecken bearbeiten“ öffnet der kleine Button mit zwei gezeichneten Wichteln
|
||||
zwischen Stift und
|
||||
Löschen den gemeinsamen Entwurf und zeigt anschließend den echten Code mit
|
||||
fünf Wichteln in einem großen Popup an. Eine vorgeschaltete Zwei-Wichtel-Vorschau
|
||||
gibt es nicht mehr. 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.
|
||||
zwischen Stift und Löschen zuerst ein großes Popup zur Codeauswahl. Die fünf
|
||||
Drehräder sind zufällig vorbelegt. Du kannst jeden Wichtel ändern; Wiederholungen
|
||||
sind erlaubt. Erst „Diesen Code verwenden“ bestätigt deine Auswahl. Abbrechen
|
||||
legt keine Gruppe an und überträgt keine Strecke. Bei lokalen Entwürfen fragt
|
||||
die App danach, ob Weg, Stationen, Bilder und Ton übertragen werden dürfen.
|
||||
Ein bereits eingesandter eigener Entwurf wird zuerst aktualisiert. Anschließend
|
||||
zeigt ein eigenes Popup den gespeicherten Code. Die Bearbeitung beginnt erst
|
||||
mit „Entwurf bearbeiten“. Das Original bleibt unverändert.
|
||||
|
||||
Danach zeigt die App fünf Wichtel als Gruppencode. Andere öffnen in der
|
||||
Streckenverwaltung „Gruppencode eingeben“. Ein eigenes breites Popup bietet
|
||||
Platz für alle fünf Drehräder. Dort schieben sie die Bilder in den fünf
|
||||
Drehrädern nach oben oder unten. Es zählen die Wichtel in der Mitte, von links
|
||||
nach rechts. Wiederholungen sind erlaubt. Pfeiltasten und die Pfeilschaltflächen
|
||||
Andere öffnen in der Streckenverwaltung „Gruppencode eingeben“. Dort schieben
|
||||
sie die Bilder in fünf Drehrädern nach oben oder unten. Es zählen die Wichtel
|
||||
in der Mitte, von links nach rechts. Pfeiltasten und Pfeilschaltflächen
|
||||
funktionieren ebenfalls. „Mitmachen“ öffnet den gemeinsamen Entwurf.
|
||||
|
||||
## Arbeiten und Einladungen
|
||||
@@ -25,12 +25,19 @@ 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 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.
|
||||
- Der Einladungscode gilt 60 Minuten. Erfolgreiches Mitmachen, Öffnen zur
|
||||
Bearbeitung, Übernehmen einer Station, gemeldete tatsächliche Bearbeitungsaktivität
|
||||
und erfolgreiches Speichern setzen die Frist erneut auf 60 Minuten.
|
||||
Listen, Codeanzeige, reine Datenabrufe und Sperrstatusabfragen verlängern sie nicht.
|
||||
- Das Code-Popup zeigt Ablaufzeit und verbleibende Minuten und Sekunden. Die
|
||||
Restzeit zählt herunter; neue Aktivität wird spätestens nach 30 Sekunden übernommen.
|
||||
- Der Code wird niemals automatisch geändert oder neu erzeugt. Nach Ablauf
|
||||
erlaubt er keinen neuen Beitritt. Bereits beigetretene Personen behalten ihren
|
||||
Zugang und können durch erneute Bearbeitung die Gültigkeit desselben Codes verlängern.
|
||||
- „Gruppencode“ zeigt den gespeicherten Code erneut an. „Code auswählen“ öffnet
|
||||
die Auswahl; nur eine ausdrückliche Bestätigung ändert oder reaktiviert den Code.
|
||||
Während einer Stationsbearbeitung wartet ein Codewechsel, bis die Stationen frei sind.
|
||||
Bereits verwendete Codes anderer Gruppen werden mit einem Hinweis abgewiesen.
|
||||
- „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
|
||||
@@ -74,7 +81,8 @@ npm run test:runtime-db
|
||||
systemctl --user restart wegwichtel.service
|
||||
```
|
||||
|
||||
`init-db` legt die drei neuen STRICT-Tabellen an. Es verändert keine bestehenden
|
||||
`init-db` legt fehlende STRICT-Tabellen an, einschließlich `collaboration_invitations`
|
||||
für die dauerhaft gespeicherten Codes. Es verändert keine bestehenden
|
||||
Streckentabellen und löscht keine Strecken. Eine Neuerzeugung oder das Löschen
|
||||
der vorhandenen Datenbank ist hierfür nicht erforderlich.
|
||||
|
||||
@@ -154,8 +162,9 @@ zur vorhandenen Anzeige des belegten Gerätespeichers. Browserdaten-Löschung ka
|
||||
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.
|
||||
Die ausgewählten Codes liegen in `collaboration_invitations`, der zugehörige
|
||||
SHA-256-Abdruck und Ablaufzeitpunkt in `collaboration_groups`. Nur die berechtigte
|
||||
eröffnende Sitzung kann den Code abrufen. Nach einem App-Neustart wird derselbe
|
||||
Code wieder angezeigt; es findet kein automatischer Ersatz statt. Bestehende
|
||||
Gruppen aus der vorherigen Version enthalten noch keinen gespeicherten Klartext.
|
||||
Für sie muss die eröffnende Person einmal ausdrücklich einen Code auswählen.
|
||||
|
||||
@@ -15,10 +15,13 @@ Sitzungen, Rollen owner/member, Codewechsel und Widerruf. Bedienung und Einspiel
|
||||
- Sitzungsgeheimnisse nur als Hash serverseitig und als HttpOnly-/SameSite-Strict-
|
||||
Cookie auf dem Client speichern; bei HTTPS zusätzlich Secure. Nicht ausgeben.
|
||||
- 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.
|
||||
Wiederholungen sind erlaubt. Das Auswahlpopup wird zufällig vorbelegt; erst die
|
||||
Bestätigung speichert den vom Nutzer gewählten Code. Server erzeugt oder
|
||||
ersetzt Codes niemals automatisch. Ungültige bzw. belegte Codes zurückweisen.
|
||||
- Codes dauerhaft in collaboration_invitations speichern; SHA-256-Abdruck und
|
||||
Ablaufzeitpunkt bleiben in collaboration_groups. Klartext nur für Besitzer
|
||||
ausliefern, auch nach Neustart. Gültigkeit 60 Minuten; falsche Versuche begrenzen.
|
||||
Variationselektoren ändern die Unicode-Zuordnung nicht.
|
||||
- Schutz auch auf den ursprünglichen /api/routes/...-Adressen für alle Gruppen-
|
||||
Dateien durchsetzen. Anders geschriebene IDs (führende Nullen, Prozentkodierung,
|
||||
Exponentialschreibweise, Groß-/Kleinschreibung der Pfade) dürfen ihn nicht umgehen.
|
||||
@@ -80,10 +83,12 @@ Sitzungen, Rollen owner/member, Codewechsel und Widerruf. Bedienung und Einspiel
|
||||
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.
|
||||
- Einladungscodes laufen nach 60 Minuten ohne Nutzung ab, bleiben unverändert
|
||||
und erlauben dann keinen neuen Beitritt. Erfolgreicher Beitritt, explizites Öffnen
|
||||
zur Bearbeitung, Sperrerwerb, gültige Aktivitätsmeldung und erfolgreiche Speicherung
|
||||
verlängern auf 60 Minuten. Bestehende Mitgliedschaften bleiben erhalten und
|
||||
können durch Bearbeitung denselben Code reaktivieren. Passive Abfragen ändern
|
||||
keine Frist. Wartung bereinigt nur abgelaufene Stationssperren, niemals Codes.
|
||||
- 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
|
||||
@@ -95,11 +100,11 @@ Sitzungen, Rollen owner/member, Codewechsel und Widerruf. Bedienung und Einspiel
|
||||
|
||||
## Code-Popups
|
||||
|
||||
- Gruppenbutton zeigt unmittelbar den echten fünfstelligen Einladungscode. Keine
|
||||
vorgeschaltete Zwei-Wichtel-Vorschau wieder einführen; sie wurde mit dem Code
|
||||
verwechselt. Eine unvollständige Codeantwort nicht anzeigen.
|
||||
- Codeanzeige und Codeeingabe verwenden getrennte breite native Dialoge. Alle
|
||||
fünf Drehräder nebeneinander anordnen; Wiederholungen weiterhin zulassen.
|
||||
- Scrollschritt in groups.js und Optionshöhe/Innenabstand in editor.css müssen
|
||||
übereinstimmen (96 Pixel). Schließen der Eingabe verwirft verspätete Antworten;
|
||||
die automatische Einladungsaktualisierung darf die Eingabe nicht überdecken.
|
||||
- Gruppenbutton öffnet zuerst fünf zufällig vorbelegte, veränderbare Drehräder.
|
||||
Auswahl vor Übertragung lokaler Strecken bestätigen lassen. Abbrechen überträgt
|
||||
nichts. Danach gespeicherten Code anzeigen; Editor erst nach ausdrücklicher Aktion.
|
||||
- Auswahl, Beitritt und Codeanzeige verwenden breite native Dialoge. Alle fünf
|
||||
Drehräder nebeneinander; Wiederholungen zulassen. Codeänderung nur ausdrücklich.
|
||||
- Scrollschritt in groups.js und Optionshöhe/Innenabstand in editor.css stimmen
|
||||
überein (96 Pixel). Schließen der Eingabe verwirft verspätete Antworten;
|
||||
die passive Einladungsabfrage darf die Eingabe nicht überdecken.
|
||||
|
||||
@@ -1652,6 +1652,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
if (shareInProgress) return;
|
||||
shareInProgress = true;
|
||||
try {
|
||||
const code = await ns.Groups.chooseCode();
|
||||
if (!code) return;
|
||||
let sourceId = routeId(route);
|
||||
if (routeSource(route) === 'local') {
|
||||
if (!globalThis.confirm(ns.I18n.t('groups.upload-confirm'))) return;
|
||||
@@ -1659,7 +1661,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
await uploadLocalRoute();
|
||||
sourceId = state.currentRoute.serverRouteId;
|
||||
}
|
||||
await ns.Groups.share(sourceId);
|
||||
await ns.Groups.share(sourceId, code);
|
||||
} finally { shareInProgress = false; }
|
||||
return;
|
||||
}
|
||||
|
||||
+78
-21
@@ -13,7 +13,9 @@ globalThis.Wegwichtel ??= {};
|
||||
const wheelStep = 96;
|
||||
const revisions = new Map();
|
||||
let selection = [];
|
||||
let codeChoice = null;
|
||||
let currentGroup = null;
|
||||
let currentExpiry = 0;
|
||||
let pending = false;
|
||||
let dialogGeneration = 0;
|
||||
const element = id => document.getElementById(id);
|
||||
@@ -42,6 +44,8 @@ globalThis.Wegwichtel ??= {};
|
||||
}
|
||||
|
||||
function errorMessage(error) {
|
||||
if (error.reason === 'code-taken') return t('error.code-taken');
|
||||
if (error.reason === 'invalid-code') return t('error.code');
|
||||
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');
|
||||
@@ -141,33 +145,68 @@ globalThis.Wegwichtel ??= {};
|
||||
event.preventDefault(); choose(event.key === 'Home' ? 0 : motifs.length - 1);
|
||||
}
|
||||
});
|
||||
paint(0);
|
||||
paint(selection[position]);
|
||||
column.append(up, viewport, down);
|
||||
return column;
|
||||
}
|
||||
|
||||
function openEntry() {
|
||||
function openEntry(chooseCode = false) {
|
||||
if (element(ids.dialog).open) element(ids.dialog).close();
|
||||
dialogGeneration += 1;
|
||||
selection = new Array(codeLength).fill(0);
|
||||
selection = Array.from({ length: codeLength }, () => chooseCode ? randomIndex(ns.GroupSymbols.list().length) : 0);
|
||||
element('group-entry-title').setAttribute('data-i18n', `groups.${chooseCode ? 'choose-code' : 'join-open'}`);
|
||||
element('group-entry-title').textContent = t(chooseCode ? 'choose-code' : 'join-open');
|
||||
element(ids.join).setAttribute('data-i18n', `groups.${chooseCode ? 'save-code' : 'join'}`);
|
||||
element(ids.join).disabled = false;
|
||||
element(ids.join).textContent = t(chooseCode ? 'save-code' : 'join');
|
||||
element(ids.entryStatus).textContent = '';
|
||||
if (!element(ids.entryDialog).open) element(ids.entryDialog).showModal();
|
||||
const wheels = element(ids.wheels);
|
||||
wheels.replaceChildren(...selection.map((_, index) => makeWheel(index)));
|
||||
if (!element(ids.entryDialog).open) element(ids.entryDialog).showModal();
|
||||
for (const [index, column] of Array.from(wheels.children).entries()) column.children[1].scrollTop = selection[index] * wheelStep;
|
||||
}
|
||||
|
||||
function randomIndex(size) {
|
||||
const values = new Uint32Array(1);
|
||||
const limit = Math.floor(0x100000000 / size) * size;
|
||||
do { globalThis.crypto.getRandomValues(values); } while (values[0] >= limit);
|
||||
return values[0] % size;
|
||||
}
|
||||
|
||||
function chooseCode() {
|
||||
if (codeChoice) return Promise.resolve(null);
|
||||
openEntry(true);
|
||||
return new Promise(resolve => { codeChoice = resolve; });
|
||||
}
|
||||
|
||||
function cancelChoice() {
|
||||
dialogGeneration += 1;
|
||||
const resolve = codeChoice;
|
||||
codeChoice = null;
|
||||
resolve?.(null);
|
||||
}
|
||||
|
||||
function updateCountdown() {
|
||||
if (!currentGroup || !element(ids.dialog).open || element(ids.invitation).hidden) return;
|
||||
const seconds = Math.max(0, Math.ceil(currentExpiry - Date.now() / 1000));
|
||||
const time = new Date(currentExpiry * 1000).toLocaleTimeString(ns.I18n.language, { hour: '2-digit', minute: '2-digit' });
|
||||
element(ids.expiry).textContent = seconds ? `${t('expires', { time })} ${t('remaining', {
|
||||
minutes: Math.floor(seconds / 60), seconds: String(seconds % 60).padStart(2, '0')
|
||||
})}` : t('code-expired');
|
||||
}
|
||||
|
||||
function showInvitation(payload) {
|
||||
if (!Array.isArray(payload.code) || payload.code.length !== codeLength) throw new Error(t('error.connection'));
|
||||
currentGroup = payload.group;
|
||||
element(ids.status).textContent = '';
|
||||
element(ids.status).textContent = payload.expired ? t('code-expired') : '';
|
||||
if (element(ids.entryDialog).open) element(ids.entryDialog).close();
|
||||
element(ids.invitation).hidden = false;
|
||||
element('group-route').textContent = payload.group.route.name || '';
|
||||
const motifs = payload.code.map(value => ns.GroupSymbols.get(value));
|
||||
element(ids.code).replaceChildren(...motifs.map(picture));
|
||||
const time = new Date(payload.expiresAt * 1000).toLocaleTimeString(ns.I18n.language, { hour: '2-digit', minute: '2-digit' });
|
||||
element(ids.expiry).textContent = t('expires', { time });
|
||||
currentExpiry = payload.expiresAt;
|
||||
if (!element(ids.dialog).open) element(ids.dialog).showModal();
|
||||
updateCountdown();
|
||||
}
|
||||
|
||||
let invitationPolling = false;
|
||||
@@ -179,14 +218,15 @@ globalThis.Wegwichtel ??= {};
|
||||
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') : '';
|
||||
element(ids.status).textContent = payload.expired ? t('code-expired') : '';
|
||||
} catch { /* Keep the invitation visible; expiry still applies on the server. */ }
|
||||
finally { invitationPolling = false; }
|
||||
}
|
||||
async function edit(group) {
|
||||
if (element(ids.dialog).open) element(ids.dialog).close();
|
||||
if (element(ids.entryDialog).open) element(ids.entryDialog).close();
|
||||
await ns.EditorUi.openGroup(group.route);
|
||||
const payload = await request(`/${group.id}/edit`, 'POST', {});
|
||||
await ns.EditorUi.openGroup(payload.group.route);
|
||||
}
|
||||
|
||||
async function renderPending() {
|
||||
@@ -228,8 +268,9 @@ globalThis.Wegwichtel ??= {};
|
||||
})));
|
||||
if (group.role === 'owner') {
|
||||
actions.appendChild(button(t('new-code'), () => guarded(async () => {
|
||||
if (!globalThis.confirm(t('new-code-confirm'))) return;
|
||||
showInvitation(await request(`/${group.id}/code`, 'POST', {}));
|
||||
const code = await chooseCode();
|
||||
if (!code) return;
|
||||
showInvitation(await request(`/${group.id}/code`, 'POST', { code }));
|
||||
await refresh();
|
||||
})));
|
||||
if (group.active) actions.appendChild(button(t('close-sharing'), () => guarded(async () => {
|
||||
@@ -238,6 +279,11 @@ globalThis.Wegwichtel ??= {};
|
||||
await refresh();
|
||||
})));
|
||||
}
|
||||
if (group.role === 'owner' && group.active) actions.appendChild(button(t('code-label'), () => guarded(async () => {
|
||||
const payload = await request(`/${group.id}/invitation`);
|
||||
if (payload.code) showInvitation(payload);
|
||||
else { const code = await chooseCode(); if (code) showInvitation(await request(`/${group.id}/code`, 'POST', { code })); }
|
||||
})));
|
||||
item.append(name, actions);
|
||||
list.appendChild(item);
|
||||
}
|
||||
@@ -249,11 +295,12 @@ globalThis.Wegwichtel ??= {};
|
||||
} catch { /* Existing drafts and forms stay usable without a connection. */ }
|
||||
}
|
||||
|
||||
async function share(routeId) {
|
||||
async function share(routeId, code = null) {
|
||||
code ??= await chooseCode();
|
||||
if (!code) return;
|
||||
try {
|
||||
const payload = await request('', 'POST', { routeId, creator_id: ns.LocalRoutes.creatorId() });
|
||||
const editor = document.getElementById('editor-dialog');
|
||||
if (!editor || editor.open) showInvitation(payload);
|
||||
const payload = await request('', 'POST', { routeId, code, creator_id: ns.LocalRoutes.creatorId() });
|
||||
showInvitation(payload);
|
||||
await refresh();
|
||||
} catch (error) { throw new Error(errorMessage(error)); }
|
||||
}
|
||||
@@ -287,8 +334,17 @@ globalThis.Wegwichtel ??= {};
|
||||
}
|
||||
|
||||
async function joinSelectedGroup() {
|
||||
const generation = dialogGeneration;
|
||||
if (!element(ids.entryDialog).open) return;
|
||||
const motifs = ns.GroupSymbols.list();
|
||||
if (codeChoice) {
|
||||
const resolve = codeChoice;
|
||||
codeChoice = null;
|
||||
const code = selection.map(index => motifs[index].unicode);
|
||||
element(ids.entryDialog).close();
|
||||
resolve(code);
|
||||
return;
|
||||
}
|
||||
const generation = dialogGeneration;
|
||||
let payload;
|
||||
try {
|
||||
payload = await request('/join', 'POST', {
|
||||
@@ -305,17 +361,18 @@ globalThis.Wegwichtel ??= {};
|
||||
|
||||
function init() {
|
||||
globalThis.setInterval?.(() => { refreshInvitation(); }, 30_000);
|
||||
globalThis.setInterval?.(updateCountdown, 1000);
|
||||
document.addEventListener('wegwichtel:point-drafts-changed', () => { renderPending(); ns.StorageUsage?.refresh(); });
|
||||
element(ids.entryDialog).addEventListener('cancel', () => { dialogGeneration += 1; });
|
||||
element(ids.entryDialog).addEventListener('close', () => { dialogGeneration += 1; });
|
||||
element(ids.entryDialog).addEventListener('cancel', cancelChoice);
|
||||
element(ids.entryDialog).addEventListener('close', cancelChoice);
|
||||
activate(element(ids.entryClose), () => element(ids.entryDialog).close());
|
||||
for (const id of [ids.joinOpen, ids.editorJoin]) activate(element(id), openEntry);
|
||||
for (const id of [ids.joinOpen, ids.editorJoin]) activate(element(id), () => openEntry());
|
||||
activate(element(ids.close), () => element(ids.dialog).close());
|
||||
activate(element(ids.join), () => guarded(joinSelectedGroup));
|
||||
activate(element(ids.join), () => codeChoice ? joinSelectedGroup() : guarded(joinSelectedGroup));
|
||||
activate(element(ids.edit), () => guarded(() => edit(currentGroup)));
|
||||
}
|
||||
|
||||
ns.Groups = Object.freeze({ share, shareButton, refresh, refreshInvitation, headersFor, observe });
|
||||
ns.Groups = Object.freeze({ share, chooseCode, shareButton, refresh, refreshInvitation, headersFor, observe });
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
else init();
|
||||
}(globalThis.Wegwichtel));
|
||||
|
||||
@@ -134,7 +134,11 @@ 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.",
|
||||
"remaining": "Noch {minutes} Min. {seconds} Sek. gültig.",
|
||||
"choose-code": "Code auswählen",
|
||||
"save-code": "Diesen Code verwenden",
|
||||
"code-expired": "Der Code ist abgelaufen. Wähle einen Code, um wieder andere einzuladen.",
|
||||
"code-wait": "In der Gruppe wird gerade eine Station bearbeitet. Warte, bis die Stationen frei sind, bevor du den Code änderst.",
|
||||
"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",
|
||||
@@ -147,8 +151,7 @@ globalThis.Wegwichtel ??= {};
|
||||
"edit": "Entwurf bearbeiten",
|
||||
"reload": "Änderungen holen",
|
||||
"reload-confirm": "Ungespeicherte Eingaben werden ersetzt. Möchtest du den aktuellen gemeinsamen Stand holen?",
|
||||
"new-code": "Neuen Code anzeigen",
|
||||
"new-code-confirm": "Der bisherige Code funktioniert danach nicht mehr. Bereits beigetretene Personen bleiben dabei. Neuen Code erstellen?",
|
||||
"new-code": "Code auswählen",
|
||||
"close-sharing": "Gruppe schließen",
|
||||
"close-confirm": "Die anderen können diesen Entwurf danach nicht mehr öffnen oder bearbeiten. Du behältst den Zugriff. Gruppe schließen?",
|
||||
"expires": "Der Code gilt bis {time}.",
|
||||
@@ -157,6 +160,7 @@ globalThis.Wegwichtel ??= {};
|
||||
"next": "Nächster Wichtel an Stelle {number}",
|
||||
"none": "Du bist noch in keiner Gruppe.",
|
||||
"error": {
|
||||
"code-taken": "Diesen Code nutzt schon eine andere Gruppe. Wähle andere Wichtel.",
|
||||
"limit": "Schließe erst eine deiner Gruppen, bevor du eine weitere öffnest.",
|
||||
"full": "Diese Gruppe ist bereits voll. Bitte um einen Code für eine andere Gruppe.",
|
||||
"rate": "Bitte warte eine Minute und versuche es dann erneut.",
|
||||
|
||||
@@ -134,7 +134,11 @@ 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.",
|
||||
"remaining": "Valid for another {minutes} min {seconds} sec.",
|
||||
"choose-code": "Choose a code",
|
||||
"save-code": "Use this code",
|
||||
"code-expired": "This code has expired. Choose a code to invite others again.",
|
||||
"code-wait": "Someone is editing a stop. Wait until the stops are free before changing the code.",
|
||||
"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",
|
||||
@@ -147,8 +151,7 @@ globalThis.Wegwichtel ??= {};
|
||||
"edit": "Edit draft",
|
||||
"reload": "Get changes",
|
||||
"reload-confirm": "Your unsaved changes will be replaced. Would you like to get the latest shared version?",
|
||||
"new-code": "Show a new code",
|
||||
"new-code-confirm": "The old code will stop working. People who have already joined will keep their access. Create a new code?",
|
||||
"new-code": "Choose a code",
|
||||
"close-sharing": "Close group",
|
||||
"close-confirm": "The others will no longer be able to open or edit this draft. You will keep your access. Close the group?",
|
||||
"expires": "This code works until {time}.",
|
||||
@@ -157,6 +160,7 @@ globalThis.Wegwichtel ??= {};
|
||||
"next": "Next gnome in position {number}",
|
||||
"none": "You have not joined a group yet.",
|
||||
"error": {
|
||||
"code-taken": "Another group is using this code. Choose different gnomes.",
|
||||
"limit": "Close one of your groups before opening another.",
|
||||
"full": "This group is full. Ask for a code for another group.",
|
||||
"rate": "Please wait a minute and try again.",
|
||||
|
||||
+11
-2
@@ -1,5 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import { createGroup, joinGroup, listGroups, renewCode, closeGroup, verifyOrigin, session, member, invitationStatus } from '../services/collaboration.js';
|
||||
import { createGroup, joinGroup, listGroups, renewCode, closeGroup, verifyOrigin, session, member, invitationStatus, extendInvitation, groupById, groupSummary } from '../services/collaboration.js';
|
||||
|
||||
import { acquirePoint, releasePoint, touchPoint, pointLocked } from '../services/point-locks.js';
|
||||
import { HttpError } from '../middleware/errors.js';
|
||||
@@ -18,13 +18,16 @@ export function createGroupsRouter(db) {
|
||||
const account = session(db, req);
|
||||
if (req.body?.heartbeat) {
|
||||
touchPoint(group, account, req.params.poiId, req.body.token);
|
||||
extendInvitation(db, group);
|
||||
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));
|
||||
const lock = acquirePoint(db, group, account, req.params.poiId, req.body);
|
||||
extendInvitation(db, group);
|
||||
return res.json(lock);
|
||||
});
|
||||
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));
|
||||
@@ -32,6 +35,12 @@ export function createGroupsRouter(db) {
|
||||
member(db, req, group);
|
||||
res.status(pointLocked(db, group, req.params.poiId) ? 423 : 204).end();
|
||||
});
|
||||
router.post('/:id/edit', (req, res) => {
|
||||
const group = groupById(db, req.params.id);
|
||||
const access = member(db, req, group);
|
||||
extendInvitation(db, group);
|
||||
res.json({ group: groupSummary(db, groupById(db, group.id), access.role) });
|
||||
});
|
||||
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)));
|
||||
|
||||
@@ -13,8 +13,7 @@ export const GROUP_SYMBOLS = Object.freeze(['🪵', '🪨', '🏮', '🌼', '
|
||||
export const GROUP_CODE_LENGTH = 5;
|
||||
export const SESSION_COOKIE = 'wegwichtel_group_session';
|
||||
const SESSION_SECONDS = 30 * 24 * 60 * 60;
|
||||
const CODE_SECONDS = 30 * 60;
|
||||
const invitations = new Map();
|
||||
const CODE_SECONDS = 60 * 60;
|
||||
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const joinFailures = new Map();
|
||||
@@ -65,19 +64,29 @@ export function owner(db, req, id) {
|
||||
return group;
|
||||
}
|
||||
|
||||
function issueCode(db, groupId) {
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
const code = Array.from({ length: GROUP_CODE_LENGTH }, () => GROUP_SYMBOLS[crypto.randomInt(GROUP_SYMBOLS.length)]);
|
||||
function selectedCode(body) {
|
||||
const code = invitationCode(body);
|
||||
if (!code) throw new HttpError(400, 'Wähle fünf Wichtel für deinen Code.', { code: 'invalid-code' });
|
||||
return code;
|
||||
}
|
||||
|
||||
function issueCode(db, groupId, code) {
|
||||
const digest = hash(code.join(''));
|
||||
if (db.prepare('SELECT 1 FROM collaboration_groups WHERE code_hash = ?').get(digest)) continue;
|
||||
const other = db.prepare('SELECT id FROM collaboration_groups WHERE code_hash = ? AND id != ?').get(digest, groupId);
|
||||
if (other) throw new HttpError(409, 'Dieser Code wird schon verwendet. Wähle andere Wichtel.', { code: 'code-taken' });
|
||||
const expiresAt = now() + CODE_SECONDS;
|
||||
transaction(db, () => {
|
||||
db.prepare('UPDATE collaboration_groups SET code_hash = ?, code_expires_at = ?, active = 1 WHERE id = ?')
|
||||
.run(digest, expiresAt, groupId);
|
||||
const invitation = { code, expiresAt };
|
||||
invitations.set(groupId, invitation);
|
||||
return invitation;
|
||||
db.prepare('INSERT INTO collaboration_invitations (group_id, code) VALUES (?, ?) ON CONFLICT(group_id) DO UPDATE SET code = excluded.code')
|
||||
.run(groupId, JSON.stringify(code));
|
||||
});
|
||||
return { code, expiresAt };
|
||||
}
|
||||
throw new HttpError(503, 'Bitte versuche es gleich noch einmal.');
|
||||
|
||||
export function extendInvitation(db, group) {
|
||||
if (group.active) db.prepare('UPDATE collaboration_groups SET code_expires_at = ? WHERE id = ? AND active = 1 AND code_hash IS NOT NULL')
|
||||
.run(now() + CODE_SECONDS, group.id);
|
||||
}
|
||||
|
||||
function copyFile(relative, routeId, folder) {
|
||||
@@ -108,6 +117,7 @@ function assertGroupQuota(db, sessionHash) {
|
||||
}
|
||||
|
||||
export function createGroup(db, req, res) {
|
||||
const code = selectedCode(req.body);
|
||||
const account = session(db, req, res, req.body?.creator_id);
|
||||
assertGroupQuota(db, account.token_hash);
|
||||
const source = getRoute(db, Number(req.body?.routeId));
|
||||
@@ -130,7 +140,7 @@ export function createGroup(db, req, res) {
|
||||
db.prepare("INSERT INTO collaboration_members (group_id, session_hash, role) VALUES (?, ?, 'owner')")
|
||||
.run(id, account.token_hash);
|
||||
});
|
||||
const invitation = issueCode(db, id);
|
||||
const invitation = issueCode(db, id, code);
|
||||
return { group: groupSummary(db, groupById(db, id), 'owner'), ...invitation };
|
||||
} catch (error) {
|
||||
if (route) {
|
||||
@@ -196,14 +206,15 @@ export function joinGroup(db, req, res) {
|
||||
db.prepare("INSERT OR IGNORE INTO collaboration_members (group_id, session_hash, role) VALUES (?, ?, 'member')")
|
||||
.run(group.id, account.token_hash);
|
||||
joinFailures.delete(key);
|
||||
return groupSummary(db, group, existing?.role || 'member');
|
||||
extendInvitation(db, group);
|
||||
return groupSummary(db, groupById(db, group.id), existing?.role || 'member');
|
||||
}
|
||||
|
||||
export function renewCode(db, req, id) {
|
||||
const group = owner(db, req, id);
|
||||
if (!group.active) assertGroupQuota(db, session(db, req).token_hash);
|
||||
if (hasPointLocks(group.route_id)) throw new HttpError(423, 'In dieser Gruppe wird gerade eine Station bearbeitet.', { code: 'group-editing' });
|
||||
const invitation = issueCode(db, id);
|
||||
const invitation = issueCode(db, id, selectedCode(req.body));
|
||||
return { group: groupSummary(db, groupById(db, id), 'owner'), ...invitation };
|
||||
}
|
||||
|
||||
@@ -214,32 +225,21 @@ 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);
|
||||
db.prepare('DELETE FROM collaboration_invitations WHERE group_id = ?').run(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 } : {}) };
|
||||
const group = owner(db, req, id);
|
||||
const invitation = db.prepare('SELECT code FROM collaboration_invitations WHERE group_id = ?').get(id);
|
||||
return { group: groupSummary(db, group, 'owner'), busy: hasPointLocks(group.route_id),
|
||||
expired: group.code_expires_at <= now(), expiresAt: group.code_expires_at,
|
||||
...(invitation ? { code: JSON.parse(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);
|
||||
}
|
||||
const groups = db.prepare("SELECT route_id FROM collaboration_groups g JOIN routes r ON r.id = g.route_id WHERE r.status = 'active'").all();
|
||||
for (const group of groups) hasPointLocks(group.route_id);
|
||||
}
|
||||
|
||||
export function startCollaborationMaintenance(db) {
|
||||
@@ -287,7 +287,10 @@ export function groupRouteGuard(db) {
|
||||
writing.add(group.id);
|
||||
res.setHeader('X-Group-Revision', String(group.revision + 1));
|
||||
res.once('finish', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) db.prepare('UPDATE collaboration_groups SET revision = revision + 1 WHERE id = ?').run(group.id);
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
db.prepare('UPDATE collaboration_groups SET revision = revision + 1 WHERE id = ?').run(group.id);
|
||||
extendInvitation(db, group);
|
||||
}
|
||||
writing.delete(group.id);
|
||||
});
|
||||
res.once('close', () => writing.delete(group.id));
|
||||
|
||||
+62
-23
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -58,7 +57,7 @@ async function fixture(t) {
|
||||
test('shared draft clones a published route and protects its contents and ownership', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {}, guest = {}, outsider = {};
|
||||
const { value: created, response } = await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201);
|
||||
const { value: created, response } = await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 201);
|
||||
assert.equal(created.code.length, 5);
|
||||
assert.ok(created.code.every(value => GROUP_SYMBOLS.includes(value)));
|
||||
assert.match(response.headers.get('set-cookie'), /HttpOnly/);
|
||||
@@ -98,14 +97,14 @@ test('shared draft clones a published route and protects its contents and owners
|
||||
await request(guest, `/routes/${route.id}`, 'DELETE', null, 403, { 'If-Match': '1' });
|
||||
await request(guest, `/routes/0${route.id}`, 'DELETE', null, 403, { 'If-Match': '1' });
|
||||
await request(guest, `/routes/${route.id}/restore`, 'POST', {}, 403, { 'If-Match': '1' });
|
||||
const renewed = (await request(host, `/groups/${created.group.id}/code`, 'POST', {})).value;
|
||||
const renewed = (await request(host, `/groups/${created.group.id}/code`, 'POST', { code: Array(5).fill('🪨') })).value;
|
||||
await request(outsider, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) }, 404);
|
||||
await request(outsider, '/groups/join', 'POST', { code: renewed.code, creator_id: 'b'.repeat(64) });
|
||||
await request(host, `/groups/${created.group.id}`, 'DELETE');
|
||||
await request(guest, `/routes/${route.id}`, 'GET', null, 403);
|
||||
assert.equal((await request(guest, '/groups')).value.groups.length, 0);
|
||||
await request(host, `/routes/${route.id}`);
|
||||
const reopened = (await request(host, `/groups/${created.group.id}/code`, 'POST', {})).value;
|
||||
const reopened = (await request(host, `/groups/${created.group.id}/code`, 'POST', { code: Array(5).fill('🪨') })).value;
|
||||
assert.equal(reopened.group.active, true);
|
||||
await request(guest, '/groups/join', 'POST', { code: reopened.code, creator_id: 'a'.repeat(64) });
|
||||
assert.equal((await request(guest, '/groups')).value.groups.length, 1);
|
||||
@@ -115,10 +114,10 @@ test('invitation expiry, request origin, invalid IDs and expired sessions are en
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {}, guest = {};
|
||||
await request(host, '/groups', 'POST', { routeId: source.id }, 400);
|
||||
await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'https://other.test' });
|
||||
await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'null' });
|
||||
await request(host, '/groups', 'POST', { routeId: 900, creator_id: 'a'.repeat(64) }, 404);
|
||||
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'https://other.test' });
|
||||
await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'null' });
|
||||
await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: 900, creator_id: 'a'.repeat(64) }, 404);
|
||||
const created = (await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
await request(host, `/groups/${'a'.repeat(32)}/code`, 'POST', {}, 404);
|
||||
db.prepare('UPDATE collaboration_groups SET code_expires_at = 0 WHERE id = ?').run(created.group.id);
|
||||
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) }, 404);
|
||||
@@ -136,7 +135,7 @@ test('invitation expiry, request origin, invalid IDs and expired sessions are en
|
||||
test('overlapping writes are locked until completion and revision changes once', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {};
|
||||
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
const created = (await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
const guard = groupRouteGuard(db);
|
||||
const req = { method: 'PUT', path: `/routes/${created.group.route.id}`, headers: { cookie: host.cookie, 'if-match': '0' } };
|
||||
const res = new EventEmitter(); res.setHeader = () => {}; res.statusCode = 200;
|
||||
@@ -153,7 +152,7 @@ test('group copying rolls back a failed media copy and rate limits invalid invit
|
||||
const { db, source, request } = await fixture(t);
|
||||
const image = db.prepare('SELECT path FROM poi_images LIMIT 1').get();
|
||||
fs.rmSync(resolveStoredFile(image.path));
|
||||
await request({}, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 404);
|
||||
await request({}, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 404);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM routes').get().total, 1);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM collaboration_groups').get().total, 0);
|
||||
const client = {};
|
||||
@@ -166,7 +165,7 @@ test('group copying rolls back a failed media copy and rate limits invalid invit
|
||||
test('point lock files bind the device and session, require confirmed takeover and reject stale content', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {}, guest = {}, outsider = {};
|
||||
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
const created = (await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) });
|
||||
const route = created.group.route, poi = route.pois[0];
|
||||
const endpoint = `/groups/routes/${route.id}/pois/${poi.id}/lock`;
|
||||
@@ -215,14 +214,14 @@ test('point lock files bind the device and session, require confirmed takeover a
|
||||
test('different station edits do not conflict through the route revision, and in-flight writes prevent takeover', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {}, guest = {};
|
||||
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
const created = (await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) });
|
||||
const route = created.group.route;
|
||||
const second = createPoi(db, route.id, { title: 'Second stop' });
|
||||
const lockPath = poiId => `/groups/routes/${route.id}/pois/${poiId}/lock`;
|
||||
const first = route.pois[0];
|
||||
const a = (await request(host, lockPath(first.id), 'POST', {})).value;
|
||||
const b = (await request(guest, lockPath(second.id), 'POST', {})).value;
|
||||
const a = (await request(host, lockPath(first.id), 'POST', { code: Array(5).fill('🪨') })).value;
|
||||
const b = (await request(guest, lockPath(second.id), 'POST', { code: Array(5).fill('🪨') })).value;
|
||||
await request(host, `/routes/${route.id}/pois/${first.id}`, 'PUT', { title: 'A' }, 200,
|
||||
{ 'X-Poi-Lock': a.token, 'If-Poi-Match': a.version, 'If-Match': '0' });
|
||||
await request(guest, `/routes/${route.id}/pois/${second.id}`, 'PUT', { title: 'B' }, 200,
|
||||
@@ -243,9 +242,8 @@ test('different station edits do not conflict through the route revision, and in
|
||||
|
||||
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;
|
||||
const created = (await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), 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);
|
||||
@@ -254,18 +252,18 @@ test('five-symbol invitations allow repeated motifs and reject other code length
|
||||
});
|
||||
|
||||
|
||||
test('invitations expire after thirty minutes, wait for active locks and rotate after sixty idle minutes', async t => {
|
||||
test('invitations extend on editing, expire after sixty idle minutes and never rotate', 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);
|
||||
const created = (await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
assert.equal(created.expiresAt, Math.floor(started / 1000) + 3600);
|
||||
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 lock = (await request(guest, endpoint, 'POST', { code: Array(5).fill('🪨') })).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;
|
||||
@@ -273,7 +271,8 @@ test('invitations expire after thirty minutes, wait for active locks and rotate
|
||||
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);
|
||||
assert.deepEqual(held.code, created.code);
|
||||
assert.equal(held.expired, false);
|
||||
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);
|
||||
@@ -292,7 +291,47 @@ test('invitations expire after thirty minutes, wait for active locks and rotate
|
||||
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) });
|
||||
assert.equal(renewed.expiresAt, Math.floor(started / 1000) + 91 * 60);
|
||||
assert.equal(renewed.expired, true);
|
||||
assert.deepEqual(renewed.code, created.code);
|
||||
await request({}, '/groups/join', 'POST', { code: renewed.code, creator_id: 'c'.repeat(64) }, 404);
|
||||
await request(host, endpoint.replace(String(poi.id) + '/lock', '99999/lock'), 'GET', null, 404);
|
||||
});
|
||||
|
||||
test('chosen codes persist across connections, collisions fail without copies and passive queries never extend expiry', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {}, guest = {};
|
||||
const code = ['🐦', '🪨', '🐦', '🎃', '🍄'];
|
||||
await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 400);
|
||||
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64), code }, 201)).value;
|
||||
assert.deepEqual(created.code, code);
|
||||
const second = openDatabase(db.location());
|
||||
try { assert.deepEqual(JSON.parse(second.prepare('SELECT code FROM collaboration_invitations WHERE group_id = ?').get(created.group.id).code), code); }
|
||||
finally { second.close(); }
|
||||
const total = db.prepare('SELECT COUNT(*) AS total FROM routes').get().total;
|
||||
const collision = await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64), code }, 409);
|
||||
assert.equal(collision.value.details.code, 'code-taken');
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM routes').get().total, total);
|
||||
await request(host, `/groups/${created.group.id}/code`, 'POST', {}, 400);
|
||||
let clock = Date.now();
|
||||
t.mock.method(Date, 'now', () => clock);
|
||||
clock += 5 * 60_000;
|
||||
const status = (await request(host, `/groups/${created.group.id}/invitation`)).value;
|
||||
assert.deepEqual(status.code, code);
|
||||
assert.equal(status.expiresAt, created.expiresAt);
|
||||
await request(guest, '/groups/join', 'POST', { code, creator_id: 'b'.repeat(64) });
|
||||
const expiry = () => db.prepare('SELECT code_expires_at FROM collaboration_groups WHERE id = ?').get(created.group.id).code_expires_at;
|
||||
assert.equal(expiry(), Math.floor(clock / 1000) + 3600);
|
||||
clock += 5 * 60_000;
|
||||
await request(host, '/groups');
|
||||
await request(guest, `/routes/${created.group.route.id}`);
|
||||
assert.equal(expiry(), Math.floor(clock / 1000) + 3300);
|
||||
await request(guest, `/groups/${created.group.id}/edit`, 'POST', {});
|
||||
assert.equal(expiry(), Math.floor(clock / 1000) + 3600);
|
||||
await request({}, `/groups/${created.group.id}/edit`, 'POST', {}, 401);
|
||||
clock += 5 * 60_000;
|
||||
await request(guest, `/routes/${created.group.route.id}`, 'PUT', { name: 'Changed again' }, 200, { 'If-Match': '0' });
|
||||
assert.equal(expiry(), Math.floor(clock / 1000) + 3600);
|
||||
await request(host, `/groups/${created.group.id}`, 'DELETE');
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM collaboration_invitations').get().total, 0);
|
||||
});
|
||||
|
||||
@@ -1013,10 +1013,13 @@ test('shared drafts use guarded server saves, preserve the creator and retain un
|
||||
test('sharing a local draft asks before sending and opens a separate shared copy', async () => {
|
||||
const h = buildEditorContext();
|
||||
const sharedIds = [];
|
||||
const code = Array(5).fill('🐦');
|
||||
let chosen = null;
|
||||
h.context.confirm = () => false;
|
||||
h.context.Wegwichtel.Groups = {
|
||||
shareButton(action) { const button = new FakeElement('', 'button'); button.dataset.editorAction = action; return button; },
|
||||
async share(id) { sharedIds.push(id); }
|
||||
async chooseCode() { return chosen; },
|
||||
async share(id, selected) { assert.equal(selected, code); sharedIds.push(id); }
|
||||
};
|
||||
await loadI18n(h.context);
|
||||
const filename = path.join(root, 'public/js/editor-ui.js');
|
||||
@@ -1027,6 +1030,10 @@ test('sharing a local draft asks before sending and opens a separate shared copy
|
||||
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
|
||||
for (let i = 0; i < 4; i += 1) await tick();
|
||||
assert.equal(h.calls.some(call => call.method === 'POST'), false);
|
||||
chosen = code;
|
||||
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
|
||||
for (let i = 0; i < 4; i += 1) await tick();
|
||||
assert.equal(h.calls.some(call => call.method === 'POST'), false, 'declining transfer sends nothing');
|
||||
h.context.confirm = () => true;
|
||||
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
|
||||
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
|
||||
|
||||
+72
-17
@@ -27,17 +27,18 @@ 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-entry-dialog', 'group-entry-close', 'group-entry-status', 'group-route',
|
||||
'group-entry-title', 'group-entry-dialog', 'group-entry-close', 'group-entry-status', 'group-route',
|
||||
'group-status', 'group-wheels', 'group-entry', 'group-invitation', 'group-code', 'group-join', 'group-edit', 'group-expiry'];
|
||||
const nodes = new Map(ids.map(id => [id, new Element()]));
|
||||
const document = new EventTarget();
|
||||
document.readyState = loading ? 'loading' : 'complete';
|
||||
document.getElementById = id => nodes.get(id);
|
||||
document.createElement = () => new Element();
|
||||
const calls = [], edits = [];
|
||||
const calls = [], edits = [], timers = [];
|
||||
const controls = { error: 0, offline: false, confirm: true, active: true, role: 'owner' };
|
||||
const group = () => ({ id: 'group-id', route: { id: 22, name: 'Waldweg' }, revision: 0, role: controls.role, active: controls.active });
|
||||
const context = { Event, document, Date, Map, Set, Headers, console, globalThis: null,
|
||||
const context = { Event, document, Date, Map, Set, Headers, console, crypto, globalThis: null,
|
||||
setInterval: (callback, delay) => { timers.push({ callback, delay }); },
|
||||
confirm: () => controls.confirm, matchMedia: () => ({ matches: false }),
|
||||
Wegwichtel: { Config: { apiBase: '/api/' }, LocalRoutes: { creatorId: () => 'a'.repeat(64) },
|
||||
EditorUi: { async openGroup(route) { edits.push(route.id); } } },
|
||||
@@ -48,7 +49,7 @@ async function harness(loading = false) {
|
||||
let value;
|
||||
if (options.method === 'GET') value = { groups: [group()] };
|
||||
else if (options.method === 'DELETE') { controls.active = false; value = { closed: true }; }
|
||||
else if (url.endsWith('/join')) value = { group: group() };
|
||||
else if (url.endsWith('/join') || url.endsWith('/edit')) value = { group: group() };
|
||||
else value = { group: group(), code: Array(5).fill('🐦'), expiresAt: 1800000000 };
|
||||
return { ok: true, json: async () => value };
|
||||
}
|
||||
@@ -59,7 +60,7 @@ async function harness(loading = false) {
|
||||
const filename = path.resolve('public/js', module + '.js');
|
||||
vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename });
|
||||
}
|
||||
return { nodes, context, calls, controls, edits, groups: context.Wegwichtel.Groups };
|
||||
return { nodes, context, calls, controls, edits, timers, groups: context.Wegwichtel.Groups };
|
||||
}
|
||||
|
||||
test('group code wheels support arrows, keyboard wrapping, scrolling and joining with ordered Unicode', async () => {
|
||||
@@ -99,7 +100,7 @@ test('group code wheels support arrows, keyboard wrapping, scrolling and joining
|
||||
|
||||
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);
|
||||
await h.groups.share(7, Array(5).fill('🐦'));
|
||||
assert.equal(JSON.parse(h.calls[0].body).routeId, 7);
|
||||
assert.equal(h.nodes.get('group-code').children.length, 5);
|
||||
assert.equal(h.nodes.get('group-entry-dialog').open, false);
|
||||
@@ -114,11 +115,10 @@ test('sharing displays five motifs and the owner can renew or close access with
|
||||
pointer(h.nodes.get('group-edit')); await tick();
|
||||
assert.deepEqual(h.edits, [22]);
|
||||
let actions = h.nodes.get('editor-group-list').children[0].children[1].children;
|
||||
h.controls.confirm = false;
|
||||
pointer(actions[2]); await tick();
|
||||
assert.equal(h.calls.some(call => call.url.endsWith('/code')), false);
|
||||
h.controls.confirm = true;
|
||||
pointer(actions[2]); await tick();
|
||||
assert.equal(h.nodes.get('group-entry-dialog').open, true);
|
||||
pointer(h.nodes.get('group-join')); await tick();
|
||||
assert.ok(h.calls.some(call => call.url.endsWith('/code')));
|
||||
actions = h.nodes.get('editor-group-list').children[0].children[1].children;
|
||||
pointer(actions[1]); await tick();
|
||||
@@ -192,9 +192,9 @@ test('unsent station changes are listed before network access and discarded only
|
||||
assert.equal(list.children.length, 0);
|
||||
});
|
||||
|
||||
test('sharing opens the actual five-symbol invitation directly without a two-symbol preview', async () => {
|
||||
test('sharing a confirmed code shows the five-symbol invitation', async () => {
|
||||
const h = await harness();
|
||||
await h.groups.share(7);
|
||||
await h.groups.share(7, Array(5).fill('🐦'));
|
||||
assert.equal(h.nodes.get('group-dialog').open, true);
|
||||
assert.equal(h.nodes.get('group-entry-dialog').open, false);
|
||||
assert.equal(h.nodes.get('group-code').children.length, 5);
|
||||
@@ -209,14 +209,14 @@ test('sharing opens the actual five-symbol invitation directly without a two-sym
|
||||
test('an incomplete invitation is never displayed as a two-symbol group code', async () => {
|
||||
const h = await harness();
|
||||
h.context.fetch = async () => ({ ok: true, json: async () => ({ group: { route: { id: 7 } }, code: ['🐦', '🪨'] }) });
|
||||
await assert.rejects(h.groups.share(7));
|
||||
await assert.rejects(h.groups.share(7, Array(5).fill('🐦')));
|
||||
assert.equal(h.nodes.get('group-dialog').open, false);
|
||||
assert.equal(h.nodes.get('group-code').children.length, 0);
|
||||
});
|
||||
|
||||
test('the open invitation refreshes its code automatically and shows a deferred renewal during station editing', async () => {
|
||||
test('the open invitation shows its current expiry without creating a code', async () => {
|
||||
const h = await harness();
|
||||
await h.groups.share(7);
|
||||
await h.groups.share(7, Array(5).fill('🐦'));
|
||||
const original = h.context.fetch;
|
||||
h.context.fetch = async (url, options) => {
|
||||
if (!url.endsWith('/invitation')) return original(url, options);
|
||||
@@ -224,14 +224,69 @@ test('the open invitation refreshes its code automatically and shows a deferred
|
||||
busy: true, expired: true, expiresAt: 1800000000 }) };
|
||||
};
|
||||
await h.groups.refreshInvitation();
|
||||
assert.match(h.nodes.get('group-status').textContent, /Station bearbeitet/);
|
||||
assert.match(h.nodes.get('group-status').textContent, /abgelaufen/);
|
||||
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('🪨') }) });
|
||||
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.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();
|
||||
});
|
||||
|
||||
test('sharing first offers five random editable symbols, sends only the confirmed code and never opens the editor', async () => {
|
||||
const h = await harness();
|
||||
const sharing = h.groups.share(7);
|
||||
assert.equal(h.nodes.get('group-entry-dialog').open, true);
|
||||
assert.equal(h.calls.length, 0);
|
||||
assert.equal(h.nodes.get('group-entry-title').textContent, 'Code auswählen');
|
||||
const columns = h.nodes.get('group-wheels').children;
|
||||
assert.equal(columns.length, 5);
|
||||
for (const column of columns) {
|
||||
const wheel = column.children[1];
|
||||
assert.ok(Number(wheel.attributes.get('aria-valuenow')) >= 1);
|
||||
assert.equal(wheel.scrollTop, (Number(wheel.attributes.get('aria-valuenow')) - 1) * 96);
|
||||
key(wheel, 'Home');
|
||||
}
|
||||
pointer(columns[2].children[1].children[5]);
|
||||
pointer(h.nodes.get('group-join'));
|
||||
await sharing;
|
||||
assert.deepEqual(JSON.parse(h.calls[0].body).code, ['🪵', '🪵', '🐦', '🪵', '🪵']);
|
||||
assert.equal(h.nodes.get('group-dialog').open, true);
|
||||
assert.deepEqual(h.edits, []);
|
||||
});
|
||||
|
||||
test('cancelling code selection sends no request and a late double tap cannot join a group', async () => {
|
||||
const h = await harness();
|
||||
const sharing = h.groups.share(7);
|
||||
pointer(h.nodes.get('group-entry-close'));
|
||||
await sharing;
|
||||
pointer(h.nodes.get('group-join'));
|
||||
await tick();
|
||||
assert.equal(h.calls.length, 0);
|
||||
assert.deepEqual(h.edits, []);
|
||||
});
|
||||
|
||||
|
||||
test('the countdown decreases, reflects extended expiry and shows expiration without changing the code', async () => {
|
||||
const h = await harness();
|
||||
let clock = 1799996400000;
|
||||
h.context.Date = class extends Date { static now() { return clock; } };
|
||||
await h.groups.share(7, Array(5).fill('🐦'));
|
||||
assert.match(h.nodes.get('group-expiry').textContent, /60 Min. 00 Sek./);
|
||||
const timer = h.timers.find(timer => timer.delay === 1000);
|
||||
clock += 45_000;
|
||||
timer.callback();
|
||||
assert.match(h.nodes.get('group-expiry').textContent, /59 Min. 15 Sek./);
|
||||
const code = h.nodes.get('group-code').children.map(image => image.src);
|
||||
h.context.fetch = async () => ({ ok: true, json: async () => ({ group: { id: 'group-id', route: { id: 22 } },
|
||||
code: Array(5).fill('🐦'), expiresAt: clock / 1000 + 3600 }) });
|
||||
await h.groups.refreshInvitation();
|
||||
assert.match(h.nodes.get('group-expiry').textContent, /60 Min. 00 Sek./);
|
||||
clock += 3600_000;
|
||||
timer.callback();
|
||||
assert.match(h.nodes.get('group-expiry').textContent, /abgelaufen/);
|
||||
assert.deepEqual(h.nodes.get('group-code').children.map(image => image.src), code);
|
||||
});
|
||||
|
||||
@@ -45,6 +45,10 @@ const EXPECTED_SCHEMA = Object.freeze({
|
||||
revision: { type: 'INTEGER', notnull: 1 },
|
||||
created_at: { type: 'TEXT', notnull: 1 }
|
||||
},
|
||||
collaboration_invitations: {
|
||||
group_id: { type: 'TEXT', notnull: 1, pk: 1 },
|
||||
code: { type: 'TEXT', notnull: 1 }
|
||||
},
|
||||
collaboration_members: {
|
||||
group_id: { type: 'TEXT', notnull: 1, pk: 1 },
|
||||
session_hash: { type: 'TEXT', notnull: 1, pk: 2 },
|
||||
|
||||
Reference in New Issue
Block a user