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 { maxRetentionSeconds, maxUploadBytes, shareDir, uploadTtlSeconds, } from '@/src/lib/config.js'; 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 { getRequestMeta } from '@/src/lib/security.js'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; function normalizeRequestId(value) { return String(value || '').trim().toUpperCase(); } function isValidRequestId(value) { return /^[A-Z2-7]{6,24}$/.test(value); } function requestPageHref(requestId, params = {}) { const query = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value) { query.set(key, String(value)); } } const encodedId = encodeURIComponent(requestId); const serialized = query.toString(); return serialized ? `/_request/${encodedId}?${serialized}` : `/_request/${encodedId}`; } function redirectToRequest(request, requestId, params = {}) { return NextResponse.redirect(new URL(requestPageHref(requestId, params), request.url), { status: 303 }); } function toBase32(buffer) { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let bits = 0; let value = 0; let output = ''; for (const byte of buffer) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { output += alphabet[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) { output += alphabet[(value << (5 - bits)) & 31]; } return output; } 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(); const resolvedParams = await params; const requestId = normalizeRequestId(resolvedParams.id); if (!isValidRequestId(requestId)) { return new NextResponse('Ungültige Anfrage-ID', { status: 400 }); } let formData; try { formData = await request.formData(); } catch { return redirectToRequest(request, requestId, { error: 'Ungültige Formulardaten.' }); } const uploadRequest = await get( `SELECT id, owner, note, expires_at, completed_at FROM upload_requests WHERE id = ?`, [requestId] ); if (!uploadRequest) { return redirectToRequest(request, requestId, { error: 'Anfrage nicht gefunden.' }); } if (Number(uploadRequest.completed_at || 0) > 0) { return redirectToRequest(request, requestId, { success: 'Diese Anfrage wurde bereits erfüllt.' }); } const now = Date.now(); if (Number(uploadRequest.expires_at || 0) <= now) { return redirectToRequest(request, requestId, { error: 'Diese Anfrage ist bereits abgelaufen.' }); } const uploadedFile = uploadedFileFromForm(formData, 'file'); if (!uploadedFile || Number(uploadedFile.size || 0) <= 0) { return redirectToRequest(request, requestId, { error: 'Keine Datei hochgeladen.' }); } if (maxUploadBytes > 0 && Number(uploadedFile.size || 0) > maxUploadBytes) { return redirectToRequest(request, 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) { const candidate = createRandomId(); const candidatePath = path.join(shareDir, candidate); try { await fs.promises.access(candidatePath, fs.constants.F_OK); } catch { storedName = candidate; storedPath = candidatePath; break; } } if (!storedName || !storedPath) { return redirectToRequest(request, requestId, { error: 'Upload-ID konnte nicht erzeugt werden.' }); } const uploadExpiry = Math.min(now + uploadTtlSeconds * 1000, now + maxRetentionSeconds * 1000); let uploadId = null; let sizeBytes = 0; 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 (?, ?, ?, ?, ?, ?, ?)`, [uploadRequest.owner, originalName, storedName, storedPath, sizeBytes, now, uploadExpiry] ); uploadId = insertResult.lastID; } catch { return redirectToRequest(request, requestId, { error: 'Upload fehlgeschlagen.' }); } const updateResult = await run( `UPDATE upload_requests SET completed_at = ?, upload_id = ?, uploaded_original_name = ?, uploaded_stored_name = ?, uploaded_stored_path = ?, uploaded_size_bytes = ?, fulfilled_by = ? WHERE id = ? AND completed_at IS NULL`, [now, uploadId, originalName, storedName, storedPath, sizeBytes, fulfilledBy || null, requestId] ); if (!updateResult || updateResult.changes < 1) { await run('DELETE FROM uploads WHERE id = ?', [uploadId]).catch(() => undefined); await fs.promises.rm(storedPath, { force: true }).catch(() => undefined); return redirectToRequest(request, requestId, { success: 'Diese Anfrage wurde bereits erfüllt.' }); } await logEvent( 'request_fulfilled', uploadRequest.owner, { request_id: requestId, upload_id: uploadId, stored_name: storedName, fulfilled_by: fulfilledBy || null }, await getRequestMeta() ); const baseUrl = new URL(request.url).origin; const downloadUrl = `${baseUrl}/_share/${encodeURIComponent(storedName)}`; const mailResult = await sendUploadRequestCompletedMail({ recipient: uploadRequest.owner, requestId, fileName: originalName, downloadUrl, requesterNote: uploadRequest.note || '', fulfilledBy, }); if (mailResult.ok) { await run('UPDATE upload_requests SET notification_sent_at = ? WHERE id = ?', [Date.now(), requestId]); return redirectToRequest(request, requestId, { success: 'Datei erfolgreich hochgeladen. Vielen Dank!' }); } await logEvent( 'request_mail_failed', uploadRequest.owner, { request_id: requestId, reason: mailResult.reason || 'unknown' }, await getRequestMeta() ); return redirectToRequest(request, requestId, { success: 'Datei hochgeladen. Hinweis: E-Mail-Benachrichtigung konnte nicht gesendet werden.', }); }