From 83fbeff16cd1c17a719a6f89675618fccefd080b Mon Sep 17 00:00:00 2001 From: Ludwig Lehnert Date: Fri, 27 Mar 2026 20:08:40 +0100 Subject: [PATCH] progress bar + better ui --- nextjs/app/%5Fshare/[filename]/route.js | 24 ++- nextjs/app/globals.css | 105 +++++++++- .../_components/upload-progress-form.js | 112 ++++++++++ nextjs/app/manage/admin/dashboard/page.js | 4 +- nextjs/app/manage/api/upload/route.js | 193 ++++++++++++++++++ nextjs/app/manage/dashboard/page.js | 52 +++-- nextjs/src/lib/actions.js | 4 +- nextjs/src/lib/files.js | 9 + 8 files changed, 474 insertions(+), 29 deletions(-) create mode 100644 nextjs/app/manage/_components/upload-progress-form.js create mode 100644 nextjs/app/manage/api/upload/route.js diff --git a/nextjs/app/%5Fshare/[filename]/route.js b/nextjs/app/%5Fshare/[filename]/route.js index c4dbdf5..5800793 100644 --- a/nextjs/app/%5Fshare/[filename]/route.js +++ b/nextjs/app/%5Fshare/[filename]/route.js @@ -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; diff --git a/nextjs/app/globals.css b/nextjs/app/globals.css index d6887d6..6cb7a17 100644 --- a/nextjs/app/globals.css +++ b/nextjs/app/globals.css @@ -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; } diff --git a/nextjs/app/manage/_components/upload-progress-form.js b/nextjs/app/manage/_components/upload-progress-form.js new file mode 100644 index 0000000..ae28b61 --- /dev/null +++ b/nextjs/app/manage/_components/upload-progress-form.js @@ -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 ( +
+ + + + + + + {uploading ? ( +
+
+ Upload läuft … + {progress}% +
+ + ) : null} + + {localError ?
{localError}
: null} + + + + ); +} diff --git a/nextjs/app/manage/admin/dashboard/page.js b/nextjs/app/manage/admin/dashboard/page.js index b92556e..89ed561 100644 --- a/nextjs/app/manage/admin/dashboard/page.js +++ b/nextjs/app/manage/admin/dashboard/page.js @@ -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 }) { {allUploads.map((item) => { - const sharePath = `/_share/${encodeURIComponent(item.stored_name)}`; + const shareName = sharedLinkName(item.stored_name); + const sharePath = `/_share/${encodeURIComponent(shareName)}`; return ( {item.owner} diff --git a/nextjs/app/manage/api/upload/route.js b/nextjs/app/manage/api/upload/route.js new file mode 100644 index 0000000..1578f51 --- /dev/null +++ b/nextjs/app/manage/api/upload/route.js @@ -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.' }), + }); +} diff --git a/nextjs/app/manage/dashboard/page.js b/nextjs/app/manage/dashboard/page.js index 00a6c90..8a336c7 100644 --- a/nextjs/app/manage/dashboard/page.js +++ b/nextjs/app/manage/dashboard/page.js @@ -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 (
@@ -51,28 +53,33 @@ export default async function DashboardPage({ searchParams }) {
-
-

Neue Datei hochladen

- + -
- +
+
+

Neue Datei hochladen

+

Der Fortschritt wird während des Uploads live angezeigt.

+ +
- - - - - - -
+ +

Aktuelle Uploads

@@ -92,7 +99,8 @@ export default async function DashboardPage({ searchParams }) { {uploads.map((item) => { - const sharePath = `/_share/${encodeURIComponent(item.stored_name)}`; + const shareName = sharedLinkName(item.stored_name); + const sharePath = `/_share/${encodeURIComponent(shareName)}`; return ( diff --git a/nextjs/src/lib/actions.js b/nextjs/src/lib/actions.js index adb13e7..ba1a38c 100644 --- a/nextjs/src/lib/actions.js +++ b/nextjs/src/lib/actions.js @@ -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); diff --git a/nextjs/src/lib/files.js b/nextjs/src/lib/files.js index a945f9a..237ee2f 100644 --- a/nextjs/src/lib/files.js +++ b/nextjs/src/lib/files.js @@ -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) {