Drop unsafe props from front component elements (#22458)
## What Front components are third-party React components rendered into the host page through a restricted element allow-list. `filterProps` (where their props become real DOM attributes) used to forward unrecognized values as-is, which left two ways to run script in the host origin: - an `on*` attribute with a string value, which React renders as an inline event handler; - a dangerous-scheme URL (`javascript:`, `data:`, `vbscript:`) on a link, which executes on navigation. ## Change `filterProps` now drops both: - `on*` props are kept only when the value is a real function (still wrapped as before); any non-function `on*` is dropped. - `javascript:` / `data:` / `vbscript:` URLs are dropped, but only on **navigation targets** (`<a>`/`<area>` `href`/`xlink:href`, `<form>` `action`, `<button>`/`<input>` `formaction`), after normalizing away control-character obfuscation (e.g. `java\tscript:`). Resource-loading attributes are left alone, so `<img src="data:image/...">` keeps working. Well-behaved components are unaffected: function handlers are still wrapped and normal URLs pass through. Host-side only, no worker or SDK changes. ## Scope: the actual behavior change is small The diff looks large, but most of it is **not** a behavior change. `createHtmlHostWrapper.ts` (~460 lines) was split into one-export-per-file utils (`filterProps`, `serializeEvent`, `parseCssString`, `hasDangerousUrlScheme`, etc.), each with its own unit test, leaving `createHtmlHostWrapper.ts` as a thin orchestrator. Those helpers were **moved unchanged** — the only real logic change is the `filterProps` hardening described above. The pre-existing render-based integration test passes untouched, which confirms the split is behavior-neutral; the rest of the new files are extractions plus added test coverage. ## Why these schemes, and only on navigation targets Per MDN, `javascript:` (and `data:`) URLs are dangerous specifically where a URL is a *navigation target*, not where it is a *resource location* (like an image `src`) — which is exactly how the check is scoped: - [`javascript:` URLs (MDN)](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/javascript) - [`data:` URLs (MDN)](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data) - [URI schemes overview (MDN)](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes) This is a prerequisite for later work that widens the raw-attribute surface (innerHTML rendering).
This commit is contained in:
+84
@@ -0,0 +1,84 @@
|
|||||||
|
import { type ReactElement } from 'react';
|
||||||
|
|
||||||
|
import { createCaretPreservingElement } from '../createCaretPreservingElement';
|
||||||
|
|
||||||
|
const getProps = (element: ReactElement): Record<string, unknown> =>
|
||||||
|
element.props as Record<string, unknown>;
|
||||||
|
|
||||||
|
describe('createCaretPreservingElement', () => {
|
||||||
|
it('should create an element of the requested tag', () => {
|
||||||
|
const element = createCaretPreservingElement(
|
||||||
|
'input',
|
||||||
|
{ type: 'text' },
|
||||||
|
undefined,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(element.type).toBe('input');
|
||||||
|
expect(getProps(element).type).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should seed the initial value from value', () => {
|
||||||
|
const element = createCaretPreservingElement(
|
||||||
|
'input',
|
||||||
|
{ value: 'hello' },
|
||||||
|
undefined,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getProps(element).defaultValue).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer defaultValue over value for the initial value', () => {
|
||||||
|
const element = createCaretPreservingElement(
|
||||||
|
'input',
|
||||||
|
{ value: 'v', defaultValue: 'd' },
|
||||||
|
undefined,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getProps(element).defaultValue).toBe('d');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply forced props', () => {
|
||||||
|
const element = createCaretPreservingElement(
|
||||||
|
'textarea',
|
||||||
|
{},
|
||||||
|
{ readOnly: true },
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getProps(element).readOnly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should notify focus state and forward the original focus handler', () => {
|
||||||
|
const setEditableFocused = jest.fn();
|
||||||
|
const onFocus = jest.fn();
|
||||||
|
const element = createCaretPreservingElement(
|
||||||
|
'input',
|
||||||
|
{ onFocus },
|
||||||
|
undefined,
|
||||||
|
setEditableFocused,
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = {} as never;
|
||||||
|
(getProps(element).onFocus as (event: unknown) => void)(event);
|
||||||
|
|
||||||
|
expect(setEditableFocused).toHaveBeenCalledWith(true);
|
||||||
|
expect(onFocus).toHaveBeenCalledWith(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should notify blur state', () => {
|
||||||
|
const setEditableFocused = jest.fn();
|
||||||
|
const element = createCaretPreservingElement(
|
||||||
|
'input',
|
||||||
|
{},
|
||||||
|
undefined,
|
||||||
|
setEditableFocused,
|
||||||
|
);
|
||||||
|
|
||||||
|
(getProps(element).onBlur as (event: unknown) => void)({} as never);
|
||||||
|
|
||||||
|
expect(setEditableFocused).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
import './setupServerRenderingGlobals';
|
||||||
|
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
|
||||||
|
import { createHtmlHostWrapper } from '../createHtmlHostWrapper';
|
||||||
|
|
||||||
|
const renderWrapper = (
|
||||||
|
htmlTag: string,
|
||||||
|
props: Record<string, unknown>,
|
||||||
|
children?: string,
|
||||||
|
): string =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
createElement(createHtmlHostWrapper(htmlTag), props, children),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('createHtmlHostWrapper prop hardening', () => {
|
||||||
|
it('should drop an on* attribute whose value is not a function', () => {
|
||||||
|
const markup = renderWrapper('div', { onmouseover: 'alert(1)' });
|
||||||
|
|
||||||
|
expect(markup).not.toContain('onmouseover');
|
||||||
|
expect(markup).not.toContain('alert(1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should drop a normalized event attribute whose value is not a function', () => {
|
||||||
|
const markup = renderWrapper('div', { onClick: 'alert(1)' });
|
||||||
|
|
||||||
|
expect(markup).not.toContain('alert(1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should drop a javascript: url on href', () => {
|
||||||
|
const markup = renderWrapper('a', { href: 'javascript:alert(1)' }, 'link');
|
||||||
|
|
||||||
|
expect(markup).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should drop a data: url on href', () => {
|
||||||
|
const markup = renderWrapper(
|
||||||
|
'a',
|
||||||
|
{ href: 'data:text/html,<script>alert(1)</script>' },
|
||||||
|
'link',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).not.toContain('data:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should drop a vbscript: url on href', () => {
|
||||||
|
const markup = renderWrapper('a', { href: 'vbscript:msgbox(1)' }, 'link');
|
||||||
|
|
||||||
|
expect(markup).not.toContain('vbscript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should drop a javascript: url on an anchor xlink:href', () => {
|
||||||
|
const markup = renderWrapper(
|
||||||
|
'a',
|
||||||
|
{ 'xlink:href': 'javascript:alert(1)' },
|
||||||
|
'link',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should drop a javascript: url on a React-style anchor xlinkHref', () => {
|
||||||
|
const markup = renderWrapper(
|
||||||
|
'a',
|
||||||
|
{ xlinkHref: 'javascript:alert(1)' },
|
||||||
|
'link',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should drop a javascript: url obfuscated with control characters', () => {
|
||||||
|
const markup = renderWrapper(
|
||||||
|
'a',
|
||||||
|
{ href: 'java\tscript:alert(1)' },
|
||||||
|
'link',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).not.toContain('script:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep a safe href', () => {
|
||||||
|
const markup = renderWrapper('a', { href: 'https://twenty.com' }, 'link');
|
||||||
|
|
||||||
|
expect(markup).toContain('href="https://twenty.com"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep a data: image on src', () => {
|
||||||
|
const dataImageUrl = 'data:image/png;base64,iVBORw0KGgo=';
|
||||||
|
const markup = renderWrapper('img', { src: dataImageUrl });
|
||||||
|
|
||||||
|
expect(markup).toContain(dataImageUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { filterProps } from '../filterProps';
|
||||||
|
|
||||||
|
const filter = (props: Record<string, unknown>, htmlTag: string) =>
|
||||||
|
filterProps(props, htmlTag) as Record<string, unknown>;
|
||||||
|
|
||||||
|
describe('filterProps', () => {
|
||||||
|
it('should drop internal remote-dom props', () => {
|
||||||
|
const result = filter(
|
||||||
|
{ element: {}, receiver: {}, components: {}, id: 'keep' },
|
||||||
|
'div',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: 'keep' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should drop undefined values', () => {
|
||||||
|
const result = filter({ title: undefined, id: 'x' }, 'div');
|
||||||
|
|
||||||
|
expect('title' in result).toBe(false);
|
||||||
|
expect(result.id).toBe('x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse the style string into an object', () => {
|
||||||
|
const result = filter({ style: 'color: red' }, 'div');
|
||||||
|
|
||||||
|
expect(result.style).toEqual({ color: 'red' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should wrap function event handlers and normalize their key', () => {
|
||||||
|
const onClick = jest.fn();
|
||||||
|
const result = filter({ onClick }, 'div');
|
||||||
|
|
||||||
|
expect(typeof result.onClick).toBe('function');
|
||||||
|
expect(result.onClick).not.toBe(onClick);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should drop event-handler props whose value is not a function', () => {
|
||||||
|
const result = filter({ onClick: 'alert(1)', onmouseover: 'x' }, 'div');
|
||||||
|
|
||||||
|
expect('onClick' in result).toBe(false);
|
||||||
|
expect('onMouseOver' in result).toBe(false);
|
||||||
|
expect('onmouseover' in result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should drop a dangerous scheme on a navigation attribute', () => {
|
||||||
|
const result = filter({ href: 'javascript:alert(1)' }, 'a');
|
||||||
|
|
||||||
|
expect('href' in result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep a dangerous scheme on a non-navigation attribute', () => {
|
||||||
|
const dataImage = 'data:image/png;base64,iVBOR';
|
||||||
|
const result = filter({ src: dataImage }, 'img');
|
||||||
|
|
||||||
|
expect(result.src).toBe(dataImage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep a safe url on a navigation attribute', () => {
|
||||||
|
const result = filter({ href: 'https://twenty.com' }, 'a');
|
||||||
|
|
||||||
|
expect(result.href).toBe('https://twenty.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
import { hasDangerousUrlScheme } from '../hasDangerousUrlScheme';
|
||||||
|
|
||||||
|
describe('hasDangerousUrlScheme', () => {
|
||||||
|
it('should detect javascript, data and vbscript schemes', () => {
|
||||||
|
expect(hasDangerousUrlScheme('javascript:alert(1)')).toBe(true);
|
||||||
|
expect(hasDangerousUrlScheme('data:text/html,<script>')).toBe(true);
|
||||||
|
expect(hasDangerousUrlScheme('vbscript:msgbox(1)')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore casing', () => {
|
||||||
|
expect(hasDangerousUrlScheme('JavaScript:alert(1)')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should see through leading and interior control-character obfuscation', () => {
|
||||||
|
expect(hasDangerousUrlScheme(' javascript:alert(1)')).toBe(true);
|
||||||
|
expect(hasDangerousUrlScheme('java\tscript:alert(1)')).toBe(true);
|
||||||
|
expect(hasDangerousUrlScheme('javascript:alert(1)')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow safe schemes and relative urls', () => {
|
||||||
|
expect(hasDangerousUrlScheme('https://twenty.com')).toBe(false);
|
||||||
|
expect(hasDangerousUrlScheme('mailto:hi@twenty.com')).toBe(false);
|
||||||
|
expect(hasDangerousUrlScheme('/relative/path')).toBe(false);
|
||||||
|
expect(hasDangerousUrlScheme('#anchor')).toBe(false);
|
||||||
|
expect(hasDangerousUrlScheme('blob:https://twenty.com/abc')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-string values', () => {
|
||||||
|
expect(hasDangerousUrlScheme(undefined)).toBe(false);
|
||||||
|
expect(hasDangerousUrlScheme(123)).toBe(false);
|
||||||
|
expect(hasDangerousUrlScheme({})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import { isEventHandlerKey } from '../isEventHandlerKey';
|
||||||
|
|
||||||
|
describe('isEventHandlerKey', () => {
|
||||||
|
it('should match keys starting with on regardless of casing', () => {
|
||||||
|
expect(isEventHandlerKey('onClick')).toBe(true);
|
||||||
|
expect(isEventHandlerKey('onclick')).toBe(true);
|
||||||
|
expect(isEventHandlerKey('ONMOUSEOVER')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match non-event keys', () => {
|
||||||
|
expect(isEventHandlerKey('href')).toBe(false);
|
||||||
|
expect(isEventHandlerKey('className')).toBe(false);
|
||||||
|
expect(isEventHandlerKey('data-testid')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
import { isNavigationUrlAttribute } from '../isNavigationUrlAttribute';
|
||||||
|
|
||||||
|
describe('isNavigationUrlAttribute', () => {
|
||||||
|
it('should treat anchor href and xlink:href as navigation attributes', () => {
|
||||||
|
expect(isNavigationUrlAttribute('a', 'href')).toBe(true);
|
||||||
|
expect(isNavigationUrlAttribute('a', 'xlink:href')).toBe(true);
|
||||||
|
expect(isNavigationUrlAttribute('a', 'xlinkHref')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore casing of the tag and the attribute', () => {
|
||||||
|
expect(isNavigationUrlAttribute('A', 'HREF')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat form action and formaction as navigation attributes', () => {
|
||||||
|
expect(isNavigationUrlAttribute('area', 'href')).toBe(true);
|
||||||
|
expect(isNavigationUrlAttribute('form', 'action')).toBe(true);
|
||||||
|
expect(isNavigationUrlAttribute('button', 'formaction')).toBe(true);
|
||||||
|
expect(isNavigationUrlAttribute('input', 'formaction')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not treat resource-loading attributes as navigation', () => {
|
||||||
|
expect(isNavigationUrlAttribute('img', 'src')).toBe(false);
|
||||||
|
expect(isNavigationUrlAttribute('a', 'src')).toBe(false);
|
||||||
|
expect(isNavigationUrlAttribute('image', 'xlink:href')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match attributes on unrelated tags', () => {
|
||||||
|
expect(isNavigationUrlAttribute('div', 'href')).toBe(false);
|
||||||
|
expect(isNavigationUrlAttribute('a', 'action')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
import { isTextLikeInputType } from '../isTextLikeInputType';
|
||||||
|
|
||||||
|
describe('isTextLikeInputType', () => {
|
||||||
|
it('should treat text-like input types as text-like', () => {
|
||||||
|
expect(isTextLikeInputType('text')).toBe(true);
|
||||||
|
expect(isTextLikeInputType('email')).toBe(true);
|
||||||
|
expect(isTextLikeInputType('password')).toBe(true);
|
||||||
|
expect(isTextLikeInputType('number')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat a missing type as text-like', () => {
|
||||||
|
expect(isTextLikeInputType(undefined)).toBe(true);
|
||||||
|
expect(isTextLikeInputType('')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore casing', () => {
|
||||||
|
expect(isTextLikeInputType('TEXT')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not treat non-text input types as text-like', () => {
|
||||||
|
expect(isTextLikeInputType('checkbox')).toBe(false);
|
||||||
|
expect(isTextLikeInputType('radio')).toBe(false);
|
||||||
|
expect(isTextLikeInputType('file')).toBe(false);
|
||||||
|
expect(isTextLikeInputType('button')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import { parseCssString } from '../parseCssString';
|
||||||
|
|
||||||
|
describe('parseCssString', () => {
|
||||||
|
it('should return the input unchanged when it is not a non-empty string', () => {
|
||||||
|
expect(parseCssString(undefined)).toBeUndefined();
|
||||||
|
expect(parseCssString('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert kebab-case properties to camelCase', () => {
|
||||||
|
expect(parseCssString('background-color: red')).toEqual({
|
||||||
|
backgroundColor: 'red',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep custom properties as-is', () => {
|
||||||
|
expect(parseCssString('--my-var: 1px')).toEqual({ '--my-var': '1px' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse multiple declarations and tolerate a trailing semicolon', () => {
|
||||||
|
expect(parseCssString('color: red; font-size: 12px;')).toEqual({
|
||||||
|
color: 'red',
|
||||||
|
fontSize: '12px',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip declarations without a colon', () => {
|
||||||
|
expect(parseCssString('color: red; invalid')).toEqual({ color: 'red' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only split on the first colon so values may contain colons', () => {
|
||||||
|
expect(parseCssString('background: url(http://example.com)')).toEqual({
|
||||||
|
background: 'url(http://example.com)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
import { serializeEvent } from '../serializeEvent';
|
||||||
|
|
||||||
|
describe('serializeEvent', () => {
|
||||||
|
it('should return an unknown type for a non-object', () => {
|
||||||
|
expect(serializeEvent(null)).toEqual({ type: 'unknown' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default the type to unknown when missing', () => {
|
||||||
|
expect(serializeEvent({})).toEqual({ type: 'unknown' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should copy only whitelisted, correctly-typed properties', () => {
|
||||||
|
const result = serializeEvent({
|
||||||
|
type: 'click',
|
||||||
|
clientX: 10,
|
||||||
|
clientY: 20,
|
||||||
|
altKey: true,
|
||||||
|
key: 'Enter',
|
||||||
|
unexpected: 'ignored',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'click',
|
||||||
|
clientX: 10,
|
||||||
|
clientY: 20,
|
||||||
|
altKey: true,
|
||||||
|
key: 'Enter',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore properties with the wrong type', () => {
|
||||||
|
const result = serializeEvent({ type: 'wheel', deltaX: 'not-a-number' });
|
||||||
|
|
||||||
|
expect(result).toEqual({ type: 'wheel' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract safe target properties', () => {
|
||||||
|
const result = serializeEvent({
|
||||||
|
type: 'change',
|
||||||
|
target: { value: 'hello', checked: true, scrollTop: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
type: 'change',
|
||||||
|
value: 'hello',
|
||||||
|
checked: true,
|
||||||
|
scrollTop: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serialize target files', () => {
|
||||||
|
const result = serializeEvent({
|
||||||
|
type: 'change',
|
||||||
|
target: {
|
||||||
|
files: {
|
||||||
|
length: 1,
|
||||||
|
0: {
|
||||||
|
name: 'a.png',
|
||||||
|
size: 1,
|
||||||
|
type: 'image/png',
|
||||||
|
lastModified: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.files).toEqual([
|
||||||
|
{ name: 'a.png', size: 1, type: 'image/png', lastModified: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
import { serializeFileList } from '../serializeFileList';
|
||||||
|
|
||||||
|
const validFile = {
|
||||||
|
name: 'report.csv',
|
||||||
|
size: 42,
|
||||||
|
type: 'text/csv',
|
||||||
|
lastModified: 1700000000000,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('serializeFileList', () => {
|
||||||
|
it('should return undefined for a non-object', () => {
|
||||||
|
expect(serializeFileList(null)).toBeUndefined();
|
||||||
|
expect(serializeFileList('files')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when there is no numeric length', () => {
|
||||||
|
expect(serializeFileList({})).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serialize only the safe metadata of each file', () => {
|
||||||
|
const result = serializeFileList({
|
||||||
|
length: 1,
|
||||||
|
0: { ...validFile, arrayBuffer: () => {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([validFile]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip entries missing required fields', () => {
|
||||||
|
const result = serializeFileList({
|
||||||
|
length: 2,
|
||||||
|
0: validFile,
|
||||||
|
1: { name: 'incomplete' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([validFile]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
import { TextDecoder, TextEncoder } from 'node:util';
|
||||||
|
|
||||||
|
class StubMessagePort {
|
||||||
|
onmessage: unknown = null;
|
||||||
|
postMessage(): void {}
|
||||||
|
addEventListener(): void {}
|
||||||
|
removeEventListener(): void {}
|
||||||
|
start(): void {}
|
||||||
|
close(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StubMessageChannel {
|
||||||
|
port1 = new StubMessagePort();
|
||||||
|
port2 = new StubMessagePort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutableGlobal = globalThis as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof mutableGlobal.MessageChannel === 'undefined') {
|
||||||
|
mutableGlobal.MessageChannel = StubMessageChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof mutableGlobal.TextEncoder === 'undefined') {
|
||||||
|
mutableGlobal.TextEncoder = TextEncoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof mutableGlobal.TextDecoder === 'undefined') {
|
||||||
|
mutableGlobal.TextDecoder = TextDecoder;
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
import { syncValuePreservingCaret } from '../syncValuePreservingCaret';
|
||||||
|
|
||||||
|
describe('syncValuePreservingCaret', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update the value when it differs', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.value = 'old';
|
||||||
|
|
||||||
|
syncValuePreservingCaret(input, 'new');
|
||||||
|
|
||||||
|
expect(input.value).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing when the value is already equal', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.value = 'same';
|
||||||
|
|
||||||
|
syncValuePreservingCaret(input, 'same');
|
||||||
|
|
||||||
|
expect(input.value).toBe('same');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve the caret selection when the element is focused', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.value = 'hello world';
|
||||||
|
input.focus();
|
||||||
|
input.setSelectionRange(2, 5);
|
||||||
|
|
||||||
|
syncValuePreservingCaret(input, 'HELLO world');
|
||||||
|
|
||||||
|
expect(input.value).toBe('HELLO world');
|
||||||
|
expect(input.selectionStart).toBe(2);
|
||||||
|
expect(input.selectionEnd).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { wrapEventHandler } from '../wrapEventHandler';
|
||||||
|
|
||||||
|
describe('wrapEventHandler', () => {
|
||||||
|
it('should return a function', () => {
|
||||||
|
expect(typeof wrapEventHandler(() => {})).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should invoke the handler with the serialized event', () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
|
||||||
|
wrapEventHandler(handler)({ type: 'click', clientX: 3 });
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith({ type: 'click', clientX: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serialize away non-whitelisted event fields before calling the handler', () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
|
||||||
|
wrapEventHandler(handler)({ type: 'click', secret: 'leaked' });
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith({ type: 'click' });
|
||||||
|
});
|
||||||
|
});
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
import { isFunction, isNonEmptyString } from '@sniptt/guards';
|
||||||
|
import React from 'react';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
|
import { type SetEditableFocused } from '@/host/contexts/FrontComponentInputFocusContext';
|
||||||
|
import { syncValuePreservingCaret } from '@/host/utils/syncValuePreservingCaret';
|
||||||
|
|
||||||
|
type CaretPreservingElement = HTMLInputElement | HTMLTextAreaElement;
|
||||||
|
|
||||||
|
export const createCaretPreservingElement = (
|
||||||
|
htmlTag: 'input' | 'textarea',
|
||||||
|
reactProps: Record<string, unknown>,
|
||||||
|
forcedProps: Record<string, unknown> | undefined,
|
||||||
|
setEditableFocused: SetEditableFocused | null,
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onFocus: forwardedOnFocus,
|
||||||
|
onBlur: forwardedOnBlur,
|
||||||
|
...rest
|
||||||
|
} = reactProps;
|
||||||
|
const initialValue = isNonEmptyString(defaultValue)
|
||||||
|
? defaultValue
|
||||||
|
: isNonEmptyString(value)
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const handleFocus = (event: React.FocusEvent<CaretPreservingElement>) => {
|
||||||
|
setEditableFocused?.(true);
|
||||||
|
if (isFunction(forwardedOnFocus)) {
|
||||||
|
forwardedOnFocus(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = (event: React.FocusEvent<CaretPreservingElement>) => {
|
||||||
|
setEditableFocused?.(false);
|
||||||
|
if (isFunction(forwardedOnBlur)) {
|
||||||
|
forwardedOnBlur(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return React.createElement(htmlTag, {
|
||||||
|
...rest,
|
||||||
|
...forcedProps,
|
||||||
|
defaultValue: initialValue,
|
||||||
|
onFocus: handleFocus,
|
||||||
|
onBlur: handleBlur,
|
||||||
|
ref: (node: CaretPreservingElement | null) => {
|
||||||
|
if (!isDefined(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNonEmptyString(value)) {
|
||||||
|
syncValuePreservingCaret(node, value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,33 +1,11 @@
|
|||||||
import {
|
|
||||||
isBoolean,
|
|
||||||
isFunction,
|
|
||||||
isNonEmptyString,
|
|
||||||
isNumber,
|
|
||||||
isObject,
|
|
||||||
isString,
|
|
||||||
isUndefined,
|
|
||||||
} from '@sniptt/guards';
|
|
||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
|
||||||
|
|
||||||
import { EVENT_TO_REACT } from '@/constants/EventToReact';
|
import { FrontComponentInputFocusContext } from '@/host/contexts/FrontComponentInputFocusContext';
|
||||||
import { type SerializedEventData } from '@/types/SerializedEventData';
|
import { createCaretPreservingElement } from '@/host/utils/createCaretPreservingElement';
|
||||||
import { type SerializedFileData } from '@/types/SerializedFileData';
|
import { filterProps } from '@/host/utils/filterProps';
|
||||||
import {
|
import { isTextLikeInputType } from '@/host/utils/isTextLikeInputType';
|
||||||
FrontComponentInputFocusContext,
|
|
||||||
type SetEditableFocused,
|
|
||||||
} from '@/host/contexts/FrontComponentInputFocusContext';
|
|
||||||
import { sanitizeIframeSandbox } from '@/host/utils/sanitizeIframeSandbox';
|
import { sanitizeIframeSandbox } from '@/host/utils/sanitizeIframeSandbox';
|
||||||
|
|
||||||
const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']);
|
|
||||||
|
|
||||||
const EVENT_NAME_MAP: Record<string, string> = Object.fromEntries(
|
|
||||||
Object.entries(EVENT_TO_REACT).map(([domEvent, reactProp]) => [
|
|
||||||
`on${domEvent}`,
|
|
||||||
reactProp,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const VOID_ELEMENTS = new Set([
|
const VOID_ELEMENTS = new Set([
|
||||||
'area',
|
'area',
|
||||||
'base',
|
'base',
|
||||||
@@ -44,365 +22,8 @@ const VOID_ELEMENTS = new Set([
|
|||||||
'wbr',
|
'wbr',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const parseCssString = (
|
|
||||||
styleString: string | undefined,
|
|
||||||
): React.CSSProperties | undefined => {
|
|
||||||
if (!isNonEmptyString(styleString)) {
|
|
||||||
return styleString as React.CSSProperties | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const style: Record<string, string> = {};
|
|
||||||
const declarations = styleString.split(';').filter(Boolean);
|
|
||||||
|
|
||||||
for (const declaration of declarations) {
|
|
||||||
const colonIndex = declaration.indexOf(':');
|
|
||||||
if (colonIndex === -1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const property = declaration.slice(0, colonIndex).trim();
|
|
||||||
const value = declaration.slice(colonIndex + 1).trim();
|
|
||||||
|
|
||||||
const isCssCustomProperty = property.startsWith('--');
|
|
||||||
|
|
||||||
const key = isCssCustomProperty
|
|
||||||
? property
|
|
||||||
: property.replace(/-([a-z])/g, (_, letter: string) =>
|
|
||||||
letter.toUpperCase(),
|
|
||||||
);
|
|
||||||
|
|
||||||
style[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return style;
|
|
||||||
};
|
|
||||||
|
|
||||||
const serializeFileList = (
|
|
||||||
files: unknown,
|
|
||||||
): SerializedFileData[] | undefined => {
|
|
||||||
if (!isObject(files)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const fileListLike = files as { length?: unknown } & Record<number, unknown>;
|
|
||||||
if (!isNumber(fileListLike.length)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serialized: SerializedFileData[] = [];
|
|
||||||
for (let index = 0; index < fileListLike.length; index++) {
|
|
||||||
const file = fileListLike[index];
|
|
||||||
if (!isObject(file)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const fileRecord = file as Record<string, unknown>;
|
|
||||||
if (
|
|
||||||
!isString(fileRecord.name) ||
|
|
||||||
!isNumber(fileRecord.size) ||
|
|
||||||
!isString(fileRecord.type) ||
|
|
||||||
!isNumber(fileRecord.lastModified)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
serialized.push({
|
|
||||||
name: fileRecord.name,
|
|
||||||
size: fileRecord.size,
|
|
||||||
type: fileRecord.type,
|
|
||||||
lastModified: fileRecord.lastModified,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return serialized;
|
|
||||||
};
|
|
||||||
|
|
||||||
const serializeEvent = (event: unknown): SerializedEventData => {
|
|
||||||
if (!isObject(event)) {
|
|
||||||
return { type: 'unknown' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const domEvent = event as Record<string, unknown>;
|
|
||||||
const serialized: SerializedEventData = {
|
|
||||||
type: isString(domEvent.type) ? domEvent.type : 'unknown',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isBoolean(domEvent.altKey)) {
|
|
||||||
serialized.altKey = domEvent.altKey;
|
|
||||||
}
|
|
||||||
if (isBoolean(domEvent.ctrlKey)) {
|
|
||||||
serialized.ctrlKey = domEvent.ctrlKey;
|
|
||||||
}
|
|
||||||
if (isBoolean(domEvent.metaKey)) {
|
|
||||||
serialized.metaKey = domEvent.metaKey;
|
|
||||||
}
|
|
||||||
if (isBoolean(domEvent.shiftKey)) {
|
|
||||||
serialized.shiftKey = domEvent.shiftKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNumber(domEvent.clientX)) {
|
|
||||||
serialized.clientX = domEvent.clientX;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.clientY)) {
|
|
||||||
serialized.clientY = domEvent.clientY;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.x)) {
|
|
||||||
serialized.x = domEvent.x;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.y)) {
|
|
||||||
serialized.y = domEvent.y;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.pageX)) {
|
|
||||||
serialized.pageX = domEvent.pageX;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.pageY)) {
|
|
||||||
serialized.pageY = domEvent.pageY;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.screenX)) {
|
|
||||||
serialized.screenX = domEvent.screenX;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.screenY)) {
|
|
||||||
serialized.screenY = domEvent.screenY;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.offsetX)) {
|
|
||||||
serialized.offsetX = domEvent.offsetX;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.offsetY)) {
|
|
||||||
serialized.offsetY = domEvent.offsetY;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.movementX)) {
|
|
||||||
serialized.movementX = domEvent.movementX;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.movementY)) {
|
|
||||||
serialized.movementY = domEvent.movementY;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.button)) {
|
|
||||||
serialized.button = domEvent.button;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.buttons)) {
|
|
||||||
serialized.buttons = domEvent.buttons;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNumber(domEvent.pointerId)) {
|
|
||||||
serialized.pointerId = domEvent.pointerId;
|
|
||||||
}
|
|
||||||
if (isString(domEvent.pointerType)) {
|
|
||||||
serialized.pointerType = domEvent.pointerType;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.pressure)) {
|
|
||||||
serialized.pressure = domEvent.pressure;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.tangentialPressure)) {
|
|
||||||
serialized.tangentialPressure = domEvent.tangentialPressure;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.tiltX)) {
|
|
||||||
serialized.tiltX = domEvent.tiltX;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.tiltY)) {
|
|
||||||
serialized.tiltY = domEvent.tiltY;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.twist)) {
|
|
||||||
serialized.twist = domEvent.twist;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.width)) {
|
|
||||||
serialized.width = domEvent.width;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.height)) {
|
|
||||||
serialized.height = domEvent.height;
|
|
||||||
}
|
|
||||||
if (isBoolean(domEvent.isPrimary)) {
|
|
||||||
serialized.isPrimary = domEvent.isPrimary;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isString(domEvent.key)) {
|
|
||||||
serialized.key = domEvent.key;
|
|
||||||
}
|
|
||||||
if (isString(domEvent.code)) {
|
|
||||||
serialized.code = domEvent.code;
|
|
||||||
}
|
|
||||||
if (isBoolean(domEvent.repeat)) {
|
|
||||||
serialized.repeat = domEvent.repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNumber(domEvent.deltaX)) {
|
|
||||||
serialized.deltaX = domEvent.deltaX;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.deltaY)) {
|
|
||||||
serialized.deltaY = domEvent.deltaY;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.deltaZ)) {
|
|
||||||
serialized.deltaZ = domEvent.deltaZ;
|
|
||||||
}
|
|
||||||
if (isNumber(domEvent.deltaMode)) {
|
|
||||||
serialized.deltaMode = domEvent.deltaMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = domEvent.target;
|
|
||||||
if (isObject(target)) {
|
|
||||||
const targetRecord = target as Record<string, unknown>;
|
|
||||||
if (isString(targetRecord.value)) {
|
|
||||||
serialized.value = targetRecord.value;
|
|
||||||
}
|
|
||||||
if (isBoolean(targetRecord.checked)) {
|
|
||||||
serialized.checked = targetRecord.checked;
|
|
||||||
}
|
|
||||||
if (isNumber(targetRecord.scrollTop)) {
|
|
||||||
serialized.scrollTop = targetRecord.scrollTop;
|
|
||||||
}
|
|
||||||
if (isNumber(targetRecord.scrollLeft)) {
|
|
||||||
serialized.scrollLeft = targetRecord.scrollLeft;
|
|
||||||
}
|
|
||||||
if (isNumber(targetRecord.currentTime)) {
|
|
||||||
serialized.currentTime = targetRecord.currentTime;
|
|
||||||
}
|
|
||||||
if (isNumber(targetRecord.duration)) {
|
|
||||||
serialized.duration = targetRecord.duration;
|
|
||||||
}
|
|
||||||
if (isBoolean(targetRecord.paused)) {
|
|
||||||
serialized.paused = targetRecord.paused;
|
|
||||||
}
|
|
||||||
if (isBoolean(targetRecord.ended)) {
|
|
||||||
serialized.ended = targetRecord.ended;
|
|
||||||
}
|
|
||||||
if (isNumber(targetRecord.volume)) {
|
|
||||||
serialized.volume = targetRecord.volume;
|
|
||||||
}
|
|
||||||
if (isBoolean(targetRecord.muted)) {
|
|
||||||
serialized.muted = targetRecord.muted;
|
|
||||||
}
|
|
||||||
if (isNumber(targetRecord.playbackRate)) {
|
|
||||||
serialized.playbackRate = targetRecord.playbackRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = serializeFileList(targetRecord.files);
|
|
||||||
if (isDefined(files)) {
|
|
||||||
serialized.files = files;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return serialized;
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapEventHandler = (handler: (detail: SerializedEventData) => void) => {
|
|
||||||
return (event: unknown) => {
|
|
||||||
handler(serializeEvent(event));
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const filterProps = <T extends object>(props: T): T => {
|
|
||||||
const filtered: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(props)) {
|
|
||||||
if (INTERNAL_PROPS.has(key) || isUndefined(value)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'style') {
|
|
||||||
filtered.style = parseCssString(value as string | undefined);
|
|
||||||
} else {
|
|
||||||
const normalizedKey = EVENT_NAME_MAP[key.toLowerCase()] || key;
|
|
||||||
|
|
||||||
if (normalizedKey.startsWith('on') && isFunction(value)) {
|
|
||||||
filtered[normalizedKey] = wrapEventHandler(
|
|
||||||
value as (detail: SerializedEventData) => void,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
filtered[normalizedKey] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered as T;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WrapperProps = { children?: React.ReactNode } & Record<string, unknown>;
|
type WrapperProps = { children?: React.ReactNode } & Record<string, unknown>;
|
||||||
|
|
||||||
const TEXT_LIKE_INPUT_TYPES = new Set([
|
|
||||||
'text',
|
|
||||||
'search',
|
|
||||||
'url',
|
|
||||||
'tel',
|
|
||||||
'password',
|
|
||||||
'email',
|
|
||||||
'number',
|
|
||||||
'',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isTextLikeInputType = (type: unknown): boolean => {
|
|
||||||
const inputType = isString(type) ? type.toLowerCase() : '';
|
|
||||||
return TEXT_LIKE_INPUT_TYPES.has(inputType);
|
|
||||||
};
|
|
||||||
|
|
||||||
type CaretPreservingElement = HTMLInputElement | HTMLTextAreaElement;
|
|
||||||
|
|
||||||
const syncValuePreservingCaret = (
|
|
||||||
element: CaretPreservingElement,
|
|
||||||
nextValue: string,
|
|
||||||
): void => {
|
|
||||||
if (element.value === nextValue) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFocused = document.activeElement === element;
|
|
||||||
const start = isFocused ? element.selectionStart : null;
|
|
||||||
const end = isFocused ? element.selectionEnd : null;
|
|
||||||
|
|
||||||
element.value = nextValue;
|
|
||||||
|
|
||||||
if (isFocused && isDefined(start) && isDefined(end)) {
|
|
||||||
try {
|
|
||||||
element.setSelectionRange(start, end);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createCaretPreservingElement = (
|
|
||||||
htmlTag: 'input' | 'textarea',
|
|
||||||
reactProps: Record<string, unknown>,
|
|
||||||
forcedProps: Record<string, unknown> | undefined,
|
|
||||||
setEditableFocused: SetEditableFocused | null,
|
|
||||||
) => {
|
|
||||||
const {
|
|
||||||
value,
|
|
||||||
defaultValue,
|
|
||||||
onFocus: forwardedOnFocus,
|
|
||||||
onBlur: forwardedOnBlur,
|
|
||||||
...rest
|
|
||||||
} = reactProps;
|
|
||||||
const initialValue = isNonEmptyString(defaultValue)
|
|
||||||
? defaultValue
|
|
||||||
: isNonEmptyString(value)
|
|
||||||
? value
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const handleFocus = (event: React.FocusEvent<CaretPreservingElement>) => {
|
|
||||||
setEditableFocused?.(true);
|
|
||||||
if (isFunction(forwardedOnFocus)) {
|
|
||||||
forwardedOnFocus(event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = (event: React.FocusEvent<CaretPreservingElement>) => {
|
|
||||||
setEditableFocused?.(false);
|
|
||||||
if (isFunction(forwardedOnBlur)) {
|
|
||||||
forwardedOnBlur(event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return React.createElement(htmlTag, {
|
|
||||||
...rest,
|
|
||||||
...forcedProps,
|
|
||||||
defaultValue: initialValue,
|
|
||||||
onFocus: handleFocus,
|
|
||||||
onBlur: handleBlur,
|
|
||||||
ref: (node: CaretPreservingElement | null) => {
|
|
||||||
if (!isDefined(node)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isNonEmptyString(value)) {
|
|
||||||
syncValuePreservingCaret(node, value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createHtmlHostWrapper = (htmlTag: string) => {
|
export const createHtmlHostWrapper = (htmlTag: string) => {
|
||||||
const isVoid = VOID_ELEMENTS.has(htmlTag);
|
const isVoid = VOID_ELEMENTS.has(htmlTag);
|
||||||
const isIframe = htmlTag === 'iframe';
|
const isIframe = htmlTag === 'iframe';
|
||||||
@@ -410,7 +31,7 @@ export const createHtmlHostWrapper = (htmlTag: string) => {
|
|||||||
|
|
||||||
return ({ children, ...props }: WrapperProps) => {
|
return ({ children, ...props }: WrapperProps) => {
|
||||||
const setEditableFocused = useContext(FrontComponentInputFocusContext);
|
const setEditableFocused = useContext(FrontComponentInputFocusContext);
|
||||||
const reactProps = filterProps(props);
|
const reactProps = filterProps(props, htmlTag);
|
||||||
|
|
||||||
const forcedProps: Record<string, unknown> | undefined = isIframe
|
const forcedProps: Record<string, unknown> | undefined = isIframe
|
||||||
? { sandbox: sanitizeIframeSandbox(reactProps.sandbox) }
|
? { sandbox: sanitizeIframeSandbox(reactProps.sandbox) }
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { isFunction, isUndefined } from '@sniptt/guards';
|
||||||
|
|
||||||
|
import { EVENT_TO_REACT } from '@/constants/EventToReact';
|
||||||
|
import { hasDangerousUrlScheme } from '@/host/utils/hasDangerousUrlScheme';
|
||||||
|
import { isEventHandlerKey } from '@/host/utils/isEventHandlerKey';
|
||||||
|
import { isNavigationUrlAttribute } from '@/host/utils/isNavigationUrlAttribute';
|
||||||
|
import { parseCssString } from '@/host/utils/parseCssString';
|
||||||
|
import { wrapEventHandler } from '@/host/utils/wrapEventHandler';
|
||||||
|
import { type SerializedEventData } from '@/types/SerializedEventData';
|
||||||
|
|
||||||
|
const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']);
|
||||||
|
|
||||||
|
const EVENT_NAME_MAP: Record<string, string> = Object.fromEntries(
|
||||||
|
Object.entries(EVENT_TO_REACT).map(([domEvent, reactProp]) => [
|
||||||
|
`on${domEvent}`,
|
||||||
|
reactProp,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const filterProps = <T extends object>(props: T, htmlTag: string): T => {
|
||||||
|
const filtered: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(props)) {
|
||||||
|
if (INTERNAL_PROPS.has(key) || isUndefined(value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'style') {
|
||||||
|
filtered.style = parseCssString(value as string | undefined);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedKey = EVENT_NAME_MAP[key.toLowerCase()] || key;
|
||||||
|
|
||||||
|
if (isEventHandlerKey(normalizedKey)) {
|
||||||
|
if (isFunction(value)) {
|
||||||
|
filtered[normalizedKey] = wrapEventHandler(
|
||||||
|
value as (detail: SerializedEventData) => void,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isNavigationUrlAttribute(htmlTag, normalizedKey) &&
|
||||||
|
hasDangerousUrlScheme(value)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered[normalizedKey] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered as T;
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { isString } from '@sniptt/guards';
|
||||||
|
|
||||||
|
const DANGEROUS_URL_SCHEMES = ['javascript:', 'vbscript:', 'data:'];
|
||||||
|
|
||||||
|
export const hasDangerousUrlScheme = (value: unknown): boolean => {
|
||||||
|
if (!isString(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedValue = value
|
||||||
|
.replace(/[\u0000-\u0020\u007F-\u009F]/g, '')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
return DANGEROUS_URL_SCHEMES.some((scheme) =>
|
||||||
|
normalizedValue.startsWith(scheme),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const isEventHandlerKey = (key: string): boolean =>
|
||||||
|
key.toLowerCase().startsWith('on');
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const NAVIGATION_URL_ATTRIBUTES_BY_TAG: Record<string, Set<string>> = {
|
||||||
|
a: new Set(['href', 'xlinkhref']),
|
||||||
|
area: new Set(['href']),
|
||||||
|
form: new Set(['action']),
|
||||||
|
button: new Set(['formaction']),
|
||||||
|
input: new Set(['formaction']),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isNavigationUrlAttribute = (
|
||||||
|
htmlTag: string,
|
||||||
|
key: string,
|
||||||
|
): boolean =>
|
||||||
|
NAVIGATION_URL_ATTRIBUTES_BY_TAG[htmlTag.toLowerCase()]?.has(
|
||||||
|
key.toLowerCase().replace(/:/g, ''),
|
||||||
|
) ?? false;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { isString } from '@sniptt/guards';
|
||||||
|
|
||||||
|
const TEXT_LIKE_INPUT_TYPES = new Set([
|
||||||
|
'text',
|
||||||
|
'search',
|
||||||
|
'url',
|
||||||
|
'tel',
|
||||||
|
'password',
|
||||||
|
'email',
|
||||||
|
'number',
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const isTextLikeInputType = (type: unknown): boolean => {
|
||||||
|
const inputType = isString(type) ? type.toLowerCase() : '';
|
||||||
|
return TEXT_LIKE_INPUT_TYPES.has(inputType);
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { isNonEmptyString } from '@sniptt/guards';
|
||||||
|
import { type CSSProperties } from 'react';
|
||||||
|
|
||||||
|
export const parseCssString = (
|
||||||
|
styleString: string | undefined,
|
||||||
|
): CSSProperties | undefined => {
|
||||||
|
if (!isNonEmptyString(styleString)) {
|
||||||
|
return styleString as CSSProperties | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style: Record<string, string> = {};
|
||||||
|
const declarations = styleString.split(';').filter(Boolean);
|
||||||
|
|
||||||
|
for (const declaration of declarations) {
|
||||||
|
const colonIndex = declaration.indexOf(':');
|
||||||
|
if (colonIndex === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const property = declaration.slice(0, colonIndex).trim();
|
||||||
|
const value = declaration.slice(colonIndex + 1).trim();
|
||||||
|
|
||||||
|
const isCssCustomProperty = property.startsWith('--');
|
||||||
|
|
||||||
|
const key = isCssCustomProperty
|
||||||
|
? property
|
||||||
|
: property.replace(/-([a-z])/g, (_, letter: string) =>
|
||||||
|
letter.toUpperCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
style[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
};
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { isBoolean, isNumber, isObject, isString } from '@sniptt/guards';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
|
import { serializeFileList } from '@/host/utils/serializeFileList';
|
||||||
|
import { type SerializedEventData } from '@/types/SerializedEventData';
|
||||||
|
|
||||||
|
export const serializeEvent = (event: unknown): SerializedEventData => {
|
||||||
|
if (!isObject(event)) {
|
||||||
|
return { type: 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const domEvent = event as Record<string, unknown>;
|
||||||
|
const serialized: SerializedEventData = {
|
||||||
|
type: isString(domEvent.type) ? domEvent.type : 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isBoolean(domEvent.altKey)) {
|
||||||
|
serialized.altKey = domEvent.altKey;
|
||||||
|
}
|
||||||
|
if (isBoolean(domEvent.ctrlKey)) {
|
||||||
|
serialized.ctrlKey = domEvent.ctrlKey;
|
||||||
|
}
|
||||||
|
if (isBoolean(domEvent.metaKey)) {
|
||||||
|
serialized.metaKey = domEvent.metaKey;
|
||||||
|
}
|
||||||
|
if (isBoolean(domEvent.shiftKey)) {
|
||||||
|
serialized.shiftKey = domEvent.shiftKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNumber(domEvent.clientX)) {
|
||||||
|
serialized.clientX = domEvent.clientX;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.clientY)) {
|
||||||
|
serialized.clientY = domEvent.clientY;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.x)) {
|
||||||
|
serialized.x = domEvent.x;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.y)) {
|
||||||
|
serialized.y = domEvent.y;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.pageX)) {
|
||||||
|
serialized.pageX = domEvent.pageX;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.pageY)) {
|
||||||
|
serialized.pageY = domEvent.pageY;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.screenX)) {
|
||||||
|
serialized.screenX = domEvent.screenX;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.screenY)) {
|
||||||
|
serialized.screenY = domEvent.screenY;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.offsetX)) {
|
||||||
|
serialized.offsetX = domEvent.offsetX;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.offsetY)) {
|
||||||
|
serialized.offsetY = domEvent.offsetY;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.movementX)) {
|
||||||
|
serialized.movementX = domEvent.movementX;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.movementY)) {
|
||||||
|
serialized.movementY = domEvent.movementY;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.button)) {
|
||||||
|
serialized.button = domEvent.button;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.buttons)) {
|
||||||
|
serialized.buttons = domEvent.buttons;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNumber(domEvent.pointerId)) {
|
||||||
|
serialized.pointerId = domEvent.pointerId;
|
||||||
|
}
|
||||||
|
if (isString(domEvent.pointerType)) {
|
||||||
|
serialized.pointerType = domEvent.pointerType;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.pressure)) {
|
||||||
|
serialized.pressure = domEvent.pressure;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.tangentialPressure)) {
|
||||||
|
serialized.tangentialPressure = domEvent.tangentialPressure;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.tiltX)) {
|
||||||
|
serialized.tiltX = domEvent.tiltX;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.tiltY)) {
|
||||||
|
serialized.tiltY = domEvent.tiltY;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.twist)) {
|
||||||
|
serialized.twist = domEvent.twist;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.width)) {
|
||||||
|
serialized.width = domEvent.width;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.height)) {
|
||||||
|
serialized.height = domEvent.height;
|
||||||
|
}
|
||||||
|
if (isBoolean(domEvent.isPrimary)) {
|
||||||
|
serialized.isPrimary = domEvent.isPrimary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isString(domEvent.key)) {
|
||||||
|
serialized.key = domEvent.key;
|
||||||
|
}
|
||||||
|
if (isString(domEvent.code)) {
|
||||||
|
serialized.code = domEvent.code;
|
||||||
|
}
|
||||||
|
if (isBoolean(domEvent.repeat)) {
|
||||||
|
serialized.repeat = domEvent.repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNumber(domEvent.deltaX)) {
|
||||||
|
serialized.deltaX = domEvent.deltaX;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.deltaY)) {
|
||||||
|
serialized.deltaY = domEvent.deltaY;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.deltaZ)) {
|
||||||
|
serialized.deltaZ = domEvent.deltaZ;
|
||||||
|
}
|
||||||
|
if (isNumber(domEvent.deltaMode)) {
|
||||||
|
serialized.deltaMode = domEvent.deltaMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = domEvent.target;
|
||||||
|
if (isObject(target)) {
|
||||||
|
const targetRecord = target as Record<string, unknown>;
|
||||||
|
if (isString(targetRecord.value)) {
|
||||||
|
serialized.value = targetRecord.value;
|
||||||
|
}
|
||||||
|
if (isBoolean(targetRecord.checked)) {
|
||||||
|
serialized.checked = targetRecord.checked;
|
||||||
|
}
|
||||||
|
if (isNumber(targetRecord.scrollTop)) {
|
||||||
|
serialized.scrollTop = targetRecord.scrollTop;
|
||||||
|
}
|
||||||
|
if (isNumber(targetRecord.scrollLeft)) {
|
||||||
|
serialized.scrollLeft = targetRecord.scrollLeft;
|
||||||
|
}
|
||||||
|
if (isNumber(targetRecord.currentTime)) {
|
||||||
|
serialized.currentTime = targetRecord.currentTime;
|
||||||
|
}
|
||||||
|
if (isNumber(targetRecord.duration)) {
|
||||||
|
serialized.duration = targetRecord.duration;
|
||||||
|
}
|
||||||
|
if (isBoolean(targetRecord.paused)) {
|
||||||
|
serialized.paused = targetRecord.paused;
|
||||||
|
}
|
||||||
|
if (isBoolean(targetRecord.ended)) {
|
||||||
|
serialized.ended = targetRecord.ended;
|
||||||
|
}
|
||||||
|
if (isNumber(targetRecord.volume)) {
|
||||||
|
serialized.volume = targetRecord.volume;
|
||||||
|
}
|
||||||
|
if (isBoolean(targetRecord.muted)) {
|
||||||
|
serialized.muted = targetRecord.muted;
|
||||||
|
}
|
||||||
|
if (isNumber(targetRecord.playbackRate)) {
|
||||||
|
serialized.playbackRate = targetRecord.playbackRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = serializeFileList(targetRecord.files);
|
||||||
|
if (isDefined(files)) {
|
||||||
|
serialized.files = files;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return serialized;
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { isNumber, isObject, isString } from '@sniptt/guards';
|
||||||
|
|
||||||
|
import { type SerializedFileData } from '@/types/SerializedFileData';
|
||||||
|
|
||||||
|
export const serializeFileList = (
|
||||||
|
files: unknown,
|
||||||
|
): SerializedFileData[] | undefined => {
|
||||||
|
if (!isObject(files)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const fileListLike = files as { length?: unknown } & Record<number, unknown>;
|
||||||
|
if (!isNumber(fileListLike.length)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized: SerializedFileData[] = [];
|
||||||
|
for (let index = 0; index < fileListLike.length; index++) {
|
||||||
|
const file = fileListLike[index];
|
||||||
|
if (!isObject(file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fileRecord = file as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
!isString(fileRecord.name) ||
|
||||||
|
!isNumber(fileRecord.size) ||
|
||||||
|
!isString(fileRecord.type) ||
|
||||||
|
!isNumber(fileRecord.lastModified)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
serialized.push({
|
||||||
|
name: fileRecord.name,
|
||||||
|
size: fileRecord.size,
|
||||||
|
type: fileRecord.type,
|
||||||
|
lastModified: fileRecord.lastModified,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return serialized;
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
|
type CaretPreservingElement = HTMLInputElement | HTMLTextAreaElement;
|
||||||
|
|
||||||
|
export const syncValuePreservingCaret = (
|
||||||
|
element: CaretPreservingElement,
|
||||||
|
nextValue: string,
|
||||||
|
): void => {
|
||||||
|
if (element.value === nextValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFocused = document.activeElement === element;
|
||||||
|
const start = isFocused ? element.selectionStart : null;
|
||||||
|
const end = isFocused ? element.selectionEnd : null;
|
||||||
|
|
||||||
|
element.value = nextValue;
|
||||||
|
|
||||||
|
if (isFocused && isDefined(start) && isDefined(end)) {
|
||||||
|
try {
|
||||||
|
element.setSelectionRange(start, end);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { serializeEvent } from '@/host/utils/serializeEvent';
|
||||||
|
import { type SerializedEventData } from '@/types/SerializedEventData';
|
||||||
|
|
||||||
|
export const wrapEventHandler =
|
||||||
|
(handler: (detail: SerializedEventData) => void) =>
|
||||||
|
(event: unknown): void => {
|
||||||
|
handler(serializeEvent(event));
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user