diff --git a/README.md b/README.md
index 8665050..f11635c 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,8 @@ Ein vollständiges Buchungssystem für Nagelstudios mit Admin-Panel, Kalender un
- [Hono](https://hono.dev/)
- [Zod](https://zod.dev/)
+> Hinweis zu DOMPurify: Wir nutzen `isomorphic-dompurify`, das DOMPurify bereits mitliefert und sowohl in Node.js als auch im Browser funktioniert. Eine zusätzliche Installation von `dompurify` ist daher nicht erforderlich und würde eine redundante Abhängigkeit erzeugen.
+
## Setup
### 1. Umgebungsvariablen konfigurieren
@@ -22,33 +24,28 @@ Kopiere die `.env.example` Datei zu `.env` und konfiguriere deine Umgebungsvaria
cp .env.example .env
```
-### 2. Admin-Passwort Hash generieren
+### 2. Admin-Passwort Hash generieren (bcrypt)
-Das Admin-Passwort wird als Base64-Hash in der `.env` Datei gespeichert. Hier sind verschiedene Methoden, um einen Hash zu generieren:
+Das Admin-Passwort wird als bcrypt-Hash in der `.env` Datei gespeichert. So erzeugst du einen Hash:
-#### PowerShell (Windows)
-```powershell
-# Einfache Methode mit Base64-Encoding
-$password = "dein_sicheres_passwort"
-$bytes = [System.Text.Encoding]::UTF8.GetBytes($password)
-$hash = [System.Convert]::ToBase64String($bytes)
-Write-Host "Password Hash: $hash"
-
-# Alternative mit PowerShell 7+ (kürzer)
-$password = "dein_sicheres_passwort"
-[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($password))
+#### Node.js (empfohlen)
+```bash
+node -e "require('bcrypt').hash('dein_sicheres_passwort', 10).then(console.log)"
```
-#### Node.js (falls verfügbar)
+Alternativ kannst du ein kleines Script verwenden (falls du es öfter brauchst):
+
```javascript
-// In der Node.js Konsole oder als separates Script
-const password = "dein_sicheres_passwort";
-const hash = Buffer.from(password).toString('base64');
-console.log("Password Hash:", hash);
+// scripts/generate-hash.js
+require('bcrypt').hash(process.argv[2] || 'dein_sicheres_passwort', 10).then(h => {
+ console.log(h);
+});
```
-#### Online-Tools (nur für Entwicklung)
-- Verwende einen Base64-Encoder wie [base64encode.org](https://www.base64encode.org/)
+Ausführen:
+```bash
+node scripts/generate-hash.js "dein_sicheres_passwort"
+```
### 3. .env Datei konfigurieren
@@ -57,7 +54,8 @@ Bearbeite deine `.env` Datei und setze die generierten Werte:
```env
# Admin Account Configuration
ADMIN_USERNAME=owner
-ADMIN_PASSWORD_HASH=ZGVpbl9zaWNoZXJlc19wYXNzd29ydA== # Dein generierter Hash
+# bcrypt-Hash des Admin-Passworts (kein Base64). Beispielwert:
+ADMIN_PASSWORD_HASH=$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
# Domain Configuration
DOMAIN=localhost:5173 # Für Produktion: deine-domain.de
@@ -209,10 +207,12 @@ Nach dem Setup kannst du dich mit den in der `.env` konfigurierten Admin-Credent
⚠️ **Wichtige Hinweise:**
- Ändere das Standard-Passwort vor dem Produktionseinsatz
-- Das Passwort wird als Base64-Hash in der `.env` Datei gespeichert
+- Das Passwort wird als bcrypt-Hash in der `.env` Datei gespeichert
- Verwende ein sicheres Passwort und generiere den entsprechenden Hash
- Die `.env` Datei sollte niemals in das Repository committet werden
+Hinweis zur Migration: Vorhandene Base64-Hashes aus älteren Versionen werden beim Server-Start automatisch in bcrypt migriert. Zusätzlich erfolgt beim nächsten erfolgreichen Login ebenfalls eine Migration, falls noch erforderlich.
+
### Security.txt Endpoint
Die Anwendung bietet einen RFC 9116 konformen Security.txt Endpoint unter `/.well-known/security.txt`:
diff --git a/package.json b/package.json
index 810ed9f..c0273d8 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,8 @@
"preview": "vite preview"
},
"dependencies": {
+ "@types/bcrypt": "^5.0.2",
+ "bcrypt": "^5.1.1",
"@hono/node-server": "^1.19.5",
"@orpc/client": "^1.8.8",
"@orpc/server": "^1.8.8",
@@ -20,6 +22,7 @@
"@tanstack/react-query": "^5.85.5",
"dotenv": "^17.2.3",
"hono": "^4.9.4",
+ "isomorphic-dompurify": "^2.16.0",
"jsonrepair": "^3.13.0",
"openai": "^5.17.0",
"react": "^19.1.1",
diff --git a/src/server/index.ts b/src/server/index.ts
index 6e253d5..4256b48 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -14,6 +14,28 @@ app.use("*", async (c, next) => {
return next();
});
+// Content-Security-Policy and other security headers
+app.use("*", async (c, next) => {
+ const isDev = process.env.NODE_ENV === 'development';
+ const directives = [
+ "default-src 'self'",
+ `script-src 'self'${isDev ? " 'unsafe-inline'" : ''}`,
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: https:",
+ "font-src 'self' data:",
+ "connect-src 'self'",
+ "frame-ancestors 'none'",
+ "base-uri 'self'",
+ "form-action 'self'",
+ ];
+ const csp = directives.join('; ');
+ c.header('Content-Security-Policy', csp);
+ c.header('X-Content-Type-Options', 'nosniff');
+ c.header('X-Frame-Options', 'DENY');
+ c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
+ await next();
+});
+
// Health check endpoint
app.get("/health", (c) => {
return c.json({ status: "ok", timestamp: new Date().toISOString() });
diff --git a/src/server/lib/email-templates.ts b/src/server/lib/email-templates.ts
index 8b214f9..484e5cc 100644
--- a/src/server/lib/email-templates.ts
+++ b/src/server/lib/email-templates.ts
@@ -1,4 +1,5 @@
import { readFile } from "node:fs/promises";
+import { sanitizeText, sanitizeHtml, sanitizePhone } from "./sanitize.js";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
@@ -60,13 +61,14 @@ async function renderBrandedEmail(title: string, bodyHtml: string): PromiseHallo ${name},
+ Hallo ${safeName},
wir haben deine Anfrage für ${formattedDate} um ${time} erhalten.
Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.
${statusUrl ? `
@@ -87,13 +89,14 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string; reviewUrl?: string }) {
const { name, date, time, cancellationUrl, reviewUrl } = params;
+ const safeName = sanitizeText(name);
const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`;
const inner = `
- Hallo ${name},
+ Hallo ${safeName},
wir haben deinen Termin am ${formattedDate} um ${time} bestätigt.
Wir freuen uns auf dich!
@@ -126,13 +129,14 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string }) {
const { name, date, time } = params;
+ const safeName = sanitizeText(name);
const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`;
const inner = `
-
Hallo ${name},
+
Hallo ${safeName},
dein Termin am ${formattedDate} um ${time} wurde abgesagt.
Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.
@@ -154,6 +158,10 @@ export async function renderAdminBookingNotificationHTML(params: {
hasInspirationPhoto: boolean;
}) {
const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params;
+ const safeName = sanitizeText(name);
+ const safeTreatment = sanitizeText(treatment);
+ const safePhone = sanitizePhone(phone);
+ const safeNotes = sanitizeHtml(notes);
const formattedDate = formatDateGerman(date);
const inner = `
Hallo Admin,
@@ -161,12 +169,12 @@ export async function renderAdminBookingNotificationHTML(params: {
📅 Buchungsdetails:
- - Name: ${name}
- - Telefon: ${phone}
- - Behandlung: ${treatment}
+ - Name: ${safeName}
+ - Telefon: ${safePhone}
+ - Behandlung: ${safeTreatment}
- Datum: ${formattedDate}
- Uhrzeit: ${time}
- ${notes ? `- Notizen: ${notes}
` : ''}
+ ${safeNotes ? `- Notizen: ${safeNotes}
` : ''}
- Inspiration-Foto: ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}
@@ -188,13 +196,15 @@ export async function renderBookingRescheduleProposalHTML(params: {
declineUrl: string;
expiresAt: string;
}) {
+ const safeName = sanitizeText(params.name);
+ const safeTreatment = sanitizeText(params.treatmentName);
const formattedOriginalDate = formatDateGerman(params.originalDate);
const formattedProposedDate = formatDateGerman(params.proposedDate);
const expiryDate = new Date(params.expiresAt);
const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
const inner = `
-
Hallo ${params.name},
+
Hallo ${safeName},
wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:
📅 Übersicht
@@ -209,7 +219,7 @@ export async function renderBookingRescheduleProposalHTML(params: {
Behandlung |
- ${params.treatmentName} |
+ ${safeTreatment} |
@@ -239,15 +249,19 @@ export async function renderAdminRescheduleDeclinedHTML(params: {
customerEmail?: string;
customerPhone?: string;
}) {
+ const safeCustomerName = sanitizeText(params.customerName);
+ const safeTreatment = sanitizeText(params.treatmentName);
+ const safeEmail = params.customerEmail ? sanitizeText(params.customerEmail) : undefined;
+ const safePhone = params.customerPhone ? sanitizeText(params.customerPhone) : undefined;
const inner = `
Hallo Admin,
-
der Kunde ${params.customerName} hat den Terminänderungsvorschlag abgelehnt.
+
der Kunde ${safeCustomerName} hat den Terminänderungsvorschlag abgelehnt.
- - Kunde: ${params.customerName}
- ${params.customerEmail ? `- E-Mail: ${params.customerEmail}
` : ''}
- ${params.customerPhone ? `- Telefon: ${params.customerPhone}
` : ''}
- - Behandlung: ${params.treatmentName}
+ - Kunde: ${safeCustomerName}
+ ${safeEmail ? `- E-Mail: ${safeEmail}
` : ''}
+ ${safePhone ? `- Telefon: ${safePhone}
` : ''}
+ - Behandlung: ${safeTreatment}
- Ursprünglicher Termin: ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)
- Abgelehnter Vorschlag: ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr
@@ -265,13 +279,15 @@ export async function renderAdminRescheduleAcceptedHTML(params: {
newTime: string;
treatmentName: string;
}) {
+ const safeCustomerName = sanitizeText(params.customerName);
+ const safeTreatment = sanitizeText(params.treatmentName);
const inner = `
Hallo Admin,
-
der Kunde ${params.customerName} hat den Terminänderungsvorschlag akzeptiert.
+
der Kunde ${safeCustomerName} hat den Terminänderungsvorschlag akzeptiert.
- - Kunde: ${params.customerName}
- - Behandlung: ${params.treatmentName}
+ - Kunde: ${safeCustomerName}
+ - Behandlung: ${safeTreatment}
- Alter Termin: ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr
- Neuer Termin: ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅
@@ -299,19 +315,24 @@ export async function renderAdminRescheduleExpiredHTML(params: {
${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.
⚠️ Abgelaufene Vorschläge:
- ${params.expiredProposals.map(proposal => `
+ ${params.expiredProposals.map(proposal => {
+ const safeName = sanitizeText(proposal.customerName);
+ const safeTreatment = sanitizeText(proposal.treatmentName);
+ const safeEmail = proposal.customerEmail ? sanitizeText(proposal.customerEmail) : undefined;
+ const safePhone = proposal.customerPhone ? sanitizeText(proposal.customerPhone) : undefined;
+ return `
- - Kunde: ${proposal.customerName}
- ${proposal.customerEmail ? `- E-Mail: ${proposal.customerEmail}
` : ''}
- ${proposal.customerPhone ? `- Telefon: ${proposal.customerPhone}
` : ''}
- - Behandlung: ${proposal.treatmentName}
+ - Kunde: ${safeName}
+ ${safeEmail ? `- E-Mail: ${safeEmail}
` : ''}
+ ${safePhone ? `- Telefon: ${safePhone}
` : ''}
+ - Behandlung: ${safeTreatment}
- Ursprünglicher Termin: ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr
- Vorgeschlagener Termin: ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr
- Abgelaufen am: ${new Date(proposal.expiredAt).toLocaleString('de-DE')}
- `).join('')}
+ `;}).join('')}
Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.
Die ursprünglichen Termine bleiben bestehen.
diff --git a/src/server/lib/sanitize.ts b/src/server/lib/sanitize.ts
new file mode 100644
index 0000000..cb61256
--- /dev/null
+++ b/src/server/lib/sanitize.ts
@@ -0,0 +1,37 @@
+import DOMPurify from "isomorphic-dompurify";
+
+/**
+ * Sanitize plain text inputs by stripping all HTML tags.
+ * Use for names, phone numbers, and simple text fields.
+ */
+export function sanitizeText(input: string | undefined): string {
+ if (!input) return "";
+ const cleaned = DOMPurify.sanitize(input, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] });
+ return cleaned.trim();
+}
+
+/**
+ * Sanitize rich text notes allowing only a minimal, safe subset of tags.
+ * Use for free-form notes or comments where basic formatting is acceptable.
+ */
+export function sanitizeHtml(input: string | undefined): string {
+ if (!input) return "";
+ const cleaned = DOMPurify.sanitize(input, {
+ ALLOWED_TAGS: ["br", "p", "strong", "em", "u", "a", "ul", "li"],
+ ALLOWED_ATTR: ["href", "title", "target", "rel"],
+ ALLOWED_URI_REGEXP: /^(?:https?:)?\/\//i,
+ KEEP_CONTENT: true,
+ });
+ return cleaned.trim();
+}
+
+/**
+ * Sanitize phone numbers by stripping HTML and keeping only digits and a few symbols.
+ * Allowed characters: digits, +, -, (, ), and spaces.
+ */
+export function sanitizePhone(input: string | undefined): string {
+ const text = sanitizeText(input);
+ return text.replace(/[^0-9+\-()\s]/g, "");
+}
+
+
diff --git a/src/server/rpc/auth.ts b/src/server/rpc/auth.ts
index 90e69c7..da547a7 100644
--- a/src/server/rpc/auth.ts
+++ b/src/server/rpc/auth.ts
@@ -3,6 +3,7 @@ import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { config } from "dotenv";
+import bcrypt from "bcrypt";
// Load environment variables from .env file
config();
@@ -29,18 +30,63 @@ type Session = z.output
;
const usersKV = createKV("users");
const sessionsKV = createKV("sessions");
-// Simple password hashing (in production, use bcrypt or similar)
-const hashPassword = (password: string): string => {
- return Buffer.from(password).toString('base64');
+// Password hashing using bcrypt
+const BCRYPT_PREFIX = "$2"; // $2a, $2b, $2y
+
+const isBase64Hash = (hash: string): boolean => {
+ if (hash.startsWith(BCRYPT_PREFIX)) return false;
+ try {
+ const decoded = Buffer.from(hash, 'base64');
+ // If re-encoding yields the same string and the decoded buffer is valid UTF-8, treat as base64
+ const reencoded = decoded.toString('base64');
+ // Additionally ensure that decoding does not produce too short/empty unless original was empty
+ return reencoded === hash && decoded.toString('utf8').length > 0;
+ } catch {
+ return false;
+ }
};
-const verifyPassword = (password: string, hash: string): boolean => {
- return hashPassword(password) === hash;
+const hashPassword = async (password: string): Promise => {
+ return bcrypt.hash(password, 10);
+};
+
+const verifyPassword = async (password: string, hash: string): Promise => {
+ if (hash.startsWith(BCRYPT_PREFIX)) {
+ return bcrypt.compare(password, hash);
+ }
+ if (isBase64Hash(hash)) {
+ const base64OfPassword = Buffer.from(password).toString('base64');
+ return base64OfPassword === hash;
+ }
+ // Unknown format -> fail closed
+ return false;
};
// Export hashPassword for external use (e.g., generating hashes for .env)
export const generatePasswordHash = hashPassword;
+// Migrate all legacy Base64 password hashes to bcrypt on server startup
+const migrateLegacyHashesOnStartup = async (): Promise => {
+ const users = await usersKV.getAllItems();
+ let migratedCount = 0;
+ for (const user of users) {
+ if (isBase64Hash(user.passwordHash)) {
+ try {
+ const plaintext = Buffer.from(user.passwordHash, 'base64').toString('utf8');
+ const bcryptHash = await hashPassword(plaintext);
+ const updatedUser: User = { ...user, passwordHash: bcryptHash };
+ await usersKV.setItem(user.id, updatedUser);
+ migratedCount += 1;
+ } catch {
+ // ignore individual failures; continue with others
+ }
+ }
+ }
+ if (migratedCount > 0) {
+ console.log(`🔄 Migrated ${migratedCount} legacy Base64 password hash(es) to bcrypt at startup.`);
+ }
+};
+
// Initialize default owner account
const initializeOwner = async () => {
const existingUsers = await usersKV.getAllItems();
@@ -49,7 +95,12 @@ const initializeOwner = async () => {
// Get admin credentials from environment variables
const adminUsername = process.env.ADMIN_USERNAME || "owner";
- const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || hashPassword("admin123");
+ let adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || await hashPassword("admin123");
+ // If provided hash looks like legacy Base64, decode to plaintext and re-hash with bcrypt
+ if (process.env.ADMIN_PASSWORD_HASH && isBase64Hash(process.env.ADMIN_PASSWORD_HASH)) {
+ const plaintext = Buffer.from(process.env.ADMIN_PASSWORD_HASH, 'base64').toString('utf8');
+ adminPasswordHash = await hashPassword(plaintext);
+ }
const adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de";
const owner: User = {
@@ -66,8 +117,14 @@ const initializeOwner = async () => {
}
};
-// Initialize on module load
-initializeOwner();
+// Initialize on module load: first migrate legacy hashes, then ensure owner exists
+(async () => {
+ try {
+ await migrateLegacyHashesOnStartup();
+ } finally {
+ await initializeOwner();
+ }
+})();
const login = os
.input(z.object({
@@ -78,10 +135,22 @@ const login = os
const users = await usersKV.getAllItems();
const user = users.find(u => u.username === input.username);
- if (!user || !verifyPassword(input.password, user.passwordHash)) {
+ if (!user) {
throw new Error("Invalid credentials");
}
+ const isValid = await verifyPassword(input.password, user.passwordHash);
+ if (!isValid) {
+ throw new Error("Invalid credentials");
+ }
+
+ // Seamless migration: if stored hash is legacy Base64, upgrade to bcrypt
+ if (isBase64Hash(user.passwordHash)) {
+ const migratedHash = await hashPassword(input.password);
+ const migratedUser = { ...user, passwordHash: migratedHash } as User;
+ await usersKV.setItem(user.id, migratedUser);
+ }
+
// Create session
const sessionId = randomUUID();
const expiresAt = new Date();
@@ -159,13 +228,14 @@ const changePassword = os
throw new Error("User not found");
}
- if (!verifyPassword(input.currentPassword, user.passwordHash)) {
+ const currentOk = await verifyPassword(input.currentPassword, user.passwordHash);
+ if (!currentOk) {
throw new Error("Current password is incorrect");
}
const updatedUser = {
...user,
- passwordHash: hashPassword(input.newPassword),
+ passwordHash: await hashPassword(input.newPassword),
};
await usersKV.setItem(user.id, updatedUser);
diff --git a/src/server/rpc/bookings.ts b/src/server/rpc/bookings.ts
index efe2d61..fe22b64 100644
--- a/src/server/rpc/bookings.ts
+++ b/src/server/rpc/bookings.ts
@@ -2,6 +2,7 @@ import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
+import { sanitizeText, sanitizeHtml, sanitizePhone } from "../lib/sanitize.js";
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
import { router as rootRouter } from "./index.js";
@@ -292,14 +293,26 @@ const create = os
treatment.duration
);
+ // Sanitize user-provided fields before storage
+ const sanitizedName = sanitizeText(input.customerName);
+ const sanitizedPhone = input.customerPhone ? sanitizePhone(input.customerPhone) : undefined;
+ const sanitizedNotes = input.notes ? sanitizeHtml(input.notes) : undefined;
+
const id = randomUUID();
const booking = {
- id,
- ...input,
+ id,
+ treatmentId: input.treatmentId,
+ customerName: sanitizedName,
+ customerEmail: input.customerEmail,
+ customerPhone: sanitizedPhone,
+ appointmentDate: input.appointmentDate,
+ appointmentTime: input.appointmentTime,
+ notes: sanitizedNotes,
+ inspirationPhoto: input.inspirationPhoto,
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
status: "pending" as const,
createdAt: new Date().toISOString()
- };
+ } as Booking;
// Save the booking
await kv.setItem(id, booking);
@@ -313,7 +326,7 @@ const create = os
const formattedDate = formatDateGerman(input.appointmentDate);
const homepageUrl = generateUrl();
const html = await renderBookingPendingHTML({
- name: input.customerName,
+ name: sanitizedName,
date: input.appointmentDate,
time: input.appointmentTime,
statusUrl: bookingUrl
@@ -321,7 +334,7 @@ const create = os
await sendEmail({
to: input.customerEmail,
subject: "Deine Terminanfrage ist eingegangen",
- text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
+ text: `Hallo ${sanitizedName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html,
}).catch(() => {});
})();
@@ -336,24 +349,24 @@ const create = os
const treatmentName = treatment?.name || "Unbekannte Behandlung";
const adminHtml = await renderAdminBookingNotificationHTML({
- name: input.customerName,
+ name: sanitizedName,
date: input.appointmentDate,
time: input.appointmentTime,
treatment: treatmentName,
- phone: input.customerPhone || "Nicht angegeben",
- notes: input.notes,
+ phone: sanitizedPhone || "Nicht angegeben",
+ notes: sanitizedNotes,
hasInspirationPhoto: !!input.inspirationPhoto
});
const homepageUrl = generateUrl();
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
- `Name: ${input.customerName}\n` +
- `Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
+ `Name: ${sanitizedName}\n` +
+ `Telefon: ${sanitizedPhone || "Nicht angegeben"}\n` +
`Behandlung: ${treatmentName}\n` +
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
`Uhrzeit: ${input.appointmentTime}\n` +
- `${input.notes ? `Notizen: ${input.notes}\n` : ''}` +
+ `${sanitizedNotes ? `Notizen: ${sanitizedNotes}\n` : ''}` +
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
`Zur Website: ${homepageUrl}\n\n` +
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
@@ -361,14 +374,14 @@ const create = os
if (input.inspirationPhoto) {
await sendEmailWithInspirationPhoto({
to: process.env.ADMIN_EMAIL,
- subject: `Neue Buchungsanfrage - ${input.customerName}`,
+ subject: `Neue Buchungsanfrage - ${sanitizedName}`,
text: adminText,
html: adminHtml,
- }, input.inspirationPhoto, input.customerName).catch(() => {});
+ }, input.inspirationPhoto, sanitizedName).catch(() => {});
} else {
await sendEmail({
to: process.env.ADMIN_EMAIL,
- subject: `Neue Buchungsanfrage - ${input.customerName}`,
+ subject: `Neue Buchungsanfrage - ${sanitizedName}`,
text: adminText,
html: adminHtml,
}).catch(() => {});
@@ -441,7 +454,7 @@ const updateStatus = os
await sendEmailWithAGBAndCalendar({
to: booking.customerEmail,
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
- text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
+ text: `Hallo ${sanitizeText(booking.customerName)},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}, {
@@ -460,7 +473,7 @@ const updateStatus = os
await sendEmail({
to: booking.customerEmail,
subject: "Dein Termin wurde abgesagt",
- text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
+ text: `Hallo ${sanitizeText(booking.customerName)},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
});
@@ -508,7 +521,7 @@ const remove = os
await sendEmail({
to: booking.customerEmail,
subject: "Dein Termin wurde abgesagt",
- text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
+ text: `Hallo ${sanitizeText(booking.customerName)},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
});
@@ -577,16 +590,21 @@ const createManual = os
treatment.duration
);
+ // Sanitize user-provided fields before storage (admin manual booking)
+ const sanitizedName = sanitizeText(input.customerName);
+ const sanitizedPhone = input.customerPhone ? sanitizePhone(input.customerPhone) : undefined;
+ const sanitizedNotes = input.notes ? sanitizeHtml(input.notes) : undefined;
+
const id = randomUUID();
const booking = {
id,
treatmentId: input.treatmentId,
- customerName: input.customerName,
+ customerName: sanitizedName,
customerEmail: input.customerEmail,
- customerPhone: input.customerPhone,
+ customerPhone: sanitizedPhone,
appointmentDate: input.appointmentDate,
appointmentTime: input.appointmentTime,
- notes: input.notes,
+ notes: sanitizedNotes,
bookedDurationMinutes: treatment.duration,
status: "confirmed" as const,
createdAt: new Date().toISOString()
@@ -607,7 +625,7 @@ const createManual = os
const homepageUrl = generateUrl();
const html = await renderBookingConfirmedHTML({
- name: input.customerName,
+ name: sanitizedName,
date: input.appointmentDate,
time: input.appointmentTime,
cancellationUrl: bookingUrl,
@@ -617,13 +635,13 @@ const createManual = os
await sendEmailWithAGBAndCalendar({
to: input.customerEmail!,
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
- text: `Hallo ${input.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
+ text: `Hallo ${sanitizedName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
html,
}, {
date: input.appointmentDate,
time: input.appointmentTime,
durationMinutes: treatment.duration,
- customerName: input.customerName,
+ customerName: sanitizedName,
treatmentName: treatment.name
});
} catch (e) {