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.
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
||||
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 { streamMultipartToPath } from '@/src/lib/multipart.js';
|
||||
import { streamBodyToPath, streamMultipartToPath } from '@/src/lib/multipart.js';
|
||||
import { getRequestMeta } from '@/src/lib/security.js';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
@@ -50,6 +50,113 @@ function redirectToRequest(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 '';
|
||||
@@ -113,11 +220,12 @@ function createRandomId() {
|
||||
return toBase32(crypto.randomBytes(5));
|
||||
}
|
||||
|
||||
export async function POST(request, { params }) {
|
||||
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 });
|
||||
@@ -131,16 +239,20 @@ export async function POST(request, { params }) {
|
||||
);
|
||||
|
||||
if (!uploadRequest) {
|
||||
return redirectToRequest(requestId, { error: 'Anfrage nicht gefunden.' });
|
||||
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 redirectToRequest(requestId, { error: 'Diese Anfrage ist bereits abgelaufen.' });
|
||||
return uploadErrorResponse(requestId, rawUpload, 'Diese Anfrage ist bereits abgelaufen.', 410);
|
||||
}
|
||||
|
||||
let storedName = '';
|
||||
@@ -158,35 +270,31 @@ export async function POST(request, { params }) {
|
||||
}
|
||||
|
||||
if (!storedName || !storedPath) {
|
||||
return redirectToRequest(requestId, { error: 'Upload-ID konnte nicht erzeugt werden.' });
|
||||
return uploadErrorResponse(requestId, rawUpload, 'Upload-ID konnte nicht erzeugt werden.', 500);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await streamMultipartToPath(request, {
|
||||
fileFieldName: 'file',
|
||||
targetPath: storedPath,
|
||||
maxFileBytes: maxUploadBytes,
|
||||
});
|
||||
parsed = await parseUpload(request, requestId, storedPath);
|
||||
} catch (error) {
|
||||
if (error && error.message === 'file-too-large') {
|
||||
return redirectToRequest(requestId, {
|
||||
error: `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`,
|
||||
});
|
||||
return uploadErrorResponse(
|
||||
requestId,
|
||||
rawUpload,
|
||||
`Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`,
|
||||
413
|
||||
);
|
||||
}
|
||||
if (error && error.message === 'missing-file') {
|
||||
return redirectToRequest(requestId, { error: 'Keine Datei hochgeladen.' });
|
||||
return uploadErrorResponse(requestId, rawUpload, 'Keine Datei hochgeladen.', 400);
|
||||
}
|
||||
return redirectToRequest(requestId, { error: 'Upload fehlgeschlagen.' });
|
||||
return uploadErrorResponse(requestId, rawUpload, 'Upload fehlgeschlagen.', 500);
|
||||
}
|
||||
|
||||
const fulfilledBy = String(parsed.fields.fulfilledBy || '').trim().slice(0, 200);
|
||||
const originalName = safeBaseName(parsed.file.filename, 'upload');
|
||||
|
||||
const uploadExpiry = Math.min(now + uploadTtlSeconds * 1000, now + maxRetentionSeconds * 1000);
|
||||
|
||||
let uploadId = null;
|
||||
const sizeBytes = parsed.file.sizeBytes;
|
||||
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)
|
||||
@@ -196,7 +304,7 @@ export async function POST(request, { params }) {
|
||||
uploadId = insertResult.lastID;
|
||||
} catch {
|
||||
await fs.promises.rm(storedPath, { force: true }).catch(() => undefined);
|
||||
return redirectToRequest(requestId, { error: 'Upload fehlgeschlagen.' });
|
||||
return uploadErrorResponse(requestId, rawUpload, 'Upload fehlgeschlagen.', 500);
|
||||
}
|
||||
|
||||
const updateResult = await run(
|
||||
@@ -215,6 +323,10 @@ export async function POST(request, { params }) {
|
||||
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.' });
|
||||
}
|
||||
|
||||
@@ -239,7 +351,12 @@ export async function POST(request, { params }) {
|
||||
|
||||
if (mailResult.ok) {
|
||||
await run('UPDATE upload_requests SET notification_sent_at = ? WHERE id = ?', [Date.now(), requestId]);
|
||||
return redirectToRequest(requestId, { success: 'Datei erfolgreich hochgeladen. Vielen Dank!' });
|
||||
return uploadSuccessResponse(
|
||||
requestId,
|
||||
rawUpload,
|
||||
'Datei erfolgreich hochgeladen. Vielen Dank!',
|
||||
downloadUrl
|
||||
);
|
||||
}
|
||||
|
||||
await logEvent(
|
||||
@@ -249,7 +366,18 @@ export async function POST(request, { params }) {
|
||||
await getRequestMeta()
|
||||
);
|
||||
|
||||
return redirectToRequest(requestId, {
|
||||
success: 'Datei hochgeladen. Hinweis: E-Mail-Benachrichtigung konnte nicht gesendet werden.',
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user