From 12a8f2ee27c55b86b55ec00b96d7e6894aa8c286 Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Thu, 24 Jul 2025 16:25:23 +0200 Subject: [PATCH] Bugfixes --- .../storycove/service/TypesenseService.java | 31 +++++- .../src/components/stories/RichTextEditor.tsx | 20 ++-- frontend/src/lib/sanitization.ts | 98 ++++++++++++++----- 3 files changed, 111 insertions(+), 38 deletions(-) diff --git a/backend/src/main/java/com/storycove/service/TypesenseService.java b/backend/src/main/java/com/storycove/service/TypesenseService.java index a00a60e..09129d7 100644 --- a/backend/src/main/java/com/storycove/service/TypesenseService.java +++ b/backend/src/main/java/com/storycove/service/TypesenseService.java @@ -201,14 +201,14 @@ public class TypesenseService { if (authorFilters != null && !authorFilters.isEmpty()) { String authorFilter = authorFilters.stream() - .map(author -> "authorName:=" + author) + .map(author -> "authorName:=" + escapeTypesenseValue(author)) .collect(Collectors.joining(" || ")); filterConditions.add("(" + authorFilter + ")"); } if (tagFilters != null && !tagFilters.isEmpty()) { String tagFilter = tagFilters.stream() - .map(tag -> "tagNames:=" + tag) + .map(tag -> "tagNames:=" + escapeTypesenseValue(tag)) .collect(Collectors.joining(" || ")); filterConditions.add("(" + tagFilter + ")"); } @@ -890,4 +890,31 @@ public class TypesenseService { return Map.of("error", e.getMessage()); } } + + /** + * Escape special characters in Typesense filter values. + * Typesense requires certain characters to be escaped or quoted. + */ + private String escapeTypesenseValue(String value) { + if (value == null) { + return ""; + } + + // If the value contains spaces, special characters, or quotes, wrap it in backticks + if (value.contains(" ") || value.contains("'") || value.contains("\"") || + value.contains("(") || value.contains(")") || value.contains("[") || + value.contains("]") || value.contains("{") || value.contains("}") || + value.contains(":") || value.contains("=") || value.contains("&") || + value.contains("|") || value.contains("!") || value.contains("<") || + value.contains(">") || value.contains("@") || value.contains("#") || + value.contains("$") || value.contains("%") || value.contains("^") || + value.contains("*") || value.contains("+") || value.contains("?") || + value.contains("\\") || value.contains("/") || value.contains("~") || + value.contains("`")) { + // Escape backticks in the value and wrap with backticks + return "`" + value.replace("`", "\\`") + "`"; + } + + return value; + } } \ No newline at end of file diff --git a/frontend/src/components/stories/RichTextEditor.tsx b/frontend/src/components/stories/RichTextEditor.tsx index 4005d58..d074095 100644 --- a/frontend/src/components/stories/RichTextEditor.tsx +++ b/frontend/src/components/stories/RichTextEditor.tsx @@ -33,18 +33,6 @@ export default function RichTextEditor({ }); }, []); - const handleVisualChange = (e: React.ChangeEvent) => { - const plainText = e.target.value; - // Convert plain text to basic HTML paragraphs - const htmlContent = plainText - .split('\n\n') - .filter(paragraph => paragraph.trim()) - .map(paragraph => `

${paragraph.replace(/\n/g, '
')}

`) - .join('\n'); - - onChange(htmlContent); - setHtmlValue(htmlContent); - }; const handleVisualContentChange = () => { const div = visualDivRef.current; @@ -198,16 +186,20 @@ export default function RichTextEditor({ // Split plain text into paragraphs and insert as HTML const paragraphs = plainText.split('\n\n').filter(p => p.trim()); + const fragment = document.createDocumentFragment(); + paragraphs.forEach((paragraph, index) => { if (index > 0) { // Add some spacing between paragraphs - range.insertNode(document.createElement('br')); + fragment.appendChild(document.createElement('br')); } const p = document.createElement('p'); p.textContent = paragraph.replace(/\n/g, ' '); - range.insertNode(p); + fragment.appendChild(p); }); + range.insertNode(fragment); + range.collapse(false); selection.removeAllRanges(); selection.addRange(range); diff --git a/frontend/src/lib/sanitization.ts b/frontend/src/lib/sanitization.ts index ab04faa..a96de2c 100644 --- a/frontend/src/lib/sanitization.ts +++ b/frontend/src/lib/sanitization.ts @@ -12,6 +12,38 @@ interface SanitizationConfig { let cachedConfig: SanitizationConfig | null = null; let configPromise: Promise | null = null; +/** + * Filter CSS properties in a style attribute value + */ +function filterCssProperties(styleValue: string, allowedProperties: string[]): string { + // Parse CSS declarations + const declarations = styleValue.split(';').map(decl => decl.trim()).filter(decl => decl); + + const filteredDeclarations = declarations.filter(declaration => { + const colonIndex = declaration.indexOf(':'); + if (colonIndex === -1) return false; + + const property = declaration.substring(0, colonIndex).trim().toLowerCase(); + const isAllowed = allowedProperties.includes(property); + + if (!isAllowed) { + console.log(`CSS property "${property}" was filtered out (not in allowed list)`); + } + + return isAllowed; + }); + + const result = filteredDeclarations.join('; '); + + if (declarations.length !== filteredDeclarations.length) { + console.log(`CSS filtering: ${declarations.length} -> ${filteredDeclarations.length} properties`); + console.log('Original:', styleValue); + console.log('Filtered:', result); + } + + return result; +} + /** * Fetch sanitization configuration from backend */ @@ -109,6 +141,23 @@ function createDOMPurifyConfig(config: SanitizationConfig) { ALLOW_DATA_ATTR: false, }; + // Clear any existing hooks and add CSS property filtering + DOMPurify.removeAllHooks(); + DOMPurify.addHook('afterSanitizeAttributes', function (node) { + // Only process elements with style attributes + if (node.hasAttribute && node.hasAttribute('style')) { + const styleValue = node.getAttribute('style'); + if (styleValue) { + const filteredStyle = filterCssProperties(styleValue, config.allowedCssProperties); + if (filteredStyle) { + node.setAttribute('style', filteredStyle); + } else { + node.removeAttribute('style'); + } + } + } + }); + return domPurifyConfig; } @@ -163,40 +212,45 @@ export function sanitizeHtmlSync(html: string): string { // Use comprehensive fallback configuration that preserves formatting console.log('Using fallback sanitization configuration with formatting support'); + const fallbackAllowedCssProperties = [ + 'color', 'font-size', 'font-weight', + 'font-style', 'text-align', 'text-decoration', 'margin', + 'padding', 'text-indent', 'line-height' + ]; + const fallbackConfig: DOMPurify.Config = { ALLOWED_TAGS: [ - // Basic block elements - 'p', 'br', 'div', 'span', 'section', 'article', - // Headers - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - // Text formatting + 'p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins', - 'sup', 'sub', 'small', 'big', 'mark', 'abbr', 'dfn', - // Code and preformatted - 'pre', 'code', 'kbd', 'samp', 'var', 'tt', - // Lists - 'ul', 'ol', 'li', 'dl', 'dt', 'dd', - // Links (but href will be removed by backend config) - 'a', - // Tables + 'sup', 'sub', 'small', 'big', 'mark', 'pre', 'code', 'kbd', 'samp', 'var', + 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col', - // Quotes and misc - 'blockquote', 'cite', 'q', 'hr', 'details', 'summary', - // Common website elements that might have formatting - 'font', 'center' + 'blockquote', 'cite', 'q', 'hr', 'details', 'summary' ], ALLOWED_ATTR: [ - 'class', 'style', 'colspan', 'rowspan', 'align', 'valign', - // Font attributes (though deprecated, websites still use them) - 'color', 'size', 'face' + 'class', 'style', 'colspan', 'rowspan' ], ALLOW_UNKNOWN_PROTOCOLS: false, SANITIZE_DOM: true, KEEP_CONTENT: true, ALLOW_DATA_ATTR: false, - // Don't strip style attributes completely - let them through for basic formatting - FORBID_ATTR: [], }; + + // Clear hooks and add CSS property filtering for fallback config + DOMPurify.removeAllHooks(); + DOMPurify.addHook('afterSanitizeAttributes', function (node) { + if (node.hasAttribute && node.hasAttribute('style')) { + const styleValue = node.getAttribute('style'); + if (styleValue) { + const filteredStyle = filterCssProperties(styleValue, fallbackAllowedCssProperties); + if (filteredStyle) { + node.setAttribute('style', filteredStyle); + } else { + node.removeAttribute('style'); + } + } + } + }); return DOMPurify.sanitize(html, fallbackConfig as any).toString(); }