Files
files/nextjs/app/%5Frequest/[id]/upload/route.js
Ludwig Lehnert a2b6054440 feat: improve upload admin workflow
Add users.is_admin SQLite migration for promoted admins.

Support curl PUT request uploads, save uploads into admin file-manager paths, default 25 GiB uploads, tabbed UI, and local dev preview.
2026-06-18 17:09:07 +00:00

384 lines
11 KiB
JavaScript

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);
}