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

This commit is contained in:
Florian Zumpe
2026-10-18 16:33:12 +02:00
parent 9c4d5f6324
commit 31768ae907
28 changed files with 2361 additions and 55 deletions
+22 -9
View File
@@ -1,4 +1,7 @@
import { Router } from 'express';
import { createGroupsRouter } from './groups.js';
import { groupRouteGuard } from '../services/collaboration.js';
import { removeUpload } from '../services/storage.js';
import { inspectUpload, upload } from '../middleware/upload.js';
import { config } from '../config.js';
import { UPLOAD_COPYRIGHT_HEADERS } from '../services/copyright.js';
@@ -32,6 +35,13 @@ function sendFileResource(res, file, contentType = null, headers = {}) {
});
}
function validatePointWrite(req, res, next) {
// Recheck after asynchronous uploads: a disconnected client may have lost its lock.
try { req.validatePointWrite?.(); }
catch (error) { removeUpload(req.file); throw error; }
next();
}
export function createApiRouter(db) {
const api = Router();
const requireWritableRoute = paramName => (req, res, next) => {
@@ -50,6 +60,9 @@ export function createApiRouter(db) {
next();
});
api.use('/groups', createGroupsRouter(db));
api.use(groupRouteGuard(db));
api.get('/health', (req, res) => {
const sqliteVersion = db.prepare('SELECT sqlite_version() AS version').get().version;
res.json({
@@ -102,14 +115,14 @@ export function createApiRouter(db) {
res.json(appendRoute(db, Number(req.params.id), req.file));
});
api.post('/routes/:id/pois', requireWritableRoute('id'), upload.none(), (req, res) => {
api.post('/routes/:id/pois', requireWritableRoute('id'), upload.none(), validatePointWrite, (req, res) => {
const poi = createPoi(db, Number(req.params.id), req.body);
res.status(201)
.location(`/api/routes/${req.params.id}/pois/${poi.id}`)
.json(poi);
});
api.put('/routes/:routeId/pois/:poiId', requireWritableRoute('routeId'), upload.none(), (req, res) => {
api.put('/routes/:routeId/pois/:poiId', requireWritableRoute('routeId'), upload.none(), validatePointWrite, (req, res) => {
res.json(updatePoi(
db,
Number(req.params.routeId),
@@ -118,7 +131,7 @@ export function createApiRouter(db) {
));
});
api.delete('/routes/:routeId/pois/:poiId', requireRouteDeleteAllowed('routeId'), (req, res) => {
api.delete('/routes/:routeId/pois/:poiId', requireRouteDeleteAllowed('routeId'), validatePointWrite, (req, res) => {
res.json(deletePoi(db, Number(req.params.routeId), Number(req.params.poiId)));
});
@@ -138,7 +151,7 @@ export function createApiRouter(db) {
sendFileResource(res, file, null, UPLOAD_COPYRIGHT_HEADERS);
});
api.post('/routes/:routeId/pois/:poiId/pictures', requireWritableRoute('routeId'), upload.single('picture'), inspectUpload('picture', 'picture', { allowJson: true }), (req, res) => {
api.post('/routes/:routeId/pois/:poiId/pictures', requireWritableRoute('routeId'), upload.single('picture'), inspectUpload('picture', 'picture', { allowJson: true }), validatePointWrite, (req, res) => {
const routeId = Number(req.params.routeId);
const poiId = Number(req.params.poiId);
const picture = createPicture(db, routeId, poiId, req.body, req.file);
@@ -147,7 +160,7 @@ export function createApiRouter(db) {
.json(picture);
});
api.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', requireWritableRoute('routeId'), upload.single('picture'), inspectUpload('picture', 'picture', { allowJson: true }), (req, res) => {
api.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', requireWritableRoute('routeId'), upload.single('picture'), inspectUpload('picture', 'picture', { allowJson: true }), validatePointWrite, (req, res) => {
res.json(updatePicture(
db,
Number(req.params.routeId),
@@ -158,7 +171,7 @@ export function createApiRouter(db) {
));
});
api.delete('/routes/:routeId/pois/:poiId/pictures/:pictureId', requireRouteDeleteAllowed('routeId'), (req, res) => {
api.delete('/routes/:routeId/pois/:poiId/pictures/:pictureId', requireRouteDeleteAllowed('routeId'), validatePointWrite, (req, res) => {
res.json(deletePicture(
db,
Number(req.params.routeId),
@@ -177,7 +190,7 @@ export function createApiRouter(db) {
sendFileResource(res, getAudioFile(db, routeId, poiId));
});
api.post('/routes/:routeId/pois/:poiId/audio', requireWritableRoute('routeId'), upload.single('audio'), inspectUpload('audio', 'audio', { allowJson: true }), (req, res) => {
api.post('/routes/:routeId/pois/:poiId/audio', requireWritableRoute('routeId'), upload.single('audio'), inspectUpload('audio', 'audio', { allowJson: true }), validatePointWrite, (req, res) => {
const routeId = Number(req.params.routeId);
const poiId = Number(req.params.poiId);
const audio = createAudio(db, routeId, poiId, req.file);
@@ -186,11 +199,11 @@ export function createApiRouter(db) {
.json(audio);
});
api.put('/routes/:routeId/pois/:poiId/audio', requireWritableRoute('routeId'), upload.single('audio'), inspectUpload('audio', 'audio', { allowJson: true }), (req, res) => {
api.put('/routes/:routeId/pois/:poiId/audio', requireWritableRoute('routeId'), upload.single('audio'), inspectUpload('audio', 'audio', { allowJson: true }), validatePointWrite, (req, res) => {
res.json(updateAudio(db, Number(req.params.routeId), Number(req.params.poiId), req.file));
});
api.delete('/routes/:routeId/pois/:poiId/audio', requireRouteDeleteAllowed('routeId'), (req, res) => {
api.delete('/routes/:routeId/pois/:poiId/audio', requireRouteDeleteAllowed('routeId'), validatePointWrite, (req, res) => {
res.json(deleteAudio(db, Number(req.params.routeId), Number(req.params.poiId)));
});
+31
View File
@@ -0,0 +1,31 @@
import { Router } from 'express';
import { createGroup, joinGroup, listGroups, renewCode, closeGroup, verifyOrigin, session, member } from '../services/collaboration.js';
import { acquirePoint, releasePoint } from '../services/point-locks.js';
import { HttpError } from '../middleware/errors.js';
export function createGroupsRouter(db) {
const router = Router();
router.use((req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
if (!['GET', 'HEAD'].includes(req.method)) verifyOrigin(req);
next();
});
router.post('/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);
const account = session(db, req);
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('/', (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) }));
router.post('/:id/code', (req, res) => res.json(renewCode(db, req, req.params.id)));
router.delete('/:id', (req, res) => res.json(closeGroup(db, req, req.params.id)));
return router;
}
+256
View File
@@ -0,0 +1,256 @@
import crypto from 'node:crypto';
import { guardPoint, clearPointLocks } from './point-locks.js';
import fs from 'node:fs';
import path from 'node:path';
import { config } from '../config.js';
import { transaction } from '../database.js';
import { HttpError } from '../middleware/errors.js';
import { createRoute, getRoute, getRouteGpxFile, createPoi } from './routes-service.js';
import { routeDirectory, relativeStoragePath, resolveStoredFile } from './storage.js';
export const GROUP_SYMBOLS = Object.freeze(['🪵', '🪨', '🏮', '🌼', '🚬', '🐦', '🍄', '🍓', '🎃']);
export const GROUP_CODE_LENGTH = 8;
export const SESSION_COOKIE = 'wegwichtel_group_session';
const SESSION_SECONDS = 30 * 24 * 60 * 60;
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();
function cookieToken(req) {
const entry = String(req.headers.cookie || '').split(';').map(value => value.trim())
.find(value => value.startsWith(`${SESSION_COOKIE}=`));
const token = entry?.slice(SESSION_COOKIE.length + 1);
return /^[a-f0-9]{64}$/.test(token || '') ? token : null;
}
export function session(db, req, res = null, creatorId = null) {
const token = cookieToken(req);
const existing = token && db.prepare('SELECT * FROM collaboration_sessions WHERE token_hash = ? AND expires_at > ?')
.get(hash(token), now());
if (existing) return existing;
if (!res) throw new HttpError(401, 'Bitte gib den Gruppencode erneut ein.');
if (!/^[a-f0-9]{64}$/.test(creatorId || '')) throw new HttpError(400, 'Die Gerätekennung fehlt.');
const secret = crypto.randomBytes(32).toString('hex');
const tokenHash = hash(secret);
db.prepare('DELETE FROM collaboration_sessions WHERE expires_at <= ?').run(now());
db.prepare('INSERT INTO collaboration_sessions (token_hash, creator_id, expires_at) VALUES (?, ?, ?)')
.run(tokenHash, creatorId, now() + SESSION_SECONDS);
res.cookie(SESSION_COOKIE, secret, {
httpOnly: true, sameSite: 'strict', path: '/api', maxAge: SESSION_SECONDS * 1000,
secure: req.secure || req.headers['x-forwarded-proto'] === 'https'
});
return { token_hash: tokenHash, creator_id: creatorId };
}
export function member(db, req, group) {
const account = session(db, req);
const result = db.prepare('SELECT role FROM collaboration_members WHERE group_id = ? AND session_hash = ?')
.get(group.id, account.token_hash);
if (!result || (!group.active && result.role !== 'owner')) throw new HttpError(403, 'Diese Gruppe ist für dich nicht geöffnet.');
return result;
}
export function groupById(db, id) {
const group = db.prepare('SELECT * FROM collaboration_groups WHERE id = ?').get(id);
if (!group) throw new HttpError(404, 'Gruppe nicht gefunden.');
return group;
}
export function owner(db, req, id) {
const group = groupById(db, id);
if (member(db, req, group).role !== 'owner') throw new HttpError(403, 'Nur die Person, die die Gruppe geöffnet hat, kann das tun.');
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)]);
const digest = hash(code.join(''));
if (db.prepare('SELECT 1 FROM collaboration_groups WHERE code_hash = ?').get(digest)) continue;
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 };
}
throw new HttpError(503, 'Bitte versuche es gleich noch einmal.');
}
function copyFile(relative, routeId, folder) {
const destination = path.join(routeDirectory(routeId), folder, `${crypto.randomUUID()}${path.extname(relative)}`);
fs.copyFileSync(resolveStoredFile(relative), destination);
return relativeStoragePath(destination);
}
function copyStations(db, sourceRouteId, targetRouteId) {
const pois = db.prepare('SELECT * FROM pois WHERE route_id = ? ORDER BY sequence, id').all(sourceRouteId);
for (const poi of pois) {
const copied = createPoi(db, targetRouteId, {
title: poi.title, description: poi.description, lat: poi.lat, lon: poi.lon,
sequence: poi.sequence, triggerRadiusM: poi.trigger_radius_m
});
if (poi.audio_path) db.prepare('UPDATE pois SET audio_path = ? WHERE id = ?')
.run(copyFile(poi.audio_path, targetRouteId, 'audio'), copied.id);
for (const image of db.prepare('SELECT * FROM poi_images WHERE poi_id = ? ORDER BY sequence, id').all(poi.id)) {
db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)')
.run(copied.id, copyFile(image.path, targetRouteId, 'images'), image.caption, image.sequence);
}
}
}
function assertGroupQuota(db, sessionHash) {
const owned = db.prepare("SELECT COUNT(*) AS total FROM collaboration_members m JOIN collaboration_groups g ON g.id = m.group_id WHERE m.session_hash = ? AND m.role = 'owner' AND g.active = 1").get(sessionHash).total;
if (owned >= 12) throw new HttpError(409, 'Schließe erst eine deiner Gruppen, bevor du eine weitere öffnest.', { code: 'group-limit' });
}
export function createGroup(db, req, res) {
const account = session(db, req, res, req.body?.creator_id);
assertGroupQuota(db, account.token_hash);
const source = getRoute(db, Number(req.body?.routeId));
const previousGroup = db.prepare('SELECT * FROM collaboration_groups WHERE route_id = ?').get(source.id);
if (previousGroup && !source.activatedAt) member(db, req, previousGroup);
if (!source.activatedAt && source.creator_id !== account.creator_id) {
throw new HttpError(403, 'Diese Strecke ist noch nicht für alle freigegeben.');
}
const upload = { path: path.join(config.storageDir, 'uploads', `${crypto.randomUUID()}.gpx`) };
fs.copyFileSync(getRouteGpxFile(db, source.id).absolutePath, upload.path);
let route;
try {
route = createRoute(db, { name: source.name, description: source.description,
schoolName: source.schoolName, creator_id: account.creator_id }, upload);
copyStations(db, source.id, route.id);
const id = crypto.randomBytes(16).toString('hex');
transaction(db, () => {
db.prepare(`INSERT INTO collaboration_groups (id, route_id, source_route_id, owner_creator_id, code_expires_at)
VALUES (?, ?, ?, ?, 0)`).run(id, route.id, source.id, account.creator_id);
db.prepare("INSERT INTO collaboration_members (group_id, session_hash, role) VALUES (?, ?, 'owner')")
.run(id, account.token_hash);
});
const invitation = issueCode(db, id);
return { group: groupSummary(db, groupById(db, id), 'owner'), ...invitation };
} catch (error) {
if (route) {
db.prepare('DELETE FROM collaboration_members WHERE group_id IN (SELECT id FROM collaboration_groups WHERE route_id = ?)').run(route.id);
db.prepare('DELETE FROM collaboration_groups WHERE route_id = ?').run(route.id);
db.prepare('DELETE FROM poi_images WHERE poi_id IN (SELECT id FROM pois WHERE route_id = ?)').run(route.id);
db.prepare('DELETE FROM pois WHERE route_id = ?').run(route.id);
db.prepare('DELETE FROM routes WHERE id = ?').run(route.id);
fs.rmSync(routeDirectory(route.id), { recursive: true, force: true });
}
fs.rmSync(upload.path, { force: true });
throw error;
}
}
export function groupSummary(db, group, role) {
return { id: group.id, route: getRoute(db, group.route_id), role,
active: Boolean(group.active), revision: group.revision, codeExpiresAt: group.code_expires_at };
}
export function listGroups(db, req) {
let account;
try { account = session(db, req); } catch (error) {
if (error.status === 401) return [];
throw error;
}
const rows = db.prepare(`SELECT g.*, m.role FROM collaboration_groups g
JOIN collaboration_members m ON m.group_id = g.id JOIN routes r ON r.id = g.route_id
WHERE m.session_hash = ? AND r.status = 'active' AND (g.active = 1 OR m.role = 'owner')`).all(account.token_hash);
return rows.map(group => groupSummary(db, group, group.role));
}
function failureKey(req) {
const localProxy = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket?.remoteAddress);
return hash(String(localProxy && req.headers['x-real-ip'] || req.socket?.remoteAddress || 'unknown'));
}
function invitationCode(body) {
const input = Array.isArray(body?.code) && body.code.length === GROUP_CODE_LENGTH ? body.code : [];
const code = input.map(value => typeof value === 'string' ? value.normalize('NFC').replaceAll(/[\uFE0E\uFE0F]/g, '') : '');
return code.length === GROUP_CODE_LENGTH && code.every(value => GROUP_SYMBOLS.includes(value)) ? code : null;
}
function failedJoin(key, attempts) {
if (joinFailures.size >= 2048) joinFailures.delete(joinFailures.keys().next().value);
joinFailures.set(key, { count: attempts?.until > now() ? attempts.count + 1 : 1, until: now() + 60 });
throw new HttpError(404, 'Der Code stimmt nicht oder ist abgelaufen.');
}
export function joinGroup(db, req, res) {
const key = failureKey(req);
const attempts = joinFailures.get(key);
if (attempts?.until > now() && attempts.count >= 20) throw new HttpError(429, 'Bitte warte eine Minute und versuche es dann erneut.');
const code = invitationCode(req.body);
const group = code && db.prepare(`SELECT g.* FROM collaboration_groups g JOIN routes r ON r.id = g.route_id
WHERE g.code_hash = ? AND g.code_expires_at > ? AND g.active = 1 AND r.status = 'active'`).get(hash(code.join('')), now());
if (!group) failedJoin(key, attempts);
const account = session(db, req, res, req.body?.creator_id);
const count = db.prepare('SELECT COUNT(*) AS total FROM collaboration_members WHERE group_id = ?').get(group.id).total;
const existing = db.prepare('SELECT role FROM collaboration_members WHERE group_id = ? AND session_hash = ?')
.get(group.id, account.token_hash);
if (!existing && count >= 50) throw new HttpError(409, 'Diese Gruppe ist bereits voll.', { code: 'group-full' });
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');
}
export function renewCode(db, req, id) {
const group = owner(db, req, id);
if (!group.active) assertGroupQuota(db, session(db, req).token_hash);
const invitation = issueCode(db, id);
return { group: groupSummary(db, groupById(db, id), 'owner'), ...invitation };
}
export function closeGroup(db, req, id) {
const group = owner(db, req, id);
clearPointLocks(group.route_id);
transaction(db, () => {
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);
});
return { closed: true };
}
export function verifyOrigin(req) {
const origin = req.headers.origin;
if (!origin) return;
let host;
try { host = new URL(origin).host; } catch { throw new HttpError(403, 'Diese Anfrage ist nicht erlaubt.'); }
if (host !== req.headers.host) throw new HttpError(403, 'Diese Anfrage ist nicht erlaubt.');
}
export function groupRouteGuard(db) {
const writing = new Set();
return (req, res, next) => {
let pathname;
try { pathname = decodeURIComponent(req.path); } catch { throw new HttpError(400, 'Ungültige Anfrage.'); }
const match = /^\/routes\/([^/]+)(\/.*)?$/i.exec(pathname);
if (!match) return next();
const group = db.prepare('SELECT g.*, r.activated_at FROM collaboration_groups g JOIN routes r ON r.id = g.route_id WHERE g.route_id = ?').get(Number(match[1]));
if (!group) return next();
const read = req.method === 'GET' || req.method === 'HEAD';
if (read && group.activated_at) return next();
member(db, req, group);
res.setHeader('X-Group-Route', String(group.route_id));
if (read) { res.setHeader('X-Group-Revision', String(group.revision)); return next(); }
verifyOrigin(req);
const resource = (match[2] || '/').toLowerCase();
if ((resource === '/' && req.method === 'DELETE') || /^\/restore\/?$/.test(resource)) {
throw new HttpError(403, 'Ein gemeinsamer Entwurf kann hier nicht gelöscht oder wiederhergestellt werden.');
}
const point = /^\/pois\/([^/]+)(?:\/|$)/i.exec(resource);
if (point) guardPoint(db, group, session(db, req), Number(point[1]), req, res);
if ((!point && req.headers['if-match'] !== String(group.revision)) || writing.has(group.id)) {
throw new HttpError(409, 'Jemand hat die Strecke inzwischen geändert. Lade sie neu, bevor du speicherst.');
}
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);
writing.delete(group.id);
});
res.once('close', () => writing.delete(group.id));
next();
};
}
+98
View File
@@ -0,0 +1,98 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { routeDirectory, resolveStoredFile } from './storage.js';
import { getPoi } from './routes-service.js';
import { HttpError } from '../middleware/errors.js';
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
const running = new Set();
const key = (routeId, poiId) => `${Number(routeId)}:${Number(poiId)}`;
function filename(routeId, poiId) {
if (![routeId, poiId].every(value => Number.isSafeInteger(Number(value)) && Number(value) > 0)) {
throw new HttpError(400, 'Ungültige Station.');
}
return path.join(routeDirectory(Number(routeId)), 'locks', `${Number(poiId)}.json`);
}
function read(routeId, poiId) {
try { return JSON.parse(fs.readFileSync(filename(routeId, poiId), 'utf8')); }
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
}
function write(routeId, poiId, lock) {
const target = filename(routeId, poiId);
fs.mkdirSync(path.dirname(target), { recursive: true });
const temporary = `${target}.${crypto.randomUUID()}.tmp`;
try {
fs.writeFileSync(temporary, JSON.stringify(lock), { mode: 0o600, flag: 'wx' });
fs.renameSync(temporary, target);
} finally { fs.rmSync(temporary, { force: true }); }
}
export function pointSnapshot(db, routeId, poiId) {
const poi = getPoi(db, routeId, poiId);
const audio = db.prepare('SELECT audio_path FROM pois WHERE id = ?').get(Number(poiId));
const images = db.prepare('SELECT path FROM poi_images WHERE poi_id = ? ORDER BY sequence, id').all(Number(poiId));
const content = relative => relative ? digest(fs.readFileSync(resolveStoredFile(relative))) : null;
const mediaVersion = digest(JSON.stringify([content(audio.audio_path), images.map(image => content(image.path)), poi.images]));
return { version: digest(JSON.stringify([poi, mediaVersion])), mediaVersion };
}
export const pointVersion = (db, routeId, poiId) => pointSnapshot(db, routeId, poiId).version;
function sameOwner(lock, account) {
return lock?.creator_id === account.creator_id && lock?.session_hash === account.token_hash;
}
export function acquirePoint(db, group, account, poiId, body) {
const route = db.prepare('SELECT status, activated_at FROM routes WHERE id = ?').get(group.route_id);
if (route?.status !== 'active' || route.activated_at) throw new HttpError(409, 'Diese Strecke kann nicht mehr bearbeitet werden.');
const poi = getPoi(db, group.route_id, Number(poiId));
const existing = read(group.route_id, poi.id);
if (running.has(key(group.route_id, poi.id))) throw new HttpError(423, 'Diese Station wird gerade gespeichert.', { code: 'point-busy' });
// Even two tabs on the same device need a confirmed takeover of a different editing token.
const resume = sameOwner(existing, account) && body?.token === existing.token;
if (existing && !resume && body?.replace !== existing.token) {
throw new HttpError(423, 'Diese Station wird bereits bearbeitet.', { code: 'point-locked', replace: existing.token });
}
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() };
write(group.route_id, poi.id, lock);
return { token: lock.token, ...pointSnapshot(db, group.route_id, poi.id), poi };
}
export function releasePoint(group, account, poiId, token) {
const existing = read(group.route_id, poiId);
if (!existing) return;
if (!sameOwner(existing, account) || existing.token !== token) throw new HttpError(423, 'Die Bearbeitung wurde übernommen.', { code: 'point-lost' });
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 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 });
}
function assertPointWrite(db, group, account, poiId, req) {
const route = db.prepare('SELECT status, activated_at FROM routes WHERE id = ?').get(group.route_id);
if (route?.status !== 'active' || route.activated_at) throw new HttpError(409, 'Diese Strecke kann nicht mehr bearbeitet werden.');
const lock = read(group.route_id, poiId);
if (!sameOwner(lock, account) || lock.token !== req.headers['x-poi-lock']) {
throw new HttpError(423, 'Die Bearbeitung wurde übernommen. Sichere deine Änderungen auf diesem Gerät.', { code: 'point-lost' });
}
if (req.headers['if-poi-match'] !== pointVersion(db, group.route_id, poiId)) {
throw new HttpError(409, 'Die Station wurde inzwischen geändert.', { code: 'point-changed' });
}
}
export function guardPoint(db, group, account, poiId, req, res) {
req.validatePointWrite = () => assertPointWrite(db, group, account, poiId, req);
req.validatePointWrite();
const id = key(group.route_id, poiId);
if (running.has(id)) throw new HttpError(423, 'Diese Station wird gerade gespeichert.', { code: 'point-busy' });
running.add(id);
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))) {
res.setHeader('X-Poi-Version', pointVersion(db, group.route_id, poiId));
res.setHeader('X-Poi-Id', String(poiId));
}
if (res.statusCode >= 200 && res.statusCode < 300 && !db.prepare('SELECT 1 FROM pois WHERE id = ?').get(Number(poiId))) fs.rmSync(filename(group.route_id, poiId), { force: true });
return json.call(this, payload);
};
const release = () => running.delete(id);
res.once('finish', release);
res.once('close', release);
}
+5 -4
View File
@@ -129,11 +129,12 @@ export function listRoutes(db, {
includeDeleted = false,
publishedOnly = false
}) {
let where = '';
let where = `WHERE NOT EXISTS (SELECT 1 FROM collaboration_groups g
WHERE g.route_id = routes.id AND routes.activated_at IS NULL)`;
if (!includeDeleted) {
where = publishedOnly
? "WHERE status = 'active' AND activated_at IS NOT NULL"
: "WHERE status = 'active'";
where += publishedOnly
? " AND status = 'active' AND activated_at IS NOT NULL"
: " AND status = 'active'";
}
const rows = db.prepare(`SELECT * FROM routes ${where} ORDER BY name COLLATE NOCASE`).all();
const origin = Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null;