progress bar + better ui

This commit is contained in:
Ludwig Lehnert
2026-03-27 20:08:40 +01:00
parent 31c7f92d7c
commit 83fbeff16c
8 changed files with 474 additions and 29 deletions

View File

@@ -0,0 +1,112 @@
'use client';
import { useState } from 'react';
function parseErrorMessage(xhr) {
const response = xhr.response;
if (response && typeof response === 'object' && response.error) {
return String(response.error);
}
try {
const parsed = JSON.parse(xhr.responseText || '{}');
if (parsed && typeof parsed.error === 'string') {
return parsed.error;
}
} catch {
}
return `Upload fehlgeschlagen (HTTP ${xhr.status}).`;
}
export function UploadProgressForm({ csrfToken }) {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [localError, setLocalError] = useState('');
function handleSubmit(event) {
event.preventDefault();
if (uploading) {
return;
}
const form = event.currentTarget;
const formData = new FormData(form);
const uploadedFile = formData.get('file');
if (!uploadedFile || typeof uploadedFile === 'string' || !uploadedFile.size) {
setLocalError('Bitte zuerst eine Datei auswählen.');
return;
}
setUploading(true);
setProgress(0);
setLocalError('');
const xhr = new XMLHttpRequest();
xhr.open('POST', '/manage/api/upload', true);
xhr.responseType = 'json';
xhr.setRequestHeader('x-csrf-token', csrfToken);
xhr.upload.onprogress = (uploadEvent) => {
if (!uploadEvent.lengthComputable || uploadEvent.total <= 0) {
return;
}
const nextValue = Math.round((uploadEvent.loaded / uploadEvent.total) * 100);
setProgress(Math.max(0, Math.min(100, nextValue)));
};
xhr.onerror = () => {
setUploading(false);
setLocalError('Netzwerkfehler beim Upload.');
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const redirectPath = xhr.response?.redirect || '/manage/dashboard?success=Upload%20abgeschlossen.';
window.location.assign(redirectPath);
return;
}
setUploading(false);
setProgress(0);
setLocalError(parseErrorMessage(xhr));
};
xhr.send(formData);
}
return (
<form className="form-grid" onSubmit={handleSubmit} encType="multipart/form-data">
<input type="hidden" name="csrfToken" value={csrfToken} />
<label className="field">
Datei
<input className="input" name="file" type="file" required disabled={uploading} />
</label>
<label className="field">
Aufbewahrung in Stunden
<input className="input" name="retentionHours" placeholder="168" disabled={uploading} />
</label>
{uploading ? (
<div className="upload-progress" role="status" aria-live="polite">
<div className="upload-progress-row">
<span className="muted">Upload läuft </span>
<strong>{progress}%</strong>
</div>
<div className="upload-progress-track" aria-hidden="true">
<div className="upload-progress-fill" style={{ width: `${progress}%` }} />
</div>
</div>
) : null}
{localError ? <div className="status error">{localError}</div> : null}
<button className="btn" type="submit" disabled={uploading}>
{uploading ? 'Wird hochgeladen …' : 'Hochladen'}
</button>
</form>
);
}

View File

@@ -5,6 +5,7 @@ import {
} from '@/src/lib/actions.js';
import { adminHash } from '@/src/lib/config.js';
import { all, get, runCleanupIfNeeded } from '@/src/lib/db.js';
import { sharedLinkName } from '@/src/lib/files.js';
import {
formatBytes,
formatCountdown,
@@ -152,7 +153,8 @@ export default async function AdminDashboardPage({ searchParams }) {
</thead>
<tbody>
{allUploads.map((item) => {
const sharePath = `/_share/${encodeURIComponent(item.stored_name)}`;
const shareName = sharedLinkName(item.stored_name);
const sharePath = `/_share/${encodeURIComponent(shareName)}`;
return (
<tr key={item.id}>
<td>{item.owner}</td>

View 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.' }),
});
}

View File

@@ -1,15 +1,16 @@
import {
deleteOwnUploadAction,
extendOwnUploadAction,
uploadFileAction,
userLogoutAction,
} from '@/src/lib/actions.js';
import { all, runCleanupIfNeeded } from '@/src/lib/db.js';
import { sharedLinkName } from '@/src/lib/files.js';
import { formatBytes, formatCountdown, formatTimestamp, readSearchParam } from '@/src/lib/format.js';
import { ensureCsrfToken, requireAuthenticatedUser } from '@/src/lib/security.js';
import { CopyLinkButton } from '../_components/copy-link-button.js';
import { StatusMessage } from '../_components/status-message.js';
import { UploadProgressForm } from '../_components/upload-progress-form.js';
export const dynamic = 'force-dynamic';
@@ -26,6 +27,7 @@ export default async function DashboardPage({ searchParams }) {
const resolvedSearchParams = await searchParams;
const error = readSearchParam(resolvedSearchParams, 'error');
const success = readSearchParam(resolvedSearchParams, 'success');
const totalBytes = uploads.reduce((total, item) => total + (Number(item.size_bytes) || 0), 0);
return (
<main className="page-shell">
@@ -51,28 +53,33 @@ export default async function DashboardPage({ searchParams }) {
</div>
</header>
<section className="panel">
<h2>Neue Datei hochladen</h2>
<StatusMessage error={error} success={success} />
<StatusMessage error={error} success={success} />
<form className="form-grid" action={uploadFileAction} encType="multipart/form-data">
<input type="hidden" name="csrfToken" value={csrfToken} />
<div className="dashboard-top-grid">
<section className="panel panel-spotlight">
<h2>Neue Datei hochladen</h2>
<p className="muted">Der Fortschritt wird während des Uploads live angezeigt.</p>
<UploadProgressForm csrfToken={csrfToken} />
</section>
<label className="field">
Datei
<input className="input" name="file" type="file" required />
</label>
<label className="field">
Aufbewahrung in Stunden
<input className="input" name="retentionHours" placeholder="168" />
</label>
<button className="btn" type="submit">
Hochladen
</button>
</form>
</section>
<aside className="panel panel-soft">
<h2>Schnellüberblick</h2>
<div className="info-stack">
<div className="info-card">
<strong>{uploads.length}</strong>
<span className="muted">aktive Uploads</span>
</div>
<div className="info-card">
<strong>{totalBytes > 0 ? formatBytes(totalBytes) : '0 B'}</strong>
<span className="muted">genutzter Speicher</span>
</div>
<div className="info-card">
<strong>/_share/&lt;id&gt;</strong>
<span className="muted">Kurzlinks ohne Dateiendung</span>
</div>
</div>
</aside>
</div>
<section className="panel">
<h2>Aktuelle Uploads</h2>
@@ -92,7 +99,8 @@ export default async function DashboardPage({ searchParams }) {
</thead>
<tbody>
{uploads.map((item) => {
const sharePath = `/_share/${encodeURIComponent(item.stored_name)}`;
const shareName = sharedLinkName(item.stored_name);
const sharePath = `/_share/${encodeURIComponent(shareName)}`;
return (
<tr key={item.id}>
<td>