chore(docker): .dockerignore angepasst; lokale Build-Schritte in Rebuild-Skripten; Doku/README zu production vs production-prebuilt aktualisiert
This commit is contained in:
@@ -1,13 +1,83 @@
|
||||
import { createKV } from "./create-kv.js";
|
||||
import { getCookie } from "hono/cookie";
|
||||
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
export const sessionsKV = createKV("sessions");
|
||||
export const usersKV = createKV("users");
|
||||
export async function assertOwner(sessionId) {
|
||||
// Cookie configuration constants
|
||||
export const SESSION_COOKIE_NAME = 'sessionId';
|
||||
export const CSRF_COOKIE_NAME = 'csrf-token';
|
||||
export const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax',
|
||||
path: '/',
|
||||
maxAge: 86400 // 24 hours
|
||||
};
|
||||
// CSRF token generation
|
||||
export function generateCSRFToken() {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
// Session extraction from cookies
|
||||
export async function getSessionFromCookies(c) {
|
||||
const sessionId = getCookie(c, SESSION_COOKIE_NAME);
|
||||
if (!sessionId)
|
||||
return null;
|
||||
const session = await sessionsKV.getItem(sessionId);
|
||||
if (!session)
|
||||
return null;
|
||||
// Check expiration
|
||||
if (new Date(session.expiresAt) < new Date()) {
|
||||
// Clean up expired session
|
||||
await sessionsKV.removeItem(sessionId);
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
// CSRF token validation
|
||||
export async function validateCSRFToken(c, sessionId) {
|
||||
const headerToken = c.req.header('X-CSRF-Token');
|
||||
if (!headerToken)
|
||||
throw new Error("CSRF token missing");
|
||||
const session = await sessionsKV.getItem(sessionId);
|
||||
if (!session?.csrfToken)
|
||||
throw new Error("Invalid session");
|
||||
// Use timing-safe comparison to prevent timing attacks
|
||||
const sessionTokenBuffer = Buffer.from(session.csrfToken, 'hex');
|
||||
const headerTokenBuffer = Buffer.from(headerToken, 'hex');
|
||||
if (sessionTokenBuffer.length !== headerTokenBuffer.length || !timingSafeEqual(sessionTokenBuffer, headerTokenBuffer)) {
|
||||
throw new Error("CSRF token mismatch");
|
||||
}
|
||||
}
|
||||
// Session rotation helper
|
||||
export async function rotateSession(oldSessionId, userId) {
|
||||
// Delete old session
|
||||
await sessionsKV.removeItem(oldSessionId);
|
||||
// Create new session with CSRF token
|
||||
const newSessionId = randomUUID();
|
||||
const csrfToken = generateCSRFToken();
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24 hours
|
||||
const newSession = {
|
||||
id: newSessionId,
|
||||
userId,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
createdAt: now.toISOString(),
|
||||
csrfToken
|
||||
};
|
||||
await sessionsKV.setItem(newSessionId, newSession);
|
||||
return newSession;
|
||||
}
|
||||
// Updated assertOwner function with CSRF validation
|
||||
export async function assertOwner(c) {
|
||||
const session = await getSessionFromCookies(c);
|
||||
if (!session)
|
||||
throw new Error("Invalid session");
|
||||
if (new Date(session.expiresAt) < new Date())
|
||||
throw new Error("Session expired");
|
||||
const user = await usersKV.getItem(session.userId);
|
||||
if (!user || user.role !== "owner")
|
||||
throw new Error("Forbidden");
|
||||
// Validate CSRF token for non-GET requests
|
||||
const method = c.req.method;
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
await validateCSRFToken(c, session.id);
|
||||
}
|
||||
}
|
||||
|
@@ -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";
|
||||
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
||||
@@ -56,12 +57,13 @@ async function renderBrandedEmail(title, bodyHtml) {
|
||||
}
|
||||
export async function renderBookingPendingHTML(params) {
|
||||
const { name, date, time, statusUrl } = 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 = `
|
||||
<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 bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
|
||||
${statusUrl ? `
|
||||
@@ -81,12 +83,13 @@ export async function renderBookingPendingHTML(params) {
|
||||
}
|
||||
export async function renderBookingConfirmedHTML(params) {
|
||||
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 = `
|
||||
<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 freuen uns auf dich!</p>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
@@ -118,12 +121,13 @@ export async function renderBookingConfirmedHTML(params) {
|
||||
}
|
||||
export async function renderBookingCancelledHTML(params) {
|
||||
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 = `
|
||||
<p>Hallo ${name},</p>
|
||||
<p>Hallo ${safeName},</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>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
@@ -136,6 +140,10 @@ export async function renderBookingCancelledHTML(params) {
|
||||
}
|
||||
export async function renderAdminBookingNotificationHTML(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 inner = `
|
||||
<p>Hallo Admin,</p>
|
||||
@@ -143,12 +151,12 @@ export async function renderAdminBookingNotificationHTML(params) {
|
||||
<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>
|
||||
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
|
||||
<li><strong>Name:</strong> ${name}</li>
|
||||
<li><strong>Telefon:</strong> ${phone}</li>
|
||||
<li><strong>Behandlung:</strong> ${treatment}</li>
|
||||
<li><strong>Name:</strong> ${safeName}</li>
|
||||
<li><strong>Telefon:</strong> ${safePhone}</li>
|
||||
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
|
||||
<li><strong>Datum:</strong> ${formattedDate}</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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -158,12 +166,14 @@ export async function renderAdminBookingNotificationHTML(params) {
|
||||
return renderBrandedEmail("Neue Buchungsanfrage - Admin-Benachrichtigung", inner);
|
||||
}
|
||||
export async function renderBookingRescheduleProposalHTML(params) {
|
||||
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 = `
|
||||
<p>Hallo ${params.name},</p>
|
||||
<p>Hallo ${safeName},</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;">
|
||||
<p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
|
||||
@@ -178,7 +188,7 @@ export async function renderBookingRescheduleProposalHTML(params) {
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -198,15 +208,19 @@ export async function renderBookingRescheduleProposalHTML(params) {
|
||||
return renderBrandedEmail("Terminänderung vorgeschlagen", inner);
|
||||
}
|
||||
export async function renderAdminRescheduleDeclinedHTML(params) {
|
||||
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 = `
|
||||
<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;">
|
||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
||||
${params.customerEmail ? `<li><strong>E-Mail:</strong> ${params.customerEmail}</li>` : ''}
|
||||
${params.customerPhone ? `<li><strong>Telefon:</strong> ${params.customerPhone}</li>` : ''}
|
||||
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
||||
<li><strong>Kunde:</strong> ${safeCustomerName}</li>
|
||||
${safeEmail ? `<li><strong>E-Mail:</strong> ${safeEmail}</li>` : ''}
|
||||
${safePhone ? `<li><strong>Telefon:</strong> ${safePhone}</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>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
|
||||
</ul>
|
||||
@@ -216,13 +230,15 @@ export async function renderAdminRescheduleDeclinedHTML(params) {
|
||||
return renderBrandedEmail("Kunde hat Terminänderung abgelehnt", inner);
|
||||
}
|
||||
export async function renderAdminRescheduleAcceptedHTML(params) {
|
||||
const safeCustomerName = sanitizeText(params.customerName);
|
||||
const safeTreatment = sanitizeText(params.treatmentName);
|
||||
const inner = `
|
||||
<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;">
|
||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
||||
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
||||
<li><strong>Kunde:</strong> ${safeCustomerName}</li>
|
||||
<li><strong>Behandlung:</strong> ${safeTreatment}</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>
|
||||
</ul>
|
||||
@@ -237,19 +253,25 @@ 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>
|
||||
<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>
|
||||
${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;">
|
||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
|
||||
<li><strong>Kunde:</strong> ${proposal.customerName}</li>
|
||||
${proposal.customerEmail ? `<li><strong>E-Mail:</strong> ${proposal.customerEmail}</li>` : ''}
|
||||
${proposal.customerPhone ? `<li><strong>Telefon:</strong> ${proposal.customerPhone}</li>` : ''}
|
||||
<li><strong>Behandlung:</strong> ${proposal.treatmentName}</li>
|
||||
<li><strong>Kunde:</strong> ${safeName}</li>
|
||||
${safeEmail ? `<li><strong>E-Mail:</strong> ${safeEmail}</li>` : ''}
|
||||
${safePhone ? `<li><strong>Telefon:</strong> ${safePhone}</li>` : ''}
|
||||
<li><strong>Behandlung:</strong> ${safeTreatment}</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>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
`).join('')}
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<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>
|
||||
|
@@ -99,19 +99,127 @@ export function checkBookingRateLimit(params) {
|
||||
*/
|
||||
export function getClientIP(headers) {
|
||||
// Check common proxy headers
|
||||
const forwardedFor = headers['x-forwarded-for'];
|
||||
const get = (name) => {
|
||||
if (typeof headers.get === 'function') {
|
||||
// Headers interface
|
||||
const v = headers.get(name);
|
||||
return v === null ? undefined : v;
|
||||
}
|
||||
return headers[name];
|
||||
};
|
||||
const forwardedFor = get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
// x-forwarded-for can contain multiple IPs, take the first one
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
const realIP = headers['x-real-ip'];
|
||||
const realIP = get('x-real-ip');
|
||||
if (realIP) {
|
||||
return realIP;
|
||||
}
|
||||
const cfConnectingIP = headers['cf-connecting-ip']; // Cloudflare
|
||||
const cfConnectingIP = get('cf-connecting-ip'); // Cloudflare
|
||||
if (cfConnectingIP) {
|
||||
return cfConnectingIP;
|
||||
}
|
||||
// No IP found
|
||||
return undefined;
|
||||
}
|
||||
/**
|
||||
* Reset a rate limit entry immediately (e.g., after successful login)
|
||||
*/
|
||||
export function resetRateLimit(key) {
|
||||
rateLimitStore.delete(key);
|
||||
}
|
||||
/**
|
||||
* Convenience helper to reset login attempts for an IP
|
||||
*/
|
||||
export function resetLoginRateLimit(ip) {
|
||||
if (!ip)
|
||||
return;
|
||||
resetRateLimit(`login:ip:${ip}`);
|
||||
}
|
||||
import { getSessionFromCookies } from "./auth.js";
|
||||
/**
|
||||
* Enforce admin rate limiting by IP and user. Throws standardized German error on exceed.
|
||||
*/
|
||||
export async function enforceAdminRateLimit(context) {
|
||||
const ip = getClientIP(context.req.raw.headers);
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (!session)
|
||||
return; // No session -> owner assertion elsewhere; no per-user throttling
|
||||
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||
if (!result.allowed) {
|
||||
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Brute-Force-Schutz für Logins (IP-basiert)
|
||||
*
|
||||
* Konfiguration:
|
||||
* - max. 5 Versuche je IP in 15 Minuten
|
||||
*
|
||||
* Schlüssel: "login:ip:${ip}"
|
||||
*/
|
||||
export function checkLoginRateLimit(ip) {
|
||||
// Wenn keine IP ermittelbar ist, erlauben (kein Tracking möglich)
|
||||
if (!ip) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 15 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
const loginConfig = {
|
||||
maxRequests: 5,
|
||||
windowMs: 15 * 60 * 1000, // 15 Minuten
|
||||
};
|
||||
const key = `login:ip:${ip}`;
|
||||
return checkRateLimit(key, loginConfig);
|
||||
}
|
||||
/**
|
||||
* Rate Limiting für Admin-Operationen
|
||||
*
|
||||
* Konfigurationen (beide Checks werden geprüft, restriktiverer gewinnt):
|
||||
* - Benutzer-basiert: 30 Anfragen je Benutzer in 5 Minuten
|
||||
* - IP-basiert: 50 Anfragen je IP in 5 Minuten
|
||||
*
|
||||
* Schlüssel:
|
||||
* - "admin:user:${userId}"
|
||||
* - "admin:ip:${ip}"
|
||||
*/
|
||||
export function checkAdminRateLimit(params) {
|
||||
const { ip, userId } = params;
|
||||
const userConfig = {
|
||||
maxRequests: 30,
|
||||
windowMs: 5 * 60 * 1000, // 5 Minuten
|
||||
};
|
||||
const ipConfig = {
|
||||
maxRequests: 50,
|
||||
windowMs: 5 * 60 * 1000, // 5 Minuten
|
||||
};
|
||||
const userKey = `admin:user:${userId}`;
|
||||
const userResult = checkRateLimit(userKey, userConfig);
|
||||
// Wenn Benutzerlimit bereits überschritten ist, direkt zurückgeben
|
||||
if (!userResult.allowed) {
|
||||
return { ...userResult, allowed: false };
|
||||
}
|
||||
// Falls IP verfügbar, zusätzlich prüfen
|
||||
if (ip) {
|
||||
const ipKey = `admin:ip:${ip}`;
|
||||
const ipResult = checkRateLimit(ipKey, ipConfig);
|
||||
if (!ipResult.allowed) {
|
||||
return { ...ipResult, allowed: false };
|
||||
}
|
||||
// Beide Checks erlaubt: restriktivere Restwerte/Reset nehmen
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: Math.min(userResult.remaining, ipResult.remaining),
|
||||
resetAt: Math.min(userResult.resetAt, ipResult.resetAt),
|
||||
};
|
||||
}
|
||||
// Kein IP-Check möglich
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: userResult.remaining,
|
||||
resetAt: userResult.resetAt,
|
||||
};
|
||||
}
|
||||
|
34
server-dist/lib/sanitize.js
Normal file
34
server-dist/lib/sanitize.js
Normal file
@@ -0,0 +1,34 @@
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
const text = sanitizeText(input);
|
||||
return text.replace(/[^0-9+\-()\s]/g, "");
|
||||
}
|
Reference in New Issue
Block a user