import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { NextResponse } from 'next/server'; import { maxRetentionSeconds, maxUploadBytes, publicBaseUrl, 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 { streamBodyToPath, streamMultipartToPath } from '@/src/lib/multipart.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(requestId, params = {}) { return new NextResponse(null, { status: 303, headers: { location: requestPageHref(requestId, params), }, }); } function textResponse(message, status = 200) { return new NextResponse(`${message}\n`, { status, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', }, }); } function isMultipartRequest(request) { return String(request.headers.get('content-type') || '') .toLowerCase() .includes('multipart/form-data'); } function uploadErrorResponse(requestId, rawUpload, message, status = 400) { if (rawUpload) { return textResponse(message, status); } return redirectToRequest(requestId, { error: message }); } function uploadSuccessResponse(requestId, rawUpload, message, downloadUrl = '') { if (rawUpload) { const body = downloadUrl ? `${message}\nDownload: ${downloadUrl}` : message; return textResponse(body, 201); } return redirectToRequest(requestId, { success: message }); } function contentDispositionFilename(value) { const raw = String(value || ''); const encodedMatch = raw.match(/filename\*=UTF-8''([^;]+)/i); if (encodedMatch) { try { return decodeURIComponent(encodedMatch[1].trim()); } catch { return encodedMatch[1].trim(); } } const quotedMatch = raw.match(/filename="([^"]+)"/i); if (quotedMatch) { return quotedMatch[1]; } const plainMatch = raw.match(/filename=([^;]+)/i); return plainMatch ? plainMatch[1].trim() : ''; } function rawUploadFilename(request, requestId) { const url = new URL(request.url); const candidate = url.searchParams.get('filename') || url.searchParams.get('name') || request.headers.get('x-file-name') || request.headers.get('x-filename') || contentDispositionFilename(request.headers.get('content-disposition')) || `${requestId}.upload`; return safeBaseName(candidate, 'upload'); } function rawFulfilledBy(request) { const url = new URL(request.url); return String(url.searchParams.get('by') || request.headers.get('x-fulfilled-by') || '') .trim() .slice(0, 200); } async function parseUpload(request, requestId, storedPath) { if (isMultipartRequest(request)) { const parsed = await streamMultipartToPath(request, { fileFieldName: 'file', targetPath: storedPath, maxFileBytes: maxUploadBytes, }); return { rawUpload: false, fulfilledBy: String(parsed.fields.fulfilledBy || '').trim().slice(0, 200), originalName: safeBaseName(parsed.file.filename, 'upload'), sizeBytes: parsed.file.sizeBytes, }; } const contentLength = Number.parseInt(String(request.headers.get('content-length') || ''), 10); if (maxUploadBytes > 0 && Number.isFinite(contentLength) && contentLength > maxUploadBytes) { throw new Error('file-too-large'); } const parsed = await streamBodyToPath(request, { targetPath: storedPath, maxBytes: maxUploadBytes, }); return { rawUpload: true, fulfilledBy: rawFulfilledBy(request), originalName: rawUploadFilename(request, requestId), sizeBytes: parsed.sizeBytes, }; } function firstForwardedPart(value) { if (!value) { return ''; } return value.split(',')[0].trim(); } function requestOrigin(request) { const forwardedHost = firstForwardedPart(request.headers.get('x-forwarded-host')); const host = forwardedHost || request.headers.get('host') || ''; if (!host) { return ''; } const forwardedProto = firstForwardedPart(request.headers.get('x-forwarded-proto')); const proto = forwardedProto || (host.startsWith('localhost') || host.startsWith('127.0.0.1') ? 'http' : 'https'); return `${proto}://${host}`; } function resolvePublicOrigin(request) { if (publicBaseUrl) { return publicBaseUrl; } const fromHeaders = requestOrigin(request); if (fromHeaders) { return fromHeaders; } try { return new URL(request.url).origin; } catch { return ''; } } 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)); } async function handleUpload(request, { params }) { await runCleanupIfNeeded(); const resolvedParams = await params; const requestId = normalizeRequestId(resolvedParams.id); const rawUpload = !isMultipartRequest(request); if (!isValidRequestId(requestId)) { return new NextResponse('Ungültige Anfrage-ID', { status: 400 }); } const uploadRequest = await get( `SELECT id, owner, note, expires_at, completed_at FROM upload_requests WHERE id = ?`, [requestId] ); if (!uploadRequest) { return uploadErrorResponse(requestId, rawUpload, 'Anfrage nicht gefunden.', 404); } if (Number(uploadRequest.completed_at || 0) > 0) { if (rawUpload) { return textResponse('Diese Anfrage wurde bereits erfüllt.', 409); } return redirectToRequest(requestId, { success: 'Diese Anfrage wurde bereits erfüllt.' }); } const now = Date.now(); if (Number(uploadRequest.expires_at || 0) <= now) { return uploadErrorResponse(requestId, rawUpload, 'Diese Anfrage ist bereits abgelaufen.', 410); } 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 uploadErrorResponse(requestId, rawUpload, 'Upload-ID konnte nicht erzeugt werden.', 500); } let parsed; try { parsed = await parseUpload(request, requestId, storedPath); } catch (error) { if (error && error.message === 'file-too-large') { return uploadErrorResponse( requestId, rawUpload, `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`, 413 ); } if (error && error.message === 'missing-file') { return uploadErrorResponse(requestId, rawUpload, 'Keine Datei hochgeladen.', 400); } return uploadErrorResponse(requestId, rawUpload, 'Upload fehlgeschlagen.', 500); } const uploadExpiry = Math.min(now + uploadTtlSeconds * 1000, now + maxRetentionSeconds * 1000); let uploadId = null; const { fulfilledBy, originalName, sizeBytes } = parsed; try { 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 { await fs.promises.rm(storedPath, { force: true }).catch(() => undefined); return uploadErrorResponse(requestId, rawUpload, 'Upload fehlgeschlagen.', 500); } 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); if (rawUpload) { return textResponse('Diese Anfrage wurde bereits erfüllt.', 409); } return redirectToRequest(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 = resolvePublicOrigin(request); 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 uploadSuccessResponse( requestId, rawUpload, 'Datei erfolgreich hochgeladen. Vielen Dank!', downloadUrl ); } await logEvent( 'request_mail_failed', uploadRequest.owner, { request_id: requestId, reason: mailResult.reason || 'unknown' }, await getRequestMeta() ); return uploadSuccessResponse( requestId, rawUpload, 'Datei hochgeladen. Hinweis: E-Mail-Benachrichtigung konnte nicht gesendet werden.', downloadUrl ); } export async function POST(request, context) { return handleUpload(request, context); } export async function PUT(request, context) { return handleUpload(request, context); }