Files
files/nextjs/app/manage/api/upload/route.js
2026-03-27 20:10:51 +01:00

210 lines
5.6 KiB
JavaScript

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 {
managementBasePath,
maxRetentionSeconds,
maxUploadBytes,
shareDir,
uploadTtlSeconds,
} from '@/src/lib/config.js';
import { logEvent, run, runCleanupIfNeeded } from '@/src/lib/db.js';
import { safeBaseName } from '@/src/lib/files.js';
import { getAuthenticatedUser, getRequestMeta, verifyCsrf } from '@/src/lib/security.js';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
function parseHours(value, fallbackSeconds) {
const parsed = Number.parseFloat(String(value || ''));
if (Number.isFinite(parsed) && parsed > 0) {
return Math.round(parsed * 3600);
}
return fallbackSeconds;
}
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 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)) {
if (value) {
query.set(key, String(value));
}
}
const serialized = query.toString();
return serialized
? `${managementBasePath}/dashboard?${serialized}`
: `${managementBasePath}/dashboard`;
}
function expectsHtml(request) {
return String(request.headers.get('accept') || '').includes('text/html');
}
function errorResponse(request, message, status = 400) {
if (expectsHtml(request)) {
const target = new URL(dashboardHref({ error: message }), request.url);
return NextResponse.redirect(target, { status: 303 });
}
return NextResponse.json({ ok: false, error: message }, { status });
}
function successResponse(request, message) {
const redirectPath = dashboardHref({ success: message });
if (expectsHtml(request)) {
const target = new URL(redirectPath, request.url);
return NextResponse.redirect(target, { status: 303 });
}
return NextResponse.json({ ok: true, redirect: redirectPath });
}
export async function POST(request) {
await runCleanupIfNeeded();
const user = await getAuthenticatedUser();
if (!user) {
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 = '';
for (let attempts = 0; attempts < 5; 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 errorResponse(request, 'Upload-ID konnte nicht erzeugt werden.', 500);
}
try {
const sizeBytes = await writeUploadedFile(uploadedFile, storedPath);
await run(
`INSERT INTO uploads (owner, original_name, stored_name, stored_path, size_bytes, uploaded_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
user.username,
originalName,
storedName,
storedPath,
sizeBytes,
now,
now + cappedRetention * 1000,
]
);
await logEvent(
'upload',
user.username,
{ name: storedName, size: Number(uploadedFile.size || 0) },
await getRequestMeta()
);
} catch {
return errorResponse(request, 'Upload fehlgeschlagen.', 500);
}
return successResponse(request, 'Upload abgeschlossen.');
}