Uploads: abgewiesene Dateien melden statt still zu verwerfen, Anlagen bis 30 MB

Eine zu große oder nicht erlaubte Datei wurde von multer abgewiesen, der Fehler
nur auf die Server-Konsole geloggt und der Browser kommentarlos auf /vorlagen
zurückgeleitet. Für den Benutzer sah das aus, als sei der Upload durchgelaufen,
die Anlage tauchte nur nirgends auf ("Upload error: File too large" im Log, die
Teildatei räumt multer selbst wieder weg). Betroffen waren alle drei Uploads der
Vorlagen-Seite: Anlagen, Unterschrift und Bewerbungsfoto.

Die Routen leiten jetzt mit ?fehler=… um – dasselbe Muster wie auf der Jobsuche –
und die Seite zeigt den Grund als Banner: zu groß (mit Limit), Dateityp nicht
erlaubt, oder keine Datei gewählt. Das Limit steht zusätzlich am Datei-Feld,
bevor jemand eine zu große Datei auswählt.

Limit für Anlagen von 15 auf 30 MB angehoben; gescannte Zeugnisse liegen
regelmäßig darüber. Die Größen sind Konstanten (ANHANG_MAX_MB / BILD_MAX_MB),
aus denen sowohl multer als auch die Meldung und der Hinweis im Formular lesen –
eine Meldung, die ein anderes Limit nennt als das durchgesetzte, wäre schlimmer
als gar keine. Der Bild-Filter für Unterschrift/Foto lehnt jetzt ebenfalls mit
einem Fehler ab statt die Datei still zu verwerfen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 17:47:24 +02:00
co-authored by Claude Opus 4.8
parent 1f2e299c55
commit ffaaafed7e
2 changed files with 93 additions and 24 deletions
+82 -23
View File
@@ -417,9 +417,61 @@ if (!fs.existsSync(basisAnhaengeDir)) {
const ATTACHMENT_FILTER = (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const ok = /^\.(pdf|png|jpe?g|gif|webp|docx?|rtf|odt|ods|txt|csv|xlsx?)$/.test(ext);
cb(ok ? null : new Error('Dateityp nicht erlaubt'), ok);
cb(ok ? null : dateitypFehler(), ok);
};
// Upload size limits. The numbers live here (not inline in the multer configs)
// because the message shown to the user quotes them — a limit that says one
// thing and enforces another is worse than no message at all.
const ANHANG_MAX_MB = 30;
const BILD_MAX_MB = 5;
function dateitypFehler() {
const err = new Error('Dateityp nicht erlaubt');
err.code = 'DATEITYP';
return err;
}
// Multer reports a rejected upload through its callback's `err`: a blown size
// limit, a blocked file type, a failed write. Those used to be logged to the
// server console and otherwise swallowed — the browser was redirected back to a
// page that simply did not list the file, which to the user looks exactly like
// "it uploaded but it isn't shown". Turn the error into a sentence they can act
// on. Returns null when the upload was fine.
function uploadFehlerText(err, maxMb) {
if (!err) return null;
if (err.code === 'LIMIT_FILE_SIZE') {
return `Die Datei ist zu groß. Erlaubt sind maximal ${maxMb} MB — bitte komprimiere sie (z. B. den Scan als PDF verkleinern) und lade sie erneut hoch.`;
}
if (err.code === 'DATEITYP') {
return 'Dieser Dateityp ist nicht erlaubt. Möglich sind PDF, Bilder (PNG, JPG, GIF, WebP), Word, ODT, RTF, Text, CSV und Excel.';
}
if (err.code === 'BILDTYP') {
return 'Für dieses Bild sind nur PNG- oder JPG-Dateien möglich.';
}
return `Die Datei konnte nicht hochgeladen werden: ${err.message || 'unbekannter Fehler'}`;
}
// Bilder (Unterschrift, Bewerbungsfoto): nur PNG/JPG, und der Dateiname muss zur
// MIME-Art passen. Wie oben mit einem Fehler ablehnen statt still zu verwerfen.
const BILD_FILTER = (req, file, cb) => {
const ok = /^image\/(png|jpe?g)$/.test(file.mimetype)
&& /^\.(png|jpe?g)$/.test(path.extname(file.originalname).toLowerCase());
cb(ok ? null : bildtypFehler(), ok);
};
function bildtypFehler() {
const err = new Error('Nur PNG oder JPG');
err.code = 'BILDTYP';
return err;
}
// Ein Ziel plus Fehlermeldung als Query-Parameter — dasselbe Muster wie auf der
// Jobsuche-Seite (?fehler=…), das die Views bereits als Banner rendern.
function mitFehler(pfad, text) {
return `${pfad}${pfad.includes('?') ? '&' : '?'}fehler=${encodeURIComponent(text)}`;
}
const uploadBasisAnhang = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, userStorageDirForReq(basisAnhaengeDir, req)),
@@ -428,7 +480,7 @@ const uploadBasisAnhang = multer({
cb(null, `${Date.now()}_${safe}`);
},
}),
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
limits: { fileSize: ANHANG_MAX_MB * 1024 * 1024 },
fileFilter: ATTACHMENT_FILTER,
}).single('datei');
@@ -447,7 +499,7 @@ const uploadInterneAnhang = multer({
cb(null, `${Date.now()}_${safe}`);
},
}),
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
limits: { fileSize: ANHANG_MAX_MB * 1024 * 1024 },
fileFilter: ATTACHMENT_FILTER,
}).single('datei');
@@ -466,10 +518,8 @@ const uploadSignatur = multer({
cb(null, `signatur_${Date.now()}${ext}`);
},
}),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (req, file, cb) => cb(null,
/^image\/(png|jpe?g)$/.test(file.mimetype)
&& /^\.(png|jpe?g)$/.test(path.extname(file.originalname).toLowerCase())),
limits: { fileSize: BILD_MAX_MB * 1024 * 1024 },
fileFilter: BILD_FILTER,
}).single('signatur');
// The single stored signature file, if any (in the current user's subdir).
@@ -511,10 +561,8 @@ const uploadFoto = multer({
cb(null, `foto_${Date.now()}${ext}`);
},
}),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (req, file, cb) => cb(null,
/^image\/(png|jpe?g)$/.test(file.mimetype)
&& /^\.(png|jpe?g)$/.test(path.extname(file.originalname).toLowerCase())),
limits: { fileSize: BILD_MAX_MB * 1024 * 1024 },
fileFilter: BILD_FILTER,
}).single('foto');
// The single stored photo file, if any (in the current user's subdir).
@@ -3008,6 +3056,10 @@ initializeDatabase().then(async () => {
basisTypOptions: BASIS_TYP_OPTIONS,
hasApiKey: Boolean(config.get('OLLAMA_API_KEY')),
hideSettings: true,
// Set by the upload routes when multer rejected a file (too large, wrong
// type) — without this the page just came back without the attachment.
fehler: req.query.fehler ? String(req.query.fehler) : null,
anhangMaxMb: ANHANG_MAX_MB,
});
} catch (error) {
console.error('Error loading vorlagen:', error);
@@ -3206,18 +3258,19 @@ initializeDatabase().then(async () => {
try {
if (err) {
console.error('Upload error:', err.message);
return res.redirect('/vorlagen');
return res.redirect(mitFehler('/vorlagen', uploadFehlerText(err, ANHANG_MAX_MB)));
}
if (req.file) {
const original = req.file.originalname || req.file.filename;
const name = (req.body.name && req.body.name.trim())
? sanitizeInput(req.body.name.trim())
: sanitizeInput(original.replace(/\.[^.]+$/, ''));
await dbRun(
'INSERT INTO basis_anhaenge (user_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)',
[uidFromReq(req), name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
);
if (!req.file) {
return res.redirect(mitFehler('/vorlagen', 'Es wurde keine Datei ausgewählt.'));
}
const original = req.file.originalname || req.file.filename;
const name = (req.body.name && req.body.name.trim())
? sanitizeInput(req.body.name.trim())
: sanitizeInput(original.replace(/\.[^.]+$/, ''));
await dbRun(
'INSERT INTO basis_anhaenge (user_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)',
[uidFromReq(req), name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
);
res.redirect('/vorlagen');
} catch (error) {
console.error('Error saving attachment:', error);
@@ -3269,7 +3322,10 @@ initializeDatabase().then(async () => {
app.post('/unterschrift', (req, res) => {
uploadSignatur(req, res, (err) => {
try {
if (err) console.error('Signature upload error:', err.message);
if (err) {
console.error('Signature upload error:', err.message);
return res.redirect(mitFehler('/vorlagen', uploadFehlerText(err, BILD_MAX_MB)));
}
if (req.file) {
// keep only the newly uploaded file (in the user's subdir). Resolve the
// dir from req.user (not currentUserId()) — this runs in the multer
@@ -3309,7 +3365,10 @@ initializeDatabase().then(async () => {
app.post('/bewerbungsfoto', (req, res) => {
uploadFoto(req, res, (err) => {
try {
if (err) console.error('Photo upload error:', err.message);
if (err) {
console.error('Photo upload error:', err.message);
return res.redirect(mitFehler('/vorlagen', uploadFehlerText(err, BILD_MAX_MB)));
}
if (req.file) {
// keep only the newly uploaded file (in the user's subdir). Resolve the
// dir from req.user — see /unterschrift handler for why (multer callback).
+11 -1
View File
@@ -15,6 +15,14 @@
Zurück zur Übersicht
</a>
<!-- Abgewiesener Upload (zu groß, falscher Dateityp): ohne diesen Banner
käme die Seite kommentarlos ohne die Datei zurück. -->
<% if (typeof fehler !== 'undefined' && fehler) { %>
<div class="mb-6 rounded-lg border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20 p-4" role="alert">
<p class="text-sm text-red-800 dark:text-red-200"><%= fehler %></p>
</div>
<% } %>
<h1 class="text-2xl font-bold text-gray-800 dark:text-white mb-2">Basis-Unterlagen (Vorlagen)</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Diese Unterlagen dienen der KI als Faktengrundlage. Beim Import einer Stelle über die Browser-Erweiterung
@@ -240,7 +248,9 @@
<form action="/anlagen" method="POST" enctype="multipart/form-data" class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4 space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Datei (PDF)</label>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Datei (PDF) &middot; max. <%= typeof anhangMaxMb !== 'undefined' ? anhangMaxMb : 15 %> MB
</label>
<input type="file" name="datei" accept="application/pdf,image/*" required
class="w-full text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-2 file:px-3 file:rounded-md file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
</div>