diff --git a/.env.example b/.env.example index efae0ca..8b8b3ec 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ SERVICE_FQDN=files.example.com LETSENCRYPT_EMAIL=user@example.com +PUBLIC_BASE_URL= DATA_DIR=/storagebox UPLOAD_TTL_SECONDS=604800 UPLOAD_MAX_BYTES=0 diff --git a/README.md b/README.md index 3eb8602..761405c 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ 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. 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 +2. Optional `PUBLIC_BASE_URL` setzen, falls absolute Links in E-Mails einen festen Host verwenden sollen +3. 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 `) +4. Stack starten: `docker compose up --build` +5. Als Admin anmelden und Benutzer über die UI anlegen diff --git a/docker-compose.yml b/docker-compose.yml index 6e47a26..ca3f1ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,6 +65,8 @@ services: environment: - DATA_DIR=/data - DB_PATH=/app/data/uploads.sqlite + - SERVICE_FQDN=${SERVICE_FQDN} + - PUBLIC_BASE_URL=${PUBLIC_BASE_URL} - UPLOAD_TTL_SECONDS=${UPLOAD_TTL_SECONDS} - UPLOAD_MAX_BYTES=${UPLOAD_MAX_BYTES} - MANAGEMENT_ADMIN_HASH=${MANAGEMENT_ADMIN_HASH} diff --git a/initialize.sh b/initialize.sh index 66962ac..e29ec63 100755 --- a/initialize.sh +++ b/initialize.sh @@ -21,7 +21,7 @@ fi 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 "1) Edit .env and set SERVICE_FQDN, LETSENCRYPT_EMAIL, DATA_DIR, UPLOAD_TTL_SECONDS, optional UPLOAD_MAX_BYTES/PUBLIC_BASE_URL" echo "2) Set MANAGEMENT_ADMIN_HASH in .env for admin login" 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]/upload/route.js b/nextjs/app/%5Frequest/[id]/upload/route.js index 9a9449c..40a30a8 100644 --- a/nextjs/app/%5Frequest/[id]/upload/route.js +++ b/nextjs/app/%5Frequest/[id]/upload/route.js @@ -9,6 +9,7 @@ import { NextResponse } from 'next/server'; import { maxRetentionSeconds, maxUploadBytes, + publicBaseUrl, shareDir, uploadTtlSeconds, } from '@/src/lib/config.js'; @@ -41,8 +42,49 @@ function requestPageHref(requestId, params = {}) { 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 redirectToRequest(requestId, params = {}) { + return new NextResponse(null, { + status: 303, + headers: { + location: requestPageHref(requestId, params), + }, + }); +} + +function firstForwardedPart(value) { + if (!value) { + return ''; + } + return value.split(',')[0].trim(); +} + +function requestOrigin(request) { + const forwardedHost = firstForwardedPart(request.headers.get('x-forwarded-host')); + const host = forwardedHost || request.headers.get('host') || ''; + if (!host) { + return ''; + } + + const forwardedProto = firstForwardedPart(request.headers.get('x-forwarded-proto')); + const proto = forwardedProto || (host.startsWith('localhost') || host.startsWith('127.0.0.1') ? 'http' : 'https'); + return `${proto}://${host}`; +} + +function resolvePublicOrigin(request) { + if (publicBaseUrl) { + return publicBaseUrl; + } + + const fromHeaders = requestOrigin(request); + if (fromHeaders) { + return fromHeaders; + } + + try { + return new URL(request.url).origin; + } catch { + return ''; + } } function toBase32(buffer) { @@ -119,7 +161,7 @@ export async function POST(request, { params }) { try { formData = await request.formData(); } catch { - return redirectToRequest(request, requestId, { error: 'Ungültige Formulardaten.' }); + return redirectToRequest(requestId, { error: 'Ungültige Formulardaten.' }); } const uploadRequest = await get( @@ -130,25 +172,25 @@ export async function POST(request, { params }) { ); if (!uploadRequest) { - return redirectToRequest(request, requestId, { error: 'Anfrage nicht gefunden.' }); + return redirectToRequest(requestId, { error: 'Anfrage nicht gefunden.' }); } if (Number(uploadRequest.completed_at || 0) > 0) { - return redirectToRequest(request, requestId, { success: 'Diese Anfrage wurde bereits erfüllt.' }); + return redirectToRequest(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.' }); + return redirectToRequest(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.' }); + return redirectToRequest(requestId, { error: 'Keine Datei hochgeladen.' }); } if (maxUploadBytes > 0 && Number(uploadedFile.size || 0) > maxUploadBytes) { - return redirectToRequest(request, requestId, { + return redirectToRequest(requestId, { error: `Datei überschreitet das Größenlimit (${maxUploadBytes} Bytes).`, }); } @@ -171,7 +213,7 @@ export async function POST(request, { params }) { } if (!storedName || !storedPath) { - return redirectToRequest(request, requestId, { error: 'Upload-ID konnte nicht erzeugt werden.' }); + return redirectToRequest(requestId, { error: 'Upload-ID konnte nicht erzeugt werden.' }); } const uploadExpiry = Math.min(now + uploadTtlSeconds * 1000, now + maxRetentionSeconds * 1000); @@ -187,7 +229,7 @@ export async function POST(request, { params }) { ); uploadId = insertResult.lastID; } catch { - return redirectToRequest(request, requestId, { error: 'Upload fehlgeschlagen.' }); + return redirectToRequest(requestId, { error: 'Upload fehlgeschlagen.' }); } const updateResult = await run( @@ -206,7 +248,7 @@ export async function POST(request, { params }) { 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.' }); + return redirectToRequest(requestId, { success: 'Diese Anfrage wurde bereits erfüllt.' }); } await logEvent( @@ -216,7 +258,7 @@ export async function POST(request, { params }) { await getRequestMeta() ); - const baseUrl = new URL(request.url).origin; + const baseUrl = resolvePublicOrigin(request); const downloadUrl = `${baseUrl}/_share/${encodeURIComponent(storedName)}`; const mailResult = await sendUploadRequestCompletedMail({ @@ -230,7 +272,7 @@ export async function POST(request, { params }) { 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!' }); + return redirectToRequest(requestId, { success: 'Datei erfolgreich hochgeladen. Vielen Dank!' }); } await logEvent( @@ -240,7 +282,7 @@ export async function POST(request, { params }) { await getRequestMeta() ); - return redirectToRequest(request, requestId, { + return redirectToRequest(requestId, { success: 'Datei hochgeladen. Hinweis: E-Mail-Benachrichtigung konnte nicht gesendet werden.', }); } diff --git a/nextjs/app/manage/api/upload/route.js b/nextjs/app/manage/api/upload/route.js index db8f320..8441d15 100644 --- a/nextjs/app/manage/api/upload/route.js +++ b/nextjs/app/manage/api/upload/route.js @@ -107,8 +107,12 @@ function expectsHtml(request) { function errorResponse(request, message, status = 400) { if (expectsHtml(request)) { - const target = new URL(dashboardHref({ error: message }), request.url); - return NextResponse.redirect(target, { status: 303 }); + return new NextResponse(null, { + status: 303, + headers: { + location: dashboardHref({ error: message }), + }, + }); } return NextResponse.json({ ok: false, error: message }, { status }); @@ -117,8 +121,12 @@ function errorResponse(request, message, status = 400) { function successResponse(request, message) { const redirectPath = dashboardHref({ success: message }); if (expectsHtml(request)) { - const target = new URL(redirectPath, request.url); - return NextResponse.redirect(target, { status: 303 }); + return new NextResponse(null, { + status: 303, + headers: { + location: redirectPath, + }, + }); } return NextResponse.json({ ok: true, redirect: redirectPath }); diff --git a/nextjs/src/lib/config.js b/nextjs/src/lib/config.js index c0cefd6..7f0ac99 100644 --- a/nextjs/src/lib/config.js +++ b/nextjs/src/lib/config.js @@ -5,15 +5,33 @@ function parseInteger(value, fallback) { return Number.isFinite(parsed) ? parsed : fallback; } +function normalizeBaseUrl(value) { + const raw = String(value || '').trim(); + if (!raw) { + return ''; + } + + const withProtocol = raw.includes('://') ? raw : `https://${raw}`; + + try { + const parsed = new URL(withProtocol); + return `${parsed.protocol}//${parsed.host}`; + } catch { + return ''; + } +} + export const managementBasePath = '/manage'; export const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data'); export const dbPath = process.env.DB_PATH || path.join(dataDir, 'uploads.sqlite'); export const shareDir = path.join(dataDir, '_share'); +export const serviceFqdn = String(process.env.SERVICE_FQDN || '').trim(); export const adminHash = process.env.MANAGEMENT_ADMIN_HASH || ''; export const uploadTtlSeconds = parseInteger(process.env.UPLOAD_TTL_SECONDS || '604800', 604800); 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 publicBaseUrl = normalizeBaseUrl(process.env.PUBLIC_BASE_URL || serviceFqdn); 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();