256 lines
7.3 KiB
JavaScript
256 lines
7.3 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 { 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 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));
|
|
}
|
|
|
|
export async function POST(request, { params }) {
|
|
await runCleanupIfNeeded();
|
|
|
|
const resolvedParams = await params;
|
|
const requestId = normalizeRequestId(resolvedParams.id);
|
|
|
|
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 redirectToRequest(requestId, { error: 'Anfrage nicht gefunden.' });
|
|
}
|
|
|
|
if (Number(uploadRequest.completed_at || 0) > 0) {
|
|
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.' });
|
|
}
|
|
|
|
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 redirectToRequest(requestId, { error: 'Upload-ID konnte nicht erzeugt werden.' });
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = await streamMultipartToPath(request, {
|
|
fileFieldName: 'file',
|
|
targetPath: storedPath,
|
|
maxFileBytes: maxUploadBytes,
|
|
});
|
|
} catch (error) {
|
|
if (error && error.message === 'file-too-large') {
|
|
return redirectToRequest(requestId, {
|
|
error: `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`,
|
|
});
|
|
}
|
|
if (error && error.message === 'missing-file') {
|
|
return redirectToRequest(requestId, { error: 'Keine Datei hochgeladen.' });
|
|
}
|
|
return redirectToRequest(requestId, { error: 'Upload fehlgeschlagen.' });
|
|
}
|
|
|
|
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;
|
|
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 redirectToRequest(requestId, { error: 'Upload fehlgeschlagen.' });
|
|
}
|
|
|
|
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);
|
|
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 redirectToRequest(requestId, { success: 'Datei erfolgreich hochgeladen. Vielen Dank!' });
|
|
}
|
|
|
|
await logEvent(
|
|
'request_mail_failed',
|
|
uploadRequest.owner,
|
|
{ request_id: requestId, reason: mailResult.reason || 'unknown' },
|
|
await getRequestMeta()
|
|
);
|
|
|
|
return redirectToRequest(requestId, {
|
|
success: 'Datei hochgeladen. Hinweis: E-Mail-Benachrichtigung konnte nicht gesendet werden.',
|
|
});
|
|
}
|