This commit is contained in:
Stefan Hardegger
2025-07-24 16:25:23 +02:00
parent a38812877a
commit 12a8f2ee27
3 changed files with 111 additions and 38 deletions

View File

@@ -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;
}
}

View File

@@ -33,18 +33,6 @@ export default function RichTextEditor({
});
}, []);
const handleVisualChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const plainText = e.target.value;
// Convert plain text to basic HTML paragraphs
const htmlContent = plainText
.split('\n\n')
.filter(paragraph => paragraph.trim())
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
.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);

View File

@@ -12,6 +12,38 @@ interface SanitizationConfig {
let cachedConfig: SanitizationConfig | null = null;
let configPromise: Promise<SanitizationConfig> | 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();
}