simplify application and remove window references
Sonarqube Scanner / Build and analyze (push) Failing after 1m27s

This commit is contained in:
Florian Zumpe
2026-06-17 16:08:01 +02:00
parent 6a4d84ac6a
commit 32f161483d
20 changed files with 560 additions and 282 deletions
+100 -67
View File
@@ -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: '<!--', closing: '-->' }),
Object.freeze({ opening: '<?', closing: '?>' }),
Object.freeze({ opening: '<!', closing: '>' })
]);
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)) {
const end = xml.indexOf('-->', 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)) {
const end = xml.indexOf('>', 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);
}
};
}
+47 -15
View File
@@ -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)
};
}
+230 -82
View File
@@ -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);
}
+44 -21
View File
@@ -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;
}