added requesting option
This commit is contained in:
@@ -5,3 +5,9 @@ UPLOAD_TTL_SECONDS=604800
|
|||||||
UPLOAD_MAX_BYTES=0
|
UPLOAD_MAX_BYTES=0
|
||||||
MANAGEMENT_ADMIN_HASH=
|
MANAGEMENT_ADMIN_HASH=
|
||||||
COOKIE_SECURE=true
|
COOKIE_SECURE=true
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_MAIL=
|
||||||
|
SMTP_NAME=
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ File server infrastructure hosted on [files.lehnert.cloud](https://files.lehnert
|
|||||||
- Benutzer-Dashboard: `/manage/login`
|
- Benutzer-Dashboard: `/manage/login`
|
||||||
- Admin-Dashboard: `/manage/admin`
|
- Admin-Dashboard: `/manage/admin`
|
||||||
- Datei-Downloads: `/_share/<datei>`
|
- Datei-Downloads: `/_share/<datei>`
|
||||||
|
- Upload-Anfragen: `/_request/<id>`
|
||||||
|
|
||||||
## Lokale Initialisierung
|
## Lokale Initialisierung
|
||||||
|
|
||||||
@@ -23,5 +24,6 @@ File server infrastructure hosted on [files.lehnert.cloud](https://files.lehnert
|
|||||||
Danach:
|
Danach:
|
||||||
|
|
||||||
1. `.env` anpassen (`SERVICE_FQDN`, `LETSENCRYPT_EMAIL`, `DATA_DIR`, `UPLOAD_TTL_SECONDS`, `MANAGEMENT_ADMIN_HASH`, optional `UPLOAD_MAX_BYTES` und `COOKIE_SECURE`)
|
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`
|
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. Als Admin anmelden und Benutzer über die UI anlegen
|
3. Stack starten: `docker compose up --build`
|
||||||
|
4. Als Admin anmelden und Benutzer über die UI anlegen
|
||||||
|
|||||||
@@ -66,7 +66,15 @@ services:
|
|||||||
- DATA_DIR=/data
|
- DATA_DIR=/data
|
||||||
- DB_PATH=/app/data/uploads.sqlite
|
- DB_PATH=/app/data/uploads.sqlite
|
||||||
- UPLOAD_TTL_SECONDS=${UPLOAD_TTL_SECONDS}
|
- UPLOAD_TTL_SECONDS=${UPLOAD_TTL_SECONDS}
|
||||||
|
- UPLOAD_MAX_BYTES=${UPLOAD_MAX_BYTES}
|
||||||
- MANAGEMENT_ADMIN_HASH=${MANAGEMENT_ADMIN_HASH}
|
- 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
|
- PORT=3000
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
@@ -75,7 +83,7 @@ services:
|
|||||||
|
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "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.entrypoints=websecure"
|
||||||
- "traefik.http.routers.nextjs.tls=true"
|
- "traefik.http.routers.nextjs.tls=true"
|
||||||
- "traefik.http.routers.nextjs.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.nextjs.tls.certresolver=letsencrypt"
|
||||||
@@ -83,7 +91,7 @@ services:
|
|||||||
- "traefik.http.services.nextjs-svc.loadbalancer.server.port=3000"
|
- "traefik.http.services.nextjs-svc.loadbalancer.server.port=3000"
|
||||||
- "traefik.http.routers.nextjs.priority=10"
|
- "traefik.http.routers.nextjs.priority=10"
|
||||||
# Optional HTTP redirect
|
# 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.entrypoints=web"
|
||||||
- "traefik.http.routers.nextjs-http.middlewares=nextjs-https-redirect"
|
- "traefik.http.routers.nextjs-http.middlewares=nextjs-https-redirect"
|
||||||
- "traefik.http.middlewares.nextjs-https-redirect.redirectscheme.scheme=https"
|
- "traefik.http.middlewares.nextjs-https-redirect.redirectscheme.scheme=https"
|
||||||
|
|||||||
@@ -23,4 +23,5 @@ echo "Initialization complete."
|
|||||||
echo "Next steps:"
|
echo "Next steps:"
|
||||||
echo "1) Edit .env and set SERVICE_FQDN, LETSENCRYPT_EMAIL, DATA_DIR, UPLOAD_TTL_SECONDS, optional UPLOAD_MAX_BYTES"
|
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 "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"
|
||||||
|
|||||||
146
nextjs/app/%5Frequest/[id]/page.js
Normal file
146
nextjs/app/%5Frequest/[id]/page.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
nextjs/app/%5Frequest/[id]/upload/route.js
Normal file
246
nextjs/app/%5Frequest/[id]/upload/route.js
Normal 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.',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -259,6 +259,35 @@ p {
|
|||||||
color: #0c4f4a;
|
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 {
|
.metric-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
createUploadRequestAction,
|
||||||
deleteOwnUploadAction,
|
deleteOwnUploadAction,
|
||||||
extendOwnUploadAction,
|
extendOwnUploadAction,
|
||||||
userLogoutAction,
|
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',
|
'SELECT id, original_name, stored_name, size_bytes, uploaded_at, expires_at FROM uploads WHERE owner = ? ORDER BY uploaded_at DESC',
|
||||||
[user.username]
|
[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 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);
|
const totalBytes = uploads.reduce((total, item) => total + (Number(item.size_bytes) || 0), 0);
|
||||||
|
const nowTs = Date.now();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page-shell">
|
<main className="page-shell">
|
||||||
@@ -76,6 +86,83 @@ export default async function DashboardPage({ searchParams }) {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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">
|
<section className="panel">
|
||||||
<h2>Aktuelle Uploads</h2>
|
<h2>Aktuelle Uploads</h2>
|
||||||
{uploads.length === 0 ? (
|
{uploads.length === 0 ? (
|
||||||
|
|||||||
10
nextjs/package-lock.json
generated
10
nextjs/package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"next": "^16.2.1",
|
"next": "^16.2.1",
|
||||||
|
"nodemailer": "^8.0.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7"
|
||||||
@@ -1855,6 +1856,15 @@
|
|||||||
"node": ">= 10.12.0"
|
"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": {
|
"node_modules/nopt": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"next": "^16.2.1",
|
"next": "^16.2.1",
|
||||||
|
"nodemailer": "^8.0.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7"
|
||||||
|
|||||||
@@ -369,6 +369,54 @@ export async function extendOwnUploadAction(formData) {
|
|||||||
redirectWithSuccess(`${managementBasePath}/dashboard`, 'Aufbewahrung aktualisiert.');
|
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) {
|
export async function adminCreateUserAction(formData) {
|
||||||
try {
|
try {
|
||||||
await verifyCsrf(formData);
|
await verifyCsrf(formData);
|
||||||
|
|||||||
@@ -14,3 +14,9 @@ export const uploadTtlSeconds = parseInteger(process.env.UPLOAD_TTL_SECONDS || '
|
|||||||
export const maxRetentionSeconds = 90 * 24 * 60 * 60;
|
export const maxRetentionSeconds = 90 * 24 * 60 * 60;
|
||||||
export const maxUploadBytes = parseInteger(process.env.UPLOAD_MAX_BYTES || '0', 0);
|
export const maxUploadBytes = parseInteger(process.env.UPLOAD_MAX_BYTES || '0', 0);
|
||||||
export const cookieSecure = process.env.COOKIE_SECURE === 'true';
|
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();
|
||||||
|
|||||||
@@ -53,9 +53,34 @@ function initializeSchema(database) {
|
|||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
created_at INTEGER 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 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 ip TEXT', () => undefined);
|
||||||
database.run('ALTER TABLE admin_logs ADD COLUMN user_agent 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
110
nextjs/src/lib/mailer.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user