210 lines
5.6 KiB
JavaScript
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.');
|
|
}
|