diff --git a/.env.example b/.env.example index cd7d458..efae0ca 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 4677d2e..3eb8602 100644 --- a/README.md +++ b/README.md @@ -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/` +- Upload-Anfragen: `/_request/` ## 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 `) +3. Stack starten: `docker compose up --build` +4. Als Admin anmelden und Benutzer über die UI anlegen diff --git a/docker-compose.yml b/docker-compose.yml index 697e905..6e47a26 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/initialize.sh b/initialize.sh index 6eae967..66962ac 100755 --- a/initialize.sh +++ b/initialize.sh @@ -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" diff --git a/nextjs/app/%5Frequest/[id]/page.js b/nextjs/app/%5Frequest/[id]/page.js new file mode 100644 index 0000000..0ddeceb --- /dev/null +++ b/nextjs/app/%5Frequest/[id]/page.js @@ -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 ( +
+
+

Ungültige Anfrage

+

Die Upload-Anfrage konnte nicht verarbeitet werden.

+
+
+ ); + } + + 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 ( +
+ +
+

Anfrage nicht gefunden

+

Diese Upload-Anfrage existiert nicht oder wurde entfernt.

+
+
+ ); + } + + return ( +
+
+
+

Datei-Anfrage

+

Anfrage-ID: {requestEntry.id}

+
+
+ + + +
+
+
+ Status + + {state === 'open' ? 'Offen' : state === 'completed' ? 'Bereits abgeschlossen' : 'Abgelaufen'} + +
+
+ Erstellt + {formatTimestamp(requestEntry.created_at)} +
+
+ Gültig bis + {formatTimestamp(requestEntry.expires_at)} +
+ {requestEntry.note ? ( +
+ Notiz + {requestEntry.note} +
+ ) : null} +
+
+ + {state === 'open' ? ( +
+

Datei hochladen

+
+ + + + + +
+
+ ) : null} + + {state === 'completed' ? ( +
+

Vielen Dank

+

+ {requestEntry.uploaded_original_name + ? `Diese Anfrage wurde bereits mit „${requestEntry.uploaded_original_name}“ abgeschlossen.` + : 'Diese Anfrage wurde bereits abgeschlossen.'} +

+
+ ) : null} +
+ ); +} diff --git a/nextjs/app/%5Frequest/[id]/upload/route.js b/nextjs/app/%5Frequest/[id]/upload/route.js new file mode 100644 index 0000000..9a9449c --- /dev/null +++ b/nextjs/app/%5Frequest/[id]/upload/route.js @@ -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.', + }); +} diff --git a/nextjs/app/globals.css b/nextjs/app/globals.css index d115890..c363e83 100644 --- a/nextjs/app/globals.css +++ b/nextjs/app/globals.css @@ -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; diff --git a/nextjs/app/manage/dashboard/page.js b/nextjs/app/manage/dashboard/page.js index 4ad9e83..989a0cc 100644 --- a/nextjs/app/manage/dashboard/page.js +++ b/nextjs/app/manage/dashboard/page.js @@ -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 (
@@ -76,6 +86,83 @@ export default async function DashboardPage({ searchParams }) { +
+

Upload-Anfragen

+

Benachrichtigung erfolgt an deinen Benutzernamen (E-Mail-Adresse).

+
+ + + + + + + +
+ + {uploadRequests.length === 0 ? ( +

Noch keine Upload-Anfragen.

+ ) : ( +
+ + + + + + + + + + + + + {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 ( + + + + + + + + + ); + })} + +
AnfrageStatusErstelltGültig bisErgebnisAktionen
+ {entry.id} + {entry.note ?
{entry.note}
: null} +
+ + {isCompleted ? 'Abgeschlossen' : isExpired ? 'Abgelaufen' : 'Offen'} + + {formatTimestamp(entry.created_at)}{formatTimestamp(entry.expires_at)}{entry.uploaded_original_name || '-'} + +
+
+ )} +
+

Aktuelle Uploads

{uploads.length === 0 ? ( diff --git a/nextjs/package-lock.json b/nextjs/package-lock.json index 81bedc2..f31111c 100644 --- a/nextjs/package-lock.json +++ b/nextjs/package-lock.json @@ -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", diff --git a/nextjs/package.json b/nextjs/package.json index d47bfe8..b1cbdaf 100644 --- a/nextjs/package.json +++ b/nextjs/package.json @@ -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" diff --git a/nextjs/src/lib/actions.js b/nextjs/src/lib/actions.js index ba1a38c..c3aee76 100644 --- a/nextjs/src/lib/actions.js +++ b/nextjs/src/lib/actions.js @@ -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); diff --git a/nextjs/src/lib/config.js b/nextjs/src/lib/config.js index ef5b29a..c0cefd6 100644 --- a/nextjs/src/lib/config.js +++ b/nextjs/src/lib/config.js @@ -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(); diff --git a/nextjs/src/lib/db.js b/nextjs/src/lib/db.js index bb1deb8..2357007 100644 --- a/nextjs/src/lib/db.js +++ b/nextjs/src/lib/db.js @@ -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); }); } diff --git a/nextjs/src/lib/mailer.js b/nextjs/src/lib/mailer.js new file mode 100644 index 0000000..0a30327 --- /dev/null +++ b/nextjs/src/lib/mailer.js @@ -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, '''); +} + +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 = [ + '

Eine Upload-Anfrage wurde abgeschlossen.

', + `

Anfrage-ID: ${escapeHtml(requestId)}

`, + `

Datei: ${escapeHtml(fileName)}

`, + fulfilledBy ? `

Hochgeladen von: ${escapeHtml(fulfilledBy)}

` : '', + requesterNote ? `

Notiz: ${escapeHtml(requesterNote)}

` : '', + `

Download öffnen

`, + ] + .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', + }; + } +}