Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/kg-default-nodes/lib/nodes/button/ButtonNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ export class ButtonNode extends generateDecoratorNode({
properties: [
{name: 'buttonText', default: ''},
{name: 'alignment', default: 'center'},
{name: 'buttonUrl', default: '', urlType: 'url'}
{name: 'buttonUrl', default: '', urlType: 'url'},
{name: 'buttonColor', default: 'accent'},
{name: 'buttonTextColor', default: '#ffffff'}
],
defaultRenderFn: renderButtonNode
}) {
Expand Down
13 changes: 10 additions & 3 deletions packages/kg-default-nodes/lib/nodes/button/button-parser.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {rgbToHex} from '../../utils/rgb-to-hex';

export function parseButtonNode(ButtonNode) {
return {
div: (nodeElem) => {
Expand All @@ -13,13 +15,18 @@ export function parseButtonNode(ButtonNode) {
}

const buttonNode = domNode?.querySelector('.kg-btn');
const buttonUrl = buttonNode.getAttribute('href');
const buttonText = buttonNode.textContent;
const buttonUrl = buttonNode?.getAttribute('href');
const buttonText = buttonNode?.textContent;
const isAccentButton = buttonNode?.classList?.contains('kg-btn-accent') ?? false;
const buttonColor = isAccentButton ? 'accent' : (rgbToHex(buttonNode?.style?.backgroundColor) || 'accent');
const buttonTextColor = rgbToHex(buttonNode?.style?.color) || '#ffffff';

const payload = {
buttonText: buttonText,
alignment: alignment,
buttonUrl: buttonUrl
buttonUrl: buttonUrl,
buttonColor: buttonColor,
buttonTextColor: buttonTextColor
};

const node = new ButtonNode(payload);
Expand Down
53 changes: 44 additions & 9 deletions packages/kg-default-nodes/lib/nodes/button/button-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,32 +20,40 @@ export function renderButtonNode(node, options = {}) {

function frontendTemplate(node, document) {
const cardClasses = getCardClasses(node);
const buttonClasses = getButtonClasses(node);
const buttonStyle = getButtonStyle(node);

const cardDiv = document.createElement('div');
cardDiv.setAttribute('class', cardClasses);

const button = document.createElement('a');
button.setAttribute('href', node.buttonUrl);
button.setAttribute('class', 'kg-btn kg-btn-accent');
button.setAttribute('class', buttonClasses);
if (buttonStyle) {
button.setAttribute('style', buttonStyle);
}
button.textContent = node.buttonText || 'Button Title';

cardDiv.appendChild(button);
return {element: cardDiv};
}

function emailTemplate(node, options, document) {
const {buttonUrl, buttonText} = node;
const {buttonUrl, buttonText, buttonColor = 'accent', buttonTextColor = '#ffffff'} = node;
const buttonClasses = buttonColor === 'accent' ? 'btn btn-accent' : 'btn';
const buttonStyle = buttonColor !== 'accent' ? `style="background-color: ${buttonColor};"` : '';
const textStyle = (buttonTextColor && (buttonColor !== 'accent' || buttonTextColor !== '#ffffff')) ? `style="color: ${buttonTextColor};"` : '';
Comment on lines +42 to +45
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unsanitized color values interpolated into email HTML via innerHTML.

buttonColor and buttonTextColor are interpolated directly into HTML template strings that are later assigned via innerHTML (lines 55–56, 65, 95, 99, 107). While the editor UI and rgbToHex parser produce safe hex strings, these values can also arrive via importJSON with arbitrary content. A crafted payload like #ff0000" onclick="alert(1) in buttonColor would break out of the style attribute in the email output.

Consider validating or sanitizing color values (e.g., a hex-color regex check) before interpolation in the email paths. The frontend path (line 33) using setAttribute is safe by contrast.

🛡️ Suggested validation helper
+const VALID_COLOR = /^#[0-9a-f]{3,8}$/i;
+
+function sanitizeColor(value, fallback) {
+    if (value === 'accent' || (typeof value === 'string' && VALID_COLOR.test(value))) {
+        return value;
+    }
+    return fallback;
+}
+
 function emailTemplate(node, options, document) {
-    const {buttonUrl, buttonText, buttonColor = 'accent', buttonTextColor = '#ffffff'} = node;
+    const {buttonUrl, buttonText} = node;
+    const buttonColor = sanitizeColor(node.buttonColor, 'accent');
+    const buttonTextColor = sanitizeColor(node.buttonTextColor, '#ffffff');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const {buttonUrl, buttonText, buttonColor = 'accent', buttonTextColor = '#ffffff'} = node;
const buttonClasses = buttonColor === 'accent' ? 'btn btn-accent' : 'btn';
const buttonStyle = buttonColor !== 'accent' ? `style="background-color: ${buttonColor};"` : '';
const textStyle = (buttonTextColor && (buttonColor !== 'accent' || buttonTextColor !== '#ffffff')) ? `style="color: ${buttonTextColor};"` : '';
const VALID_COLOR = /^#[0-9a-f]{3,8}$/i;
function sanitizeColor(value, fallback) {
if (value === 'accent' || (typeof value === 'string' && VALID_COLOR.test(value))) {
return value;
}
return fallback;
}
function emailTemplate(node, options, document) {
const {buttonUrl, buttonText} = node;
const buttonColor = sanitizeColor(node.buttonColor, 'accent');
const buttonTextColor = sanitizeColor(node.buttonTextColor, '#ffffff');
const buttonClasses = buttonColor === 'accent' ? 'btn btn-accent' : 'btn';
const buttonStyle = buttonColor !== 'accent' ? `style="background-color: ${buttonColor};"` : '';
const textStyle = (buttonTextColor && (buttonColor !== 'accent' || buttonTextColor !== '#ffffff')) ? `style="color: ${buttonTextColor};"` : '';
🤖 Prompt for AI Agents
In `@packages/kg-default-nodes/lib/nodes/button/button-renderer.js` around lines
42 - 45, The buttonColor and buttonTextColor values are interpolated into email
HTML via innerHTML (constructed into buttonStyle and textStyle from
node.buttonColor/node.buttonTextColor) and must be validated/sanitized first;
add a validation step (e.g., verify against a strict hex-color regex like
/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/) and only use the value if it passes,
otherwise fall back to a safe default (e.g., '#ffffff' or the existing 'accent'
branch); apply this check before building buttonStyle and textStyle and before
any code path that sets innerHTML or consumes importJSON-provided node values so
untrusted payloads cannot break out of the style attribute.


let cardHtml;
if (options.feature?.emailCustomization) {
cardHtml = html`
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td>
<table class="btn btn-accent" border="0" cellspacing="0" cellpadding="0" align="${node.alignment}">
<table class="${buttonClasses}" border="0" cellspacing="0" cellpadding="0" align="${node.alignment}">
<tr>
<td align="center">
<a href="${buttonUrl}">${buttonText}</a>
<td align="center" ${buttonStyle}>
<a href="${buttonUrl}" ${textStyle}>${buttonText}</a>
</td>
</tr>
</table>
Expand All @@ -59,9 +67,10 @@ function emailTemplate(node, options, document) {
} else if (options.feature?.emailCustomizationAlpha) {
const buttonHtml = renderEmailButton({
alignment: node.alignment,
color: 'accent',
color: buttonColor,
url: buttonUrl,
text: buttonText
text: buttonText,
textColor: buttonTextColor
});

cardHtml = html`
Expand All @@ -80,12 +89,14 @@ function emailTemplate(node, options, document) {
element.innerHTML = cardHtml;
return {element, type: 'inner'};
} else {
const wrapperStyle = buttonColor !== 'accent' ? `style="background-color: ${buttonColor};"` : '';
const wrapperClass = buttonColor === 'accent' ? 'btn btn-accent' : 'btn';
cardHtml = html`
<div class="btn btn-accent">
<div class="${wrapperClass}" ${wrapperStyle}>
<table border="0" cellspacing="0" cellpadding="0" align="${node.alignment}">
<tr>
<td align="center">
<a href="${buttonUrl}">${buttonText}</a>
<a href="${buttonUrl}" ${textStyle}>${buttonText}</a>
</td>
</tr>
</table>
Expand All @@ -107,3 +118,27 @@ function getCardClasses(node) {

return cardClasses.join(' ');
}

function getButtonClasses(node) {
const buttonClasses = ['kg-btn'];

if (node.buttonColor === 'accent' || !node.buttonColor) {
buttonClasses.push('kg-btn-accent');
}

return buttonClasses.join(' ');
}

function getButtonStyle(node) {
const styles = [];

if (node.buttonColor && node.buttonColor !== 'accent') {
styles.push(`background-color: ${node.buttonColor}`);
}

if (node.buttonTextColor && (node.buttonColor !== 'accent' || node.buttonTextColor !== '#ffffff')) {
styles.push(`color: ${node.buttonTextColor}`);
}

return styles.join('; ');
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,33 @@ import {html} from '../tagged-template-fns.mjs';
* @param {string} [options.alignment]
* @param {string} [options.color='accent']
* @param {string} [options.text='']
* @param {string} [options.textColor]
* @param {string} [options.textColor='#ffffff']
* @param {string} [options.url='']
* @returns {string}
*/
export function renderEmailButton({
alignment = '',
color = 'accent',
text = '',
textColor = '#ffffff',
url = ''
} = {}) {
const buttonClasses = clsx(
'btn',
color === 'accent' && 'btn-accent'
);
const buttonStyle = color !== 'accent' ? ` style="background-color: ${color};"` : '';
const textStyle = (textColor && (color !== 'accent' || textColor !== '#ffffff')) ? ` style="color: ${textColor};"` : '';

return html`
<table class="${buttonClasses}" border="0" cellspacing="0" cellpadding="0" align="${alignment}">
<tbody>
<tr>
<td align="center">
<a href="${url}">${text}</a>
<td align="center"${buttonStyle}>
<a href="${url}"${textStyle}>${text}</a>
</td>
</tr>
</tbody>
</table>
`;
}
}
88 changes: 86 additions & 2 deletions packages/kg-default-nodes/test/nodes/button.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ describe('ButtonNode', function () {
dataset = {
buttonText: 'click me',
buttonUrl: 'http://blog.com/post1',
alignment: 'center'
alignment: 'center',
buttonColor: 'accent',
buttonTextColor: '#ffffff'
};
exportOptions = {
dom
Expand All @@ -50,6 +52,8 @@ describe('ButtonNode', function () {
buttonNode.buttonUrl.should.equal(dataset.buttonUrl);
buttonNode.buttonText.should.equal(dataset.buttonText);
buttonNode.alignment.should.equal(dataset.alignment);
buttonNode.buttonColor.should.equal(dataset.buttonColor);
buttonNode.buttonTextColor.should.equal(dataset.buttonTextColor);
}));

it('has setters for all properties', editorTest(function () {
Expand All @@ -66,6 +70,14 @@ describe('ButtonNode', function () {
buttonNode.alignment.should.equal('center');
buttonNode.alignment = 'left';
buttonNode.alignment.should.equal('left');

buttonNode.buttonColor.should.equal('accent');
buttonNode.buttonColor = '#ff0000';
buttonNode.buttonColor.should.equal('#ff0000');

buttonNode.buttonTextColor.should.equal('#ffffff');
buttonNode.buttonTextColor = '#000000';
buttonNode.buttonTextColor.should.equal('#000000');
}));

it('has getDataset() convenience method', editorTest(function () {
Expand Down Expand Up @@ -132,6 +144,18 @@ describe('ButtonNode', function () {
output.should.containEql('<td align="center">');
}));

it('renders custom button colors', editorTest(function () {
const customDataset = {
...dataset,
buttonColor: '#ff0000',
buttonTextColor: '#000000'
};
const buttonNode = $createButtonNode(customDataset);
const {element} = buttonNode.exportDOM(exportOptions);

element.outerHTML.should.prettifyTo(html`<div class="kg-card kg-button-card kg-align-center"><a href="http://blog.com/post1" class="kg-btn" style="background-color: #ff0000; color: #000000">click me</a></div>`);
}));

it('renders for email target (emailCustomization)', editorTest(function () {
const buttonNode = $createButtonNode(dataset);
const options = {
Expand Down Expand Up @@ -196,6 +220,43 @@ describe('ButtonNode', function () {
`);
}));

it('renders custom colors for email targets', editorTest(function () {
const customDataset = {
...dataset,
buttonColor: '#ff0000',
buttonTextColor: '#000000'
};
const buttonNode = $createButtonNode(customDataset);
const options = {
target: 'email',
feature: {
emailCustomization: true
}
};
const {element} = buttonNode.exportDOM({...exportOptions, ...options});
const output = element.innerHTML;

output.should.prettifyTo(html`
<table border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<table class="btn" border="0" cellspacing="0" cellpadding="0" align="center">
<tbody>
<tr>
<td align="center" style="background-color: #ff0000;">
<a href="http://blog.com/post1" style="color: #000000;">click me</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
`);
}));

it('renders an empty span with a missing buttonUrl', editorTest(function () {
const buttonNode = $createButtonNode();
const {element} = buttonNode.exportDOM(exportOptions);
Expand All @@ -214,7 +275,9 @@ describe('ButtonNode', function () {
version: 1,
buttonUrl: dataset.buttonUrl,
buttonText: dataset.buttonText,
alignment: dataset.alignment
alignment: dataset.alignment,
buttonColor: dataset.buttonColor,
buttonTextColor: dataset.buttonTextColor
});
}));
});
Expand Down Expand Up @@ -245,6 +308,8 @@ describe('ButtonNode', function () {
buttonNode.buttonUrl.should.equal(dataset.buttonUrl);
buttonNode.buttonText.should.equal(dataset.buttonText);
buttonNode.alignment.should.equal(dataset.alignment);
buttonNode.buttonColor.should.equal(dataset.buttonColor);
buttonNode.buttonTextColor.should.equal(dataset.buttonTextColor);

done();
} catch (e) {
Expand Down Expand Up @@ -276,6 +341,8 @@ describe('ButtonNode', function () {
nodes[0].buttonUrl.should.equal('http://someblog.com/somepost');
nodes[0].buttonText.should.equal('click me');
nodes[0].alignment.should.equal('center');
nodes[0].buttonColor.should.equal('accent');
nodes[0].buttonTextColor.should.equal('#ffffff');
}));

it('preserves relative urls in content', editorTest(function () {
Expand All @@ -289,6 +356,23 @@ describe('ButtonNode', function () {
nodes[0].buttonUrl.should.equal('#/portal/signup');
nodes[0].buttonText.should.equal('Subscribe 1');
nodes[0].alignment.should.equal('center');
nodes[0].buttonColor.should.equal('accent');
nodes[0].buttonTextColor.should.equal('#ffffff');
}));

it('parses custom button colors', editorTest(function () {
const document = createDocument(html`
<div class="kg-card kg-button-card kg-align-left">
<a href="http://someblog.com/somepost" class="kg-btn" style="background-color: rgb(255, 0, 0); color: rgb(0, 0, 0);">Subscribe 1</a>
</div>
`);
const nodes = $generateNodesFromDOM(editor, document);
nodes.length.should.equal(1);
nodes[0].buttonUrl.should.equal('http://someblog.com/somepost');
nodes[0].buttonText.should.equal('Subscribe 1');
nodes[0].alignment.should.equal('left');
nodes[0].buttonColor.should.equal('#ff0000');
nodes[0].buttonTextColor.should.equal('#000000');
}));
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {useCollaborationContext} from '@lexical/react/LexicalCollaborationContex
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {useSharedHistoryContext} from '../context/SharedHistoryContext';
import {useSharedOnChangeContext} from '../context/SharedOnChangeContext';
import {isMobileViewport} from '../utils/isMobileViewport';

const KoenigComposableEditor = ({
onChange,
Expand Down Expand Up @@ -52,6 +53,37 @@ const KoenigComposableEditor = ({

const isNested = !!editor._parentEditor;
const isDragReorderEnabled = isDragEnabled && !readOnly && !isNested;
const [showMobileSlashHint, setShowMobileSlashHint] = React.useState(false);

React.useEffect(() => {
if (isNested) {
setShowMobileSlashHint(false);
return;
}

const updateViewportState = () => {
setShowMobileSlashHint(isMobileViewport());
};

updateViewportState();
window.addEventListener('resize', updateViewportState);

return () => {
window.removeEventListener('resize', updateViewportState);
};
}, [isNested]);

const resolvedPlaceholderText = React.useMemo(() => {
if (typeof placeholderText === 'string') {
return placeholderText;
}

if (showMobileSlashHint) {
return 'Begin writing your post... Type / to open the card menu.';
}

return undefined;
}, [placeholderText, showMobileSlashHint]);

const {onChange: sharedOnChange} = useSharedOnChangeContext();
const _onChange = React.useCallback((editorState) => {
Expand Down Expand Up @@ -101,7 +133,7 @@ const KoenigComposableEditor = ({
</div>
}
ErrorBoundary={KoenigErrorBoundary}
placeholder={placeholder || <EditorPlaceholder className={placeholderClassName} text={placeholderText} />}
placeholder={placeholder || <EditorPlaceholder className={placeholderClassName} text={resolvedPlaceholderText} />}
/>
<LinkPlugin />
<OnChangePlugin ignoreHistoryMergeTagChange={false} ignoreSelectionChange={true} onChange={_onChange} />
Expand Down
Loading