included serverside mime detection
Sonarqube Scanner / Build and analyze (push) Successful in 1m6s

This commit is contained in:
Florian Zumpe
2026-06-17 11:42:43 +02:00
parent 3b3069ca8c
commit 0def9b23db
11 changed files with 574 additions and 126 deletions
+108 -48
View File
@@ -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);
}
};