This commit is contained in:
+108
-48
@@ -1,35 +1,40 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { TextDecoder } from 'node:util';
|
||||
import multer from 'multer';
|
||||
import { fileTypeFromFile } from 'file-type';
|
||||
import { detectAv } from '@file-type/av';
|
||||
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, '_');
|
||||
const safe = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload';
|
||||
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
|
||||
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 });
|
||||
}
|
||||
|
||||
function decodeBase64(value, fieldName) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
@@ -59,56 +64,111 @@ function jsonFileDescriptor(value, fieldName) {
|
||||
throw new HttpError(400, `${fieldName} muss ein JSON-Objekt sein.`);
|
||||
}
|
||||
|
||||
const originalname = String(value.filename ?? '').trim();
|
||||
if (!originalname) throw new HttpError(400, `${fieldName}.filename ist erforderlich.`);
|
||||
|
||||
let mimetype = String(value.contentType ?? value.mimeType ?? '').trim().toLowerCase();
|
||||
const originalname = String(value.filename ?? `${fieldName}.upload`).trim() || `${fieldName}.upload`;
|
||||
let base64 = value.base64;
|
||||
|
||||
if (value.dataUrl != null) {
|
||||
if (typeof value.dataUrl !== 'string') {
|
||||
throw new HttpError(400, `${fieldName}.dataUrl muss eine Zeichenkette sein.`);
|
||||
}
|
||||
const match = /^data:([^;,]+);base64,(.*)$/s.exec(value.dataUrl.trim());
|
||||
const match = /^data:[^,]*;base64,(.*)$/s.exec(value.dataUrl.trim());
|
||||
if (!match) {
|
||||
throw new HttpError(400, `${fieldName}.dataUrl muss eine Base64-Data-URL sein.`);
|
||||
}
|
||||
if (!mimetype) mimetype = match[1].trim().toLowerCase();
|
||||
base64 = match[2];
|
||||
base64 = match[1];
|
||||
}
|
||||
|
||||
if (!mimetype) throw new HttpError(400, `${fieldName}.contentType ist erforderlich.`);
|
||||
return { originalname, mimetype, buffer: decodeBase64(base64, fieldName) };
|
||||
return { originalname, buffer: decodeBase64(base64, fieldName) };
|
||||
}
|
||||
|
||||
export function jsonUpload(fieldName) {
|
||||
return function parseJsonUpload(req, res, next) {
|
||||
function materializeJsonUpload(req, fieldName) {
|
||||
if (req.file || req.body?.[fieldName] == null) return;
|
||||
|
||||
const descriptor = jsonFileDescriptor(req.body[fieldName], fieldName);
|
||||
if (!descriptor) return;
|
||||
|
||||
const destination = path.join(config.storageDir, 'uploads');
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
const safe = descriptor.originalname.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload';
|
||||
const filename = `${Date.now()}-${crypto.randomUUID()}-${safe}`;
|
||||
const filePath = path.join(destination, filename);
|
||||
fs.writeFileSync(filePath, descriptor.buffer, { flag: 'wx' });
|
||||
|
||||
req.file = {
|
||||
fieldname: fieldName,
|
||||
originalname: descriptor.originalname,
|
||||
encoding: '7bit',
|
||||
mimetype: 'application/octet-stream',
|
||||
destination,
|
||||
filename,
|
||||
path: filePath,
|
||||
size: descriptor.buffer.length
|
||||
};
|
||||
delete req.body[fieldName];
|
||||
}
|
||||
|
||||
function detectGpx(file) {
|
||||
let xml;
|
||||
try {
|
||||
const data = fs.readFileSync(file.path);
|
||||
xml = new TextDecoder('utf-8', { fatal: true }).decode(data);
|
||||
} catch {
|
||||
throw new HttpError(415, 'Der Upload ist keine gültig kodierte GPX-Datei.');
|
||||
}
|
||||
|
||||
const start = xml.slice(0, 131072).replace(/^\uFEFF/, '');
|
||||
if (!/<(?:[A-Za-z_][\w.-]*:)?gpx(?:\s|>)/i.test(start)) {
|
||||
throw new HttpError(415, 'Der Upload enthält kein GPX-Dokument.');
|
||||
}
|
||||
return { ext: 'gpx', mime: 'application/gpx+xml' };
|
||||
}
|
||||
|
||||
async function detectBinary(file) {
|
||||
try {
|
||||
return await fileTypeFromFile(file.path, {
|
||||
customDetectors: [detectAv],
|
||||
mpegOffsetTolerance: 10
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
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 || !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}`);
|
||||
}
|
||||
}
|
||||
|
||||
file.mimetype = detected.mime;
|
||||
file.detectedMime = detected.mime;
|
||||
file.detectedExtension = detected.ext;
|
||||
return file;
|
||||
}
|
||||
|
||||
export function inspectUpload(fieldName, kind, { allowJson = false } = {}) {
|
||||
return async function inspectUploadedFile(req, res, next) {
|
||||
try {
|
||||
if (req.file || req.body?.[fieldName] == null) return next();
|
||||
|
||||
const descriptor = jsonFileDescriptor(req.body[fieldName], fieldName);
|
||||
if (!descriptor) return next();
|
||||
|
||||
const destination = path.join(config.storageDir, 'uploads');
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
const safe = descriptor.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const filename = `${Date.now()}-${crypto.randomUUID()}-${safe}`;
|
||||
const filePath = path.join(destination, filename);
|
||||
fs.writeFileSync(filePath, descriptor.buffer, { flag: 'wx' });
|
||||
|
||||
req.file = {
|
||||
fieldname: fieldName,
|
||||
originalname: descriptor.originalname,
|
||||
encoding: '7bit',
|
||||
mimetype: descriptor.mimetype,
|
||||
destination,
|
||||
filename,
|
||||
path: filePath,
|
||||
size: descriptor.buffer.length
|
||||
};
|
||||
delete req.body[fieldName];
|
||||
if (allowJson) materializeJsonUpload(req, fieldName);
|
||||
if (!req.file) return next();
|
||||
await detectAndValidate(req.file, kind);
|
||||
next();
|
||||
} catch (error) {
|
||||
removeTemporaryFile(req.file);
|
||||
req.file = undefined;
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
+13
-9
@@ -1,9 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { jsonUpload, upload } from '../middleware/upload.js';
|
||||
import { inspectUpload, upload } from '../middleware/upload.js';
|
||||
import { config } from '../config.js';
|
||||
import {
|
||||
listRoutes, getRoute, getRouteGpxFile, createRoute, updateRoute, appendRoute,
|
||||
listPois, getPoi, createPoi, updatePoi, softDeleteRoute, restoreRoute
|
||||
listPois, getPoi, createPoi, updatePoi, deletePoi, softDeleteRoute, restoreRoute
|
||||
} from '../services/routes-service.js';
|
||||
import {
|
||||
listPictures, getPicture, getPictureFile, createPicture, updatePicture, deletePicture,
|
||||
@@ -68,16 +68,16 @@ export function createApiRouter(db) {
|
||||
res.json(getPoi(db, Number(req.params.routeId), Number(req.params.poiId)));
|
||||
});
|
||||
|
||||
api.post('/routes', upload.single('gpx'), (req, res) => {
|
||||
api.post('/routes', upload.single('gpx'), inspectUpload('gpx', '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) => {
|
||||
api.put('/routes/:id', upload.single('gpx'), inspectUpload('gpx', 'gpx'), (req, res) => {
|
||||
res.json(updateRoute(db, Number(req.params.id), req.body, req.file));
|
||||
});
|
||||
|
||||
api.post('/routes/:id/append', upload.single('gpx'), (req, res) => {
|
||||
api.post('/routes/:id/append', upload.single('gpx'), inspectUpload('gpx', 'gpx'), (req, res) => {
|
||||
res.json(appendRoute(db, Number(req.params.id), req.file));
|
||||
});
|
||||
|
||||
@@ -97,6 +97,10 @@ export function createApiRouter(db) {
|
||||
));
|
||||
});
|
||||
|
||||
api.delete('/routes/:routeId/pois/:poiId', (req, res) => {
|
||||
res.json(deletePoi(db, Number(req.params.routeId), Number(req.params.poiId)));
|
||||
});
|
||||
|
||||
api.get('/routes/:routeId/pois/:poiId/pictures', (req, res) => {
|
||||
res.json({ pictures: listPictures(db, Number(req.params.routeId), Number(req.params.poiId)) });
|
||||
});
|
||||
@@ -112,7 +116,7 @@ export function createApiRouter(db) {
|
||||
sendFileResource(res, getPictureFile(db, routeId, poiId, pictureId));
|
||||
});
|
||||
|
||||
api.post('/routes/:routeId/pois/:poiId/pictures', upload.single('picture'), jsonUpload('picture'), (req, res) => {
|
||||
api.post('/routes/:routeId/pois/:poiId/pictures', upload.single('picture'), inspectUpload('picture', 'picture', { allowJson: true }), (req, res) => {
|
||||
const routeId = Number(req.params.routeId);
|
||||
const poiId = Number(req.params.poiId);
|
||||
const picture = createPicture(db, routeId, poiId, req.body, req.file);
|
||||
@@ -121,7 +125,7 @@ export function createApiRouter(db) {
|
||||
.json(picture);
|
||||
});
|
||||
|
||||
api.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', upload.single('picture'), jsonUpload('picture'), (req, res) => {
|
||||
api.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', upload.single('picture'), inspectUpload('picture', 'picture', { allowJson: true }), (req, res) => {
|
||||
res.json(updatePicture(
|
||||
db,
|
||||
Number(req.params.routeId),
|
||||
@@ -151,7 +155,7 @@ export function createApiRouter(db) {
|
||||
sendFileResource(res, getAudioFile(db, routeId, poiId));
|
||||
});
|
||||
|
||||
api.post('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), jsonUpload('audio'), (req, res) => {
|
||||
api.post('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), inspectUpload('audio', 'audio', { allowJson: true }), (req, res) => {
|
||||
const routeId = Number(req.params.routeId);
|
||||
const poiId = Number(req.params.poiId);
|
||||
const audio = createAudio(db, routeId, poiId, req.file);
|
||||
@@ -160,7 +164,7 @@ export function createApiRouter(db) {
|
||||
.json(audio);
|
||||
});
|
||||
|
||||
api.put('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), jsonUpload('audio'), (req, res) => {
|
||||
api.put('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), inspectUpload('audio', 'audio', { allowJson: true }), (req, res) => {
|
||||
res.json(updateAudio(db, Number(req.params.routeId), Number(req.params.poiId), req.file));
|
||||
});
|
||||
|
||||
|
||||
@@ -20,10 +20,7 @@ const AUDIO_MIME_TYPES = new Set([
|
||||
'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 uniqueFilename = file => `${crypto.randomUUID()}.${file.detectedExtension}`;
|
||||
const pictureUrl = (routeId, poiId, pictureId) => `/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`;
|
||||
const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio`;
|
||||
|
||||
@@ -50,16 +47,16 @@ function poiOnRoute(db, routeId, poiId) {
|
||||
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 baseMime = String(file.detectedMime || file.mimetype || '').split(';', 1)[0].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);
|
||||
? IMAGE_MIME_TYPES.has(baseMime)
|
||||
: AUDIO_MIME_TYPES.has(baseMime);
|
||||
|
||||
if (!valid) {
|
||||
if (!valid || !file.detectedExtension) {
|
||||
removeUpload(file);
|
||||
throw new HttpError(415, kind === 'picture'
|
||||
? 'Der Upload ist keine unterstützte Bilddatei.'
|
||||
: 'Der Upload ist keine unterstützte Audiodatei.');
|
||||
? 'Der Dateiinhalt ist keine unterstützte Bilddatei.'
|
||||
: 'Der Dateiinhalt ist keine unterstützte Audiodatei.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +122,7 @@ export function createPicture(db, routeId, poiId, fields, file) {
|
||||
const caption = String(fields.caption ?? '');
|
||||
const root = ensureRouteDirectories(routeId);
|
||||
|
||||
relativePath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file.originalname)));
|
||||
relativePath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file)));
|
||||
const result = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)')
|
||||
.run(poiId, relativePath, caption, sequence);
|
||||
return getPicture(db, routeId, poiId, Number(result.lastInsertRowid));
|
||||
@@ -150,7 +147,7 @@ export function updatePicture(db, routeId, poiId, pictureId, fields, file) {
|
||||
if (file) {
|
||||
validateUpload(file, 'picture');
|
||||
const root = ensureRouteDirectories(routeId);
|
||||
newPath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file.originalname)));
|
||||
newPath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file)));
|
||||
}
|
||||
|
||||
db.prepare('UPDATE poi_images SET path = ?, caption = ?, sequence = ? WHERE id = ? AND poi_id = ?')
|
||||
@@ -216,7 +213,7 @@ function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting }
|
||||
}
|
||||
|
||||
const root = ensureRouteDirectories(routeId);
|
||||
relativePath = moveUploadedFile(file, path.join(root, 'audio', uniqueFilename(file.originalname)));
|
||||
relativePath = moveUploadedFile(file, path.join(root, 'audio', uniqueFilename(file)));
|
||||
db.prepare('UPDATE pois SET audio_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND route_id = ?')
|
||||
.run(relativePath, poiId, routeId);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { parseGpx } from './gpx.js';
|
||||
import { distanceMeters, routeMetrics } from './geo.js';
|
||||
import {
|
||||
ensureRouteDirectories, moveUploadedFile, routeDirectory,
|
||||
softDeleteRouteDirectory, restoreRouteDirectory, resolveStoredFile, removeUpload
|
||||
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()}`;
|
||||
@@ -188,6 +188,22 @@ export function updatePoi(db, routeId, poiId, fields) {
|
||||
return listPois(db, existing.route_id).find(poi => poi.id === Number(poiId));
|
||||
}
|
||||
|
||||
export function deletePoi(db, routeId, poiId) {
|
||||
const existing = db.prepare(`SELECT p.id, 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 (!existing) throw new HttpError(404, 'POI nicht gefunden.');
|
||||
|
||||
const images = db.prepare('SELECT path FROM poi_images WHERE poi_id = ?').all(poiId);
|
||||
transaction(db, () => {
|
||||
db.prepare('DELETE FROM poi_images WHERE poi_id = ?').run(poiId);
|
||||
db.prepare('DELETE FROM pois WHERE id = ? AND route_id = ?').run(poiId, routeId);
|
||||
});
|
||||
|
||||
for (const image of images) 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);
|
||||
if (!route) throw new HttpError(404, 'Aktive Strecke nicht gefunden.');
|
||||
|
||||
Reference in New Issue
Block a user