see comments

This commit is contained in:
Rajpreet0 2026-05-26 10:49:27 +02:00
parent 04e1c8794a
commit 4cb9f7fade
5 changed files with 120 additions and 28 deletions

View file

@ -209,12 +209,15 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
// E-Mail-Versand (FR.10) – darf den Webhook NICHT zum Crashen bringen.
// Bei Fehler: nur loggen, sonst riskiert man Stripe-Retry-Schleifen und
// mehrfache Buchungs-Verarbeitung.
// Außerdem: harte 2-Sekunden-Grenze pro Mail, damit ein träger SMTP-Server
// den Webhook nicht über sein Timeout-Limit zieht. Der Versand läuft dann
// im Hintergrund weiter; der Webhook gibt nur früher 200 zurück.
const recipientEmail = session.customer_email ?? session.customer_details?.email ?? null;
if (recipientEmail) {
if (outcome === "booked") {
await sendBookingConfirmation(recipientEmail, courseId, amountPaidCents);
await withTimeout(sendBookingConfirmation(recipientEmail, courseId, amountPaidCents), 2000);
} else if (needsRefund && refundSucceeded) {
await sendBookingRefund(recipientEmail, courseId, amountPaidCents);
await withTimeout(sendBookingRefund(recipientEmail, courseId, amountPaidCents), 2000);
}
}
@ -222,6 +225,18 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
revalidatePath("/courses/myCourses");
}
/**
* Wartet maximal `ms` Millisekunden auf das Promise. Läuft das Promise länger,
* wird `null` zurückgegeben – das eigentliche Promise läuft im Hintergrund weiter.
* Wird genutzt, um den Stripe-Webhook nicht über sein Timeout-Limit hinauszuziehen.
*/
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null> {
return Promise.race([
promise,
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)),
]);
}
/**
* Lädt Kurs+Business und schickt die Buchungs-Bestätigung an den Kunden.
* Schluckt Fehler bewusst (siehe Caller-Kommentar).

View file

@ -5,6 +5,8 @@
* erfolgreich bezahlt wurde (Stripe Webhook checkout.session.completed).
*/
import { escapeHtml, sanitizeUrl, singleLine } from "./utils";
interface BookingConfirmationEmailParams {
courseName: string;
courseDate: Date;
@ -24,6 +26,7 @@ function formatDate(date: Date): string {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "Europe/Berlin",
});
}
@ -46,7 +49,17 @@ export function generateBookingConfirmationEmail({
coverImageUrl,
companyName = "Ihr Unternehmen",
}: BookingConfirmationEmailParams): { subject: string; html: string; text: string } {
const subject = `Buchungsbestätigung – ${courseName}`;
// Defense gegen Markup-Injection: alle dynamischen Werte vor dem HTML-Einsetzen escapen.
const safeCourseName = escapeHtml(courseName);
const safeBusinessName = escapeHtml(businessName);
const safeBusinessAddress = businessAddress ? escapeHtml(businessAddress) : "";
const safeTimeFrom = escapeHtml(timeFrom);
const safeTimeTo = escapeHtml(timeTo);
const safeCompanyName = escapeHtml(companyName);
const safeMyCoursesUrl = sanitizeUrl(myCoursesUrl);
const safeCoverImageUrl = coverImageUrl ? sanitizeUrl(coverImageUrl) : null;
const subject = singleLine(`Buchungsbestätigung – ${courseName}`);
const formattedDate = formatDate(courseDate);
const formattedPrice = formatPrice(amountPaidCents);
@ -65,12 +78,12 @@ export function generateBookingConfirmationEmail({
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
${
coverImageUrl
safeCoverImageUrl
? `
<!-- Cover Image Banner -->
<tr>
<td style="padding: 0; line-height: 0;">
<img src="${coverImageUrl}" alt="${courseName}" width="600" style="display: block; width: 100%; max-width: 600px; height: auto; object-fit: cover;" />
<img src="${safeCoverImageUrl}" alt="${safeCourseName}" width="600" style="display: block; width: 100%; max-width: 600px; height: auto; object-fit: cover;" />
</td>
</tr>
`
@ -103,20 +116,20 @@ export function generateBookingConfirmationEmail({
<tr>
<td style="padding: 24px;">
<p style="margin: 0 0 4px; color: #999999; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px;">Kurs</p>
<p style="margin: 0 0 16px; color: #333333; font-size: 18px; font-weight: 600;">${courseName}</p>
<p style="margin: 0 0 16px; color: #333333; font-size: 18px; font-weight: 600;">${safeCourseName}</p>
<p style="margin: 0 0 4px; color: #999999; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px;">Anbieter</p>
<p style="margin: 0 0 16px; color: #333333; font-size: 15px;">${businessName}</p>
<p style="margin: 0 0 16px; color: #333333; font-size: 15px;">${safeBusinessName}</p>
<p style="margin: 0 0 4px; color: #999999; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px;">Termin</p>
<p style="margin: 0 0 4px; color: #333333; font-size: 15px;">${formattedDate}</p>
<p style="margin: 0 0 16px; color: #333333; font-size: 15px;">${timeFrom} – ${timeTo} Uhr</p>
<p style="margin: 0 0 16px; color: #333333; font-size: 15px;">${safeTimeFrom} – ${safeTimeTo} Uhr</p>
${
businessAddress
safeBusinessAddress
? `
<p style="margin: 0 0 4px; color: #999999; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px;">Ort</p>
<p style="margin: 0 0 16px; color: #333333; font-size: 15px;">${businessAddress}</p>
<p style="margin: 0 0 16px; color: #333333; font-size: 15px;">${safeBusinessAddress}</p>
`
: ""
}
@ -141,7 +154,7 @@ export function generateBookingConfirmationEmail({
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0 0 20px;">
<tr>
<td align="center">
<a href="${myCoursesUrl}"
<a href="${safeMyCoursesUrl}"
style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 16px 40px; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 4px 6px rgba(102, 126, 234, 0.4);">
Zu meinen Kursen
</a>
@ -153,12 +166,12 @@ export function generateBookingConfirmationEmail({
Oder kopieren Sie diesen Link in Ihren Browser:
</p>
<p style="margin: 8px 0 0; color: #667eea; font-size: 14px; word-break: break-all; text-align: center;">
${myCoursesUrl}
${safeMyCoursesUrl}
</p>
<div style="border-top: 1px solid #e0e0e0; padding-top: 20px; margin-top: 30px;">
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">
Bei Fragen zu Ihrer Buchung wenden Sie sich bitte direkt an den Anbieter <strong>${businessName}</strong>.
Bei Fragen zu Ihrer Buchung wenden Sie sich bitte direkt an den Anbieter <strong>${safeBusinessName}</strong>.
</p>
</div>
</td>
@ -168,7 +181,7 @@ export function generateBookingConfirmationEmail({
<tr>
<td style="background-color: #f8f9fa; padding: 30px; text-align: center; border-top: 1px solid #e0e0e0;">
<p style="margin: 0 0 10px; color: #666666; font-size: 14px;">
${companyName}
${safeCompanyName}
</p>
<p style="margin: 0; color: #999999; font-size: 12px;">
Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail.
@ -198,7 +211,7 @@ ${businessAddress ? `Ort: ${businessAddress}\n` : ""}Bezahlt: ${formattedPrice}
Wir wünschen Ihnen viel Spaß im Kurs!
Zu meinen Kursen: ${myCoursesUrl}
Zu meinen Kursen: ${safeMyCoursesUrl}
Bei Fragen zu Ihrer Buchung wenden Sie sich bitte direkt an den Anbieter ${businessName}.

View file

@ -6,6 +6,8 @@
* (Stripe Webhook: capacity_exceeded → refund).
*/
import { escapeHtml, singleLine } from "./utils";
interface BookingRefundEmailParams {
courseName: string;
businessName: string;
@ -26,7 +28,12 @@ export function generateBookingRefundEmail({
amountRefundedCents,
companyName = "Ihr Unternehmen",
}: BookingRefundEmailParams): { subject: string; html: string; text: string } {
const subject = `Rückerstattung – ${courseName}`;
// Defense gegen Markup-Injection in der Mail.
const safeCourseName = escapeHtml(courseName);
const safeBusinessName = escapeHtml(businessName);
const safeCompanyName = escapeHtml(companyName);
const subject = singleLine(`Rückerstattung – ${courseName}`);
const formattedPrice = formatPrice(amountRefundedCents);
const html = `
@ -60,7 +67,7 @@ export function generateBookingRefundEmail({
</p>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
leider war der Kurs <strong>${courseName}</strong> von <strong>${businessName}</strong>
leider war der Kurs <strong>${safeCourseName}</strong> von <strong>${safeBusinessName}</strong>
bereits ausgebucht, als Ihre Zahlung bei uns eingegangen ist.
</p>
@ -86,7 +93,7 @@ export function generateBookingRefundEmail({
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Es tut uns leid für die Unannehmlichkeiten. Sie können gerne einen anderen
freien Kurs bei <strong>${businessName}</strong> buchen.
freien Kurs bei <strong>${safeBusinessName}</strong> buchen.
</p>
<div style="border-top: 1px solid #e0e0e0; padding-top: 20px; margin-top: 30px;">
@ -102,7 +109,7 @@ export function generateBookingRefundEmail({
<tr>
<td style="background-color: #f8f9fa; padding: 30px; text-align: center; border-top: 1px solid #e0e0e0;">
<p style="margin: 0 0 10px; color: #666666; font-size: 14px;">
${companyName}
${safeCompanyName}
</p>
<p style="margin: 0; color: #999999; font-size: 12px;">
Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail.

View file

@ -0,0 +1,42 @@
/**
* Gemeinsame Helper für E-Mail-Templates.
* - HTML-Escaping für dynamische Werte (Defense gegen Markup-Injection in Mails)
* - URL-Validierung für href/src (nur http/https erlauben)
*/
/**
* Escapt HTML-Sonderzeichen in einem String, damit der Wert sicher in HTML-Content
* und in Attribut-Werten verwendet werden kann.
*/
export function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
/**
* Liefert die URL zurück, wenn sie http oder https ist – sonst "#".
* Verhindert javascript:- oder data:-URLs in Mail-Links.
*/
export function sanitizeUrl(value: string): string {
try {
const url = new URL(value);
if (url.protocol === "http:" || url.protocol === "https:") {
return url.toString();
}
return "#";
} catch {
return "#";
}
}
/**
* Entfernt Zeilenumbrüche aus einem String – wichtig für E-Mail-Subjects
* (Header-Injection-Prävention).
*/
export function singleLine(value: string): string {
return value.replace(/[\r\n]+/g, " ");
}

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useSyncExternalStore } from "react";
import Image from "next/image";
import { Clock, MapPin, Users, Mail, Phone, DoorOpen, Info } from "lucide-react";
import { Button } from "@/src/components/ui/button";
@ -8,16 +8,30 @@ import { Dialog, DialogContent, DialogTitle } from "@/src/components/ui/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
import DOMPurify from "dompurify";
// DOMPurify hat im Server-Build kein window — wir sanitizen nur im Browser.
// Vor Hydration zeigen wir leeren Inhalt; sobald hydriert, kommt der saubere HTML.
function sanitizeHtml(dirty: string): string {
if (typeof window === "undefined") return "";
return DOMPurify.sanitize(dirty);
}
import { checkUserBookingStatus } from "../../actions/booking-actions";
import { toast } from "sonner";
// useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und
// `true` im Browser. Damit haben Server- und initialer Client-Render denselben
// Wert (kein Hydration-Mismatch), und nach dem Mount geht der Wert auf true –
// ohne setState im Effect, also lint-konform.
const subscribe = () => () => {};
const getClientSnapshot = () => true;
const getServerSnapshot = () => false;
/**
* Sanitisiert den HTML-Wert nach dem Mount.
* Server- und initialer Client-Render liefern "" – nach dem Mount läuft
* DOMPurify.sanitize synchron während des Re-Renders. So gibt es keinen
* Hydration-Mismatch und DOMPurify wird nie auf dem Server aufgerufen
* (wo es kein `window` hat).
*/
function useSanitizedHtml(dirty: string | undefined): string {
const isClient = useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot);
if (!isClient || !dirty) return "";
return DOMPurify.sanitize(dirty);
}
const levelConfig: Record<string, { label: string; bg: string; text: string }> = {
any: { label: "Jedes Niveau", bg: "bg-gray-200", text: "text-gray-700" },
beginner: { label: "Anfänger", bg: "bg-blue-200", text: "text-blue-700" },
@ -99,6 +113,7 @@ const BookingDialog = ({
open: boolean;
onOpenChange: (v: boolean) => void;
}) => {
const sanitizedDescription = useSanitizedHtml(course.description);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isBooked, setIsBooked] = useState(false);
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
@ -291,7 +306,7 @@ const BookingDialog = ({
<b>Was Sie beachten sollten:</b>
<div
className="prose prose-sm max-w-none text-gray-600 mt-1"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(course.description) }}
dangerouslySetInnerHTML={{ __html: sanitizedDescription }}
/>
</div>
</div>