check if routes are writable or published

This commit is contained in:
Florian Zumpe
2026-09-30 01:17:55 +02:00
parent 7424eba5f8
commit bff06824d8
3 changed files with 227 additions and 13 deletions
+22 -13
View File
@@ -5,6 +5,7 @@ import {
listRoutes, getRoute, getRouteGpxFile, createRoute, updateRoute, appendRoute,
listPois, getPoi, createPoi, updatePoi, deletePoi, softDeleteRoute, restoreRoute
} from '../services/routes-service.js';
import { assertRouteDeleteAllowed, assertRouteWritable } from '../services/route-write-access.js';
import {
listPictures, getPicture, getPictureFile, createPicture, updatePicture, deletePicture,
getAudio, getAudioFile, createAudio, updateAudio, deleteAudio
@@ -31,6 +32,14 @@ function sendFileResource(res, file, contentType = null) {
export function createApiRouter(db) {
const api = Router();
const requireWritableRoute = paramName => (req, res, next) => {
assertRouteWritable(db, Number(req.params[paramName]));
next();
};
const requireRouteDeleteAllowed = paramName => (req, res, next) => {
assertRouteDeleteAllowed(db, Number(req.params[paramName]), req);
next();
};
api.use((req, res, next) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
@@ -83,22 +92,22 @@ export function createApiRouter(db) {
res.status(201).location(`/api/routes/${route.id}`).json(route);
});
api.put('/routes/:id', upload.single('gpx'), inspectUpload('gpx', 'gpx'), (req, res) => {
api.put('/routes/:id', requireWritableRoute('id'), upload.single('gpx'), inspectUpload('gpx', 'gpx'), (req, res) => {
res.json(updateRoute(db, Number(req.params.id), req.body, req.file));
});
api.post('/routes/:id/append', upload.single('gpx'), inspectUpload('gpx', 'gpx'), (req, res) => {
api.post('/routes/:id/append', requireWritableRoute('id'), upload.single('gpx'), inspectUpload('gpx', 'gpx'), (req, res) => {
res.json(appendRoute(db, Number(req.params.id), req.file));
});
api.post('/routes/:id/pois', upload.none(), (req, res) => {
api.post('/routes/:id/pois', requireWritableRoute('id'), upload.none(), (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', upload.none(), (req, res) => {
api.put('/routes/:routeId/pois/:poiId', requireWritableRoute('routeId'), upload.none(), (req, res) => {
res.json(updatePoi(
db,
Number(req.params.routeId),
@@ -107,7 +116,7 @@ export function createApiRouter(db) {
));
});
api.delete('/routes/:routeId/pois/:poiId', (req, res) => {
api.delete('/routes/:routeId/pois/:poiId', requireRouteDeleteAllowed('routeId'), (req, res) => {
res.json(deletePoi(db, Number(req.params.routeId), Number(req.params.poiId)));
});
@@ -126,7 +135,7 @@ export function createApiRouter(db) {
sendFileResource(res, getPictureFile(db, routeId, poiId, pictureId));
});
api.post('/routes/:routeId/pois/:poiId/pictures', 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 }), (req, res) => {
const routeId = Number(req.params.routeId);
const poiId = Number(req.params.poiId);
const picture = createPicture(db, routeId, poiId, req.body, req.file);
@@ -135,7 +144,7 @@ export function createApiRouter(db) {
.json(picture);
});
api.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', 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 }), (req, res) => {
res.json(updatePicture(
db,
Number(req.params.routeId),
@@ -146,7 +155,7 @@ export function createApiRouter(db) {
));
});
api.delete('/routes/:routeId/pois/:poiId/pictures/:pictureId', (req, res) => {
api.delete('/routes/:routeId/pois/:poiId/pictures/:pictureId', requireRouteDeleteAllowed('routeId'), (req, res) => {
res.json(deletePicture(
db,
Number(req.params.routeId),
@@ -165,7 +174,7 @@ export function createApiRouter(db) {
sendFileResource(res, getAudioFile(db, routeId, poiId));
});
api.post('/routes/:routeId/pois/:poiId/audio', 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 }), (req, res) => {
const routeId = Number(req.params.routeId);
const poiId = Number(req.params.poiId);
const audio = createAudio(db, routeId, poiId, req.file);
@@ -174,19 +183,19 @@ export function createApiRouter(db) {
.json(audio);
});
api.put('/routes/:routeId/pois/:poiId/audio', 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 }), (req, res) => {
res.json(updateAudio(db, Number(req.params.routeId), Number(req.params.poiId), req.file));
});
api.delete('/routes/:routeId/pois/:poiId/audio', (req, res) => {
api.delete('/routes/:routeId/pois/:poiId/audio', requireRouteDeleteAllowed('routeId'), (req, res) => {
res.json(deleteAudio(db, Number(req.params.routeId), Number(req.params.poiId)));
});
api.delete('/routes/:id', (req, res) => {
api.delete('/routes/:id', requireRouteDeleteAllowed('id'), (req, res) => {
res.json({ route: softDeleteRoute(db, Number(req.params.id)), softDeleted: true });
});
api.post('/routes/:id/restore', (req, res) => {
api.post('/routes/:id/restore', requireWritableRoute('id'), (req, res) => {
res.json({ route: restoreRoute(db, Number(req.params.id)), restored: true });
});
+54
View File
@@ -0,0 +1,54 @@
import { HttpError } from '../middleware/errors.js';
function getRouteState(db, routeId) {
const route = db.prepare(`SELECT id, activated_at AS activatedAt
FROM routes WHERE id = ?`).get(routeId);
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
return route;
}
function hasForwardingHeader(req, name) {
const value = req.headers?.[name];
if (Array.isArray(value)) return value.some(item => String(item).trim().length > 0);
return value != null && String(value).trim().length > 0;
}
function isLoopbackAddress(address) {
if (typeof address !== 'string' || address.length === 0) return false;
if (address === '::1') return true;
const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address;
return /^127(?:\.\d{1,3}){3}$/.test(ipv4);
}
export function isDirectLocalRequest(req) {
if (!isLoopbackAddress(req.socket?.remoteAddress)) return false;
return ![
'forwarded',
'x-forwarded-for',
'x-real-ip'
].some(header => hasForwardingHeader(req, header));
}
export function assertRouteWritable(db, routeId) {
const route = getRouteState(db, routeId);
if (route.activatedAt != null) {
throw new HttpError(409, 'Freigegebene Strecken können nicht mehr verändert werden.');
}
return route;
}
export function assertRouteDeleteAllowed(db, routeId, req) {
const route = getRouteState(db, routeId);
if (route.activatedAt == null) return route;
if (!isDirectLocalRequest(req)) {
throw new HttpError(
403,
'Freigegebene Strecken dürfen nur durch einen direkten lokalen Serveraufruf gelöscht werden.'
);
}
return route;
}