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

This commit is contained in:
Florian Zumpe
2026-10-18 17:24:41 +02:00
parent 31768ae907
commit 0e4cdfbd54
18 changed files with 400 additions and 49 deletions
+13 -2
View File
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { createGroup, joinGroup, listGroups, renewCode, closeGroup, verifyOrigin, session, member } from '../services/collaboration.js';
import { createGroup, joinGroup, listGroups, renewCode, closeGroup, verifyOrigin, session, member, invitationStatus } from '../services/collaboration.js';
import { acquirePoint, releasePoint } from '../services/point-locks.js';
import { acquirePoint, releasePoint, touchPoint, pointLocked } from '../services/point-locks.js';
import { HttpError } from '../middleware/errors.js';
export function createGroupsRouter(db) {
@@ -16,12 +16,23 @@ export function createGroupsRouter(db) {
if (!group) throw new HttpError(404, 'Gruppe nicht gefunden.');
member(db, req, group);
const account = session(db, req);
if (req.body?.heartbeat) {
touchPoint(group, account, req.params.poiId, req.body.token);
return res.status(204).end();
}
if (req.body?.release) {
releasePoint(group, account, req.params.poiId, req.body.token);
return res.json({ released: true });
}
return res.json(acquirePoint(db, group, account, req.params.poiId, req.body));
});
router.get('/routes/:routeId/pois/:poiId/lock', (req, res) => {
const group = db.prepare('SELECT * FROM collaboration_groups WHERE route_id = ?').get(Number(req.params.routeId));
if (!group) throw new HttpError(404, 'Gruppe nicht gefunden.');
member(db, req, group);
res.status(pointLocked(db, group, req.params.poiId) ? 423 : 204).end();
});
router.get('/:id/invitation', (req, res) => res.json(invitationStatus(db, req, req.params.id)));
router.get('/', (req, res) => res.json({ groups: listGroups(db, req) }));
router.post('/', (req, res) => res.status(201).json(createGroup(db, req, res)));
router.post('/join', (req, res) => res.json({ group: joinGroup(db, req, res) }));
+43 -4
View File
@@ -1,5 +1,5 @@
import crypto from 'node:crypto';
import { guardPoint, clearPointLocks } from './point-locks.js';
import { guardPoint, clearPointLocks, hasPointLocks } from './point-locks.js';
import fs from 'node:fs';
import path from 'node:path';
import { config } from '../config.js';
@@ -9,10 +9,11 @@ import { createRoute, getRoute, getRouteGpxFile, createPoi } from './routes-serv
import { routeDirectory, relativeStoragePath, resolveStoredFile } from './storage.js';
export const GROUP_SYMBOLS = Object.freeze(['🪵', '🪨', '🏮', '🌼', '🚬', '🐦', '🍄', '🍓', '🎃']);
export const GROUP_CODE_LENGTH = 8;
export const GROUP_CODE_LENGTH = 5;
export const SESSION_COOKIE = 'wegwichtel_group_session';
const SESSION_SECONDS = 30 * 24 * 60 * 60;
const CODE_SECONDS = 60 * 60;
const CODE_SECONDS = 30 * 60;
const invitations = new Map();
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
const now = () => Math.floor(Date.now() / 1000);
const joinFailures = new Map();
@@ -71,7 +72,9 @@ function issueCode(db, groupId) {
const expiresAt = now() + CODE_SECONDS;
db.prepare('UPDATE collaboration_groups SET code_hash = ?, code_expires_at = ?, active = 1 WHERE id = ?')
.run(digest, expiresAt, groupId);
return { code, expiresAt };
const invitation = { code, expiresAt };
invitations.set(groupId, invitation);
return invitation;
}
throw new HttpError(503, 'Bitte versuche es gleich noch einmal.');
}
@@ -198,6 +201,7 @@ export function joinGroup(db, req, res) {
export function renewCode(db, req, id) {
const group = owner(db, req, id);
if (!group.active) assertGroupQuota(db, session(db, req).token_hash);
if (hasPointLocks(group.route_id)) throw new HttpError(423, 'In dieser Gruppe wird gerade eine Station bearbeitet.', { code: 'group-editing' });
const invitation = issueCode(db, id);
return { group: groupSummary(db, groupById(db, id), 'owner'), ...invitation };
}
@@ -209,9 +213,44 @@ export function closeGroup(db, req, id) {
db.prepare('UPDATE collaboration_groups SET active = 0, code_hash = NULL, code_expires_at = 0 WHERE id = ?').run(id);
db.prepare("DELETE FROM collaboration_members WHERE group_id = ? AND role = 'member'").run(id);
});
invitations.delete(id);
return { closed: true };
}
export function invitationStatus(db, req, id) {
let group = owner(db, req, id);
const busy = hasPointLocks(group.route_id);
if (group.active && group.code_expires_at <= now() && !busy) {
issueCode(db, id);
group = groupById(db, id);
}
const invitation = invitations.get(id);
return { group: groupSummary(db, group, 'owner'), busy, expired: group.code_expires_at <= now(), expiresAt: group.code_expires_at,
...(invitation?.expiresAt === group.code_expires_at ? { code: invitation.code } : {}) };
}
export function maintainCollaboration(db) {
const groups = db.prepare(`SELECT g.*, r.activated_at FROM collaboration_groups g JOIN routes r ON r.id = g.route_id
WHERE r.status = 'active'`).all();
for (const group of groups) {
const busy = hasPointLocks(group.route_id);
if (!group.active || group.activated_at) { invitations.delete(group.id); continue; }
const ownerExists = db.prepare(`SELECT 1 FROM collaboration_members m JOIN collaboration_sessions s ON s.token_hash = m.session_hash
WHERE m.group_id = ? AND m.role = 'owner' AND s.expires_at > ?`).get(group.id, now());
if (ownerExists && group.code_expires_at <= now() && !busy) issueCode(db, group.id);
}
}
export function startCollaborationMaintenance(db) {
const sweep = () => {
try { maintainCollaboration(db); } catch (error) { console.error('Gruppenpflege fehlgeschlagen:', error); }
};
sweep();
const timer = setInterval(sweep, 60_000);
timer.unref();
return () => clearInterval(timer);
}
export function verifyOrigin(req) {
const origin = req.headers.origin;
if (!origin) return;
+33 -1
View File
@@ -7,6 +7,7 @@ import { HttpError } from '../middleware/errors.js';
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
const running = new Set();
export const POINT_IDLE_MS = 60 * 60 * 1000;
const key = (routeId, poiId) => `${Number(routeId)}:${Number(poiId)}`;
function filename(routeId, poiId) {
if (![routeId, poiId].every(value => Number.isSafeInteger(Number(value)) && Number(value) > 0)) {
@@ -15,7 +16,15 @@ function filename(routeId, poiId) {
return path.join(routeDirectory(Number(routeId)), 'locks', `${Number(poiId)}.json`);
}
function read(routeId, poiId) {
try { return JSON.parse(fs.readFileSync(filename(routeId, poiId), 'utf8')); }
try {
const lock = JSON.parse(fs.readFileSync(filename(routeId, poiId), 'utf8'));
const lastActivity = Date.parse(lock.updated_at || lock.created_at);
if (!running.has(key(routeId, poiId)) && (!Number.isFinite(lastActivity) || Date.now() - lastActivity >= POINT_IDLE_MS)) {
fs.rmSync(filename(routeId, poiId), { force: true });
return null;
}
return lock;
}
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
}
function write(routeId, poiId, lock) {
@@ -52,6 +61,7 @@ export function acquirePoint(db, group, account, poiId, body) {
}
const lock = resume ? existing : { creator_id: account.creator_id, session_hash: account.token_hash,
token: crypto.randomBytes(32).toString('hex'), created_at: new Date().toISOString() };
lock.updated_at = new Date(Date.now()).toISOString();
write(group.route_id, poi.id, lock);
return { token: lock.token, ...pointSnapshot(db, group.route_id, poi.id), poi };
}
@@ -62,6 +72,27 @@ export function releasePoint(group, account, poiId, token) {
if (running.has(key(group.route_id, poiId))) throw new HttpError(423, 'Diese Station wird gerade gespeichert.', { code: 'point-busy' });
fs.rmSync(filename(group.route_id, poiId), { force: true });
}
export function touchPoint(group, account, poiId, token) {
const lock = read(group.route_id, poiId);
if (!sameOwner(lock, account) || lock.token !== token) throw new HttpError(423, 'Die Bearbeitung wurde übernommen.', { code: 'point-lost' });
lock.updated_at = new Date(Date.now()).toISOString();
write(group.route_id, poiId, lock);
}
export function pointLocked(db, group, poiId) {
getPoi(db, group.route_id, Number(poiId));
return Boolean(read(group.route_id, poiId));
}
export function hasPointLocks(routeId) {
const directory = path.join(routeDirectory(routeId), 'locks');
let entries;
try { entries = fs.readdirSync(directory, { withFileTypes: true }); }
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
let locked = false;
for (const entry of entries) {
if (entry.isFile() && /^\d+\.json$/.test(entry.name) && read(routeId, Number(entry.name.slice(0, -5)))) locked = true;
}
return locked;
}
export function clearPointLocks(routeId) {
if ([...running].some(id => id.startsWith(`${Number(routeId)}:`))) throw new HttpError(423, 'Eine Station wird gerade gespeichert.', { code: 'point-busy' });
fs.rmSync(path.join(routeDirectory(routeId), 'locks'), { recursive: true, force: true });
@@ -86,6 +117,7 @@ export function guardPoint(db, group, account, poiId, req, res) {
const json = res.json;
res.json = function (payload) {
if (res.statusCode >= 200 && res.statusCode < 300 && db.prepare('SELECT 1 FROM pois WHERE id = ?').get(Number(poiId))) {
touchPoint(group, account, poiId, req.headers['x-poi-lock']);
res.setHeader('X-Poi-Version', pointVersion(db, group.route_id, poiId));
res.setHeader('X-Poi-Id', String(poiId));
}