feat: security audit, rate limiting, server-side pricing, html escaping and security headers

This commit is contained in:
MichalC
2026-08-01 13:24:41 +02:00
parent f07f2c2407
commit 47a9ac07fb
24 changed files with 292 additions and 151 deletions
+5 -8
View File
@@ -3,6 +3,7 @@ import Image from 'next/image';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getPosts, getPostBySlug, getRelatedPosts } from '@/content/posts';
import { JsonLd } from '@/components/ui/JsonLd';
import { CtaBanner } from '@/components/sections/CtaBanner';
import { Calendar, User, ArrowLeft, ArrowRight } from 'lucide-react';
import { companyDetails } from '@/content/company';
@@ -139,14 +140,8 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
return (
<main className="bg-slate-50 pt-24 pb-16">
{/* Skrypty JSON-LD dla SEO */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLdArticle) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLdBreadcrumb) }}
/>
<JsonLd data={jsonLdArticle} />
<JsonLd data={jsonLdBreadcrumb} />
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Powrót */}
@@ -203,6 +198,8 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
prose-img:rounded-xl prose-img:shadow-md
marker:text-accent"
>
{/* Dane blogowe są obecnie lokalnym, zaufanym contentem; nie dodajemy ciężkiej biblioteki DOMPurify bez potrzeby.
Przy wdrożeniu CMS lub wyświetlaniu treści od użytkowników wymagane będzie sanitizowanie HTML na serwerze. */}
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</div>
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import Image from 'next/image';
import { Check, Phone, ArrowRight, Wrench, Zap, Wind } from 'lucide-react';
@@ -111,7 +112,7 @@ export default function CennikMontazuPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="relative text-white py-24 md:py-32 overflow-hidden">
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import Image from 'next/image';
import { Check, Phone, Wrench, ArrowRight, Building2, Thermometer, Wind, Zap, Clock } from 'lucide-react';
@@ -108,7 +109,7 @@ export default function KlimatyzacjaDoBiuraPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="relative text-white py-24 md:py-32 overflow-hidden">
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import Image from 'next/image';
import { Phone, Wrench, ArrowRight, Home, Thermometer, Wind, Zap } from 'lucide-react';
@@ -86,7 +87,7 @@ export default function KlimatyzacjaDoDomuPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="relative text-white py-24 md:py-32 overflow-hidden">
+2 -4
View File
@@ -1,4 +1,5 @@
import { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { Clock, Phone, ArrowRight } from 'lucide-react';
@@ -63,10 +64,7 @@ export default async function MiastoPage({ params }: Props) {
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<JsonLd data={jsonLd} />
{/* HERO */}
<HeroSection
+2 -1
View File
@@ -1,4 +1,5 @@
import { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import { HeroSection } from '@/components/sections/HeroSection';
import { InfoHighlights } from '@/components/sections/InfoHighlights';
import { AcTypesSection } from '@/components/sections/AcTypesSection';
@@ -71,7 +72,7 @@ export default function KlimatyzacjaPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }} />
<JsonLd data={faqJsonLd} />
<main>
<HeroSection
eyebrow="15+ lat doświadczenia · Split i multisplit · Uprawnienia F-gaz"
@@ -1,4 +1,5 @@
import { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { MapPin, Clock, Phone, ArrowRight } from 'lucide-react';
@@ -67,10 +68,7 @@ export default async function DzielnicaPage({ params }: Props) {
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<JsonLd data={jsonLd} />
<HeroSection
eyebrow={`Klimatyzacja ${district.name} · Wrocław · F-Gaz`}
+1 -3
View File
@@ -6,7 +6,6 @@ import { verifyRecaptcha } from "@/lib/recaptcha";
const resend = new Resend(process.env.RESEND_API_KEY);
// Schemat walidacji zgodny z tym po stronie klienta
const contactFormSchema = z.object({
name: z.string().min(2, "Imię musi mieć min. 2 znaki"),
phone: z.string().min(9, "Wprowadź poprawny numer telefonu"),
@@ -30,7 +29,7 @@ export async function submitContactForm(prevState: unknown, formData: FormData)
}
}
// 1. Walidacja danych z formularza
const rawData = {
name: formData.get("name"),
phone: formData.get("phone"),
@@ -106,7 +105,6 @@ export async function submitContactForm(prevState: unknown, formData: FormData)
console.log("Wysłano e-mail (ID):", data?.id);
// 3. Zwrotka o sukcesie
return {
success: true,
message: "Dziękujemy za wiadomość! Skontaktujemy się z Tobą najszybciej jak to możliwe.",
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import Image from 'next/image';
import { Check, Phone, Wrench, ArrowRight, ShieldCheck, Clock, Star } from 'lucide-react';
@@ -87,7 +88,7 @@ export default function MontazKlimatyzacjiPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="relative text-white py-24 md:py-32 overflow-hidden">
+2 -1
View File
@@ -1,4 +1,5 @@
import { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import { HeroSection } from '@/components/sections/HeroSection';
import { BenefitsGrid } from '@/components/sections/BenefitsGrid';
import { HeatPumpTypes } from '@/components/sections/HeatPumpTypes';
@@ -70,7 +71,7 @@ export default function PompyCieplaPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqPompyJsonLd) }} />
<JsonLd data={faqPompyJsonLd} />
<main>
<HeroSection
title="Pompy ciepła — niższe rachunki, niezależność, dofinansowanie"
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import { Check, Phone, Wrench, AlertTriangle, ArrowRight } from 'lucide-react';
import { companyDetails } from '@/content/company';
@@ -72,7 +73,7 @@ export default function SerwisDaikinPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="bg-primary text-white py-16 md:py-24">
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import Image from 'next/image';
import { Check, Phone, Wrench, ArrowRight, Thermometer, Wind, Zap, ShieldCheck } from 'lucide-react';
@@ -120,7 +121,7 @@ export default function SerwisKlimatyzacjiPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="relative text-white py-24 md:py-32 overflow-hidden">
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import { Check, Phone, Wrench, AlertTriangle, ArrowRight } from 'lucide-react';
import { companyDetails } from '@/content/company';
@@ -68,7 +69,7 @@ export default function SerwisLGPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="bg-primary text-white py-16 md:py-24">
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import { Check, Phone, Wrench, AlertTriangle, ArrowRight } from 'lucide-react';
import { companyDetails } from '@/content/company';
@@ -72,7 +73,7 @@ export default function SerwisMitsubishiPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="bg-primary text-white py-16 md:py-24">
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import { Check, Phone, Wrench, AlertTriangle, ArrowRight } from 'lucide-react';
import { companyDetails } from '@/content/company';
@@ -72,7 +73,7 @@ export default function SerwisSamsungPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="bg-primary text-white py-16 md:py-24">
+63 -26
View File
@@ -1,35 +1,46 @@
'use server';
import { z } from 'zod';
import { Resend } from 'resend';
import { verifyRecaptcha } from '@/lib/recaptcha';
import { headers } from 'next/headers';
import { getProductBySlug } from '@/content/products';
import { escapeHtml, rateLimit, verifyRecaptcha, baseContactSchema } from '@/lib/security';
import { getMailClient } from '@/lib/mail';
const resend = new Resend(process.env.RESEND_API_KEY);
const orderSchema = z.object({
name: z.string().min(2),
phone: z.string().min(9),
email: z.string().email(),
const orderSchema = baseContactSchema.extend({
address: z.string().optional(),
notes: z.string().optional(),
consent: z.string(),
products: z.string(),
total: z.string(),
total: z.string(), // We don't trust this total, just checking if it exists
});
const productItemSchema = z.array(z.object({
slug: z.string(),
quantity: z.number().int().min(1).max(100),
}));
export async function submitOrderForm(prevState: unknown, formData: FormData) {
try {
const ip = (await headers()).get('x-forwarded-for') || 'unknown';
if (!rateLimit(ip)) {
return { success: false, message: 'Wysłano zbyt wiele zapytań. Spróbuj ponownie później.' };
}
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY && process.env.RECAPTCHA_SECRET_KEY) {
const token = formData.get('recaptchaToken') as string;
if (!token) {
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
}
const { success } = await verifyRecaptcha(token);
const success = await verifyRecaptcha(token);
if (!success) {
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
}
}
const mailClient = getMailClient();
if (!mailClient) {
return { success: false, message: "Funkcja wysyłania wiadomości jest tymczasowo niedostępna." };
}
const data = orderSchema.parse({
name: formData.get('name'),
phone: formData.get('phone'),
@@ -41,18 +52,44 @@ export async function submitOrderForm(prevState: unknown, formData: FormData) {
total: formData.get('total'),
});
const products = JSON.parse(data.products);
const total = parseFloat(data.total);
let rawProducts;
try {
rawProducts = JSON.parse(data.products);
} catch {
return { success: false, message: "Błąd przetwarzania koszyka." };
}
const productRows = products.map((p: { name: string; quantity: number; price: number }) =>
`<tr><td style="padding:8px;border-bottom:1px solid #f1f5f9;">${p.name}</td><td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:center;">${p.quantity}</td><td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:right;">${(p.price * p.quantity).toLocaleString('pl-PL')} zł</td></tr>`
const parsedProducts = productItemSchema.parse(rawProducts);
if (parsedProducts.length === 0) {
return { success: false, message: "Koszyk jest pusty." };
}
let calculatedTotal = 0;
const validatedProducts = [];
for (const p of parsedProducts) {
const dbProduct = getProductBySlug(p.slug);
if (!dbProduct || !dbProduct.price) {
return { success: false, message: "Jeden z produktów w koszyku jest niedostępny." };
}
validatedProducts.push({
name: dbProduct.name,
quantity: p.quantity,
price: dbProduct.price
});
calculatedTotal += dbProduct.price * p.quantity;
}
const productRows = validatedProducts.map(p =>
`<tr><td style="padding:8px;border-bottom:1px solid #f1f5f9;">${escapeHtml(p.name)}</td><td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:center;">${p.quantity}</td><td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:right;">${(p.price * p.quantity).toLocaleString('pl-PL')} zł</td></tr>`
).join('');
await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL || '[email protected]',
to: process.env.RESEND_TO_EMAIL || '[email protected]',
await mailClient.resend.emails.send({
from: mailClient.fromEmail,
to: mailClient.toEmail,
replyTo: data.email,
subject: `Nowe zamówienie od ${data.name} — ${total.toLocaleString('pl-PL')} zł`,
subject: `Nowe zamówienie od ${escapeHtml(data.name)} — ${calculatedTotal.toLocaleString('pl-PL')} zł`,
html: `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#0F2A47;padding:20px;text-align:center;">
@@ -60,11 +97,11 @@ export async function submitOrderForm(prevState: unknown, formData: FormData) {
</div>
<div style="padding:24px;">
<h2 style="font-size:16px;margin-top:0;">Dane klienta</h2>
<p><strong>Imię:</strong> ${data.name}</p>
<p><strong>Telefon:</strong> <a href="tel:${data.phone}">${data.phone}</a></p>
<p><strong>Email:</strong> ${data.email}</p>
${data.address ? `<p><strong>Adres:</strong> ${data.address}</p>` : ''}
${data.notes ? `<p><strong>Uwagi:</strong> ${data.notes}</p>` : ''}
<p><strong>Imię:</strong> ${escapeHtml(data.name)}</p>
<p><strong>Telefon:</strong> <a href="tel:${escapeHtml(data.phone)}">${escapeHtml(data.phone)}</a></p>
<p><strong>Email:</strong> ${escapeHtml(data.email)}</p>
${data.address ? `<p><strong>Adres:</strong> ${escapeHtml(data.address)}</p>` : ''}
${data.notes ? `<p><strong>Uwagi:</strong> ${escapeHtml(data.notes)}</p>` : ''}
<h2 style="font-size:16px;">Zamówione produkty</h2>
<table style="width:100%;border-collapse:collapse;">
<thead><tr style="background:#f8fafc;">
@@ -75,7 +112,7 @@ export async function submitOrderForm(prevState: unknown, formData: FormData) {
<tbody>${productRows}</tbody>
<tfoot><tr>
<td colspan="2" style="padding:8px;font-weight:bold;">Łącznie (urządzenia)</td>
<td style="padding:8px;font-weight:bold;text-align:right;">${total.toLocaleString('pl-PL')} zł</td>
<td style="padding:8px;font-weight:bold;text-align:right;">${calculatedTotal.toLocaleString('pl-PL')} zł</td>
</tr></tfoot>
</table>
<p style="margin-top:16px;font-size:12px;color:#64748b;">+ koszt montażu do ustalenia telefonicznie</p>
@@ -86,6 +123,6 @@ export async function submitOrderForm(prevState: unknown, formData: FormData) {
return { success: true, message: 'Dziękujemy! Skontaktujemy się z Tobą w ciągu 24 godzin, aby potwierdzić zamówienie i ustalić termin montażu.' };
} catch {
return { success: false, message: 'Wystąpił błąd. Spróbuj ponownie lub zadzwoń do nas bezpośrednio.' };
return { success: false, message: 'Wystąpił błąd podczas wysyłania zamówienia. Spróbuj ponownie.' };
}
}
+39 -55
View File
@@ -1,118 +1,102 @@
"use server";
import { z } from "zod";
import { Resend } from "resend";
import { verifyRecaptcha } from "@/lib/recaptcha";
import { headers } from "next/headers";
import { escapeHtml, rateLimit, verifyRecaptcha, baseContactSchema } from "@/lib/security";
import { getMailClient } from "@/lib/mail";
const resend = new Resend(process.env.RESEND_API_KEY);
const multisplitFormSchema = z.object({
name: z.string().min(2, "Imię musi mieć min. 2 znaki"),
phone: z.string().min(9, "Wprowadź poprawny numer telefonu"),
email: z.union([z.literal(''), z.string().email("Niepoprawny adres e-mail")]).optional(),
const multisplitFormSchema = baseContactSchema.extend({
city: z.string().min(1, "Wybierz miasto"),
rooms: z.string().optional(),
message: z.string().optional(),
consent: z.boolean().refine((val) => val === true, "Zgoda RODO jest wymagana"),
productName: z.string(),
productSku: z.string(),
recaptchaToken: z.string().optional(),
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function submitMultisplitForm(data: Record<string, any>) {
export async function submitMultisplitForm(data: Record<string, unknown>) {
try {
const ip = (await headers()).get('x-forwarded-for') || 'unknown';
if (!rateLimit(ip)) {
return { success: false, message: 'Wysłano zbyt wiele zapytań. Spróbuj ponownie później.' };
}
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY && process.env.RECAPTCHA_SECRET_KEY) {
if (!data.recaptchaToken) {
if (!data.recaptchaToken || typeof data.recaptchaToken !== 'string') {
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
}
const { success } = await verifyRecaptcha(data.recaptchaToken);
const success = await verifyRecaptcha(data.recaptchaToken);
if (!success) {
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
}
}
const validatedData = multisplitFormSchema.parse(data);
const mailClient = getMailClient();
if (!mailClient) {
return { success: false, message: "Funkcja wysyłania wiadomości jest tymczasowo niedostępna." };
}
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL || "[email protected]",
to: process.env.RESEND_TO_EMAIL || "[email protected]",
// Convert boolean consent to string for base schema compatibility if it comes as boolean
const processData = { ...data };
if (typeof processData.consent === 'boolean') {
processData.consent = processData.consent ? "true" : "false";
}
const validatedData = multisplitFormSchema.parse(processData);
const { error } = await mailClient.resend.emails.send({
from: mailClient.fromEmail,
to: mailClient.toEmail,
replyTo: validatedData.email || undefined,
subject: `Zapytanie o multisplit: ${validatedData.productName} (${validatedData.productSku})`,
subject: `Zapytanie o multisplit: ${escapeHtml(validatedData.productName)} (${escapeHtml(validatedData.productSku)})`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; background-color: #ffffff;">
<div style="background-color: #0F2A47; padding: 20px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Zapytanie o multisplit</h1>
<p style="color: #2BA8E0; margin: 5px 0 0 0; font-size: 14px;">Domena: thermcool.pl</p>
</div>
<div style="padding: 24px; color: #1e293b;">
<h2 style="font-size: 18px; margin-top: 0; border-bottom: 2px solid #f1f5f9; padding-bottom: 10px;">Dane kontaktowe:</h2>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9; width: 120px;"><strong>Imię:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${validatedData.name}</td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.name)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>Telefon:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><a href="tel:${validatedData.phone}" style="color: #0F2A47; text-decoration: none;">${validatedData.phone}</a></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.phone)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>E-mail:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><a href="mailto:${validatedData.email}" style="color: #0F2A47; text-decoration: none;">${validatedData.email || 'Brak'}</a></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.email || 'Brak')}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>Miasto:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${validatedData.city}</td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.city)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>Produkt:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${validatedData.productName} (${validatedData.productSku})</td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.productName)} (${escapeHtml(validatedData.productSku)})</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>Pokoje:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${validatedData.rooms}</td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.rooms || '')}</td>
</tr>
</table>
<h2 style="font-size: 18px; border-bottom: 2px solid #f1f5f9; padding-bottom: 10px;">Dodatkowe informacje:</h2>
<div style="background-color: #f8fafc; padding: 15px; border-radius: 6px; font-size: 15px; line-height: 1.6; white-space: pre-wrap;">
${validatedData.message || 'Brak'}
${escapeHtml(validatedData.message || 'Brak')}
</div>
<p style="margin-top: 24px; font-size: 12px; color: #64748b; text-align: center;">
Zgoda RODO została zaakceptowana: <strong>${validatedData.consent ? "Tak" : "Nie"}</strong>
</p>
</div>
<div style="background-color: #f1f5f9; padding: 15px; text-align: center; font-size: 12px; color: #64748b;">
Wiadomość wygenerowana automatycznie z formularza multisplit na stronie thermcool.pl
</div>
</div>
`,
});
if (error) {
console.error("Resend Error:", error);
return {
success: false,
message: "Nie udało się wysłać wiadomości ze względu na błąd serwera pocztowego.",
};
return { success: false, message: "Wystąpił problem z wysłaniem. Spróbuj ponownie." };
}
return {
success: true,
message: "Dziękujemy za zapytanie! Skontaktujemy się z Tobą najszybciej jak to możliwe.",
};
return { success: true, message: "Dziękujemy za zapytanie! Skontaktujemy się z Tobą najszybciej jak to możliwe." };
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
message: "Wystąpił błąd walidacji. Sprawdź poprawność danych.",
errors: error.flatten().fieldErrors,
};
}
return {
success: false,
message: "Wystąpił nieoczekiwany błąd serwera. Spróbuj ponownie później.",
};
return { success: false, message: "Błąd walidacji lub błąd serwera. Spróbuj ponownie później." };
}
}
+55 -39
View File
@@ -1,71 +1,87 @@
'use server';
import { Resend } from 'resend';
import { verifyRecaptcha } from '@/lib/recaptcha';
import { z } from 'zod';
import { headers } from "next/headers";
import { escapeHtml, rateLimit, verifyRecaptcha, baseContactSchema } from "@/lib/security";
import { getMailClient } from "@/lib/mail";
const resend = new Resend(process.env.RESEND_API_KEY);
const awariaFormSchema = baseContactSchema.omit({ consent: true }).extend({
isUrgent: z.boolean().optional(),
brand: z.string().min(1, "Wymagana nazwa urządzenia"),
model: z.string().optional(),
errorCode: z.string().optional(),
problemType: z.string().optional(),
description: z.string().optional(),
warranty: z.string().optional(),
lastService: z.string().optional(),
city: z.string().min(1, "Wymagane miasto"),
preferredDate: z.string().optional(),
preferredTime: z.string().optional(),
recaptchaToken: z.string().optional(),
});
export async function submitAwariaForm(data: {
isUrgent: boolean;
brand: string;
model: string;
errorCode: string;
problemType: string;
description: string;
warranty: string;
lastService: string;
name: string;
phone: string;
city: string;
preferredDate: string;
preferredTime: string;
recaptchaToken?: string;
}) {
export async function submitAwariaForm(data: Record<string, unknown>) {
try {
const ip = (await headers()).get('x-forwarded-for') || 'unknown';
if (!rateLimit(ip)) {
return { success: false, message: 'Wysłano zbyt wiele zapytań. Spróbuj ponownie później.' };
}
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY && process.env.RECAPTCHA_SECRET_KEY) {
if (!data.recaptchaToken) {
if (!data.recaptchaToken || typeof data.recaptchaToken !== 'string') {
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
}
const { success } = await verifyRecaptcha(data.recaptchaToken);
const success = await verifyRecaptcha(data.recaptchaToken);
if (!success) {
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
}
}
await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL || '[email protected]',
to: process.env.RESEND_TO_EMAIL || '[email protected]',
subject: `${data.isUrgent ? '🚨 PILNE — ' : ''}Zgłoszenie serwisowe: ${data.brand} — ${data.city}`,
const mailClient = getMailClient();
if (!mailClient) {
return { success: false, message: "Funkcja wysyłania wiadomości jest tymczasowo niedostępna." };
}
// Default missing fields for zod validation
const parsedData = awariaFormSchema.parse({
...data,
email: data.email || '[email protected]',
});
await mailClient.resend.emails.send({
from: mailClient.fromEmail,
to: mailClient.toEmail,
subject: `${parsedData.isUrgent ? '🚨 PILNE — ' : ''}Zgłoszenie serwisowe: ${escapeHtml(parsedData.brand)} — ${escapeHtml(parsedData.city)}`,
html: `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="background:${data.isUrgent ? '#ef4444' : '#0F2A47'};padding:20px;text-align:center;">
<div style="background:${parsedData.isUrgent ? '#ef4444' : '#0F2A47'};padding:20px;text-align:center;">
<h1 style="color:#fff;margin:0;font-size:20px;">
${data.isUrgent ? '🚨 PILNE — ' : ''}Zgłoszenie serwisowe ThermCool
${parsedData.isUrgent ? '🚨 PILNE — ' : ''}Zgłoszenie serwisowe ThermCool
</h1>
</div>
<div style="padding:24px;">
<h2 style="font-size:16px;margin-top:0;">Dane klienta</h2>
<p><strong>Imię:</strong> ${data.name}</p>
<p><strong>Telefon:</strong> <a href="tel:${data.phone}">${data.phone}</a></p>
<p><strong>Miasto:</strong> ${data.city}</p>
${data.preferredDate ? `<p><strong>Preferowany termin:</strong> ${data.preferredDate} ${data.preferredTime || ''}</p>` : ''}
<p><strong>Imię:</strong> ${escapeHtml(parsedData.name)}</p>
<p><strong>Telefon:</strong> <a href="tel:${escapeHtml(parsedData.phone)}">${escapeHtml(parsedData.phone)}</a></p>
<p><strong>Miasto:</strong> ${escapeHtml(parsedData.city)}</p>
${parsedData.preferredDate ? `<p><strong>Preferowany termin:</strong> ${escapeHtml(parsedData.preferredDate)} ${escapeHtml(parsedData.preferredTime || '')}</p>` : ''}
<h2 style="font-size:16px;">Urządzenie</h2>
<p><strong>Marka:</strong> ${data.brand}</p>
${data.model ? `<p><strong>Model:</strong> ${data.model}</p>` : ''}
${data.warranty ? `<p><strong>Gwarancja:</strong> ${data.warranty}</p>` : ''}
${data.lastService ? `<p><strong>Ostatni serwis:</strong> ${data.lastService}</p>` : ''}
<p><strong>Marka:</strong> ${escapeHtml(parsedData.brand)}</p>
${parsedData.model ? `<p><strong>Model:</strong> ${escapeHtml(parsedData.model)}</p>` : ''}
${parsedData.warranty ? `<p><strong>Gwarancja:</strong> ${escapeHtml(parsedData.warranty)}</p>` : ''}
${parsedData.lastService ? `<p><strong>Ostatni serwis:</strong> ${escapeHtml(parsedData.lastService)}</p>` : ''}
<h2 style="font-size:16px;">Problem</h2>
<p><strong>Typ problemu:</strong> ${data.problemType}</p>
${data.errorCode ? `<p><strong>Kod błędu:</strong> ${data.errorCode}</p>` : ''}
${data.description ? `<p><strong>Opis:</strong> ${data.description}</p>` : ''}
<p><strong>Typ problemu:</strong> ${escapeHtml(parsedData.problemType || '')}</p>
${parsedData.errorCode ? `<p><strong>Kod błędu:</strong> ${escapeHtml(parsedData.errorCode)}</p>` : ''}
${parsedData.description ? `<p><strong>Opis:</strong> ${escapeHtml(parsedData.description)}</p>` : ''}
</div>
</div>
`,
});
return { success: true };
} catch {
return { success: false };
return { success: false, message: 'Wystąpił błąd podczas wysyłania.' };
}
}