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

@@ -31,6 +31,28 @@ function contentDisposition(filename) {
return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`;
}
function escapeLike(value) {
return String(value || '').replace(/[\\%_]/g, '\\$&');
}
async function findUploadRow(fileName) {
const exactRow = await get('SELECT id, original_name, stored_path FROM uploads WHERE stored_name = ?', [
fileName,
]);
if (exactRow || fileName.includes('.')) {
return exactRow;
}
const likePattern = `${escapeLike(fileName)}.%`;
return get(
`SELECT id, original_name, stored_path FROM uploads
WHERE stored_name = ? OR stored_name LIKE ? ESCAPE '\\'
ORDER BY CASE WHEN stored_name = ? THEN 0 ELSE 1 END, id DESC
LIMIT 1`,
[fileName, likePattern, fileName]
);
}
export async function GET(request, { params }) {
await runCleanupIfNeeded();
@@ -40,7 +62,7 @@ export async function GET(request, { params }) {
return new NextResponse('Ungültiger Dateiname', { status: 400 });
}
const row = await get('SELECT id, original_name, stored_path FROM uploads WHERE stored_name = ?', [fileName]);
const row = await findUploadRow(fileName);
let filePath;
let downloadName;

View File

@@ -5,6 +5,7 @@
--bg-accent: #d9ece4;
--surface: #ffffff;
--surface-soft: #f7fafc;
--surface-glass: rgb(255 255 255 / 0.72);
--text-main: #10243a;
--text-muted: #566b81;
--line: #d6e0ea;
@@ -59,7 +60,7 @@ p {
margin: 0 auto;
padding: 2.2rem 0 3.5rem;
display: grid;
gap: 1.5rem;
gap: 1.6rem;
animation: page-enter 240ms ease-out both;
}
@@ -73,6 +74,11 @@ p {
align-items: center;
justify-content: space-between;
gap: 0.9rem;
background: linear-gradient(170deg, var(--surface-glass), rgb(255 255 255 / 0.5));
border: 1px solid rgb(214 224 234 / 0.9);
border-radius: var(--radius);
padding: 0.95rem 1.05rem;
backdrop-filter: blur(6px);
}
.header-main {
@@ -92,11 +98,34 @@ p {
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.1rem;
padding: 1.2rem;
display: grid;
gap: 0.95rem;
}
.panel.panel-soft {
background: linear-gradient(180deg, #f5fafc, #ffffff);
}
.panel.panel-spotlight {
position: relative;
overflow: hidden;
}
.panel.panel-spotlight::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(circle at 90% 0%, rgb(15 118 110 / 0.12), transparent 45%),
radial-gradient(circle at 10% 100%, rgb(15 118 110 / 0.08), transparent 40%);
}
.panel.panel-spotlight > * {
position: relative;
}
.panel.centered {
text-align: center;
justify-items: center;
@@ -184,6 +213,12 @@ p {
transform: translateY(1px);
}
.btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.btn.secondary {
background: #fff;
border-color: var(--line);
@@ -245,6 +280,64 @@ p {
letter-spacing: -0.02em;
}
.dashboard-top-grid {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.9fr);
}
.info-stack {
display: grid;
gap: 0.65rem;
}
.info-card {
border-radius: var(--radius-sm);
border: 1px solid var(--line);
background: #fff;
padding: 0.7rem 0.8rem;
display: grid;
gap: 0.2rem;
}
.info-card strong {
font-family: var(--font-heading);
letter-spacing: -0.02em;
font-size: 1.02rem;
}
.upload-progress {
display: grid;
gap: 0.45rem;
border-radius: var(--radius-sm);
border: 1px solid #bde1d9;
background: #ecf8f4;
padding: 0.58rem 0.66rem;
}
.upload-progress-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
font-size: 0.86rem;
}
.upload-progress-track {
width: 100%;
height: 8px;
border-radius: 999px;
overflow: hidden;
background: #d7ebe4;
}
.upload-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #0f766e, #189181);
transition: width 140ms linear;
}
.table-wrap {
width: 100%;
overflow-x: auto;
@@ -343,10 +436,18 @@ tbody tr:hover {
padding-top: 1.3rem;
}
.page-header {
padding: 0.8rem;
}
.panel {
padding: 0.85rem;
}
.dashboard-top-grid {
grid-template-columns: 1fr;
}
table {
min-width: 620px;
}

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>

View File

@@ -23,7 +23,6 @@ import {
isValidNodeName,
resolveAdminPath,
safeBaseName,
sanitizeExtension,
sanitizeRelativePath,
} from './files.js';
import {
@@ -264,8 +263,7 @@ export async function uploadFileAction(formData) {
const now = Date.now();
const originalName = safeBaseName(uploadedFile.name, 'upload');
const extension = sanitizeExtension(originalName);
const storedName = `${createRandomId()}${extension}`;
const storedName = createRandomId();
const storedPath = path.join(shareDir, storedName);
const retentionSeconds = parseHours(formData.get('retentionHours'), uploadTtlSeconds);

View File

@@ -53,6 +53,15 @@ export function sanitizeExtension(value) {
return /^\.[a-z0-9]{1,10}$/.test(extension) ? extension : '';
}
export function sharedLinkName(storedName) {
const value = String(storedName || '').trim();
const extension = sanitizeExtension(value);
if (!extension) {
return value;
}
return value.slice(0, -extension.length);
}
export function adminFilesHref(relativePath = '') {
const clean = sanitizeRelativePath(relativePath);
if (!clean) {