added image and audio REST endpoints

This commit is contained in:
Florian Zumpe
2026-06-16 23:52:37 +02:00
parent b910bce8df
commit c98f304ffa
13 changed files with 1126 additions and 494 deletions
+78 -4
View File
@@ -5,6 +5,10 @@ import {
listRoutes, getRoute, createRoute, updateRoute, appendRoute,
listPois, getPoi, createPoi, updatePoi, softDeleteRoute, restoreRoute
} from '../services/routes-service.js';
import {
listPictures, getPictureContent, createPicture, updatePicture, deletePicture,
listAudio, getAudioContent, createAudio, updateAudio, deleteAudio
} from '../services/media-service.js';
const numberOrUndefined = value => {
if (value == null || value === '') return undefined;
@@ -12,6 +16,26 @@ const numberOrUndefined = value => {
return Number.isFinite(parsed) ? parsed : undefined;
};
const positiveInteger = (value, name) => {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
const error = new Error(`${name} muss eine positive Ganzzahl sein.`);
error.status = 400;
throw error;
}
return parsed;
};
function sendMedia(res, resource) {
res.set({
'Cache-Control': 'private, max-age=60',
'Content-Disposition': `inline; filename="${resource.filename.replace(/["\\]/g, '_')}"`,
'X-Content-Type-Options': 'nosniff'
});
res.type(resource.contentType);
res.sendFile(resource.absolutePath);
}
export function createApiRouter(db) {
const api = Router();
@@ -61,13 +85,63 @@ export function createApiRouter(db) {
res.json(appendRoute(db, Number(req.params.id), req.file));
});
api.post('/routes/:id/pois', upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'images', maxCount: 20 }]), (req, res) => {
const poi = createPoi(db, Number(req.params.id), req.body, req.files);
api.post('/routes/:id/pois', upload.none(), (req, res) => {
const poi = createPoi(db, Number(req.params.id), req.body);
res.status(201).location(`/api/pois/${poi.id}`).json(poi);
});
api.put('/pois/:id', upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'images', maxCount: 20 }]), (req, res) => {
res.json(updatePoi(db, Number(req.params.id), req.body, req.files));
api.put('/pois/:id', upload.none(), (req, res) => {
res.json(updatePoi(db, Number(req.params.id), req.body));
});
api.get('/routes/:id/pictures', (req, res) => {
res.json({ pictures: listPictures(db, Number(req.params.id), { poiId: req.query.poiId }) });
});
api.get('/routes/:id/pictures/:pictureId', (req, res) => {
sendMedia(res, getPictureContent(db, Number(req.params.id), Number(req.params.pictureId)));
});
api.post('/routes/:id/pictures', upload.single('picture'), (req, res) => {
const picture = createPicture(db, Number(req.params.id), req.body, req.file);
res.status(201)
.location(`/api/routes/${req.params.id}/pictures/${picture.id}`)
.json(picture);
});
api.put('/routes/:id/pictures/:pictureId', upload.single('picture'), (req, res) => {
res.json(updatePicture(db, Number(req.params.id), Number(req.params.pictureId), req.body, req.file));
});
api.delete('/routes/:id/pictures/:pictureId', (req, res) => {
res.json(deletePicture(db, Number(req.params.id), Number(req.params.pictureId)));
});
api.get('/routes/:id/audio', (req, res) => {
if (req.query.poiId == null || req.query.poiId === '') {
res.json({ audio: listAudio(db, Number(req.params.id)) });
return;
}
const poiId = positiveInteger(req.query.poiId, 'poiId');
sendMedia(res, getAudioContent(db, Number(req.params.id), poiId));
});
api.post('/routes/:id/audio', upload.single('audio'), (req, res) => {
const audio = createAudio(db, Number(req.params.id), req.body, req.file);
res.status(201)
.location(`/api/routes/${req.params.id}/audio?poiId=${encodeURIComponent(audio.poiId)}`)
.json(audio);
});
api.put('/routes/:id/audio', upload.single('audio'), (req, res) => {
const poiId = positiveInteger(req.query.poiId ?? req.body.poiId, 'poiId');
res.json(updateAudio(db, Number(req.params.id), poiId, req.file));
});
api.delete('/routes/:id/audio', (req, res) => {
const poiId = positiveInteger(req.query.poiId ?? req.body?.poiId, 'poiId');
res.json(deleteAudio(db, Number(req.params.id), poiId));
});
api.delete('/routes/:id', (req, res) => {
+301
View File
@@ -0,0 +1,301 @@
import path from 'node:path';
import crypto from 'node:crypto';
import { HttpError } from '../middleware/errors.js';
import {
ensureRouteDirectories,
moveUploadedFile,
removeStoredFile,
removeUpload,
resolveStoredFile
} from './storage.js';
const IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
const AUDIO_MIME_TYPES = new Set([
'audio/mpeg',
'audio/mp4',
'audio/x-m4a',
'audio/aac',
'audio/ogg',
'audio/wav',
'audio/webm'
]);
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
const AUDIO_EXTENSIONS = new Set(['.mp3', '.mp4', '.m4a', '.aac', '.ogg', '.wav', '.webm']);
const uniqueFilename = original => `${crypto.randomUUID()}${path.extname(original).toLowerCase()}`;
const pictureApiUrl = (routeId, pictureId) => `/api/routes/${routeId}/pictures/${pictureId}`;
const audioApiUrl = (routeId, poiId) => `/api/routes/${routeId}/audio?poiId=${encodeURIComponent(poiId)}`;
const CONTENT_TYPES = Object.freeze({
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.mp3': 'audio/mpeg',
'.mp4': 'audio/mp4',
'.m4a': 'audio/mp4',
'.aac': 'audio/aac',
'.ogg': 'audio/ogg',
'.wav': 'audio/wav',
'.webm': 'audio/webm'
});
function contentResource(relativePath) {
const absolutePath = resolveStoredFile(relativePath);
return {
absolutePath,
contentType: CONTENT_TYPES[path.extname(absolutePath).toLowerCase()] || 'application/octet-stream',
filename: path.basename(absolutePath)
};
}
function requirePositiveInteger(value, name) {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new HttpError(400, `${name} muss eine positive Ganzzahl sein.`);
}
return parsed;
}
function optionalSequence(value, fallback) {
if (value == null || value === '') return fallback;
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new HttpError(400, 'sequence muss eine nichtnegative Ganzzahl sein.');
}
return parsed;
}
function activeRoute(db, routeId) {
const route = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(routeId);
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
return route;
}
function poiOnRoute(db, routeId, poiId) {
const poi = db.prepare(`
SELECT p.id, p.route_id, p.title, p.audio_path
FROM pois p
JOIN routes r ON r.id = p.route_id
WHERE p.id = ? AND p.route_id = ? AND r.status = 'active'
`).get(poiId, routeId);
if (!poi) throw new HttpError(404, 'POI auf dieser Strecke nicht gefunden.');
return poi;
}
function validateUpload(file, kind) {
if (!file) throw new HttpError(400, `Eine ${kind === 'picture' ? 'Bilddatei' : 'Audiodatei'} ist erforderlich.`);
const extension = path.extname(file.originalname).toLowerCase();
const valid = kind === 'picture'
? IMAGE_MIME_TYPES.has(file.mimetype) || IMAGE_EXTENSIONS.has(extension)
: AUDIO_MIME_TYPES.has(file.mimetype) || AUDIO_EXTENSIONS.has(extension);
if (!valid) {
removeUpload(file);
throw new HttpError(415, kind === 'picture'
? 'Der Upload ist keine unterstützte Bilddatei.'
: 'Der Upload ist keine unterstützte Audiodatei.');
}
}
function pictureFromRow(row) {
return {
id: row.id,
routeId: row.route_id,
poiId: row.poi_id,
poiTitle: row.poi_title,
caption: row.caption,
sequence: row.sequence,
url: pictureApiUrl(row.route_id, row.id),
createdAt: row.created_at
};
}
const pictureSelect = `
SELECT i.id, i.poi_id, p.route_id, p.title AS poi_title,
i.path, i.caption, i.sequence, i.created_at
FROM poi_images i
JOIN pois p ON p.id = i.poi_id
JOIN routes r ON r.id = p.route_id
`;
export function listPictures(db, routeId, { poiId } = {}) {
activeRoute(db, routeId);
const filterPoiId = poiId == null || poiId === '' ? null : requirePositiveInteger(poiId, 'poiId');
const rows = filterPoiId == null
? db.prepare(`${pictureSelect} WHERE p.route_id = ? AND r.status = 'active' ORDER BY p.sequence, p.id, i.sequence, i.id`).all(routeId)
: db.prepare(`${pictureSelect} WHERE p.route_id = ? AND p.id = ? AND r.status = 'active' ORDER BY i.sequence, i.id`).all(routeId, filterPoiId);
return rows.map(pictureFromRow);
}
export function getPicture(db, routeId, pictureId) {
activeRoute(db, routeId);
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId);
if (!row) throw new HttpError(404, 'Bild auf dieser Strecke nicht gefunden.');
return pictureFromRow(row);
}
export function getPictureContent(db, routeId, pictureId) {
activeRoute(db, routeId);
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId);
if (!row) throw new HttpError(404, 'Bild auf dieser Strecke nicht gefunden.');
return contentResource(row.path);
}
export function createPicture(db, routeId, fields, file) {
let relativePath;
try {
activeRoute(db, routeId);
validateUpload(file, 'picture');
const poiId = requirePositiveInteger(fields.poiId, 'poiId');
poiOnRoute(db, routeId, poiId);
const sequence = optionalSequence(fields.sequence,
db.prepare('SELECT COALESCE(MAX(sequence), -1) + 1 AS value FROM poi_images WHERE poi_id = ?').get(poiId).value);
const caption = String(fields.caption ?? '');
const root = ensureRouteDirectories(routeId);
relativePath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file.originalname)));
const result = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)')
.run(poiId, relativePath, caption, sequence);
return getPicture(db, routeId, Number(result.lastInsertRowid));
} catch (error) {
if (relativePath) removeStoredFile(relativePath);
else removeUpload(file);
throw error;
}
}
export function updatePicture(db, routeId, pictureId, fields, file) {
let newPath;
let oldPath;
try {
const existing = getPicture(db, routeId, pictureId);
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId);
oldPath = row.path;
const poiId = fields.poiId == null || fields.poiId === ''
? existing.poiId
: requirePositiveInteger(fields.poiId, 'poiId');
poiOnRoute(db, routeId, poiId);
const caption = fields.caption == null ? existing.caption : String(fields.caption);
const sequence = optionalSequence(fields.sequence, existing.sequence);
newPath = row.path;
if (file) {
validateUpload(file, 'picture');
const root = ensureRouteDirectories(routeId);
newPath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file.originalname)));
}
db.prepare('UPDATE poi_images SET poi_id = ?, path = ?, caption = ?, sequence = ? WHERE id = ?')
.run(poiId, newPath, caption, sequence, pictureId);
if (file && newPath !== oldPath) removeStoredFile(oldPath);
return getPicture(db, routeId, pictureId);
} catch (error) {
if (file) {
if (newPath && newPath !== oldPath) removeStoredFile(newPath);
else removeUpload(file);
}
throw error;
}
}
export function deletePicture(db, routeId, pictureId) {
const picture = getPicture(db, routeId, pictureId);
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId);
db.prepare('DELETE FROM poi_images WHERE id = ?').run(pictureId);
removeStoredFile(row.path);
return { id: picture.id, routeId: picture.routeId, poiId: picture.poiId, deleted: true };
}
function audioFromRow(row) {
return {
routeId: row.route_id,
poiId: row.poi_id,
poiTitle: row.poi_title,
url: audioApiUrl(row.route_id, row.poi_id),
updatedAt: row.updated_at
};
}
const audioSelect = `
SELECT p.id AS poi_id, p.route_id, p.title AS poi_title,
p.audio_path, p.updated_at
FROM pois p
JOIN routes r ON r.id = p.route_id
`;
export function listAudio(db, routeId, { poiId } = {}) {
activeRoute(db, routeId);
const filterPoiId = poiId == null || poiId === '' ? null : requirePositiveInteger(poiId, 'poiId');
const rows = filterPoiId == null
? db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.audio_path IS NOT NULL AND r.status = 'active' ORDER BY p.sequence, p.id`).all(routeId)
: db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`).all(routeId, filterPoiId);
return rows.map(audioFromRow);
}
export function getAudio(db, routeId, poiId) {
activeRoute(db, routeId);
const row = db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`)
.get(routeId, poiId);
if (!row) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.');
return audioFromRow(row);
}
export function getAudioContent(db, routeId, poiId) {
activeRoute(db, routeId);
const row = db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`)
.get(routeId, poiId);
if (!row) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.');
return contentResource(row.audio_path);
}
function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting }) {
let relativePath;
let oldPath;
try {
activeRoute(db, routeId);
validateUpload(file, 'audio');
const poi = poiOnRoute(db, routeId, poiId);
oldPath = poi.audio_path;
if (requireAbsent && poi.audio_path) {
throw new HttpError(409, 'Für diesen POI ist bereits eine Audiodatei hinterlegt. Verwende PUT zum Ersetzen.');
}
if (requireExisting && !poi.audio_path) {
throw new HttpError(404, 'Für diesen POI ist noch keine Audiodatei hinterlegt. Verwende POST zum Anlegen.');
}
const root = ensureRouteDirectories(routeId);
relativePath = moveUploadedFile(file, path.join(root, 'audio', uniqueFilename(file.originalname)));
db.prepare('UPDATE pois SET audio_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
.run(relativePath, poiId);
if (oldPath && oldPath !== relativePath) removeStoredFile(oldPath);
return getAudio(db, routeId, poiId);
} catch (error) {
if (relativePath && relativePath !== oldPath) removeStoredFile(relativePath);
else removeUpload(file);
throw error;
}
}
export function createAudio(db, routeId, fields, file) {
const poiId = requirePositiveInteger(fields.poiId, 'poiId');
return storeAudio(db, routeId, poiId, file, { requireAbsent: true, requireExisting: false });
}
export function updateAudio(db, routeId, poiId, file) {
return storeAudio(db, routeId, poiId, file, { requireAbsent: false, requireExisting: true });
}
export function deleteAudio(db, routeId, poiId) {
const audio = getAudio(db, routeId, poiId);
const poi = poiOnRoute(db, routeId, poiId);
db.prepare('UPDATE pois SET audio_path = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(poiId);
removeStoredFile(poi.audio_path);
return { routeId: audio.routeId, poiId: audio.poiId, deleted: true };
}
+24 -39
View File
@@ -1,19 +1,16 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { transaction } from '../database.js';
import { config } from '../config.js';
import { HttpError } from '../middleware/errors.js';
import { parseGpx } from './gpx.js';
import { distanceMeters, routeMetrics } from './geo.js';
import {
ensureRouteDirectories, moveUploadedFile, relativeStoragePath, routeDirectory,
ensureRouteDirectories, moveUploadedFile, routeDirectory,
softDeleteRouteDirectory, restoreRouteDirectory, safeMediaUrl, removeUpload
} 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 uniqueFilename = original => `${crypto.randomUUID()}${path.extname(original).toLowerCase()}`;
function rowToRoute(row, includeDeleted = false) {
if (!row || (!includeDeleted && row.status !== 'active')) return null;
return {
@@ -132,8 +129,13 @@ export function listPois(db, routeId) {
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,
audioUrl: safeMediaUrl(poi.audio_path),
images: imageStatement.all(poi.id).map(image => ({ id: image.id, caption: image.caption, sequence: image.sequence, url: safeMediaUrl(image.path) }))
audioUrl: poi.audio_path ? `/api/routes/${routeId}/audio?poiId=${encodeURIComponent(poi.id)}` : null,
images: imageStatement.all(poi.id).map(image => ({
id: image.id,
caption: image.caption,
sequence: image.sequence,
url: `/api/routes/${routeId}/pictures/${image.id}`
}))
}));
}
@@ -143,49 +145,32 @@ export function getPoi(db, poiId) {
return listPois(db, row.routeId).find(poi => poi.id === Number(poiId));
}
export function createPoi(db, routeId, fields, files = {}) {
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 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 poiId = Number(result.lastInsertRowid);
const routeRoot = ensureRouteDirectories(routeId);
const audio = files.audio?.[0];
if (audio) {
const relative = moveUploadedFile(audio, path.join(routeRoot, 'audio', uniqueFilename(audio.originalname)));
db.prepare('UPDATE pois SET audio_path = ? WHERE id = ?').run(relative, poiId);
}
const insertImage = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)');
for (const [index, image] of (files.images || []).entries()) {
const relative = moveUploadedFile(image, path.join(routeRoot, 'images', uniqueFilename(image.originalname)));
insertImage.run(poiId, relative, '', index);
}
return listPois(db, routeId).find(poi => poi.id === poiId);
return listPois(db, routeId).find(poi => poi.id === Number(result.lastInsertRowid));
}
export function updatePoi(db, poiId, fields, files = {}) {
export function updatePoi(db, 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 = ?`).get(poiId);
if (!existing || 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 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,
fields.lat == null ? existing.lat : Number.parseFloat(fields.lat), fields.lon == null ? existing.lon : Number.parseFloat(fields.lon),
fields.triggerRadiusM == null ? existing.trigger_radius_m : Number.parseFloat(fields.triggerRadiusM),
fields.sequence == null ? existing.sequence : Number.parseInt(fields.sequence, 10), poiId);
const root = ensureRouteDirectories(existing.route_id);
const audio = files.audio?.[0];
if (audio) {
const relative = moveUploadedFile(audio, path.join(root, 'audio', uniqueFilename(audio.originalname)));
db.prepare('UPDATE pois SET audio_path = ? WHERE id = ?').run(relative, poiId);
}
const maxSeq = db.prepare('SELECT COALESCE(MAX(sequence), -1) AS value FROM poi_images WHERE poi_id = ?').get(poiId).value;
const insert = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)');
for (const [index, image] of (files.images || []).entries()) {
const relative = moveUploadedFile(image, path.join(root, 'images', uniqueFilename(image.originalname)));
insert.run(poiId, relative, '', maxSeq + index + 1);
}
.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));
}
+28
View File
@@ -25,6 +25,34 @@ export function removeUpload(uploaded) {
if (uploaded?.path && fs.existsSync(uploaded.path)) fs.rmSync(uploaded.path, { force: true });
}
export function resolveStoredFile(relativePath) {
if (!relativePath || !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.');
}
return target;
}
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.');
}
if (!fs.existsSync(target)) return false;
fs.rmSync(target, { force: true });
return true;
}
export function softDeleteRouteDirectory(id) {
const source = routeDirectory(id);
if (!fs.existsSync(source)) throw new HttpError(409, 'Streckenverzeichnis fehlt; Löschmarkierung wurde nicht ausgeführt.');