group code popup
This commit is contained in:
+13
-2
@@ -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) }));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user