progress bar + better ui
This commit is contained in:
193
nextjs/app/manage/api/upload/route.js
Normal file
193
nextjs/app/manage/api/upload/route.js
Normal file
@@ -0,0 +1,193 @@
|
||||
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 jsonError(message, status = 400) {
|
||||
return NextResponse.json({ ok: false, error: message }, { status });
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
await runCleanupIfNeeded();
|
||||
|
||||
const user = await getAuthenticatedUser();
|
||||
if (!user) {
|
||||
return jsonError('Nicht angemeldet.', 401);
|
||||
}
|
||||
|
||||
let formData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
} catch {
|
||||
return jsonError('Ungültige Formulardaten.', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyCsrf(formData);
|
||||
} catch {
|
||||
return jsonError('CSRF-Prüfung fehlgeschlagen.', 403);
|
||||
}
|
||||
|
||||
const uploadedFile = uploadedFileFromForm(formData, 'file');
|
||||
if (!uploadedFile || Number(uploadedFile.size || 0) <= 0) {
|
||||
return jsonError('Keine Datei hochgeladen.', 400);
|
||||
}
|
||||
|
||||
if (maxUploadBytes > 0 && Number(uploadedFile.size || 0) > maxUploadBytes) {
|
||||
return jsonError(`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 jsonError('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 jsonError('Upload fehlgeschlagen.', 500);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
redirect: dashboardHref({ success: 'Upload abgeschlossen.' }),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user