initial commit

This commit is contained in:
Florian Zumpe
2026-06-16 10:31:41 +02:00
parent e607876d37
commit eb559b16fb
282 changed files with 3118 additions and 41422 deletions
+29
View File
@@ -0,0 +1,29 @@
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const resolveFromRoot = (value, fallback) => path.resolve(projectRoot, value || fallback);
export const config = Object.freeze({
projectRoot,
host: process.env.HOST || '127.0.0.1',
port: Number.parseInt(process.env.PORT || '47145', 10),
instanceName: process.env.INSTANCE_NAME || 'Atlas',
dataDir: resolveFromRoot(process.env.DATA_DIR, 'data'),
storageDir: resolveFromRoot(process.env.STORAGE_DIR, 'storage'),
maxUploadBytes: Number.parseInt(process.env.MAX_UPLOAD_MB || '50', 10) * 1024 * 1024,
defaultRouteRadiusKm: Number.parseFloat(process.env.DEFAULT_ROUTE_RADIUS_KM || '25'),
defaultPoiTriggerMeters: Number.parseFloat(process.env.DEFAULT_POI_TRIGGER_METERS || '80')
});
export function ensureRuntimeDirectories() {
for (const directory of [
config.dataDir,
path.join(config.storageDir, 'active', 'routes'),
path.join(config.storageDir, 'trash', 'routes'),
path.join(config.storageDir, 'uploads')
]) {
fs.mkdirSync(directory, { recursive: true });
}
}
+27
View File
@@ -0,0 +1,27 @@
import fs from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { config } from './config.js';
export function openDatabase(filename = path.join(config.dataDir, 'wegwichtel.sqlite')) {
const db = new DatabaseSync(filename, {
enableForeignKeyConstraints: true,
timeout: 5000
});
db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;');
const schema = fs.readFileSync(path.join(config.projectRoot, 'database', 'schema.sql'), 'utf8');
db.exec(schema);
return db;
}
export function transaction(db, callback) {
db.exec('BEGIN IMMEDIATE');
try {
const result = callback();
db.exec('COMMIT');
return result;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
+25
View File
@@ -0,0 +1,25 @@
export class HttpError extends Error {
constructor(status, message, details = undefined) {
super(message);
this.name = 'HttpError';
this.status = status;
this.details = details;
}
}
export function notFoundHandler(req, res, next) {
next(new HttpError(404, 'Ressource nicht gefunden.'));
}
export function errorHandler(error, req, res, next) {
if (res.headersSent) return next(error);
let status = Number.isInteger(error.status) ? error.status : 500;
if (error.code === 'LIMIT_FILE_SIZE') status = 413;
if (error.code === 'LIMIT_UNEXPECTED_FILE') status = 400;
if (status >= 500) console.error(error);
res.status(status).json({
error: error.name || 'Error',
message: status >= 500 ? 'Interner Serverfehler.' : error.message,
...(error.details ? { details: error.details } : {})
});
}
+30
View File
@@ -0,0 +1,30 @@
import path from 'node:path';
import crypto from 'node:crypto';
import multer from 'multer';
import { config } from '../config.js';
import { HttpError } from './errors.js';
const storage = multer.diskStorage({
destination: path.join(config.storageDir, 'uploads'),
filename(req, file, callback) {
const safe = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
callback(null, `${Date.now()}-${crypto.randomUUID()}-${safe}`);
}
});
function fileFilter(req, file, callback) {
const allowed = new Set([
'application/gpx+xml', 'application/xml', 'text/xml',
'image/jpeg', 'image/png', 'image/webp',
'audio/mpeg', 'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/ogg', 'audio/wav', 'audio/webm'
]);
const extension = path.extname(file.originalname).toLowerCase();
if (allowed.has(file.mimetype) || extension === '.gpx') return callback(null, true);
callback(new HttpError(415, `Dateityp nicht unterstützt: ${file.originalname}`));
}
export const upload = multer({
storage,
limits: { fileSize: config.maxUploadBytes, files: 25 },
fileFilter
});
+83
View File
@@ -0,0 +1,83 @@
import { Router } from 'express';
import { upload } from '../middleware/upload.js';
import { config } from '../config.js';
import {
listRoutes, getRoute, createRoute, updateRoute, appendRoute,
listPois, getPoi, createPoi, updatePoi, softDeleteRoute, restoreRoute
} from '../services/routes-service.js';
const numberOrUndefined = value => {
if (value == null || value === '') return undefined;
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
export function createApiRouter(db) {
const api = Router();
api.get('/health', (req, res) => {
const sqliteVersion = db.prepare('SELECT sqlite_version() AS version').get().version;
res.json({
ok: true,
service: 'wegwichtel',
instance: config.instanceName,
socket: `${config.host}:${config.port}`,
sqliteVersion,
timestamp: new Date().toISOString()
});
});
api.get('/routes', (req, res) => {
res.json({ routes: listRoutes(db, {
lat: numberOrUndefined(req.query.lat),
lon: numberOrUndefined(req.query.lon),
radiusKm: numberOrUndefined(req.query.radiusKm),
includeDeleted: req.query.includeDeleted === 'true'
}) });
});
api.get('/routes/:id', (req, res) => {
res.json(getRoute(db, Number(req.params.id), req.query.includeDeleted === 'true'));
});
api.get('/routes/:id/pois', (req, res) => {
getRoute(db, Number(req.params.id));
res.json({ pois: listPois(db, Number(req.params.id)) });
});
api.get('/pois/:id', (req, res) => {
res.json(getPoi(db, Number(req.params.id)));
});
api.post('/routes', upload.single('gpx'), (req, res) => {
const route = createRoute(db, req.body, req.file);
res.status(201).location(`/api/routes/${route.id}`).json(route);
});
api.put('/routes/:id', upload.single('gpx'), (req, res) => {
res.json(updateRoute(db, Number(req.params.id), req.body, req.file));
});
api.post('/routes/:id/append', upload.single('gpx'), (req, res) => {
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);
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.delete('/routes/:id', (req, res) => {
res.json({ route: softDeleteRoute(db, Number(req.params.id)), softDeleted: true });
});
api.post('/routes/:id/restore', (req, res) => {
res.json({ route: restoreRoute(db, Number(req.params.id)), restored: true });
});
return api;
}
+36
View File
@@ -0,0 +1,36 @@
const EARTH_RADIUS_M = 6_371_000;
const radians = degrees => degrees * Math.PI / 180;
export function distanceMeters(a, b) {
const dLat = radians(b.lat - a.lat);
const dLon = radians(b.lon - a.lon);
const lat1 = radians(a.lat);
const lat2 = radians(b.lat);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(h));
}
export function routeMetrics(points) {
if (!points.length) throw new Error('Die GPX-Datei enthält keine Trackpunkte.');
let distance = 0;
let gain = 0;
for (let i = 1; i < points.length; i += 1) {
distance += distanceMeters(points[i - 1], points[i]);
const previous = points[i - 1].elevation;
const current = points[i].elevation;
if (Number.isFinite(previous) && Number.isFinite(current) && current > previous) gain += current - previous;
}
const lats = points.map(point => point.lat);
const lons = points.map(point => point.lon);
return {
startLat: points[0].lat,
startLon: points[0].lon,
centerLat: (Math.min(...lats) + Math.max(...lats)) / 2,
centerLon: (Math.min(...lons) + Math.max(...lons)) / 2,
minLat: Math.min(...lats), minLon: Math.min(...lons),
maxLat: Math.max(...lats), maxLon: Math.max(...lons),
distanceM: distance,
elevationGainM: gain,
pointCount: points.length
};
}
+25
View File
@@ -0,0 +1,25 @@
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];
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,
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) };
}
+228
View File
@@ -0,0 +1,228 @@
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,
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 {
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,
gpxUrl: row.gpx_path ? safeMediaUrl(row.gpx_path) : null,
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();
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'));
}
export function getRoute(db, id, includeDeleted = false) {
const route = rowToRoute(db.prepare('SELECT * FROM routes WHERE id = ?').get(id), 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 };
}
export function createRoute(db, fields, gpxFile) {
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
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.');
let routeId;
let finalGpx;
try {
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 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);
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);
return id;
});
ensureRouteDirectories(routeId);
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);
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 });
}
throw error;
}
}
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'));
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);
}
});
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);
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);
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);
});
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');
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) }))
}));
}
export function getPoi(db, poiId) {
const row = db.prepare(`SELECT p.route_id AS routeId FROM pois p JOIN routes r ON r.id = p.route_id WHERE p.id = ? AND r.status = 'active'`).get(poiId);
if (!row) throw new HttpError(404, 'POI nicht gefunden.');
return listPois(db, row.routeId).find(poi => poi.id === Number(poiId));
}
export function createPoi(db, routeId, fields, files = {}) {
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 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);
}
export function updatePoi(db, poiId, fields, files = {}) {
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.');
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);
}
return listPois(db, existing.route_id).find(poi => poi.id === Number(poiId));
}
export function softDeleteRoute(db, id) {
const route = db.prepare("SELECT * 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=?`)
.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);
});
} 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);
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 = ?)`)
.run(moved.sourcePrefix, moved.targetPrefix, id);
});
} catch (error) {
fs.renameSync(moved.destination, moved.source);
throw error;
}
return getRoute(db, id);
}
+50
View File
@@ -0,0 +1,50 @@
import fs from 'node:fs';
import path from 'node:path';
import { config } from '../config.js';
import { HttpError } from '../middleware/errors.js';
const activeRoot = path.join(config.storageDir, 'active', 'routes');
const trashRoot = path.join(config.storageDir, 'trash', 'routes');
export const routeDirectory = id => path.join(activeRoot, String(id));
export const relativeStoragePath = absolute => path.relative(config.storageDir, absolute).split(path.sep).join('/');
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 });
return root;
}
export function moveUploadedFile(uploaded, destination) {
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.renameSync(uploaded.path, destination);
return relativeStoragePath(destination);
}
export function removeUpload(uploaded) {
if (uploaded?.path && fs.existsSync(uploaded.path)) fs.rmSync(uploaded.path, { force: 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.');
const destination = path.join(trashRoot, `${id}-${new Date().toISOString().replace(/[:.]/g, '-')}`);
fs.renameSync(source, destination);
return { sourcePrefix: relativeStoragePath(source), targetPrefix: relativeStoragePath(destination), destination };
}
export function restoreRouteDirectory(id, trashRelativePath) {
const source = path.join(config.storageDir, 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 };
}
export function safeMediaUrl(relativePath) {
if (!relativePath || !relativePath.startsWith('active/')) return null;
const publicPath = relativePath.slice('active/'.length);
return `/media/${publicPath.split('/').map(encodeURIComponent).join('/')}`;
}