group editor implemented
This commit is contained in:
+22
-9
@@ -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)));
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user