(hopefully) fixed upload for large files
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
@@ -16,6 +14,7 @@ import {
|
||||
import { get, logEvent, run, runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||
import { safeBaseName } from '@/src/lib/files.js';
|
||||
import { sendUploadRequestCompletedMail } from '@/src/lib/mailer.js';
|
||||
import { streamMultipartToPath } from '@/src/lib/multipart.js';
|
||||
import { getRequestMeta } from '@/src/lib/security.js';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
@@ -114,39 +113,6 @@ function createRandomId() {
|
||||
return toBase32(crypto.randomBytes(5));
|
||||
}
|
||||
|
||||
function uploadedFileFromForm(formData, fieldName = 'file') {
|
||||
const candidate = formData.get(fieldName);
|
||||
if (!candidate || typeof candidate === 'string') {
|
||||
return null;
|
||||
}
|
||||
if (typeof candidate.arrayBuffer !== 'function') {
|
||||
return null;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function writeUploadedFile(uploadedFile, targetPath) {
|
||||
try {
|
||||
if (typeof uploadedFile.stream === 'function') {
|
||||
await pipeline(Readable.fromWeb(uploadedFile.stream()), fs.createWriteStream(targetPath));
|
||||
} else {
|
||||
const buffer = Buffer.from(await uploadedFile.arrayBuffer());
|
||||
await fs.promises.writeFile(targetPath, buffer);
|
||||
}
|
||||
} catch (error) {
|
||||
await fs.promises.rm(targetPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const declaredSize = Number(uploadedFile.size || 0);
|
||||
if (Number.isFinite(declaredSize) && declaredSize >= 0) {
|
||||
return declaredSize;
|
||||
}
|
||||
|
||||
const stat = await fs.promises.stat(targetPath);
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
export async function POST(request, { params }) {
|
||||
await runCleanupIfNeeded();
|
||||
|
||||
@@ -157,13 +123,6 @@ export async function POST(request, { params }) {
|
||||
return new NextResponse('Ungültige Anfrage-ID', { status: 400 });
|
||||
}
|
||||
|
||||
let formData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
} catch {
|
||||
return redirectToRequest(requestId, { error: 'Ungültige Formulardaten.' });
|
||||
}
|
||||
|
||||
const uploadRequest = await get(
|
||||
`SELECT id, owner, note, expires_at, completed_at
|
||||
FROM upload_requests
|
||||
@@ -184,20 +143,6 @@ export async function POST(request, { params }) {
|
||||
return redirectToRequest(requestId, { error: 'Diese Anfrage ist bereits abgelaufen.' });
|
||||
}
|
||||
|
||||
const uploadedFile = uploadedFileFromForm(formData, 'file');
|
||||
if (!uploadedFile || Number(uploadedFile.size || 0) <= 0) {
|
||||
return redirectToRequest(requestId, { error: 'Keine Datei hochgeladen.' });
|
||||
}
|
||||
|
||||
if (maxUploadBytes > 0 && Number(uploadedFile.size || 0) > maxUploadBytes) {
|
||||
return redirectToRequest(requestId, {
|
||||
error: `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`,
|
||||
});
|
||||
}
|
||||
|
||||
const fulfilledBy = String(formData.get('fulfilledBy') || '').trim().slice(0, 200);
|
||||
const originalName = safeBaseName(uploadedFile.name, 'upload');
|
||||
|
||||
let storedName = '';
|
||||
let storedPath = '';
|
||||
for (let attempts = 0; attempts < 12; attempts += 1) {
|
||||
@@ -216,12 +161,33 @@ export async function POST(request, { params }) {
|
||||
return redirectToRequest(requestId, { error: 'Upload-ID konnte nicht erzeugt werden.' });
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await streamMultipartToPath(request, {
|
||||
fileFieldName: 'file',
|
||||
targetPath: storedPath,
|
||||
maxFileBytes: maxUploadBytes,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error && error.message === 'file-too-large') {
|
||||
return redirectToRequest(requestId, {
|
||||
error: `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`,
|
||||
});
|
||||
}
|
||||
if (error && error.message === 'missing-file') {
|
||||
return redirectToRequest(requestId, { error: 'Keine Datei hochgeladen.' });
|
||||
}
|
||||
return redirectToRequest(requestId, { error: 'Upload fehlgeschlagen.' });
|
||||
}
|
||||
|
||||
const fulfilledBy = String(parsed.fields.fulfilledBy || '').trim().slice(0, 200);
|
||||
const originalName = safeBaseName(parsed.file.filename, 'upload');
|
||||
|
||||
const uploadExpiry = Math.min(now + uploadTtlSeconds * 1000, now + maxRetentionSeconds * 1000);
|
||||
|
||||
let uploadId = null;
|
||||
let sizeBytes = 0;
|
||||
const sizeBytes = parsed.file.sizeBytes;
|
||||
try {
|
||||
sizeBytes = await writeUploadedFile(uploadedFile, storedPath);
|
||||
const insertResult = await run(
|
||||
`INSERT INTO uploads (owner, original_name, stored_name, stored_path, size_bytes, uploaded_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
@@ -229,6 +195,7 @@ export async function POST(request, { params }) {
|
||||
);
|
||||
uploadId = insertResult.lastID;
|
||||
} catch {
|
||||
await fs.promises.rm(storedPath, { force: true }).catch(() => undefined);
|
||||
return redirectToRequest(requestId, { error: 'Upload fehlgeschlagen.' });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import {
|
||||
@@ -14,8 +11,9 @@ import {
|
||||
uploadTtlSeconds,
|
||||
} from '@/src/lib/config.js';
|
||||
import { logEvent, run, runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||
import { streamMultipartToPath } from '@/src/lib/multipart.js';
|
||||
import { safeBaseName } from '@/src/lib/files.js';
|
||||
import { getAuthenticatedUser, getRequestMeta, verifyCsrf } from '@/src/lib/security.js';
|
||||
import { getAuthenticatedUser, getRequestMeta, verifyCsrfToken } from '@/src/lib/security.js';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -55,39 +53,6 @@ function createRandomId() {
|
||||
return toBase32(crypto.randomBytes(5));
|
||||
}
|
||||
|
||||
async function writeUploadedFile(uploadedFile, targetPath) {
|
||||
try {
|
||||
if (typeof uploadedFile.stream === 'function') {
|
||||
await pipeline(Readable.fromWeb(uploadedFile.stream()), fs.createWriteStream(targetPath));
|
||||
} else {
|
||||
const buffer = Buffer.from(await uploadedFile.arrayBuffer());
|
||||
await fs.promises.writeFile(targetPath, buffer);
|
||||
}
|
||||
} catch (error) {
|
||||
await fs.promises.rm(targetPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const declaredSize = Number(uploadedFile.size || 0);
|
||||
if (Number.isFinite(declaredSize) && declaredSize >= 0) {
|
||||
return declaredSize;
|
||||
}
|
||||
|
||||
const stat = await fs.promises.stat(targetPath);
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
function uploadedFileFromForm(formData, fieldName = 'file') {
|
||||
const candidate = formData.get(fieldName);
|
||||
if (!candidate || typeof candidate === 'string') {
|
||||
return null;
|
||||
}
|
||||
if (typeof candidate.arrayBuffer !== 'function') {
|
||||
return null;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function dashboardHref(params = {}) {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
@@ -140,32 +105,7 @@ export async function POST(request) {
|
||||
return errorResponse(request, 'Nicht angemeldet.', 401);
|
||||
}
|
||||
|
||||
let formData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
} catch {
|
||||
return errorResponse(request, 'Ungültige Formulardaten.', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
return errorResponse(request, 'CSRF-Prüfung fehlgeschlagen.', 403);
|
||||
}
|
||||
|
||||
const uploadedFile = uploadedFileFromForm(formData, 'file');
|
||||
if (!uploadedFile || Number(uploadedFile.size || 0) <= 0) {
|
||||
return errorResponse(request, 'Keine Datei hochgeladen.', 400);
|
||||
}
|
||||
|
||||
if (maxUploadBytes > 0 && Number(uploadedFile.size || 0) > maxUploadBytes) {
|
||||
return errorResponse(request, `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`, 413);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const originalName = safeBaseName(uploadedFile.name, 'upload');
|
||||
const retentionSeconds = parseHours(formData.get('retentionHours'), uploadTtlSeconds);
|
||||
const cappedRetention = Math.min(retentionSeconds, maxRetentionSeconds);
|
||||
|
||||
let storedName = '';
|
||||
let storedPath = '';
|
||||
@@ -186,8 +126,36 @@ export async function POST(request) {
|
||||
return errorResponse(request, 'Upload-ID konnte nicht erzeugt werden.', 500);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
const sizeBytes = await writeUploadedFile(uploadedFile, storedPath);
|
||||
parsed = await streamMultipartToPath(request, {
|
||||
fileFieldName: 'file',
|
||||
targetPath: storedPath,
|
||||
maxFileBytes: maxUploadBytes,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error && error.message === 'file-too-large') {
|
||||
return errorResponse(request, `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`, 413);
|
||||
}
|
||||
if (error && error.message === 'missing-file') {
|
||||
return errorResponse(request, 'Keine Datei hochgeladen.', 400);
|
||||
}
|
||||
return errorResponse(request, 'Upload fehlgeschlagen.', 500);
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyCsrfToken(parsed.fields.csrfToken);
|
||||
} catch {
|
||||
await fs.promises.rm(storedPath, { force: true }).catch(() => undefined);
|
||||
return errorResponse(request, 'CSRF-Prüfung fehlgeschlagen.', 403);
|
||||
}
|
||||
|
||||
const retentionSeconds = parseHours(parsed.fields.retentionHours, uploadTtlSeconds);
|
||||
const cappedRetention = Math.min(retentionSeconds, maxRetentionSeconds);
|
||||
const originalName = safeBaseName(parsed.file.filename, 'upload');
|
||||
|
||||
try {
|
||||
const sizeBytes = parsed.file.sizeBytes;
|
||||
|
||||
await run(
|
||||
`INSERT INTO uploads (owner, original_name, stored_name, stored_path, size_bytes, uploaded_at, expires_at)
|
||||
@@ -206,10 +174,11 @@ export async function POST(request) {
|
||||
await logEvent(
|
||||
'upload',
|
||||
user.username,
|
||||
{ name: storedName, size: Number(uploadedFile.size || 0) },
|
||||
{ name: storedName, size: sizeBytes },
|
||||
await getRequestMeta()
|
||||
);
|
||||
} catch {
|
||||
await fs.promises.rm(storedPath, { force: true }).catch(() => undefined);
|
||||
return errorResponse(request, 'Upload fehlgeschlagen.', 500);
|
||||
}
|
||||
|
||||
|
||||
20
nextjs/package-lock.json
generated
20
nextjs/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"busboy": "^1.6.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"next": "^16.2.1",
|
||||
"nodemailer": "^8.0.4",
|
||||
@@ -931,6 +932,17 @@
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cacache": {
|
||||
"version": "15.3.0",
|
||||
"resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz",
|
||||
@@ -2348,6 +2360,14 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"busboy": "^1.6.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"next": "^16.2.1",
|
||||
"nodemailer": "^8.0.4",
|
||||
|
||||
110
nextjs/src/lib/multipart.js
Normal file
110
nextjs/src/lib/multipart.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import fs from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import Busboy from 'busboy';
|
||||
|
||||
export async function streamMultipartToPath(request, options) {
|
||||
const { fileFieldName = 'file', targetPath, maxFileBytes = 0 } = options;
|
||||
|
||||
if (!request.body) {
|
||||
throw new Error('missing-body');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const limits = { files: 1, fields: 20 };
|
||||
if (maxFileBytes > 0) {
|
||||
limits.fileSize = maxFileBytes;
|
||||
}
|
||||
|
||||
const parser = Busboy({ headers: Object.fromEntries(request.headers), limits });
|
||||
const fields = {};
|
||||
let uploadInfo = null;
|
||||
let writeStream = null;
|
||||
let writeFinished = false;
|
||||
let writeError = null;
|
||||
let hitSizeLimit = false;
|
||||
|
||||
function fail(error) {
|
||||
if (writeStream) {
|
||||
writeStream.destroy();
|
||||
}
|
||||
fs.promises.rm(targetPath, { force: true }).catch(() => undefined).finally(() => reject(error));
|
||||
}
|
||||
|
||||
parser.on('field', (name, value) => {
|
||||
fields[name] = value;
|
||||
});
|
||||
|
||||
parser.on('file', (name, file, info) => {
|
||||
if (name !== fileFieldName || uploadInfo) {
|
||||
file.resume();
|
||||
return;
|
||||
}
|
||||
|
||||
writeStream = fs.createWriteStream(targetPath, { flags: 'wx' });
|
||||
uploadInfo = {
|
||||
filename: info.filename || '',
|
||||
mimeType: info.mimeType || '',
|
||||
sizeBytes: 0,
|
||||
};
|
||||
|
||||
file.on('data', (chunk) => {
|
||||
uploadInfo.sizeBytes += chunk.length;
|
||||
});
|
||||
|
||||
file.on('limit', () => {
|
||||
hitSizeLimit = true;
|
||||
});
|
||||
|
||||
file.on('error', (error) => {
|
||||
writeError = error;
|
||||
});
|
||||
|
||||
writeStream.on('error', (error) => {
|
||||
writeError = error;
|
||||
});
|
||||
|
||||
writeStream.on('close', () => {
|
||||
writeFinished = true;
|
||||
});
|
||||
|
||||
file.pipe(writeStream);
|
||||
});
|
||||
|
||||
parser.on('error', (error) => {
|
||||
fail(error);
|
||||
});
|
||||
|
||||
parser.on('finish', () => {
|
||||
const complete = () => {
|
||||
if (writeError) {
|
||||
fail(writeError);
|
||||
return;
|
||||
}
|
||||
if (hitSizeLimit) {
|
||||
fail(new Error('file-too-large'));
|
||||
return;
|
||||
}
|
||||
if (!uploadInfo) {
|
||||
fail(new Error('missing-file'));
|
||||
return;
|
||||
}
|
||||
resolve({ fields, file: uploadInfo });
|
||||
};
|
||||
|
||||
if (!writeStream) {
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (writeFinished) {
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
|
||||
writeStream.on('close', complete);
|
||||
});
|
||||
|
||||
Readable.fromWeb(request.body).pipe(parser);
|
||||
});
|
||||
}
|
||||
@@ -90,11 +90,25 @@ export async function verifyCsrf(formData) {
|
||||
providedToken = String(headerStore.get('x-csrf-token') || '');
|
||||
}
|
||||
|
||||
await verifyCsrfTokenValue(expectedToken, providedToken);
|
||||
}
|
||||
|
||||
async function verifyCsrfTokenValue(expectedToken, providedToken) {
|
||||
if (!expectedToken || !providedToken || expectedToken !== providedToken) {
|
||||
throw new Error('csrf-token-mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyCsrfToken(token) {
|
||||
await verifySameOrigin();
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const expectedToken = cookieStore.get(csrfCookieName)?.value || '';
|
||||
const providedToken = String(token || '');
|
||||
|
||||
await verifyCsrfTokenValue(expectedToken, providedToken);
|
||||
}
|
||||
|
||||
export async function getRequestMeta() {
|
||||
const headerStore = await headers();
|
||||
const forwardedFor = firstForwardedPart(headerStore.get('x-forwarded-for'));
|
||||
|
||||
Reference in New Issue
Block a user