diff --git a/package-lock.json b/package-lock.json
index 69d7fda..84e81b1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "wegwichtel-next",
- "version": "0.12.8",
+ "version": "0.12.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wegwichtel-next",
- "version": "0.12.8",
+ "version": "0.12.9",
"hasInstallScript": true,
"dependencies": {
"@file-type/av": "0.2.0",
diff --git a/package.json b/package.json
index 0b95a70..fa5266e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "wegwichtel-next",
- "version": "0.12.8",
+ "version": "0.12.9",
"private": true,
"description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.",
"type": "module",
diff --git a/public/css/app.css b/public/css/app.css
index a5b0566..1f5bc8f 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -99,7 +99,7 @@ button,
padding: .35rem;
border-color: rgba(255, 255, 255, .45);
border-radius: .45rem;
- background: rgba(255, 255, 255, .12);
+ background: var(--primary-dark);
color: #fff;
}
@@ -289,38 +289,45 @@ button,
color: var(--muted);
}
-.route-facts {
+#route-page dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: .5rem;
margin: 1rem 0;
}
-.route-facts div {
+#route-page dl > div {
padding: .75rem;
border-radius: .5rem;
background: var(--surface);
text-align: center;
}
-.route-facts dt {
+#route-page dt {
color: var(--muted);
font-size: .8rem;
}
-.route-facts dd {
+#route-page dd {
margin: .25rem 0 0;
font-weight: 700;
}
-.route-controls {
+#route-page fieldset,
+#slideshow > fieldset {
+ min-width: 0;
+ border: 0;
+}
+
+#route-page fieldset {
display: grid;
+ padding: 0;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: .5rem;
margin: 0 0 1rem;
}
-.route-controls button {
+#route-page fieldset button {
display: grid;
place-items: center;
min-width: 0;
@@ -334,23 +341,35 @@ button,
touch-action: manipulation;
}
-.route-controls button:focus-visible {
+#route-page fieldset button:focus-visible {
outline: .2rem solid var(--focus);
outline-offset: .15rem;
}
-.route-controls button:disabled {
+#route-page fieldset button:disabled {
cursor: default;
filter: grayscale(.65);
opacity: .42;
}
-.route-controls img {
+#route-page fieldset img {
width: 2rem;
height: 2rem;
pointer-events: none;
}
+#route-page fieldset > legend,
+#slideshow > fieldset > legend {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
#start-route {
background: var(--primary);
border-color: var(--primary-dark);
@@ -386,8 +405,9 @@ button,
background: #111;
}
-#slide-controls {
+#slideshow > fieldset {
display: grid;
+ margin: 0;
grid-template-columns: var(--icon-button-size) minmax(0, 1fr) var(--icon-button-size);
align-items: stretch;
gap: .5rem;
@@ -396,7 +416,7 @@ button,
padding: .65rem;
}
-#slide-controls .ui-button {
+#slideshow > fieldset .ui-button {
display: grid;
place-items: center;
min-width: 0;
@@ -405,13 +425,13 @@ button,
padding: .45rem;
}
-#slide-controls .ui-button img {
+#slideshow > fieldset .ui-button img {
width: 1.2rem;
height: 1.2rem;
pointer-events: none;
}
-.slide-meta {
+#slideshow > fieldset > div {
display: flex;
min-width: 0;
min-height: var(--icon-button-size);
@@ -521,17 +541,17 @@ button,
grid-template-columns: minmax(0, 1fr) 3rem;
}
- .route-facts {
+ #route-page dl {
gap: .35rem;
}
- .route-facts div {
+ #route-page dl > div {
padding: .6rem .25rem;
}
}
@media (max-width: 22rem) {
- .route-facts {
+ #route-page dl {
grid-template-columns: 1fr;
}
}
diff --git a/public/index.html b/public/index.html
index 807cfa8..0b1d64d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -8,8 +8,8 @@
-
-
+
+
🧭
Wegwichtel
Der Wegwichtel bereitet Routen und Medien vor.
@@ -31,20 +31,20 @@
-
+
-
Standort wird ermittelt.
+
Standort wird für nahe Routen lokal auf diesem Gerät ermittelt.
-
+
-
+
Alle Routen
@@ -64,7 +64,7 @@
-
+
-
+
@@ -113,18 +114,19 @@
-
+
diff --git a/public/js/api-client.js b/public/js/api-client.js
index c9b9203..89250ed 100644
--- a/public/js/api-client.js
+++ b/public/js/api-client.js
@@ -8,4 +8,4 @@
routes: position => request('/routes' + (position ? `?lat=${encodeURIComponent(position.lat)}&lon=${encodeURIComponent(position.lon)}&radiusKm=${ns.Config.routeRadiusKm}` : '')),
route: id => request(`/routes/${encodeURIComponent(id)}`)
};
-}(window.Wegwichtel, window.jQuery));
+}(globalThis.Wegwichtel, globalThis.jQuery));
diff --git a/public/js/app.js b/public/js/app.js
index e4df5bc..c4bf422 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -9,7 +9,6 @@
routeState: 'idle',
activePage: null,
routeProgressIndex: null,
- activePoiId: null,
deviceHeading: null
};
@@ -21,7 +20,7 @@
$(document)
.on('pointerup', selector, function (event) {
const pointer = event.originalEvent;
- if (pointer && pointer.isPrimary === false) return;
+ if (pointer?.isPrimary === false) return;
if (pointer?.pointerType === 'mouse' && pointer.button !== 0) return;
event.preventDefault();
handler.call(this, event);
@@ -38,10 +37,10 @@
if (!target.length) throw new Error(`Unbekannte Ansicht: ${pageSelector}`);
const previous = state.activePage;
- $('.app-page').attr('hidden', true).removeClass('is-active');
- target.removeAttr('hidden').addClass('is-active');
+ $('.app-page').attr('hidden', true);
+ target.removeAttr('hidden');
state.activePage = pageSelector;
- window.scrollTo({ top: 0, behavior: options.instant ? 'auto' : 'smooth' });
+ globalThis.scrollTo({ top: 0, behavior: options.instant ? 'auto' : 'smooth' });
if (options.updateHistory === false) return;
const historyState = { page: pageSelector, previous };
@@ -52,7 +51,7 @@
function normalizeSearch(value) {
return String(value ?? '')
.normalize('NFKD')
- .replace(/[\u0300-\u036f]/g, '')
+ .replaceAll(/[\u0300-\u036f]/g, '')
.toLocaleLowerCase('de');
}
@@ -172,11 +171,10 @@
}
function setActivePoi(poiId) {
- state.activePoiId = poiId == null ? null : Number(poiId);
$('#poi-list [data-poi-id]').removeAttr('aria-current');
- if (state.activePoiId != null) {
- $(`#poi-list [data-poi-id="${state.activePoiId}"]`).attr('aria-current', 'step');
- }
+ if (poiId == null) return;
+
+ $(`#poi-list [data-poi-id="${Number(poiId)}"]`).attr('aria-current', 'step');
}
function endRoute({ announce = true, returnToRoutes = false } = {}) {
@@ -451,7 +449,7 @@
const metrics = updatePoiIndicators(position);
if (state.routeState !== 'running') return;
- let nearest = nearestPendingPoi(metrics);
+ const nearest = nearestPendingPoi(metrics);
updateNavigation(position);
$('#tracking-status').text(
@@ -463,7 +461,6 @@
state.triggered.add(nearest.poi.id);
ns.Vibration.start(ns.Vibration.Patterns.ACTIVE_POI);
showPoi(nearest.poi);
- nearest = nearestPendingPoi(metrics);
updateNavigation(position);
}
}
@@ -545,7 +542,7 @@
endRoute({ returnToRoutes: true });
});
- window.addEventListener('popstate', event => {
+ globalThis.addEventListener('popstate', event => {
const page = event.state?.page || '#routes-page';
navigate(page, { updateHistory: false, instant: true });
});
@@ -587,4 +584,4 @@
hideNavigation();
}
};
-}(window.Wegwichtel, window.jQuery));
+}(globalThis.Wegwichtel, globalThis.jQuery));
diff --git a/public/js/audio-player.js b/public/js/audio-player.js
index 3fa6682..2fd03a7 100644
--- a/public/js/audio-player.js
+++ b/public/js/audio-player.js
@@ -36,4 +36,4 @@
unload
};
-}(window.Wegwichtel, window.jQuery));
+}(globalThis.Wegwichtel, globalThis.jQuery));
diff --git a/public/js/bootstrap-loader.js b/public/js/bootstrap-loader.js
index 9baf6a7..b948167 100644
--- a/public/js/bootstrap-loader.js
+++ b/public/js/bootstrap-loader.js
@@ -41,7 +41,7 @@
}
function initializeJqueryUi() {
- const $ = window.jQuery;
+ const $ = globalThis.jQuery;
$('#bootstrap-retry').button({ icon: 'ui-icon-refresh' });
$('.back-button').button();
$('#clear-route-search').button({ icon: 'ui-icon-close', showLabel: false });
@@ -56,7 +56,7 @@
try {
await loadScript(
`vendor/jquery/jquery-${versions.jquery}.min.js`,
- () => window.jQuery?.fn?.jquery === versions.jquery
+ () => globalThis.jQuery?.fn?.jquery === versions.jquery
);
mark('jquery', 'done');
@@ -64,20 +64,20 @@
await loadStyle('css/app.css');
await loadScript(
`vendor/jquery-ui/jquery-ui-${versions.jqueryUi}.min.js`,
- () => window.jQuery?.ui?.version === versions.jqueryUi
+ () => globalThis.jQuery?.ui?.version === versions.jqueryUi
);
initializeJqueryUi();
mark('jquery-ui', 'done');
for (const module of ['config', 'api-client', 'distance', 'geolocation', 'orientation', 'slideshow', 'audio-player', 'vibration', 'app']) {
- await loadScript(`js/${module}.js`, () => Boolean(window.Wegwichtel));
+ await loadScript(`js/${module}.js`, () => Boolean(globalThis.Wegwichtel));
}
mark('modules', 'done');
- await window.Wegwichtel.App.initialize({ markStep: mark });
+ await globalThis.Wegwichtel.App.initialize({ markStep: mark });
document.getElementById('bootstrap-screen').hidden = true;
document.getElementById('app-shell').hidden = false;
- window.Wegwichtel.App.navigate('#routes-page', { replace: true });
+ globalThis.Wegwichtel.App.navigate('#routes-page', { replace: true });
} catch (error) {
console.error(error);
const current = steps.find(step => !document.querySelector(`[data-step="${step}"]`)?.classList.contains('done'));
diff --git a/public/js/config.js b/public/js/config.js
index 3c1cad1..366cea1 100644
--- a/public/js/config.js
+++ b/public/js/config.js
@@ -1,2 +1,2 @@
-window.Wegwichtel = window.Wegwichtel || {};
-window.Wegwichtel.Config = Object.freeze({ apiBase: '/api', routeRadiusKm: 25, geolocationTimeoutMs: 12000 });
+globalThis.Wegwichtel ??= {};
+globalThis.Wegwichtel.Config = Object.freeze({ apiBase: '/api', routeRadiusKm: 25, geolocationTimeoutMs: 12000 });
diff --git a/public/js/distance.js b/public/js/distance.js
index 0986261..c9beab8 100644
--- a/public/js/distance.js
+++ b/public/js/distance.js
@@ -30,4 +30,4 @@
return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km`;
}
};
-}(window.Wegwichtel));
+}(globalThis.Wegwichtel));
diff --git a/public/js/geolocation.js b/public/js/geolocation.js
index 0b0d097..0c7a237 100644
--- a/public/js/geolocation.js
+++ b/public/js/geolocation.js
@@ -1,8 +1,17 @@
(function (ns) {
'use strict';
+ const WATCH_OPTIONS = Object.freeze({
+ enableHighAccuracy: true,
+ timeout: 20000,
+ maximumAge: 5000
+ });
let watchId = null;
+ function geolocation() {
+ return globalThis.navigator?.geolocation;
+ }
+
function normalize(position) {
return {
lat: position.coords.latitude,
@@ -15,19 +24,24 @@
};
}
+ function unsupportedError() {
+ return new Error('Dieses Gerät unterstützt keine Geolokalisierung.');
+ }
+
ns.Geo = {
current() {
return new Promise((resolve, reject) => {
- if (!navigator.geolocation) {
- reject(new Error('Dieses Gerät unterstützt keine Geolokalisierung.'));
+ const service = geolocation();
+ if (!service) {
+ reject(unsupportedError());
return;
}
- navigator.geolocation.getCurrentPosition(
+ service.getCurrentPosition(
position => resolve(normalize(position)),
reject,
{
- enableHighAccuracy: true,
+ ...WATCH_OPTIONS,
timeout: ns.Config.geolocationTimeoutMs,
maximumAge: 30000
}
@@ -37,25 +51,23 @@
start(callback, onError) {
this.stop();
- if (!navigator.geolocation) {
- onError?.(new Error('Dieses Gerät unterstützt keine Geolokalisierung.'));
+ const service = geolocation();
+ if (!service) {
+ onError?.(unsupportedError());
return;
}
- watchId = navigator.geolocation.watchPosition(
+ watchId = service.watchPosition(
position => callback(normalize(position)),
onError,
- {
- enableHighAccuracy: true,
- timeout: 20000,
- maximumAge: 5000
- }
+ WATCH_OPTIONS
);
},
stop() {
- if (watchId != null) navigator.geolocation.clearWatch(watchId);
+ const service = geolocation();
+ if (watchId != null) service?.clearWatch(watchId);
watchId = null;
}
};
-}(window.Wegwichtel));
+}(globalThis.Wegwichtel));
diff --git a/public/js/orientation.js b/public/js/orientation.js
index 5ed9f1a..ffe0370 100644
--- a/public/js/orientation.js
+++ b/public/js/orientation.js
@@ -22,7 +22,7 @@
}
async function requestPermission() {
- const orientation = window.DeviceOrientationEvent;
+ const orientation = globalThis.DeviceOrientationEvent;
if (!orientation) return false;
if (typeof orientation.requestPermission === 'function') {
@@ -51,14 +51,14 @@
if (heading != null) callback?.(heading);
};
- eventNames.forEach(name => window.addEventListener(name, listener, true));
+ eventNames.forEach(name => globalThis.addEventListener(name, listener, true));
return true;
},
stop() {
- if (listener) eventNames.forEach(name => window.removeEventListener(name, listener, true));
+ if (listener) eventNames.forEach(name => globalThis.removeEventListener(name, listener, true));
listener = null;
callback = null;
}
};
-}(window.Wegwichtel));
+}(globalThis.Wegwichtel));
diff --git a/public/js/slideshow.js b/public/js/slideshow.js
index 9ea0b03..035b7a0 100644
--- a/public/js/slideshow.js
+++ b/public/js/slideshow.js
@@ -9,7 +9,7 @@
$(document)
.on('pointerup', selector, function (event) {
const pointer = event.originalEvent;
- if (pointer && pointer.isPrimary === false) return;
+ if (pointer?.isPrimary === false) return;
if (pointer?.pointerType === 'mouse' && pointer.button !== 0) return;
event.preventDefault();
handler.call(this, event);
@@ -29,7 +29,6 @@
function render() {
const hasImages = images.length > 0;
$('#slideshow').toggle(hasImages);
- $('#slide-controls').attr('data-image-count', images.length);
if (!hasImages) return;
const image = images[index];
@@ -86,4 +85,4 @@
if (deltaX < 0) ns.Slideshow.next();
else ns.Slideshow.previous();
});
-}(window.Wegwichtel, window.jQuery));
+}(globalThis.Wegwichtel, globalThis.jQuery));
diff --git a/public/js/vibration.js b/public/js/vibration.js
index b81f8a4..078c7d2 100644
--- a/public/js/vibration.js
+++ b/public/js/vibration.js
@@ -30,7 +30,7 @@
if (!Number.isFinite(duration) || duration < 0) {
throw new TypeError('Die Vibrationsdauer muss eine nichtnegative Millisekundenangabe sein.');
}
- return duration;
+ return [duration];
}
function start(pattern = Patterns.LONG) {
@@ -50,4 +50,4 @@
start,
stop
});
-}(window.Wegwichtel));
+}(globalThis.Wegwichtel));
diff --git a/src/middleware/upload.js b/src/middleware/upload.js
index e2a221b..272e09b 100644
--- a/src/middleware/upload.js
+++ b/src/middleware/upload.js
@@ -8,6 +8,25 @@ import { detectAv } from '@file-type/av';
import { config } from '../config.js';
import { HttpError } from './errors.js';
+const MAX_GPX_PREFIX_BYTES = 131072;
+const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/;
+const IGNORED_XML_MARKUP = Object.freeze([
+ Object.freeze({ opening: '' }),
+ Object.freeze({ opening: '', closing: '?>' }),
+ Object.freeze({ opening: '' })
+]);
+
+const ALLOWED_TYPES = Object.freeze({
+ picture: Object.freeze({
+ mime: new Set(['image/jpeg', 'image/png', 'image/webp']),
+ ext: new Set(['jpg', 'png', 'webp'])
+ }),
+ audio: Object.freeze({
+ mime: new Set(['audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/ogg', 'audio/wav', 'audio/webm']),
+ ext: new Set(['mp3', 'm4a', 'aac', 'ogg', 'opus', 'spx', 'wav', 'webm'])
+ })
+});
+
function sanitizeFilename(value) {
return String(value).replaceAll(/[^a-zA-Z0-9._-]/g, '_') || 'upload';
}
@@ -35,38 +54,40 @@ function isXmlNameCharacter(character, first = false) {
return !first && ((code >= 48 && code <= 57) || character === '.' || character === '-' || character === ':');
}
+function ignoredMarkupEnd(xml, opening) {
+ const markup = IGNORED_XML_MARKUP.find(item => xml.startsWith(item.opening, opening));
+ if (!markup) return null;
+
+ const closing = xml.indexOf(markup.closing, opening + markup.opening.length);
+ return closing < 0 ? -1 : closing + markup.closing.length;
+}
+
+function readXmlName(xml, start) {
+ if (start >= xml.length || !isXmlNameCharacter(xml[start], true)) return null;
+
+ let end = start + 1;
+ while (end < xml.length && isXmlNameCharacter(xml[end])) end += 1;
+ return xml.slice(start, end);
+}
+
function rootElementName(xml) {
let cursor = 0;
+
while (cursor < xml.length) {
const opening = xml.indexOf('<', cursor);
if (opening < 0 || opening + 1 >= xml.length) return null;
- if (xml.startsWith('', opening + 4);
- if (end < 0) return null;
- cursor = end + 3;
+ const nextCursor = ignoredMarkupEnd(xml, opening);
+ if (nextCursor === -1) return null;
+ if (nextCursor != null) {
+ cursor = nextCursor;
continue;
}
- if (xml.startsWith('', opening)) {
- const end = xml.indexOf('?>', opening + 2);
- if (end < 0) return null;
- cursor = end + 2;
- continue;
- }
- if (xml.startsWith('', opening + 2);
- if (end < 0) return null;
- cursor = end + 1;
- continue;
- }
- if (xml.startsWith('', opening)) return null;
- let end = opening + 1;
- if (!isXmlNameCharacter(xml[end], true)) return null;
- end += 1;
- while (end < xml.length && isXmlNameCharacter(xml[end])) end += 1;
- return xml.slice(opening + 1, end);
+ if (xml.startsWith('', opening)) return null;
+ return readXmlName(xml, opening + 1);
}
+
return null;
}
@@ -83,19 +104,29 @@ export const upload = multer({
limits: { fileSize: config.maxUploadBytes, files: 25 }
});
-const ALLOWED_TYPES = Object.freeze({
- picture: Object.freeze({
- mime: new Set(['image/jpeg', 'image/png', 'image/webp']),
- ext: new Set(['jpg', 'png', 'webp'])
- }),
- audio: Object.freeze({
- mime: new Set(['audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/ogg', 'audio/wav', 'audio/webm']),
- ext: new Set(['mp3', 'm4a', 'aac', 'ogg', 'opus', 'spx', 'wav', 'webm'])
- })
-});
-
function removeTemporaryFile(file) {
- if (file?.path && fs.existsSync(file.path)) fs.rmSync(file.path, { force: true });
+ if (!file?.path || !fs.existsSync(file.path)) return;
+ fs.rmSync(file.path, { force: true });
+}
+
+function assertValidBase64(compact, fieldName) {
+ if (!BASE64_PATTERN.test(compact) || compact.length % 4 === 1) {
+ throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`);
+ }
+}
+
+function assertCanonicalBase64(compact, buffer, fieldName) {
+ const canonicalInput = stripBase64Padding(compact);
+ const canonicalOutput = stripBase64Padding(buffer.toString('base64'));
+ if (canonicalInput !== canonicalOutput) {
+ throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`);
+ }
+}
+
+function assertUploadSize(buffer) {
+ if (buffer.length <= config.maxUploadBytes) return;
+ const limitMb = Math.floor(config.maxUploadBytes / 1024 / 1024);
+ throw new HttpError(413, `Die Datei überschreitet das Limit von ${limitMb} MB.`);
}
function decodeBase64(value, fieldName) {
@@ -104,19 +135,11 @@ function decodeBase64(value, fieldName) {
}
const compact = value.replaceAll(/\s/g, '');
- if (!/^[A-Za-z0-9+/]*={0,2}$/.test(compact) || compact.length % 4 === 1) {
- throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`);
- }
+ assertValidBase64(compact, fieldName);
const buffer = Buffer.from(compact, 'base64');
- const canonicalInput = stripBase64Padding(compact);
- const canonicalOutput = stripBase64Padding(buffer.toString('base64'));
- if (canonicalInput !== canonicalOutput) {
- throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`);
- }
- if (buffer.length > config.maxUploadBytes) {
- throw new HttpError(413, `Die Datei überschreitet das Limit von ${Math.floor(config.maxUploadBytes / 1024 / 1024)} MB.`);
- }
+ assertCanonicalBase64(compact, buffer, fieldName);
+ assertUploadSize(buffer);
return buffer;
}
@@ -126,7 +149,8 @@ function jsonFileDescriptor(value, fieldName) {
throw new HttpError(400, `${fieldName} muss ein JSON-Objekt sein.`);
}
- const originalname = String(value.filename ?? `${fieldName}.upload`).trim() || `${fieldName}.upload`;
+ const fallbackName = `${fieldName}.upload`;
+ const originalname = String(value.filename ?? fallbackName).trim() || fallbackName;
let base64 = value.base64;
if (value.dataUrl != null) {
@@ -174,10 +198,9 @@ function detectGpx(file) {
throw new HttpError(415, 'Der Upload ist keine gültig kodierte GPX-Datei.');
}
- const prefix = xml.slice(0, 131072);
+ const prefix = xml.slice(0, MAX_GPX_PREFIX_BYTES);
const start = prefix.startsWith('\uFEFF') ? prefix.slice(1) : prefix;
- const rootName = rootElementName(start);
- const localName = rootName?.split(':').at(-1);
+ const localName = rootElementName(start)?.split(':').at(-1);
if (localName !== 'gpx') {
throw new HttpError(415, 'Der Upload enthält kein GPX-Dokument.');
}
@@ -195,25 +218,34 @@ async function detectBinary(file) {
}
}
+function unsupportedContentMessage(kind) {
+ return kind === 'picture'
+ ? 'Der Dateiinhalt ist kein unterstütztes Bildformat.'
+ : 'Der Dateiinhalt ist kein unterstütztes Audioformat.';
+}
+
+function unsupportedFormatMessage(kind, mime) {
+ return kind === 'picture'
+ ? `Nicht unterstütztes Bildformat: ${mime}`
+ : `Nicht unterstütztes Audioformat: ${mime}`;
+}
+
+function validateDetectedType(detected, kind) {
+ if (kind === 'gpx') return;
+
+ const rules = ALLOWED_TYPES[kind];
+ const baseMime = String(detected.mime).split(';', 1)[0].toLowerCase();
+ const extension = String(detected.ext).toLowerCase();
+ if (!rules?.mime.has(baseMime) || !rules.ext.has(extension)) {
+ throw new HttpError(415, unsupportedFormatMessage(kind, detected.mime));
+ }
+}
+
async function detectAndValidate(file, kind) {
const detected = kind === 'gpx' ? detectGpx(file) : await detectBinary(file);
- if (!detected) {
- throw new HttpError(415, kind === 'picture'
- ? 'Der Dateiinhalt ist kein unterstütztes Bildformat.'
- : 'Der Dateiinhalt ist kein unterstütztes Audioformat.');
- }
-
- if (kind !== 'gpx') {
- const rules = ALLOWED_TYPES[kind];
- const baseMime = String(detected.mime).split(';', 1)[0].toLowerCase();
- const extension = String(detected.ext).toLowerCase();
- if (!rules?.mime.has(baseMime) || !rules?.ext.has(extension)) {
- throw new HttpError(415, kind === 'picture'
- ? `Nicht unterstütztes Bildformat: ${detected.mime}`
- : `Nicht unterstütztes Audioformat: ${detected.mime}`);
- }
- }
+ if (!detected) throw new HttpError(415, unsupportedContentMessage(kind));
+ validateDetectedType(detected, kind);
file.mimetype = detected.mime;
file.detectedMime = detected.mime;
file.detectedExtension = detected.ext;
@@ -225,12 +257,13 @@ export function inspectUpload(fieldName, kind, { allowJson = false } = {}) {
try {
if (allowJson) materializeJsonUpload(req, fieldName);
if (!req.file) return next();
+
await detectAndValidate(req.file, kind);
- next();
+ return next();
} catch (error) {
removeTemporaryFile(req.file);
req.file = undefined;
- next(error);
+ return next(error);
}
};
}
diff --git a/src/services/gpx.js b/src/services/gpx.js
index d5c250b..19a680a 100644
--- a/src/services/gpx.js
+++ b/src/services/gpx.js
@@ -2,24 +2,56 @@ import { XMLParser } from 'fast-xml-parser';
import { HttpError } from '../middleware/errors.js';
import { routeMetrics } from './geo.js';
-const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', trimValues: true });
-const list = value => value == null ? [] : Array.isArray(value) ? value : [value];
+const parser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: '@_',
+ trimValues: true
+});
-export function parseGpx(xml) {
- let parsed;
- try { parsed = parser.parse(xml); }
- catch (error) { throw new HttpError(400, 'Die GPX-Datei ist kein gültiges XML.', error.message); }
- const gpx = parsed?.gpx;
- if (!gpx) throw new HttpError(400, 'GPX-Wurzelelement fehlt.');
- const tracks = list(gpx.trk);
- const points = tracks.flatMap(track => list(track.trkseg)).flatMap(segment => list(segment.trkpt)).map((point, index) => ({
- sequence: index,
+function asList(value) {
+ if (value == null) return [];
+ return Array.isArray(value) ? value : [value];
+}
+
+function parseXml(xml) {
+ try {
+ return parser.parse(xml);
+ } catch (error) {
+ throw new HttpError(400, 'Die GPX-Datei ist kein gültiges XML.', error.message);
+ }
+}
+
+function toRoutePoint(point, sequence) {
+ return {
+ sequence,
lat: Number.parseFloat(point['@_lat']),
lon: Number.parseFloat(point['@_lon']),
elevation: point.ele == null ? null : Number.parseFloat(point.ele),
recordedAt: point.time || null
- })).filter(point => Number.isFinite(point.lat) && Number.isFinite(point.lon));
- if (!points.length) throw new HttpError(400, 'Die GPX-Datei enthält keine verwertbaren Trackpunkte.');
- const name = tracks[0]?.name || gpx.metadata?.name || null;
- return { name, points, metrics: routeMetrics(points) };
+ };
+}
+
+function trackPoints(tracks) {
+ return tracks
+ .flatMap(track => asList(track.trkseg))
+ .flatMap(segment => asList(segment.trkpt))
+ .map(toRoutePoint)
+ .filter(point => Number.isFinite(point.lat) && Number.isFinite(point.lon));
+}
+
+export function parseGpx(xml) {
+ const gpx = parseXml(xml)?.gpx;
+ if (!gpx) throw new HttpError(400, 'GPX-Wurzelelement fehlt.');
+
+ const tracks = asList(gpx.trk);
+ const points = trackPoints(tracks);
+ if (!points.length) {
+ throw new HttpError(400, 'Die GPX-Datei enthält keine verwertbaren Trackpunkte.');
+ }
+
+ return {
+ name: tracks[0]?.name || gpx.metadata?.name || null,
+ points,
+ metrics: routeMetrics(points)
+ };
}
diff --git a/src/services/routes-service.js b/src/services/routes-service.js
index 22f5344..21586ac 100644
--- a/src/services/routes-service.js
+++ b/src/services/routes-service.js
@@ -6,49 +6,152 @@ import { HttpError } from '../middleware/errors.js';
import { parseGpx } from './gpx.js';
import { distanceMeters, routeMetrics } from './geo.js';
import {
- ensureRouteDirectories, moveUploadedFile, routeDirectory,
- softDeleteRouteDirectory, restoreRouteDirectory, resolveStoredFile, removeUpload, removeStoredFile
+ ensureRouteDirectories,
+ moveUploadedFile,
+ routeDirectory,
+ softDeleteRouteDirectory,
+ restoreRouteDirectory,
+ resolveStoredFile,
+ removeUpload,
+ removeStoredFile
} from './storage.js';
-const slugify = value => value.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80) || `route-${Date.now()}`;
+const INSERT_POINT_SQL = 'INSERT INTO route_points (route_id, sequence, lat, lon, elevation, recorded_at) VALUES (?, ?, ?, ?, ?, ?)';
+const UPDATE_METRICS_SQL = `UPDATE routes SET
+ start_lat=?, start_lon=?, center_lat=?, center_lon=?, min_lat=?, min_lon=?, max_lat=?, max_lon=?,
+ distance_m=?, elevation_gain_m=?, point_count=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`;
+
+function slugify(value) {
+ const normalized = String(value).toLowerCase().normalize('NFKD');
+ const withoutMarks = normalized.replaceAll(/[\u0300-\u036f]/g, '');
+ const separated = withoutMarks.replaceAll(/[^a-z0-9]+/g, '-');
+ const trimmed = separated.replaceAll(/(?:^-+|-+$)/g, '');
+ return trimmed.slice(0, 80) || `route-${Date.now()}`;
+}
+
const routeGpxUrl = routeId => `/api/routes/${routeId}/gpx`;
const pictureUrl = (routeId, poiId, pictureId) => `/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`;
const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio`;
+
function rowToRoute(row, includeDeleted = false) {
if (!row || (!includeDeleted && row.status !== 'active')) return null;
+
return {
- id: row.id, slug: row.slug, name: row.name, description: row.description,
- schoolName: row.school_name, status: row.status,
+ id: row.id,
+ slug: row.slug,
+ name: row.name,
+ description: row.description,
+ schoolName: row.school_name,
+ status: row.status,
start: row.start_lat == null ? null : { lat: row.start_lat, lon: row.start_lon },
center: row.center_lat == null ? null : { lat: row.center_lat, lon: row.center_lon },
- bounds: row.min_lat == null ? null : { minLat: row.min_lat, minLon: row.min_lon, maxLat: row.max_lat, maxLon: row.max_lon },
- distanceM: row.distance_m, elevationGainM: row.elevation_gain_m, pointCount: row.point_count,
+ bounds: row.min_lat == null
+ ? null
+ : { minLat: row.min_lat, minLon: row.min_lon, maxLat: row.max_lat, maxLon: row.max_lon },
+ distanceM: row.distance_m,
+ elevationGainM: row.elevation_gain_m,
+ pointCount: row.point_count,
gpxUrl: row.gpx_path && row.status === 'active' ? routeGpxUrl(row.id) : null,
- createdAt: row.created_at, updatedAt: row.updated_at, deletedAt: row.deleted_at
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ deletedAt: row.deleted_at
};
}
-export function listRoutes(db, { lat, lon, radiusKm = config.defaultRouteRadiusKm, includeDeleted = false }) {
- const rows = db.prepare(`SELECT * FROM routes ${includeDeleted ? '' : "WHERE status = 'active'"} ORDER BY name COLLATE NOCASE`).all();
+function routeComparator(origin) {
+ if (origin) return (left, right) => left.proximityM - right.proximityM;
+ return (left, right) => left.name.localeCompare(right.name, 'de');
+}
+
+function insertRoutePoints(db, routeId, points, offset = 0) {
+ const insert = db.prepare(INSERT_POINT_SQL);
+ points.forEach((point, index) => {
+ insert.run(routeId, offset + index, point.lat, point.lon, point.elevation, point.recordedAt);
+ });
+}
+
+function updateMetrics(db, routeId, metrics) {
+ db.prepare(UPDATE_METRICS_SQL).run(
+ metrics.startLat,
+ metrics.startLon,
+ metrics.centerLat,
+ metrics.centerLon,
+ metrics.minLat,
+ metrics.minLon,
+ metrics.maxLat,
+ metrics.maxLon,
+ metrics.distanceM,
+ metrics.elevationGainM,
+ metrics.pointCount,
+ routeId
+ );
+}
+
+function uniqueSlug(db, source) {
+ const base = slugify(source);
+ let candidate = base;
+ let suffix = 2;
+
+ while (db.prepare('SELECT 1 FROM routes WHERE slug = ?').get(candidate)) {
+ candidate = `${base}-${suffix}`;
+ suffix += 1;
+ }
+
+ return candidate;
+}
+
+function cleanupFailedRoute(db, routeId, gpxFile) {
+ removeUpload(gpxFile);
+ if (!routeId) return;
+
+ db.prepare('DELETE FROM route_points WHERE route_id = ?').run(routeId);
+ db.prepare('DELETE FROM routes WHERE id = ?').run(routeId);
+ fs.rmSync(routeDirectory(routeId), { recursive: true, force: true });
+}
+
+function rewriteMediaPaths(db, routeId, sourcePrefix, targetPrefix) {
+ db.prepare('UPDATE pois SET audio_path = REPLACE(audio_path, ?, ?) WHERE route_id = ? AND audio_path IS NOT NULL')
+ .run(sourcePrefix, targetPrefix, routeId);
+ db.prepare(`UPDATE poi_images SET path = REPLACE(path, ?, ?)
+ WHERE poi_id IN (SELECT id FROM pois WHERE route_id = ?)`)
+ .run(sourcePrefix, targetPrefix, routeId);
+}
+
+export function listRoutes(db, {
+ lat,
+ lon,
+ radiusKm = config.defaultRouteRadiusKm,
+ includeDeleted = false
+}) {
+ const where = includeDeleted ? '' : "WHERE 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;
- return rows.map(row => {
- const route = rowToRoute(row, includeDeleted);
- const proximityM = origin && route.start ? distanceMeters(origin, route.start) : null;
- return { ...route, proximityM };
- }).filter(route => !origin || route.proximityM <= radiusKm * 1000).sort((a, b) => origin ? a.proximityM - b.proximityM : a.name.localeCompare(b.name, 'de'));
+ const maximumDistance = radiusKm * 1000;
+
+ return rows
+ .map(row => {
+ const route = rowToRoute(row, includeDeleted);
+ const proximityM = origin && route?.start ? distanceMeters(origin, route.start) : null;
+ return { ...route, proximityM };
+ })
+ .filter(route => !origin || route.proximityM <= maximumDistance)
+ .sort(routeComparator(origin));
}
export function getRoute(db, id, includeDeleted = false) {
- const route = rowToRoute(db.prepare('SELECT * FROM routes WHERE id = ?').get(id), includeDeleted);
+ const row = db.prepare('SELECT * FROM routes WHERE id = ?').get(id);
+ const route = rowToRoute(row, includeDeleted);
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
- const points = db.prepare('SELECT sequence, lat, lon, elevation, recorded_at AS recordedAt FROM route_points WHERE route_id = ? ORDER BY sequence').all(id);
- const pois = listPois(db, id);
- return { ...route, points, pois };
+
+ const points = db.prepare(`SELECT sequence, lat, lon, elevation, recorded_at AS recordedAt
+ FROM route_points WHERE route_id = ? ORDER BY sequence`).all(id);
+ return { ...route, points, pois: listPois(db, id) };
}
export function getRouteGpxFile(db, id) {
const route = db.prepare("SELECT id, slug, gpx_path FROM routes WHERE id = ? AND status = 'active'").get(id);
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
+
return {
absolutePath: resolveStoredFile(route.gpx_path),
filename: `${route.slug}.gpx`
@@ -57,38 +160,49 @@ export function getRouteGpxFile(db, id) {
export function createRoute(db, fields, gpxFile) {
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
+
let routeId;
try {
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
const name = String(fields.name || parsed.name || '').trim();
if (!name) throw new HttpError(400, 'Ein Streckenname ist erforderlich.');
+
routeId = transaction(db, () => {
- let slug = slugify(fields.slug || name);
- let suffix = 2;
- while (db.prepare('SELECT 1 FROM routes WHERE slug = ?').get(slug)) slug = `${slugify(fields.slug || name)}-${suffix++}`;
+ const slug = uniqueSlug(db, fields.slug || name);
+ const metrics = parsed.metrics;
const result = db.prepare(`INSERT INTO routes
- (slug, name, description, school_name, gpx_path, start_lat, start_lon, center_lat, center_lon, min_lat, min_lon, max_lat, max_lon, distance_m, elevation_gain_m, point_count)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
- .run(slug, name, fields.description || '', fields.schoolName || '', 'pending', parsed.metrics.startLat, parsed.metrics.startLon,
- parsed.metrics.centerLat, parsed.metrics.centerLon, parsed.metrics.minLat, parsed.metrics.minLon, parsed.metrics.maxLat, parsed.metrics.maxLon,
- parsed.metrics.distanceM, parsed.metrics.elevationGainM, parsed.metrics.pointCount);
+ (slug, name, description, school_name, gpx_path, start_lat, start_lon, center_lat, center_lon,
+ min_lat, min_lon, max_lat, max_lon, distance_m, elevation_gain_m, point_count)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
+ slug,
+ name,
+ fields.description || '',
+ fields.schoolName || '',
+ 'pending',
+ metrics.startLat,
+ metrics.startLon,
+ metrics.centerLat,
+ metrics.centerLon,
+ metrics.minLat,
+ metrics.minLon,
+ metrics.maxLat,
+ metrics.maxLon,
+ metrics.distanceM,
+ metrics.elevationGainM,
+ metrics.pointCount
+ );
const id = Number(result.lastInsertRowid);
- const insertPoint = db.prepare('INSERT INTO route_points (route_id, sequence, lat, lon, elevation, recorded_at) VALUES (?, ?, ?, ?, ?, ?)');
- for (const point of parsed.points) insertPoint.run(id, point.sequence, point.lat, point.lon, point.elevation, point.recordedAt);
+ insertRoutePoints(db, id, parsed.points);
return id;
});
+
ensureRouteDirectories(routeId);
- const finalGpx = path.join(routeDirectory(routeId), 'route.gpx');
- const gpxPath = moveUploadedFile(gpxFile, finalGpx);
- db.prepare("UPDATE routes SET gpx_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(gpxPath, routeId);
+ const gpxPath = moveUploadedFile(gpxFile, path.join(routeDirectory(routeId), 'route.gpx'));
+ db.prepare('UPDATE routes SET gpx_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
+ .run(gpxPath, routeId);
return getRoute(db, routeId);
} catch (error) {
- removeUpload(gpxFile);
- if (routeId) {
- db.prepare('DELETE FROM route_points WHERE route_id = ?').run(routeId);
- db.prepare('DELETE FROM routes WHERE id = ?').run(routeId);
- fs.rmSync(routeDirectory(routeId), { recursive: true, force: true });
- }
+ cleanupFailedRoute(db, routeId, gpxFile);
throw error;
}
}
@@ -96,52 +210,63 @@ export function createRoute(db, fields, gpxFile) {
export function updateRoute(db, id, fields, gpxFile) {
const existing = db.prepare("SELECT * FROM routes WHERE id = ? AND status = 'active'").get(id);
if (!existing) throw new HttpError(404, 'Strecke nicht gefunden.');
- let parsed = null;
- if (gpxFile) parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
+
+ const parsed = gpxFile ? parseGpx(fs.readFileSync(gpxFile.path, 'utf8')) : null;
transaction(db, () => {
- db.prepare(`UPDATE routes SET name = ?, description = ?, school_name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`)
- .run(fields.name ?? existing.name, fields.description ?? existing.description, fields.schoolName ?? existing.school_name, id);
- if (parsed) {
- db.prepare('DELETE FROM route_points WHERE route_id = ?').run(id);
- const insertPoint = db.prepare('INSERT INTO route_points (route_id, sequence, lat, lon, elevation, recorded_at) VALUES (?, ?, ?, ?, ?, ?)');
- for (const point of parsed.points) insertPoint.run(id, point.sequence, point.lat, point.lon, point.elevation, point.recordedAt);
- const m = parsed.metrics;
- db.prepare(`UPDATE routes SET start_lat=?, start_lon=?, center_lat=?, center_lon=?, min_lat=?, min_lon=?, max_lat=?, max_lon=?, distance_m=?, elevation_gain_m=?, point_count=? WHERE id=?`)
- .run(m.startLat, m.startLon, m.centerLat, m.centerLon, m.minLat, m.minLon, m.maxLat, m.maxLon, m.distanceM, m.elevationGainM, m.pointCount, id);
- }
+ db.prepare(`UPDATE routes SET name = ?, description = ?, school_name = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?`).run(
+ fields.name ?? existing.name,
+ fields.description ?? existing.description,
+ fields.schoolName ?? existing.school_name,
+ id
+ );
+
+ if (!parsed) return;
+ db.prepare('DELETE FROM route_points WHERE route_id = ?').run(id);
+ insertRoutePoints(db, id, parsed.points);
+ updateMetrics(db, id, parsed.metrics);
});
+
if (gpxFile) moveUploadedFile(gpxFile, path.join(routeDirectory(id), 'route.gpx'));
return getRoute(db, id);
}
export function appendRoute(db, id, gpxFile) {
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
- const existing = db.prepare("SELECT * FROM routes WHERE id = ? AND status = 'active'").get(id);
+
+ const existing = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(id);
if (!existing) throw new HttpError(404, 'Strecke nicht gefunden.');
+
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
- const current = db.prepare('SELECT lat, lon, elevation, recorded_at AS recordedAt FROM route_points WHERE route_id = ? ORDER BY sequence').all(id);
- const all = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence }));
- const m = routeMetrics(all);
+ const current = db.prepare(`SELECT lat, lon, elevation, recorded_at AS recordedAt
+ FROM route_points WHERE route_id = ? ORDER BY sequence`).all(id);
+ const allPoints = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence }));
+
transaction(db, () => {
- const insert = db.prepare('INSERT INTO route_points (route_id, sequence, lat, lon, elevation, recorded_at) VALUES (?, ?, ?, ?, ?, ?)');
- for (let i = 0; i < parsed.points.length; i += 1) {
- const p = parsed.points[i]; insert.run(id, current.length + i, p.lat, p.lon, p.elevation, p.recordedAt);
- }
- db.prepare(`UPDATE routes SET start_lat=?, start_lon=?, center_lat=?, center_lon=?, min_lat=?, min_lon=?, max_lat=?, max_lon=?, distance_m=?, elevation_gain_m=?, point_count=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`)
- .run(m.startLat, m.startLon, m.centerLat, m.centerLon, m.minLat, m.minLon, m.maxLat, m.maxLon, m.distanceM, m.elevationGainM, m.pointCount, id);
+ insertRoutePoints(db, id, parsed.points, current.length);
+ updateMetrics(db, id, routeMetrics(allPoints));
});
+
removeUpload(gpxFile);
return getRoute(db, id);
}
export function listPois(db, routeId) {
const pois = db.prepare('SELECT * FROM pois WHERE route_id = ? ORDER BY sequence, id').all(routeId);
- const imageStatement = db.prepare('SELECT id, path, caption, sequence FROM poi_images WHERE poi_id = ? ORDER BY sequence, id');
+ const images = db.prepare(`SELECT id, path, caption, sequence FROM poi_images
+ WHERE poi_id = ? ORDER BY sequence, id`);
+
return pois.map(poi => ({
- id: poi.id, routeId: poi.route_id, title: poi.title, description: poi.description,
- lat: poi.lat, lon: poi.lon, triggerRadiusM: poi.trigger_radius_m, sequence: poi.sequence,
+ id: poi.id,
+ routeId: poi.route_id,
+ title: poi.title,
+ description: poi.description,
+ lat: poi.lat,
+ lon: poi.lon,
+ triggerRadiusM: poi.trigger_radius_m,
+ sequence: poi.sequence,
audioUrl: poi.audio_path ? audioUrl(poi.route_id, poi.id) : null,
- images: imageStatement.all(poi.id).map(image => ({
+ images: images.all(poi.id).map(image => ({
id: image.id,
caption: image.caption,
sequence: image.sequence,
@@ -160,30 +285,51 @@ export function getPoi(db, routeId, poiId) {
export function createPoi(db, routeId, fields) {
const route = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(routeId);
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
+
const lat = Number.parseFloat(fields.lat);
const lon = Number.parseFloat(fields.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
throw new HttpError(400, 'Gültige POI-Koordinaten sind erforderlich.');
}
- const result = db.prepare(`INSERT INTO pois (route_id, title, description, lat, lon, trigger_radius_m, sequence) VALUES (?, ?, ?, ?, ?, ?, ?)`)
- .run(routeId, fields.title || 'Unbenannter POI', fields.description || '', lat, lon,
- Number.parseFloat(fields.triggerRadiusM || config.defaultPoiTriggerMeters), Number.parseInt(fields.sequence || '0', 10));
+
+ const result = db.prepare(`INSERT INTO pois
+ (route_id, title, description, lat, lon, trigger_radius_m, sequence) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
+ routeId,
+ fields.title || 'Unbenannter POI',
+ fields.description || '',
+ lat,
+ lon,
+ Number.parseFloat(fields.triggerRadiusM || config.defaultPoiTriggerMeters),
+ Number.parseInt(fields.sequence || '0', 10)
+ );
return listPois(db, routeId).find(poi => poi.id === Number(result.lastInsertRowid));
}
export function updatePoi(db, routeId, poiId, fields) {
const existing = db.prepare(`SELECT p.*, r.status FROM pois p JOIN routes r ON r.id = p.route_id
WHERE p.id = ? AND p.route_id = ?`).get(poiId, routeId);
- if (!existing || existing.status !== 'active') throw new HttpError(404, 'POI nicht gefunden.');
+ if (existing?.status !== 'active') throw new HttpError(404, 'POI nicht gefunden.');
+
const lat = fields.lat == null ? existing.lat : Number.parseFloat(fields.lat);
const lon = fields.lon == null ? existing.lon : Number.parseFloat(fields.lon);
- const radius = fields.triggerRadiusM == null ? existing.trigger_radius_m : Number.parseFloat(fields.triggerRadiusM);
+ const radius = fields.triggerRadiusM == null
+ ? existing.trigger_radius_m
+ : Number.parseFloat(fields.triggerRadiusM);
const sequence = fields.sequence == null ? existing.sequence : Number.parseInt(fields.sequence, 10);
if (![lat, lon, radius].every(Number.isFinite) || !Number.isInteger(sequence) || sequence < 0) {
throw new HttpError(400, 'POI-Koordinaten, Auslöseradius oder Reihenfolge sind ungültig.');
}
- db.prepare(`UPDATE pois SET title=?, description=?, lat=?, lon=?, trigger_radius_m=?, sequence=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`)
- .run(fields.title ?? existing.title, fields.description ?? existing.description, lat, lon, radius, sequence, poiId);
+
+ db.prepare(`UPDATE pois SET title=?, description=?, lat=?, lon=?, trigger_radius_m=?, sequence=?,
+ updated_at=CURRENT_TIMESTAMP WHERE id=?`).run(
+ fields.title ?? existing.title,
+ fields.description ?? existing.description,
+ lat,
+ lon,
+ radius,
+ sequence,
+ poiId
+ );
return listPois(db, existing.route_id).find(poi => poi.id === Number(poiId));
}
@@ -198,45 +344,47 @@ export function deletePoi(db, routeId, poiId) {
db.prepare('DELETE FROM pois WHERE id = ? AND route_id = ?').run(poiId, routeId);
});
- for (const image of images) removeStoredFile(image.path);
+ images.forEach(image => removeStoredFile(image.path));
removeStoredFile(existing.audio_path);
return { id: poiId, routeId, deleted: true };
}
export function softDeleteRoute(db, id) {
- const route = db.prepare("SELECT * FROM routes WHERE id = ? AND status = 'active'").get(id);
+ const route = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(id);
if (!route) throw new HttpError(404, 'Aktive Strecke nicht gefunden.');
+
const moved = softDeleteRouteDirectory(id);
try {
transaction(db, () => {
- db.prepare(`UPDATE routes SET status='deleted', deleted_at=CURRENT_TIMESTAMP, trash_path=?, gpx_path=REPLACE(gpx_path, ?, ?), updated_at=CURRENT_TIMESTAMP WHERE id=?`)
+ db.prepare(`UPDATE routes SET status='deleted', deleted_at=CURRENT_TIMESTAMP, trash_path=?,
+ gpx_path=REPLACE(gpx_path, ?, ?), updated_at=CURRENT_TIMESTAMP WHERE id=?`)
.run(moved.targetPrefix, moved.sourcePrefix, moved.targetPrefix, id);
- db.prepare('UPDATE pois SET audio_path = REPLACE(audio_path, ?, ?) WHERE route_id = ? AND audio_path IS NOT NULL').run(moved.sourcePrefix, moved.targetPrefix, id);
- db.prepare(`UPDATE poi_images SET path = REPLACE(path, ?, ?) WHERE poi_id IN (SELECT id FROM pois WHERE route_id = ?)`)
- .run(moved.sourcePrefix, moved.targetPrefix, id);
+ rewriteMediaPaths(db, id, moved.sourcePrefix, moved.targetPrefix);
});
} catch (error) {
fs.renameSync(moved.destination, routeDirectory(id));
throw error;
}
+
return rowToRoute(db.prepare('SELECT * FROM routes WHERE id = ?').get(id), true);
}
export function restoreRoute(db, id) {
- const route = db.prepare("SELECT * FROM routes WHERE id = ? AND status = 'deleted'").get(id);
+ const route = db.prepare("SELECT trash_path FROM routes WHERE id = ? AND status = 'deleted'").get(id);
if (!route) throw new HttpError(404, 'Gelöschte Strecke nicht gefunden.');
+
const moved = restoreRouteDirectory(id, route.trash_path);
try {
transaction(db, () => {
- db.prepare(`UPDATE routes SET status='active', deleted_at=NULL, trash_path=NULL, gpx_path=REPLACE(gpx_path, ?, ?), updated_at=CURRENT_TIMESTAMP WHERE id=?`)
- .run(moved.sourcePrefix, moved.targetPrefix, id);
- db.prepare('UPDATE pois SET audio_path = REPLACE(audio_path, ?, ?) WHERE route_id = ? AND audio_path IS NOT NULL').run(moved.sourcePrefix, moved.targetPrefix, id);
- db.prepare(`UPDATE poi_images SET path = REPLACE(path, ?, ?) WHERE poi_id IN (SELECT id FROM pois WHERE route_id = ?)`)
+ db.prepare(`UPDATE routes SET status='active', deleted_at=NULL, trash_path=NULL,
+ gpx_path=REPLACE(gpx_path, ?, ?), updated_at=CURRENT_TIMESTAMP WHERE id=?`)
.run(moved.sourcePrefix, moved.targetPrefix, id);
+ rewriteMediaPaths(db, id, moved.sourcePrefix, moved.targetPrefix);
});
} catch (error) {
fs.renameSync(moved.destination, moved.source);
throw error;
}
+
return getRoute(db, id);
}
diff --git a/src/services/storage.js b/src/services/storage.js
index 3bf9be4..a1139c4 100644
--- a/src/services/storage.js
+++ b/src/services/storage.js
@@ -5,13 +5,31 @@ import { HttpError } from '../middleware/errors.js';
const activeRoot = path.join(config.storageDir, 'active', 'routes');
const trashRoot = path.join(config.storageDir, 'trash', 'routes');
+const storageRoot = path.resolve(config.storageDir);
export const routeDirectory = id => path.join(activeRoot, String(id));
export const relativeStoragePath = absolute => path.relative(config.storageDir, absolute).split(path.sep).join('/');
+function storedPath(relativePath) {
+ const target = path.resolve(storageRoot, relativePath);
+ const insideStorage = target !== storageRoot && target.startsWith(`${storageRoot}${path.sep}`);
+ if (!insideStorage) throw new HttpError(500, 'Ungültiger interner Medienpfad.');
+ return target;
+}
+
+function isFile(target) {
+ try {
+ return fs.statSync(target).isFile();
+ } catch (error) {
+ if (error.code === 'ENOENT') return false;
+ throw error;
+ }
+}
+
export function ensureRouteDirectories(id) {
const root = routeDirectory(id);
- for (const dir of [root, path.join(root, 'images'), path.join(root, 'audio')]) fs.mkdirSync(dir, { recursive: true });
+ [root, path.join(root, 'images'), path.join(root, 'audio')]
+ .forEach(directory => fs.mkdirSync(directory, { recursive: true }));
return root;
}
@@ -22,16 +40,14 @@ export function moveUploadedFile(uploaded, destination) {
}
export function removeUpload(uploaded) {
- if (uploaded?.path && fs.existsSync(uploaded.path)) fs.rmSync(uploaded.path, { force: true });
+ if (!uploaded?.path || !fs.existsSync(uploaded.path)) return;
+ fs.rmSync(uploaded.path, { force: true });
}
export function removeStoredFile(relativePath) {
if (!relativePath) return false;
- const storageRoot = path.resolve(config.storageDir);
- const target = path.resolve(storageRoot, relativePath);
- if (target !== storageRoot && !target.startsWith(`${storageRoot}${path.sep}`)) {
- throw new HttpError(500, 'Ungültiger interner Medienpfad.');
- }
+
+ const target = storedPath(relativePath);
if (!fs.existsSync(target)) return false;
fs.rmSync(target, { force: true });
return true;
@@ -39,10 +55,18 @@ export function removeStoredFile(relativePath) {
export function softDeleteRouteDirectory(id) {
const source = routeDirectory(id);
- if (!fs.existsSync(source)) throw new HttpError(409, 'Streckenverzeichnis fehlt; Löschmarkierung wurde nicht ausgeführt.');
- const destination = path.join(trashRoot, `${id}-${new Date().toISOString().replace(/[:.]/g, '-')}`);
+ if (!fs.existsSync(source)) {
+ throw new HttpError(409, 'Streckenverzeichnis fehlt; Löschmarkierung wurde nicht ausgeführt.');
+ }
+
+ const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-');
+ const destination = path.join(trashRoot, `${id}-${timestamp}`);
fs.renameSync(source, destination);
- return { sourcePrefix: relativeStoragePath(source), targetPrefix: relativeStoragePath(destination), destination };
+ return {
+ sourcePrefix: relativeStoragePath(source),
+ targetPrefix: relativeStoragePath(destination),
+ destination
+ };
}
export function restoreRouteDirectory(id, trashRelativePath) {
@@ -50,23 +74,22 @@ export function restoreRouteDirectory(id, trashRelativePath) {
const destination = routeDirectory(id);
if (!fs.existsSync(source)) throw new HttpError(409, 'Papierkorbverzeichnis der Strecke fehlt.');
if (fs.existsSync(destination)) throw new HttpError(409, 'Aktives Streckenverzeichnis existiert bereits.');
+
fs.renameSync(source, destination);
- return { sourcePrefix: relativeStoragePath(source), targetPrefix: relativeStoragePath(destination), source, destination };
+ return {
+ sourcePrefix: relativeStoragePath(source),
+ targetPrefix: relativeStoragePath(destination),
+ source,
+ destination
+ };
}
export function resolveStoredFile(relativePath) {
- if (!relativePath || !relativePath.startsWith('active/')) {
+ if (!relativePath?.startsWith('active/')) {
throw new HttpError(404, 'Aktive Mediendatei nicht gefunden.');
}
- const storageRoot = path.resolve(config.storageDir);
- const target = path.resolve(storageRoot, relativePath);
- if (target === storageRoot || !target.startsWith(`${storageRoot}${path.sep}`)) {
- throw new HttpError(500, 'Ungültiger interner Medienpfad.');
- }
- if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
- throw new HttpError(404, 'Mediendatei nicht gefunden.');
- }
+ const target = storedPath(relativePath);
+ if (!isFile(target)) throw new HttpError(404, 'Mediendatei nicht gefunden.');
return target;
}
-
diff --git a/test/mobile-navigation-ui.test.js b/test/mobile-navigation-ui.test.js
index d20fc82..1807092 100644
--- a/test/mobile-navigation-ui.test.js
+++ b/test/mobile-navigation-ui.test.js
@@ -68,15 +68,23 @@ test('mobile navigation footer contains a rotating SVG arrow and exact metre out
test('client bearing calculation points north and east correctly', async () => {
const source = await read('public/js/distance.js');
- const context = { window: { Wegwichtel: {} } };
+ const context = { Wegwichtel: {} };
vm.runInNewContext(source, context);
- const distance = context.window.Wegwichtel.Distance;
+ const distance = context.Wegwichtel.Distance;
assert.ok(Math.abs(distance.bearing({ lat: 0, lon: 0 }, { lat: 1, lon: 0 })) < 0.001);
assert.ok(Math.abs(distance.bearing({ lat: 0, lon: 0 }, { lat: 0, lon: 1 }) - 90) < 0.001);
});
+
+test('authored browser modules use the portable global object', async () => {
+ const scripts = (await clientSources()).join('\n');
+
+ assert.match(scripts, /globalThis\.Wegwichtel/);
+ assert.doesNotMatch(scripts, /\bwindow\./);
+});
+
test('route controls use accessible icon buttons with start, pause and stop states', async () => {
const html = await read('public/index.html');
const app = await read('public/js/app.js');
@@ -88,13 +96,15 @@ test('route controls use accessible icon buttons with start, pause and stop stat
assert.match(html, /aria-label="Route starten"/);
assert.match(html, /aria-label="Route pausieren"/);
assert.match(html, /aria-label="Route beenden"/);
+ assert.match(html, /