docs: README dompurify note; fix: sanitize plaintext names in booking emails; feat: use sanitizePhone in email templates; feat: extend sanitizeHtml allowed tags and URL allowlist
This commit is contained in:
44
README.md
44
README.md
@@ -12,6 +12,8 @@ Ein vollständiges Buchungssystem für Nagelstudios mit Admin-Panel, Kalender un
|
|||||||
- [Hono](https://hono.dev/)
|
- [Hono](https://hono.dev/)
|
||||||
- [Zod](https://zod.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
|
## Setup
|
||||||
|
|
||||||
### 1. Umgebungsvariablen konfigurieren
|
### 1. Umgebungsvariablen konfigurieren
|
||||||
@@ -22,33 +24,28 @@ Kopiere die `.env.example` Datei zu `.env` und konfiguriere deine Umgebungsvaria
|
|||||||
cp .env.example .env
|
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)
|
#### Node.js (empfohlen)
|
||||||
```powershell
|
```bash
|
||||||
# Einfache Methode mit Base64-Encoding
|
node -e "require('bcrypt').hash('dein_sicheres_passwort', 10).then(console.log)"
|
||||||
$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 (falls verfügbar)
|
Alternativ kannst du ein kleines Script verwenden (falls du es öfter brauchst):
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// In der Node.js Konsole oder als separates Script
|
// scripts/generate-hash.js
|
||||||
const password = "dein_sicheres_passwort";
|
require('bcrypt').hash(process.argv[2] || 'dein_sicheres_passwort', 10).then(h => {
|
||||||
const hash = Buffer.from(password).toString('base64');
|
console.log(h);
|
||||||
console.log("Password Hash:", hash);
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Online-Tools (nur für Entwicklung)
|
Ausführen:
|
||||||
- Verwende einen Base64-Encoder wie [base64encode.org](https://www.base64encode.org/)
|
```bash
|
||||||
|
node scripts/generate-hash.js "dein_sicheres_passwort"
|
||||||
|
```
|
||||||
|
|
||||||
### 3. .env Datei konfigurieren
|
### 3. .env Datei konfigurieren
|
||||||
|
|
||||||
@@ -57,7 +54,8 @@ Bearbeite deine `.env` Datei und setze die generierten Werte:
|
|||||||
```env
|
```env
|
||||||
# Admin Account Configuration
|
# Admin Account Configuration
|
||||||
ADMIN_USERNAME=owner
|
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 Configuration
|
||||||
DOMAIN=localhost:5173 # Für Produktion: deine-domain.de
|
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:**
|
⚠️ **Wichtige Hinweise:**
|
||||||
- Ändere das Standard-Passwort vor dem Produktionseinsatz
|
- Ä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
|
- Verwende ein sicheres Passwort und generiere den entsprechenden Hash
|
||||||
- Die `.env` Datei sollte niemals in das Repository committet werden
|
- 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
|
### Security.txt Endpoint
|
||||||
|
|
||||||
Die Anwendung bietet einen RFC 9116 konformen Security.txt Endpoint unter `/.well-known/security.txt`:
|
Die Anwendung bietet einen RFC 9116 konformen Security.txt Endpoint unter `/.well-known/security.txt`:
|
||||||
|
@@ -12,6 +12,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
"@hono/node-server": "^1.19.5",
|
"@hono/node-server": "^1.19.5",
|
||||||
"@orpc/client": "^1.8.8",
|
"@orpc/client": "^1.8.8",
|
||||||
"@orpc/server": "^1.8.8",
|
"@orpc/server": "^1.8.8",
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
"@tanstack/react-query": "^5.85.5",
|
"@tanstack/react-query": "^5.85.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"hono": "^4.9.4",
|
"hono": "^4.9.4",
|
||||||
|
"isomorphic-dompurify": "^2.16.0",
|
||||||
"jsonrepair": "^3.13.0",
|
"jsonrepair": "^3.13.0",
|
||||||
"openai": "^5.17.0",
|
"openai": "^5.17.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
@@ -14,6 +14,28 @@ app.use("*", async (c, next) => {
|
|||||||
return 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
|
// Health check endpoint
|
||||||
app.get("/health", (c) => {
|
app.get("/health", (c) => {
|
||||||
return c.json({ status: "ok", timestamp: new Date().toISOString() });
|
return c.json({ status: "ok", timestamp: new Date().toISOString() });
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { sanitizeText, sanitizeHtml, sanitizePhone } from "./sanitize.js";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
@@ -60,13 +61,14 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
|
|||||||
|
|
||||||
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string }) {
|
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string }) {
|
||||||
const { name, date, time, statusUrl } = params;
|
const { name, date, time, statusUrl } = params;
|
||||||
|
const safeName = sanitizeText(name);
|
||||||
const formattedDate = formatDateGerman(date);
|
const formattedDate = formatDateGerman(date);
|
||||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
const legalUrl = `${protocol}://${domain}/legal`;
|
const legalUrl = `${protocol}://${domain}/legal`;
|
||||||
|
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo ${name},</p>
|
<p>Hallo ${safeName},</p>
|
||||||
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
|
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
|
||||||
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
|
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
|
||||||
${statusUrl ? `
|
${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 }) {
|
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string; reviewUrl?: string }) {
|
||||||
const { name, date, time, cancellationUrl, reviewUrl } = params;
|
const { name, date, time, cancellationUrl, reviewUrl } = params;
|
||||||
|
const safeName = sanitizeText(name);
|
||||||
const formattedDate = formatDateGerman(date);
|
const formattedDate = formatDateGerman(date);
|
||||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
const legalUrl = `${protocol}://${domain}/legal`;
|
const legalUrl = `${protocol}://${domain}/legal`;
|
||||||
|
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo ${name},</p>
|
<p>Hallo ${safeName},</p>
|
||||||
<p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p>
|
<p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p>
|
||||||
<p>Wir freuen uns auf dich!</p>
|
<p>Wir freuen uns auf dich!</p>
|
||||||
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
@@ -126,13 +129,14 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
|
|||||||
|
|
||||||
export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string }) {
|
export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string }) {
|
||||||
const { name, date, time } = params;
|
const { name, date, time } = params;
|
||||||
|
const safeName = sanitizeText(name);
|
||||||
const formattedDate = formatDateGerman(date);
|
const formattedDate = formatDateGerman(date);
|
||||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
const legalUrl = `${protocol}://${domain}/legal`;
|
const legalUrl = `${protocol}://${domain}/legal`;
|
||||||
|
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo ${name},</p>
|
<p>Hallo ${safeName},</p>
|
||||||
<p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
|
<p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
|
||||||
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
|
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
|
||||||
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
@@ -154,6 +158,10 @@ export async function renderAdminBookingNotificationHTML(params: {
|
|||||||
hasInspirationPhoto: boolean;
|
hasInspirationPhoto: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params;
|
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 formattedDate = formatDateGerman(date);
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo Admin,</p>
|
<p>Hallo Admin,</p>
|
||||||
@@ -161,12 +169,12 @@ export async function renderAdminBookingNotificationHTML(params: {
|
|||||||
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Buchungsdetails:</p>
|
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Buchungsdetails:</p>
|
||||||
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
|
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
|
||||||
<li><strong>Name:</strong> ${name}</li>
|
<li><strong>Name:</strong> ${safeName}</li>
|
||||||
<li><strong>Telefon:</strong> ${phone}</li>
|
<li><strong>Telefon:</strong> ${safePhone}</li>
|
||||||
<li><strong>Behandlung:</strong> ${treatment}</li>
|
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
|
||||||
<li><strong>Datum:</strong> ${formattedDate}</li>
|
<li><strong>Datum:</strong> ${formattedDate}</li>
|
||||||
<li><strong>Uhrzeit:</strong> ${time}</li>
|
<li><strong>Uhrzeit:</strong> ${time}</li>
|
||||||
${notes ? `<li><strong>Notizen:</strong> ${notes}</li>` : ''}
|
${safeNotes ? `<li><strong>Notizen:</strong> ${safeNotes}</li>` : ''}
|
||||||
<li><strong>Inspiration-Foto:</strong> ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}</li>
|
<li><strong>Inspiration-Foto:</strong> ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,13 +196,15 @@ export async function renderBookingRescheduleProposalHTML(params: {
|
|||||||
declineUrl: string;
|
declineUrl: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}) {
|
}) {
|
||||||
|
const safeName = sanitizeText(params.name);
|
||||||
|
const safeTreatment = sanitizeText(params.treatmentName);
|
||||||
const formattedOriginalDate = formatDateGerman(params.originalDate);
|
const formattedOriginalDate = formatDateGerman(params.originalDate);
|
||||||
const formattedProposedDate = formatDateGerman(params.proposedDate);
|
const formattedProposedDate = formatDateGerman(params.proposedDate);
|
||||||
const expiryDate = new Date(params.expiresAt);
|
const expiryDate = new Date(params.expiresAt);
|
||||||
const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo ${params.name},</p>
|
<p>Hallo ${safeName},</p>
|
||||||
<p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</p>
|
<p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</p>
|
||||||
<div style="background-color: #f8fafc; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
<div style="background-color: #f8fafc; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
<p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
|
<p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
|
||||||
@@ -209,7 +219,7 @@ export async function renderBookingRescheduleProposalHTML(params: {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td>
|
<td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td>
|
||||||
<td style="padding:6px 0;">${params.treatmentName}</td>
|
<td style="padding:6px 0;">${safeTreatment}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,15 +249,19 @@ export async function renderAdminRescheduleDeclinedHTML(params: {
|
|||||||
customerEmail?: string;
|
customerEmail?: string;
|
||||||
customerPhone?: 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 = `
|
const inner = `
|
||||||
<p>Hallo Admin,</p>
|
<p>Hallo Admin,</p>
|
||||||
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p>
|
<p>der Kunde <strong>${safeCustomerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p>
|
||||||
<div style="background-color:#f8fafc; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
<div style="background-color:#f8fafc; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
||||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||||
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
<li><strong>Kunde:</strong> ${safeCustomerName}</li>
|
||||||
${params.customerEmail ? `<li><strong>E-Mail:</strong> ${params.customerEmail}</li>` : ''}
|
${safeEmail ? `<li><strong>E-Mail:</strong> ${safeEmail}</li>` : ''}
|
||||||
${params.customerPhone ? `<li><strong>Telefon:</strong> ${params.customerPhone}</li>` : ''}
|
${safePhone ? `<li><strong>Telefon:</strong> ${safePhone}</li>` : ''}
|
||||||
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
|
||||||
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</li>
|
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</li>
|
||||||
<li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
|
<li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -265,13 +279,15 @@ export async function renderAdminRescheduleAcceptedHTML(params: {
|
|||||||
newTime: string;
|
newTime: string;
|
||||||
treatmentName: string;
|
treatmentName: string;
|
||||||
}) {
|
}) {
|
||||||
|
const safeCustomerName = sanitizeText(params.customerName);
|
||||||
|
const safeTreatment = sanitizeText(params.treatmentName);
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo Admin,</p>
|
<p>Hallo Admin,</p>
|
||||||
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p>
|
<p>der Kunde <strong>${safeCustomerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p>
|
||||||
<div style="background-color:#ecfeff; border-left:4px solid #10b981; padding:16px; margin:16px 0; border-radius:4px;">
|
<div style="background-color:#ecfeff; border-left:4px solid #10b981; padding:16px; margin:16px 0; border-radius:4px;">
|
||||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||||
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
<li><strong>Kunde:</strong> ${safeCustomerName}</li>
|
||||||
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
|
||||||
<li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</li>
|
<li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</li>
|
||||||
<li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li>
|
<li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -299,19 +315,24 @@ export async function renderAdminRescheduleExpiredHTML(params: {
|
|||||||
<p><strong>${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen</strong> und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.</p>
|
<p><strong>${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen</strong> und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.</p>
|
||||||
<div style="background-color:#fef2f2; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
<div style="background-color:#fef2f2; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
||||||
<p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p>
|
<p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p>
|
||||||
${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 `
|
||||||
<div style="background-color:#ffffff; border:1px solid #fecaca; border-radius:4px; padding:12px; margin:8px 0;">
|
<div style="background-color:#ffffff; border:1px solid #fecaca; border-radius:4px; padding:12px; margin:8px 0;">
|
||||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
|
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
|
||||||
<li><strong>Kunde:</strong> ${proposal.customerName}</li>
|
<li><strong>Kunde:</strong> ${safeName}</li>
|
||||||
${proposal.customerEmail ? `<li><strong>E-Mail:</strong> ${proposal.customerEmail}</li>` : ''}
|
${safeEmail ? `<li><strong>E-Mail:</strong> ${safeEmail}</li>` : ''}
|
||||||
${proposal.customerPhone ? `<li><strong>Telefon:</strong> ${proposal.customerPhone}</li>` : ''}
|
${safePhone ? `<li><strong>Telefon:</strong> ${safePhone}</li>` : ''}
|
||||||
<li><strong>Behandlung:</strong> ${proposal.treatmentName}</li>
|
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
|
||||||
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</li>
|
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</li>
|
||||||
<li><strong>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li>
|
<li><strong>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li>
|
||||||
<li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
|
<li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`;}).join('')}
|
||||||
</div>
|
</div>
|
||||||
<p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p>
|
<p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p>
|
||||||
<p>Die ursprünglichen Termine bleiben bestehen.</p>
|
<p>Die ursprünglichen Termine bleiben bestehen.</p>
|
||||||
|
37
src/server/lib/sanitize.ts
Normal file
37
src/server/lib/sanitize.ts
Normal file
@@ -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, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { createKV } from "../lib/create-kv.js";
|
import { createKV } from "../lib/create-kv.js";
|
||||||
import { config } from "dotenv";
|
import { config } from "dotenv";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
|
||||||
// Load environment variables from .env file
|
// Load environment variables from .env file
|
||||||
config();
|
config();
|
||||||
@@ -29,18 +30,63 @@ type Session = z.output<typeof SessionSchema>;
|
|||||||
const usersKV = createKV<User>("users");
|
const usersKV = createKV<User>("users");
|
||||||
const sessionsKV = createKV<Session>("sessions");
|
const sessionsKV = createKV<Session>("sessions");
|
||||||
|
|
||||||
// Simple password hashing (in production, use bcrypt or similar)
|
// Password hashing using bcrypt
|
||||||
const hashPassword = (password: string): string => {
|
const BCRYPT_PREFIX = "$2"; // $2a, $2b, $2y
|
||||||
return Buffer.from(password).toString('base64');
|
|
||||||
|
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 => {
|
const hashPassword = async (password: string): Promise<string> => {
|
||||||
return hashPassword(password) === hash;
|
return bcrypt.hash(password, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyPassword = async (password: string, hash: string): Promise<boolean> => {
|
||||||
|
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 hashPassword for external use (e.g., generating hashes for .env)
|
||||||
export const generatePasswordHash = hashPassword;
|
export const generatePasswordHash = hashPassword;
|
||||||
|
|
||||||
|
// Migrate all legacy Base64 password hashes to bcrypt on server startup
|
||||||
|
const migrateLegacyHashesOnStartup = async (): Promise<void> => {
|
||||||
|
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
|
// Initialize default owner account
|
||||||
const initializeOwner = async () => {
|
const initializeOwner = async () => {
|
||||||
const existingUsers = await usersKV.getAllItems();
|
const existingUsers = await usersKV.getAllItems();
|
||||||
@@ -49,7 +95,12 @@ const initializeOwner = async () => {
|
|||||||
|
|
||||||
// Get admin credentials from environment variables
|
// Get admin credentials from environment variables
|
||||||
const adminUsername = process.env.ADMIN_USERNAME || "owner";
|
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 adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de";
|
||||||
|
|
||||||
const owner: User = {
|
const owner: User = {
|
||||||
@@ -66,8 +117,14 @@ const initializeOwner = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize on module load
|
// Initialize on module load: first migrate legacy hashes, then ensure owner exists
|
||||||
initializeOwner();
|
(async () => {
|
||||||
|
try {
|
||||||
|
await migrateLegacyHashesOnStartup();
|
||||||
|
} finally {
|
||||||
|
await initializeOwner();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const login = os
|
const login = os
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
@@ -78,10 +135,22 @@ const login = os
|
|||||||
const users = await usersKV.getAllItems();
|
const users = await usersKV.getAllItems();
|
||||||
const user = users.find(u => u.username === input.username);
|
const user = users.find(u => u.username === input.username);
|
||||||
|
|
||||||
if (!user || !verifyPassword(input.password, user.passwordHash)) {
|
if (!user) {
|
||||||
throw new Error("Invalid credentials");
|
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
|
// Create session
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
const expiresAt = new Date();
|
const expiresAt = new Date();
|
||||||
@@ -159,13 +228,14 @@ const changePassword = os
|
|||||||
throw new Error("User not found");
|
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");
|
throw new Error("Current password is incorrect");
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUser = {
|
const updatedUser = {
|
||||||
...user,
|
...user,
|
||||||
passwordHash: hashPassword(input.newPassword),
|
passwordHash: await hashPassword(input.newPassword),
|
||||||
};
|
};
|
||||||
|
|
||||||
await usersKV.setItem(user.id, updatedUser);
|
await usersKV.setItem(user.id, updatedUser);
|
||||||
|
@@ -2,6 +2,7 @@ import { call, os } from "@orpc/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { createKV } from "../lib/create-kv.js";
|
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 { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
|
||||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
|
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
|
||||||
import { router as rootRouter } from "./index.js";
|
import { router as rootRouter } from "./index.js";
|
||||||
@@ -292,14 +293,26 @@ const create = os
|
|||||||
treatment.duration
|
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 id = randomUUID();
|
||||||
const booking = {
|
const booking = {
|
||||||
id,
|
id,
|
||||||
...input,
|
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
|
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
|
||||||
status: "pending" as const,
|
status: "pending" as const,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
};
|
} as Booking;
|
||||||
|
|
||||||
// Save the booking
|
// Save the booking
|
||||||
await kv.setItem(id, booking);
|
await kv.setItem(id, booking);
|
||||||
@@ -313,7 +326,7 @@ const create = os
|
|||||||
const formattedDate = formatDateGerman(input.appointmentDate);
|
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||||
const homepageUrl = generateUrl();
|
const homepageUrl = generateUrl();
|
||||||
const html = await renderBookingPendingHTML({
|
const html = await renderBookingPendingHTML({
|
||||||
name: input.customerName,
|
name: sanitizedName,
|
||||||
date: input.appointmentDate,
|
date: input.appointmentDate,
|
||||||
time: input.appointmentTime,
|
time: input.appointmentTime,
|
||||||
statusUrl: bookingUrl
|
statusUrl: bookingUrl
|
||||||
@@ -321,7 +334,7 @@ const create = os
|
|||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: input.customerEmail,
|
to: input.customerEmail,
|
||||||
subject: "Deine Terminanfrage ist eingegangen",
|
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,
|
html,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
})();
|
})();
|
||||||
@@ -336,24 +349,24 @@ const create = os
|
|||||||
const treatmentName = treatment?.name || "Unbekannte Behandlung";
|
const treatmentName = treatment?.name || "Unbekannte Behandlung";
|
||||||
|
|
||||||
const adminHtml = await renderAdminBookingNotificationHTML({
|
const adminHtml = await renderAdminBookingNotificationHTML({
|
||||||
name: input.customerName,
|
name: sanitizedName,
|
||||||
date: input.appointmentDate,
|
date: input.appointmentDate,
|
||||||
time: input.appointmentTime,
|
time: input.appointmentTime,
|
||||||
treatment: treatmentName,
|
treatment: treatmentName,
|
||||||
phone: input.customerPhone || "Nicht angegeben",
|
phone: sanitizedPhone || "Nicht angegeben",
|
||||||
notes: input.notes,
|
notes: sanitizedNotes,
|
||||||
hasInspirationPhoto: !!input.inspirationPhoto
|
hasInspirationPhoto: !!input.inspirationPhoto
|
||||||
});
|
});
|
||||||
|
|
||||||
const homepageUrl = generateUrl();
|
const homepageUrl = generateUrl();
|
||||||
|
|
||||||
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
||||||
`Name: ${input.customerName}\n` +
|
`Name: ${sanitizedName}\n` +
|
||||||
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
|
`Telefon: ${sanitizedPhone || "Nicht angegeben"}\n` +
|
||||||
`Behandlung: ${treatmentName}\n` +
|
`Behandlung: ${treatmentName}\n` +
|
||||||
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
|
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
|
||||||
`Uhrzeit: ${input.appointmentTime}\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` +
|
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
|
||||||
`Zur Website: ${homepageUrl}\n\n` +
|
`Zur Website: ${homepageUrl}\n\n` +
|
||||||
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
|
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
|
||||||
@@ -361,14 +374,14 @@ const create = os
|
|||||||
if (input.inspirationPhoto) {
|
if (input.inspirationPhoto) {
|
||||||
await sendEmailWithInspirationPhoto({
|
await sendEmailWithInspirationPhoto({
|
||||||
to: process.env.ADMIN_EMAIL,
|
to: process.env.ADMIN_EMAIL,
|
||||||
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
subject: `Neue Buchungsanfrage - ${sanitizedName}`,
|
||||||
text: adminText,
|
text: adminText,
|
||||||
html: adminHtml,
|
html: adminHtml,
|
||||||
}, input.inspirationPhoto, input.customerName).catch(() => {});
|
}, input.inspirationPhoto, sanitizedName).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: process.env.ADMIN_EMAIL,
|
to: process.env.ADMIN_EMAIL,
|
||||||
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
subject: `Neue Buchungsanfrage - ${sanitizedName}`,
|
||||||
text: adminText,
|
text: adminText,
|
||||||
html: adminHtml,
|
html: adminHtml,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
@@ -441,7 +454,7 @@ const updateStatus = os
|
|||||||
await sendEmailWithAGBAndCalendar({
|
await sendEmailWithAGBAndCalendar({
|
||||||
to: booking.customerEmail,
|
to: booking.customerEmail,
|
||||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
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,
|
html,
|
||||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||||
}, {
|
}, {
|
||||||
@@ -460,7 +473,7 @@ const updateStatus = os
|
|||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: booking.customerEmail,
|
to: booking.customerEmail,
|
||||||
subject: "Dein Termin wurde abgesagt",
|
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,
|
html,
|
||||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||||
});
|
});
|
||||||
@@ -508,7 +521,7 @@ const remove = os
|
|||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: booking.customerEmail,
|
to: booking.customerEmail,
|
||||||
subject: "Dein Termin wurde abgesagt",
|
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,
|
html,
|
||||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||||
});
|
});
|
||||||
@@ -577,16 +590,21 @@ const createManual = os
|
|||||||
treatment.duration
|
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 id = randomUUID();
|
||||||
const booking = {
|
const booking = {
|
||||||
id,
|
id,
|
||||||
treatmentId: input.treatmentId,
|
treatmentId: input.treatmentId,
|
||||||
customerName: input.customerName,
|
customerName: sanitizedName,
|
||||||
customerEmail: input.customerEmail,
|
customerEmail: input.customerEmail,
|
||||||
customerPhone: input.customerPhone,
|
customerPhone: sanitizedPhone,
|
||||||
appointmentDate: input.appointmentDate,
|
appointmentDate: input.appointmentDate,
|
||||||
appointmentTime: input.appointmentTime,
|
appointmentTime: input.appointmentTime,
|
||||||
notes: input.notes,
|
notes: sanitizedNotes,
|
||||||
bookedDurationMinutes: treatment.duration,
|
bookedDurationMinutes: treatment.duration,
|
||||||
status: "confirmed" as const,
|
status: "confirmed" as const,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
@@ -607,7 +625,7 @@ const createManual = os
|
|||||||
const homepageUrl = generateUrl();
|
const homepageUrl = generateUrl();
|
||||||
|
|
||||||
const html = await renderBookingConfirmedHTML({
|
const html = await renderBookingConfirmedHTML({
|
||||||
name: input.customerName,
|
name: sanitizedName,
|
||||||
date: input.appointmentDate,
|
date: input.appointmentDate,
|
||||||
time: input.appointmentTime,
|
time: input.appointmentTime,
|
||||||
cancellationUrl: bookingUrl,
|
cancellationUrl: bookingUrl,
|
||||||
@@ -617,13 +635,13 @@ const createManual = os
|
|||||||
await sendEmailWithAGBAndCalendar({
|
await sendEmailWithAGBAndCalendar({
|
||||||
to: input.customerEmail!,
|
to: input.customerEmail!,
|
||||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
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,
|
html,
|
||||||
}, {
|
}, {
|
||||||
date: input.appointmentDate,
|
date: input.appointmentDate,
|
||||||
time: input.appointmentTime,
|
time: input.appointmentTime,
|
||||||
durationMinutes: treatment.duration,
|
durationMinutes: treatment.duration,
|
||||||
customerName: input.customerName,
|
customerName: sanitizedName,
|
||||||
treatmentName: treatment.name
|
treatmentName: treatment.name
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
Reference in New Issue
Block a user