added requesting option

This commit is contained in:
Ludwig Lehnert
2026-03-28 08:43:04 +01:00
parent 12e4bcddc6
commit 48bfe69d09
14 changed files with 730 additions and 5 deletions

View File

@@ -0,0 +1,146 @@
import { get, runCleanupIfNeeded } from '@/src/lib/db.js';
import { formatTimestamp, readSearchParam } from '@/src/lib/format.js';
import { StatusMessage } from '@/app/manage/_components/status-message.js';
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 requestState(requestEntry, now) {
if (!requestEntry) {
return 'missing';
}
if (Number(requestEntry.completed_at || 0) > 0) {
return 'completed';
}
if (Number(requestEntry.expires_at || 0) <= now) {
return 'expired';
}
return 'open';
}
export default async function UploadRequestPage({ params, searchParams }) {
await runCleanupIfNeeded();
const resolvedParams = await params;
const requestId = normalizeRequestId(resolvedParams.id);
if (!isValidRequestId(requestId)) {
return (
<main className="page-shell narrow">
<section className="panel centered">
<h1>Ungültige Anfrage</h1>
<p className="muted">Die Upload-Anfrage konnte nicht verarbeitet werden.</p>
</section>
</main>
);
}
const requestEntry = await get(
`SELECT id, note, created_at, expires_at, completed_at, uploaded_original_name
FROM upload_requests
WHERE id = ?`,
[requestId]
);
const now = Date.now();
const state = requestState(requestEntry, now);
const resolvedSearchParams = await searchParams;
const error = readSearchParam(resolvedSearchParams, 'error');
const success = readSearchParam(resolvedSearchParams, 'success');
if (state === 'missing') {
return (
<main className="page-shell narrow">
<StatusMessage error={error} success={success} />
<section className="panel centered">
<h1>Anfrage nicht gefunden</h1>
<p className="muted">Diese Upload-Anfrage existiert nicht oder wurde entfernt.</p>
</section>
</main>
);
}
return (
<main className="page-shell narrow">
<header className="page-header">
<div className="header-main">
<h1>Datei-Anfrage</h1>
<p className="muted">Anfrage-ID: {requestEntry.id}</p>
</div>
</header>
<StatusMessage error={error} success={success} />
<section className="panel">
<div className="info-stack">
<div className="info-card">
<strong>Status</strong>
<span className="muted">
{state === 'open' ? 'Offen' : state === 'completed' ? 'Bereits abgeschlossen' : 'Abgelaufen'}
</span>
</div>
<div className="info-card">
<strong>Erstellt</strong>
<span className="muted">{formatTimestamp(requestEntry.created_at)}</span>
</div>
<div className="info-card">
<strong>Gültig bis</strong>
<span className="muted">{formatTimestamp(requestEntry.expires_at)}</span>
</div>
{requestEntry.note ? (
<div className="info-card">
<strong>Notiz</strong>
<span className="muted">{requestEntry.note}</span>
</div>
) : null}
</div>
</section>
{state === 'open' ? (
<section className="panel panel-spotlight">
<h2>Datei hochladen</h2>
<form
className="form-grid"
method="post"
action={`/_request/${encodeURIComponent(requestEntry.id)}/upload`}
encType="multipart/form-data"
>
<label className="field">
Datei
<input className="input" type="file" name="file" required />
</label>
<label className="field">
Dein Name (optional)
<input className="input" name="fulfilledBy" placeholder="z. B. Max Mustermann" />
</label>
<button className="btn" type="submit">
Datei senden
</button>
</form>
</section>
) : null}
{state === 'completed' ? (
<section className="panel centered">
<h2>Vielen Dank</h2>
<p className="muted">
{requestEntry.uploaded_original_name
? `Diese Anfrage wurde bereits mit „${requestEntry.uploaded_original_name}“ abgeschlossen.`
: 'Diese Anfrage wurde bereits abgeschlossen.'}
</p>
</section>
) : null}
</main>
);
}

View File

@@ -0,0 +1,246 @@
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 {
maxRetentionSeconds,
maxUploadBytes,
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 { 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(request, requestId, params = {}) {
return NextResponse.redirect(new URL(requestPageHref(requestId, params), request.url), { status: 303 });
}
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));
}
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;
}
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;
}
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 });
}
let formData;
try {
formData = await request.formData();
} catch {
return redirectToRequest(request, requestId, { error: 'Ungültige Formulardaten.' });
}
const uploadRequest = await get(
`SELECT id, owner, note, expires_at, completed_at
FROM upload_requests
WHERE id = ?`,
[requestId]
);
if (!uploadRequest) {
return redirectToRequest(request, requestId, { error: 'Anfrage nicht gefunden.' });
}
if (Number(uploadRequest.completed_at || 0) > 0) {
return redirectToRequest(request, requestId, { success: 'Diese Anfrage wurde bereits erfüllt.' });
}
const now = Date.now();
if (Number(uploadRequest.expires_at || 0) <= now) {
return redirectToRequest(request, requestId, { error: 'Diese Anfrage ist bereits abgelaufen.' });
}
const uploadedFile = uploadedFileFromForm(formData, 'file');
if (!uploadedFile || Number(uploadedFile.size || 0) <= 0) {
return redirectToRequest(request, requestId, { error: 'Keine Datei hochgeladen.' });
}
if (maxUploadBytes > 0 && Number(uploadedFile.size || 0) > maxUploadBytes) {
return redirectToRequest(request, requestId, {
error: `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`,
});
}
const fulfilledBy = String(formData.get('fulfilledBy') || '').trim().slice(0, 200);
const originalName = safeBaseName(uploadedFile.name, 'upload');
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(request, requestId, { error: 'Upload-ID konnte nicht erzeugt werden.' });
}
const uploadExpiry = Math.min(now + uploadTtlSeconds * 1000, now + maxRetentionSeconds * 1000);
let uploadId = null;
let sizeBytes = 0;
try {
sizeBytes = await writeUploadedFile(uploadedFile, storedPath);
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 {
return redirectToRequest(request, 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(request, 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 = new URL(request.url).origin;
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(request, 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(request, requestId, {
success: 'Datei hochgeladen. Hinweis: E-Mail-Benachrichtigung konnte nicht gesendet werden.',
});
}