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

@@ -5,3 +5,9 @@ UPLOAD_TTL_SECONDS=604800
UPLOAD_MAX_BYTES=0
MANAGEMENT_ADMIN_HASH=
COOKIE_SECURE=true
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_MAIL=
SMTP_NAME=

View File

@@ -13,6 +13,7 @@ File server infrastructure hosted on [files.lehnert.cloud](https://files.lehnert
- Benutzer-Dashboard: `/manage/login`
- Admin-Dashboard: `/manage/admin`
- Datei-Downloads: `/_share/<datei>`
- Upload-Anfragen: `/_request/<id>`
## Lokale Initialisierung
@@ -23,5 +24,6 @@ File server infrastructure hosted on [files.lehnert.cloud](https://files.lehnert
Danach:
1. `.env` anpassen (`SERVICE_FQDN`, `LETSENCRYPT_EMAIL`, `DATA_DIR`, `UPLOAD_TTL_SECONDS`, `MANAGEMENT_ADMIN_HASH`, optional `UPLOAD_MAX_BYTES` und `COOKIE_SECURE`)
2. Stack starten: `docker compose up --build`
3. Als Admin anmelden und Benutzer über die UI anlegen
2. Für Upload-Anfragen mit E-Mail-Benachrichtigung SMTP setzen (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_MAIL`, `SMTP_NAME`; Absender: `SMTP_NAME <SMTP_MAIL>`)
3. Stack starten: `docker compose up --build`
4. Als Admin anmelden und Benutzer über die UI anlegen

View File

@@ -66,7 +66,15 @@ services:
- DATA_DIR=/data
- DB_PATH=/app/data/uploads.sqlite
- UPLOAD_TTL_SECONDS=${UPLOAD_TTL_SECONDS}
- UPLOAD_MAX_BYTES=${UPLOAD_MAX_BYTES}
- MANAGEMENT_ADMIN_HASH=${MANAGEMENT_ADMIN_HASH}
- COOKIE_SECURE=${COOKIE_SECURE}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- SMTP_MAIL=${SMTP_MAIL}
- SMTP_NAME=${SMTP_NAME}
- PORT=3000
volumes:
@@ -75,7 +83,7 @@ services:
labels:
- "traefik.enable=true"
- "traefik.http.routers.nextjs.rule=Host(`${SERVICE_FQDN}`) && (PathPrefix(`/manage`) || PathPrefix(`/_share`))"
- "traefik.http.routers.nextjs.rule=Host(`${SERVICE_FQDN}`) && (PathPrefix(`/manage`) || PathPrefix(`/_share`) || PathPrefix(`/_request`))"
- "traefik.http.routers.nextjs.entrypoints=websecure"
- "traefik.http.routers.nextjs.tls=true"
- "traefik.http.routers.nextjs.tls.certresolver=letsencrypt"
@@ -83,7 +91,7 @@ services:
- "traefik.http.services.nextjs-svc.loadbalancer.server.port=3000"
- "traefik.http.routers.nextjs.priority=10"
# Optional HTTP redirect
- "traefik.http.routers.nextjs-http.rule=Host(`${SERVICE_FQDN}`) && (PathPrefix(`/manage`) || PathPrefix(`/_share`))"
- "traefik.http.routers.nextjs-http.rule=Host(`${SERVICE_FQDN}`) && (PathPrefix(`/manage`) || PathPrefix(`/_share`) || PathPrefix(`/_request`))"
- "traefik.http.routers.nextjs-http.entrypoints=web"
- "traefik.http.routers.nextjs-http.middlewares=nextjs-https-redirect"
- "traefik.http.middlewares.nextjs-https-redirect.redirectscheme.scheme=https"

View File

@@ -23,4 +23,5 @@ echo "Initialization complete."
echo "Next steps:"
echo "1) Edit .env and set SERVICE_FQDN, LETSENCRYPT_EMAIL, DATA_DIR, UPLOAD_TTL_SECONDS, optional UPLOAD_MAX_BYTES"
echo "2) Set MANAGEMENT_ADMIN_HASH in .env for admin login"
echo "3) Start with docker compose up --build"
echo "3) Optional for upload-request notifications: set SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_MAIL, SMTP_NAME"
echo "4) Start with docker compose up --build"

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

View File

@@ -259,6 +259,35 @@ p {
color: #0c4f4a;
}
.badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid transparent;
padding: 0.18rem 0.5rem;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.badge.primary {
background: #e5f5ef;
border-color: #bce6d9;
color: #0c4f4a;
}
.badge.success {
background: #e6f7ec;
border-color: #bee8ce;
color: #1c6b42;
}
.badge.danger {
background: #fee7e4;
border-color: #fbc0b8;
color: #a72d20;
}
.metric-grid {
display: grid;
gap: 0.8rem;

View File

@@ -1,4 +1,5 @@
import {
createUploadRequestAction,
deleteOwnUploadAction,
extendOwnUploadAction,
userLogoutAction,
@@ -23,11 +24,20 @@ export default async function DashboardPage({ searchParams }) {
'SELECT id, original_name, stored_name, size_bytes, uploaded_at, expires_at FROM uploads WHERE owner = ? ORDER BY uploaded_at DESC',
[user.username]
);
const uploadRequests = await all(
`SELECT id, note, created_at, expires_at, completed_at, uploaded_original_name
FROM upload_requests
WHERE owner = ?
ORDER BY created_at DESC
LIMIT 200`,
[user.username]
);
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);
const nowTs = Date.now();
return (
<main className="page-shell">
@@ -76,6 +86,83 @@ export default async function DashboardPage({ searchParams }) {
</aside>
</div>
<section className="panel">
<h2>Upload-Anfragen</h2>
<p className="muted">Benachrichtigung erfolgt an deinen Benutzernamen (E-Mail-Adresse).</p>
<form className="form-grid" action={createUploadRequestAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<label className="field">
Notiz (optional)
<input className="input" name="note" placeholder="z. B. Bitte die Rechnung als PDF senden" />
</label>
<label className="field">
Gültig in Stunden
<input className="input" name="expiresHours" placeholder="72" />
</label>
<button className="btn" type="submit">
Anfrage erstellen
</button>
</form>
{uploadRequests.length === 0 ? (
<p className="muted">Noch keine Upload-Anfragen.</p>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Anfrage</th>
<th>Status</th>
<th>Erstellt</th>
<th>Gültig bis</th>
<th>Ergebnis</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{uploadRequests.map((entry) => {
const requestPath = `/_request/${encodeURIComponent(entry.id)}`;
const isCompleted = Number(entry.completed_at || 0) > 0;
const isExpired = !isCompleted && Number(entry.expires_at || 0) <= nowTs;
return (
<tr key={entry.id}>
<td>
<strong>{entry.id}</strong>
{entry.note ? <div className="muted">{entry.note}</div> : null}
</td>
<td>
<span
className={`badge ${
isCompleted ? 'success' : isExpired ? 'danger' : 'primary'
}`}
>
{isCompleted ? 'Abgeschlossen' : isExpired ? 'Abgelaufen' : 'Offen'}
</span>
</td>
<td>{formatTimestamp(entry.created_at)}</td>
<td>{formatTimestamp(entry.expires_at)}</td>
<td>{entry.uploaded_original_name || '-'}</td>
<td>
<div className="row-actions">
<a className="btn secondary" href={requestPath}>
Öffnen
</a>
<CopyLinkButton path={requestPath} label="Upload-Anfrage" />
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
<section className="panel">
<h2>Aktuelle Uploads</h2>
{uploads.length === 0 ? (

View File

@@ -11,6 +11,7 @@
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.3",
"next": "^16.2.1",
"nodemailer": "^8.0.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sqlite3": "^5.1.7"
@@ -1855,6 +1856,15 @@
"node": ">= 10.12.0"
}
},
"node_modules/nodemailer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",

View File

@@ -11,6 +11,7 @@
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.3",
"next": "^16.2.1",
"nodemailer": "^8.0.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sqlite3": "^5.1.7"

View File

@@ -369,6 +369,54 @@ export async function extendOwnUploadAction(formData) {
redirectWithSuccess(`${managementBasePath}/dashboard`, 'Aufbewahrung aktualisiert.');
}
export async function createUploadRequestAction(formData) {
await runCleanupIfNeeded();
try {
await verifyCsrf(formData);
} catch {
redirectWithError(`${managementBasePath}/dashboard`, 'CSRF-Prüfung fehlgeschlagen.');
}
const user = await requireAuthenticatedUser();
const note = String(formData.get('note') || '').trim().slice(0, 500);
const now = Date.now();
const expiresSeconds = parseHours(formData.get('expiresHours'), 72 * 3600);
const expiresAt = Math.min(now + expiresSeconds * 1000, now + maxRetentionSeconds * 1000);
let requestId = '';
for (let attempts = 0; attempts < 12; attempts += 1) {
const candidate = createRandomId();
const existing = await get('SELECT id FROM upload_requests WHERE id = ?', [candidate]);
if (!existing) {
requestId = candidate;
break;
}
}
if (!requestId) {
redirectWithError(`${managementBasePath}/dashboard`, 'Anfrage-ID konnte nicht erzeugt werden.');
}
try {
await run(
'INSERT INTO upload_requests (id, owner, note, created_at, expires_at) VALUES (?, ?, ?, ?, ?)',
[requestId, user.username, note || null, now, expiresAt]
);
} catch {
redirectWithError(`${managementBasePath}/dashboard`, 'Anfrage konnte nicht gespeichert werden.');
}
await logEvent(
'request_create',
user.username,
{ request_id: requestId, expires_at: expiresAt, note: note || null },
await getRequestMeta()
);
redirectWithSuccess(`${managementBasePath}/dashboard`, 'Upload-Anfrage erstellt.');
}
export async function adminCreateUserAction(formData) {
try {
await verifyCsrf(formData);

View File

@@ -14,3 +14,9 @@ export const uploadTtlSeconds = parseInteger(process.env.UPLOAD_TTL_SECONDS || '
export const maxRetentionSeconds = 90 * 24 * 60 * 60;
export const maxUploadBytes = parseInteger(process.env.UPLOAD_MAX_BYTES || '0', 0);
export const cookieSecure = process.env.COOKIE_SECURE === 'true';
export const smtpHost = String(process.env.SMTP_HOST || '').trim();
export const smtpPort = parseInteger(process.env.SMTP_PORT || '587', 587);
export const smtpUser = String(process.env.SMTP_USER || '').trim();
export const smtpPass = String(process.env.SMTP_PASS || '');
export const smtpMail = String(process.env.SMTP_MAIL || '').trim();
export const smtpName = String(process.env.SMTP_NAME || '').trim();

View File

@@ -53,9 +53,34 @@ function initializeSchema(database) {
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
)`);
database.run(`CREATE TABLE IF NOT EXISTS upload_requests (
id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
note TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
completed_at INTEGER,
upload_id INTEGER,
uploaded_original_name TEXT,
uploaded_stored_name TEXT,
uploaded_stored_path TEXT,
uploaded_size_bytes INTEGER,
fulfilled_by TEXT,
notification_sent_at INTEGER
)`);
database.run('CREATE INDEX IF NOT EXISTS upload_requests_owner_idx ON upload_requests(owner)');
database.run('CREATE INDEX IF NOT EXISTS upload_requests_expires_idx ON upload_requests(expires_at)');
database.run('CREATE INDEX IF NOT EXISTS upload_requests_completed_idx ON upload_requests(completed_at)');
database.run('ALTER TABLE uploads ADD COLUMN downloads INTEGER DEFAULT 0', () => undefined);
database.run('ALTER TABLE admin_logs ADD COLUMN ip TEXT', () => undefined);
database.run('ALTER TABLE admin_logs ADD COLUMN user_agent TEXT', () => undefined);
database.run('ALTER TABLE upload_requests ADD COLUMN upload_id INTEGER', () => undefined);
database.run('ALTER TABLE upload_requests ADD COLUMN uploaded_original_name TEXT', () => undefined);
database.run('ALTER TABLE upload_requests ADD COLUMN uploaded_stored_name TEXT', () => undefined);
database.run('ALTER TABLE upload_requests ADD COLUMN uploaded_stored_path TEXT', () => undefined);
database.run('ALTER TABLE upload_requests ADD COLUMN uploaded_size_bytes INTEGER', () => undefined);
database.run('ALTER TABLE upload_requests ADD COLUMN fulfilled_by TEXT', () => undefined);
database.run('ALTER TABLE upload_requests ADD COLUMN notification_sent_at INTEGER', () => undefined);
});
}

110
nextjs/src/lib/mailer.js Normal file
View File

@@ -0,0 +1,110 @@
import nodemailer from 'nodemailer';
import {
smtpHost,
smtpMail,
smtpName,
smtpPass,
smtpPort,
smtpUser,
} from './config.js';
const state = globalThis.__filesLehnertMailerState || (globalThis.__filesLehnertMailerState = {});
function isSmtpConfigured() {
return Boolean(smtpHost && smtpPort > 0 && smtpUser && smtpPass && smtpMail);
}
function senderAddress() {
if (!smtpMail) {
return '';
}
return smtpName ? `${smtpName} <${smtpMail}>` : smtpMail;
}
function smtpTransport() {
if (!isSmtpConfigured()) {
return null;
}
if (!state.transporter) {
state.transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpPort === 465,
auth: {
user: smtpUser,
pass: smtpPass,
},
});
}
return state.transporter;
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export async function sendUploadRequestCompletedMail({
recipient,
requestId,
fileName,
downloadUrl,
requesterNote,
fulfilledBy,
}) {
const transporter = smtpTransport();
if (!transporter) {
return {
ok: false,
reason: 'smtp-not-configured',
};
}
const noteText = requesterNote ? `Notiz: ${requesterNote}\n` : '';
const fulfilledByText = fulfilledBy ? `Hochgeladen von: ${fulfilledBy}\n` : '';
const text = [
'Eine Upload-Anfrage wurde abgeschlossen.',
'',
`Anfrage-ID: ${requestId}`,
`Datei: ${fileName}`,
fulfilledByText.trim(),
noteText.trim(),
`Download: ${downloadUrl}`,
]
.filter(Boolean)
.join('\n');
const htmlLines = [
'<p>Eine Upload-Anfrage wurde abgeschlossen.</p>',
`<p><strong>Anfrage-ID:</strong> ${escapeHtml(requestId)}</p>`,
`<p><strong>Datei:</strong> ${escapeHtml(fileName)}</p>`,
fulfilledBy ? `<p><strong>Hochgeladen von:</strong> ${escapeHtml(fulfilledBy)}</p>` : '',
requesterNote ? `<p><strong>Notiz:</strong> ${escapeHtml(requesterNote)}</p>` : '',
`<p><a href="${escapeHtml(downloadUrl)}">Download öffnen</a></p>`,
]
.filter(Boolean)
.join('');
try {
await transporter.sendMail({
from: senderAddress(),
to: recipient,
subject: `Upload-Anfrage ${requestId} abgeschlossen`,
text,
html: htmlLines,
});
return { ok: true };
} catch (error) {
return {
ok: false,
reason: error && error.message ? error.message : 'smtp-send-failed',
};
}
}