From 4cb9f7fade35a83241fda8576ef43afc2668420f Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Tue, 26 May 2026 10:49:27 +0200 Subject: [PATCH] see comments --- src/app/api/stripe/webhook/route.ts | 19 ++++++++- .../mail/templates/booking-confirmation.ts | 39 +++++++++++------ src/lib/mail/templates/booking-refund.ts | 15 +++++-- src/lib/mail/templates/utils.ts | 42 +++++++++++++++++++ .../ui/components/PublicCourseCard.tsx | 33 +++++++++++---- 5 files changed, 120 insertions(+), 28 deletions(-) create mode 100644 src/lib/mail/templates/utils.ts diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 598b1b2..d629b08 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -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(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve(null), ms)), + ]); +} + /** * Lädt Kurs+Business und schickt die Buchungs-Bestätigung an den Kunden. * Schluckt Fehler bewusst (siehe Caller-Kommentar). diff --git a/src/lib/mail/templates/booking-confirmation.ts b/src/lib/mail/templates/booking-confirmation.ts index 17dadb1..c20a9d1 100644 --- a/src/lib/mail/templates/booking-confirmation.ts +++ b/src/lib/mail/templates/booking-confirmation.ts @@ -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({ ${ - coverImageUrl + safeCoverImageUrl ? ` ` @@ -103,20 +116,20 @@ export function generateBookingConfirmationEmail({
- ${courseName} + ${safeCourseName}

Kurs

-

${courseName}

+

${safeCourseName}

Anbieter

-

${businessName}

+

${safeBusinessName}

Termin

${formattedDate}

-

${timeFrom} – ${timeTo} Uhr

+

${safeTimeFrom} – ${safeTimeTo} Uhr

${ - businessAddress + safeBusinessAddress ? `

Ort

-

${businessAddress}

+

${safeBusinessAddress}

` : "" } @@ -141,7 +154,7 @@ export function generateBookingConfirmationEmail({ @@ -168,7 +181,7 @@ export function generateBookingConfirmationEmail({
- Zu meinen Kursen @@ -153,12 +166,12 @@ export function generateBookingConfirmationEmail({ Oder kopieren Sie diesen Link in Ihren Browser:

- ${myCoursesUrl} + ${safeMyCoursesUrl}

- Bei Fragen zu Ihrer Buchung wenden Sie sich bitte direkt an den Anbieter ${businessName}. + Bei Fragen zu Ihrer Buchung wenden Sie sich bitte direkt an den Anbieter ${safeBusinessName}.

- ${companyName} + ${safeCompanyName}

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}. diff --git a/src/lib/mail/templates/booking-refund.ts b/src/lib/mail/templates/booking-refund.ts index 307eb5d..0db5493 100644 --- a/src/lib/mail/templates/booking-refund.ts +++ b/src/lib/mail/templates/booking-refund.ts @@ -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({

- leider war der Kurs ${courseName} von ${businessName} + leider war der Kurs ${safeCourseName} von ${safeBusinessName} bereits ausgebucht, als Ihre Zahlung bei uns eingegangen ist.

@@ -86,7 +93,7 @@ export function generateBookingRefundEmail({

Es tut uns leid für die Unannehmlichkeiten. Sie können gerne einen anderen - freien Kurs bei ${businessName} buchen. + freien Kurs bei ${safeBusinessName} buchen.

@@ -102,7 +109,7 @@ export function generateBookingRefundEmail({

- ${companyName} + ${safeCompanyName}

Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail. diff --git a/src/lib/mail/templates/utils.ts b/src/lib/mail/templates/utils.ts new file mode 100644 index 0000000..3626907 --- /dev/null +++ b/src/lib/mail/templates/utils.ts @@ -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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +/** + * 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, " "); +} diff --git a/src/modules/courses/ui/components/PublicCourseCard.tsx b/src/modules/courses/ui/components/PublicCourseCard.tsx index 6fa9eda..57bf38c 100644 --- a/src/modules/courses/ui/components/PublicCourseCard.tsx +++ b/src/modules/courses/ui/components/PublicCourseCard.tsx @@ -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 = { 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 = ({ Was Sie beachten sollten: