progress bar + better ui
This commit is contained in:
@@ -31,6 +31,28 @@ function contentDisposition(filename) {
|
|||||||
return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
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 }) {
|
export async function GET(request, { params }) {
|
||||||
await runCleanupIfNeeded();
|
await runCleanupIfNeeded();
|
||||||
|
|
||||||
@@ -40,7 +62,7 @@ export async function GET(request, { params }) {
|
|||||||
return new NextResponse('Ungültiger Dateiname', { status: 400 });
|
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 filePath;
|
||||||
let downloadName;
|
let downloadName;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
--bg-accent: #d9ece4;
|
--bg-accent: #d9ece4;
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--surface-soft: #f7fafc;
|
--surface-soft: #f7fafc;
|
||||||
|
--surface-glass: rgb(255 255 255 / 0.72);
|
||||||
--text-main: #10243a;
|
--text-main: #10243a;
|
||||||
--text-muted: #566b81;
|
--text-muted: #566b81;
|
||||||
--line: #d6e0ea;
|
--line: #d6e0ea;
|
||||||
@@ -59,7 +60,7 @@ p {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2.2rem 0 3.5rem;
|
padding: 2.2rem 0 3.5rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.6rem;
|
||||||
animation: page-enter 240ms ease-out both;
|
animation: page-enter 240ms ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +74,11 @@ p {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.9rem;
|
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 {
|
.header-main {
|
||||||
@@ -92,11 +98,34 @@ p {
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
padding: 1.1rem;
|
padding: 1.2rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.95rem;
|
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 {
|
.panel.centered {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
@@ -184,6 +213,12 @@ p {
|
|||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.btn.secondary {
|
.btn.secondary {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-color: var(--line);
|
border-color: var(--line);
|
||||||
@@ -245,6 +280,64 @@ p {
|
|||||||
letter-spacing: -0.02em;
|
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 {
|
.table-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -343,10 +436,18 @@ tbody tr:hover {
|
|||||||
padding-top: 1.3rem;
|
padding-top: 1.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
padding: 0.85rem;
|
padding: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-top-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
min-width: 620px;
|
min-width: 620px;
|
||||||
}
|
}
|
||||||
|
|||||||
112
nextjs/app/manage/_components/upload-progress-form.js
Normal file
112
nextjs/app/manage/_components/upload-progress-form.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '@/src/lib/actions.js';
|
} from '@/src/lib/actions.js';
|
||||||
import { adminHash } from '@/src/lib/config.js';
|
import { adminHash } from '@/src/lib/config.js';
|
||||||
import { all, get, runCleanupIfNeeded } from '@/src/lib/db.js';
|
import { all, get, runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||||
|
import { sharedLinkName } from '@/src/lib/files.js';
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatCountdown,
|
formatCountdown,
|
||||||
@@ -152,7 +153,8 @@ export default async function AdminDashboardPage({ searchParams }) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{allUploads.map((item) => {
|
{allUploads.map((item) => {
|
||||||
const sharePath = `/_share/${encodeURIComponent(item.stored_name)}`;
|
const shareName = sharedLinkName(item.stored_name);
|
||||||
|
const sharePath = `/_share/${encodeURIComponent(shareName)}`;
|
||||||
return (
|
return (
|
||||||
<tr key={item.id}>
|
<tr key={item.id}>
|
||||||
<td>{item.owner}</td>
|
<td>{item.owner}</td>
|
||||||
|
|||||||
193
nextjs/app/manage/api/upload/route.js
Normal file
193
nextjs/app/manage/api/upload/route.js
Normal 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.' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
deleteOwnUploadAction,
|
deleteOwnUploadAction,
|
||||||
extendOwnUploadAction,
|
extendOwnUploadAction,
|
||||||
uploadFileAction,
|
|
||||||
userLogoutAction,
|
userLogoutAction,
|
||||||
} from '@/src/lib/actions.js';
|
} from '@/src/lib/actions.js';
|
||||||
import { all, runCleanupIfNeeded } from '@/src/lib/db.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 { formatBytes, formatCountdown, formatTimestamp, readSearchParam } from '@/src/lib/format.js';
|
||||||
import { ensureCsrfToken, requireAuthenticatedUser } from '@/src/lib/security.js';
|
import { ensureCsrfToken, requireAuthenticatedUser } from '@/src/lib/security.js';
|
||||||
|
|
||||||
import { CopyLinkButton } from '../_components/copy-link-button.js';
|
import { CopyLinkButton } from '../_components/copy-link-button.js';
|
||||||
import { StatusMessage } from '../_components/status-message.js';
|
import { StatusMessage } from '../_components/status-message.js';
|
||||||
|
import { UploadProgressForm } from '../_components/upload-progress-form.js';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ export default async function DashboardPage({ searchParams }) {
|
|||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
const error = readSearchParam(resolvedSearchParams, 'error');
|
const error = readSearchParam(resolvedSearchParams, 'error');
|
||||||
const success = readSearchParam(resolvedSearchParams, 'success');
|
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||||
|
const totalBytes = uploads.reduce((total, item) => total + (Number(item.size_bytes) || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page-shell">
|
<main className="page-shell">
|
||||||
@@ -51,28 +53,33 @@ export default async function DashboardPage({ searchParams }) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="panel">
|
<StatusMessage error={error} success={success} />
|
||||||
<h2>Neue Datei hochladen</h2>
|
|
||||||
<StatusMessage error={error} success={success} />
|
|
||||||
|
|
||||||
<form className="form-grid" action={uploadFileAction} encType="multipart/form-data">
|
<div className="dashboard-top-grid">
|
||||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
<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">
|
<aside className="panel panel-soft">
|
||||||
Datei
|
<h2>Schnellüberblick</h2>
|
||||||
<input className="input" name="file" type="file" required />
|
<div className="info-stack">
|
||||||
</label>
|
<div className="info-card">
|
||||||
|
<strong>{uploads.length}</strong>
|
||||||
<label className="field">
|
<span className="muted">aktive Uploads</span>
|
||||||
Aufbewahrung in Stunden
|
</div>
|
||||||
<input className="input" name="retentionHours" placeholder="168" />
|
<div className="info-card">
|
||||||
</label>
|
<strong>{totalBytes > 0 ? formatBytes(totalBytes) : '0 B'}</strong>
|
||||||
|
<span className="muted">genutzter Speicher</span>
|
||||||
<button className="btn" type="submit">
|
</div>
|
||||||
Hochladen
|
<div className="info-card">
|
||||||
</button>
|
<strong>/_share/<id></strong>
|
||||||
</form>
|
<span className="muted">Kurzlinks ohne Dateiendung</span>
|
||||||
</section>
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<h2>Aktuelle Uploads</h2>
|
<h2>Aktuelle Uploads</h2>
|
||||||
@@ -92,7 +99,8 @@ export default async function DashboardPage({ searchParams }) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{uploads.map((item) => {
|
{uploads.map((item) => {
|
||||||
const sharePath = `/_share/${encodeURIComponent(item.stored_name)}`;
|
const shareName = sharedLinkName(item.stored_name);
|
||||||
|
const sharePath = `/_share/${encodeURIComponent(shareName)}`;
|
||||||
return (
|
return (
|
||||||
<tr key={item.id}>
|
<tr key={item.id}>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
isValidNodeName,
|
isValidNodeName,
|
||||||
resolveAdminPath,
|
resolveAdminPath,
|
||||||
safeBaseName,
|
safeBaseName,
|
||||||
sanitizeExtension,
|
|
||||||
sanitizeRelativePath,
|
sanitizeRelativePath,
|
||||||
} from './files.js';
|
} from './files.js';
|
||||||
import {
|
import {
|
||||||
@@ -264,8 +263,7 @@ export async function uploadFileAction(formData) {
|
|||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const originalName = safeBaseName(uploadedFile.name, 'upload');
|
const originalName = safeBaseName(uploadedFile.name, 'upload');
|
||||||
const extension = sanitizeExtension(originalName);
|
const storedName = createRandomId();
|
||||||
const storedName = `${createRandomId()}${extension}`;
|
|
||||||
const storedPath = path.join(shareDir, storedName);
|
const storedPath = path.join(shareDir, storedName);
|
||||||
|
|
||||||
const retentionSeconds = parseHours(formData.get('retentionHours'), uploadTtlSeconds);
|
const retentionSeconds = parseHours(formData.get('retentionHours'), uploadTtlSeconds);
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ export function sanitizeExtension(value) {
|
|||||||
return /^\.[a-z0-9]{1,10}$/.test(extension) ? extension : '';
|
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 = '') {
|
export function adminFilesHref(relativePath = '') {
|
||||||
const clean = sanitizeRelativePath(relativePath);
|
const clean = sanitizeRelativePath(relativePath);
|
||||||
if (!clean) {
|
if (!clean) {
|
||||||
|
|||||||
Reference in New Issue
Block a user