Skip to content

Commit

Permalink
Apply isUrl option to pasted links #2239 (#2240)
Browse files Browse the repository at this point in the history
* Apply isUrl option to pasted links #2239

* Sanitize link URLs

* Sanitize link URLs before rendering

* Fix broken tests

* Separate changesets

* Separate changeset for breaking change

---------

Co-authored-by: Joe Anderson <joe@mousetrapped.co.uk>
  • Loading branch information
OliverWales and 12joan committed Feb 28, 2023
1 parent 0077402 commit 93dd571
Show file tree
Hide file tree
Showing 17 changed files with 297 additions and 33 deletions.
5 changes: 5 additions & 0 deletions .changeset/great-actors-work-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@udecode/plate-core': minor
---

- Add `sanitizeUrl` util to check if URL has an allowed scheme
12 changes: 12 additions & 0 deletions .changeset/great-actors-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@udecode/plate-link': minor
---

- `upsertLink`:
- Removed `isUrl`
- Added `skipValidation`
- Check that URL scheme is valid when:
- Upserting links
- Deserializing links from HTL
- Passing `href` to `nodeProps`
- Rendering the `OpenLinkButton` in `FloatingLink`
6 changes: 6 additions & 0 deletions .changeset/quiet-wombats-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@udecode/plate-link': major
---

- Add `allowedSchemes` plugin option
- Any URL schemes other than `http(s)`, `mailto` and `tel` must be added to `allowedSchemes`, otherwise they will not be included in links
6 changes: 6 additions & 0 deletions docs/docs/plugins/link.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ interface LinkPlugin {
*/
triggerFloatingLinkHotkeys?: string | string[];

/**
* List of allowed URL schemes.
* @default ['http', 'https', 'mailto', 'tel']
*/
allowedSchemes?: string[];

/**
* Callback to validate an url.
* @default isUrl
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/utils/misc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ export * from './jotai';
export * from './mergeProps';
export * from './nanoid';
export * from './react-hotkeys-hook';
export * from './sanitizeUrl';
export * from './type-utils';
export * from './zustood';
47 changes: 47 additions & 0 deletions packages/core/src/utils/misc/sanitizeUrl.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { sanitizeUrl } from './sanitizeUrl';

describe('sanitizeUrl', () => {
describe('when permitInvalid is false', () => {
const options = {
allowedSchemes: ['http'],
permitInvalid: false,
};

it('should return null when url is invalid', () => {
expect(sanitizeUrl('invalid', options)).toBeNull();
});

it('should return null when url has disallowed scheme', () => {
// eslint-disable-next-line no-script-url
expect(sanitizeUrl('javascript://example.com/', options)).toBeNull();
});

it('should return url when url is valid', () => {
expect(sanitizeUrl('http://example.com/', options)).toBe(
'http://example.com/'
);
});
});

describe('when permitInvalid is true', () => {
const options = {
allowedSchemes: ['http'],
permitInvalid: true,
};

it('should return url when url is invalid', () => {
expect(sanitizeUrl('invalid', options)).toBe('invalid');
});

it('should return null when url has disallowed scheme', () => {
// eslint-disable-next-line no-script-url
expect(sanitizeUrl('javascript://example.com/', options)).toBeNull();
});

it('should return url when url is valid', () => {
expect(sanitizeUrl('http://example.com/', options)).toBe(
'http://example.com/'
);
});
});
});
28 changes: 28 additions & 0 deletions packages/core/src/utils/misc/sanitizeUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export interface SanitizeUrlOptions {
allowedSchemes?: string[];
permitInvalid?: boolean;
}

export const sanitizeUrl = (
url: string | undefined,
{ allowedSchemes, permitInvalid = false }: SanitizeUrlOptions
): string | null => {
if (!url) return null;

let parsedUrl: URL | null = null;

try {
parsedUrl = new URL(url);
} catch (error) {
return permitInvalid ? url : null;
}

if (
allowedSchemes &&
!allowedSchemes.includes(parsedUrl.protocol.slice(0, -1))
) {
return null;
}

return parsedUrl.href;
};
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '@udecode/plate-core';
import { ELEMENT_LINK } from '../../createLinkPlugin';
import { TLinkElement } from '../../types';
import { getLinkAttributes } from '../../utils/index';

export const useOpenLinkButton = (
props: HTMLPropsAs<'a'>
Expand All @@ -31,12 +32,13 @@ export const useOpenLinkButton = (
return {};
}

const [link] = entry;
const [element] = entry;
const linkAttributes = getLinkAttributes(editor, element);

return {
'aria-label': 'Open link in a new tab',
...linkAttributes,
target: '_blank',
href: link.url,
'aria-label': 'Open link in a new tab',
onMouseOver: (e) => {
e.stopPropagation();
},
Expand Down
8 changes: 4 additions & 4 deletions packages/nodes/link/src/components/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,17 @@ import {
Value,
} from '@udecode/plate-core';
import { TLinkElement } from '../types';
import { getLinkAttributes } from '../utils/index';

export type LinkRootProps = PlateRenderElementProps<Value, TLinkElement> &
HTMLPropsAs<'a'>;

export const useLink = (props: LinkRootProps): HTMLPropsAs<'a'> => {
const { editor } = props;

const _props = useElementProps<TLinkElement, 'a'>({
...props,
elementToAttributes: (element) => ({
href: element.url,
target: element.target,
}),
elementToAttributes: (element) => getLinkAttributes(editor, element),
});

return {
Expand Down
37 changes: 27 additions & 10 deletions packages/nodes/link/src/createLinkPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
createPluginFactory,
isUrl as isUrlProtocol,
isUrl,
RangeBeforeOptions,
} from '@udecode/plate-core';
import { getLinkAttributes, validateUrl } from './utils/index';
import { TLinkElement } from './types';
import { withLink } from './withLink';

export const ELEMENT_LINK = 'a';
Expand All @@ -27,6 +29,12 @@ export interface LinkPlugin {
*/
triggerFloatingLinkHotkeys?: string | string[];

/**
* List of allowed URL schemes.
* @default ['http', 'https', 'mailto', 'tel']
*/
allowedSchemes?: string[];

/**
* Callback to validate an url.
* @default isUrl
Expand All @@ -53,12 +61,10 @@ export const createLinkPlugin = createPluginFactory<LinkPlugin>({
key: ELEMENT_LINK,
isElement: true,
isInline: true,
props: ({ element }) => ({
nodeProps: { href: element?.url, target: element?.target },
}),
withOverrides: withLink,
options: {
isUrl: isUrlProtocol,
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
isUrl,
rangeBeforeOptions: {
matchString: ' ',
skipInvalid: true,
Expand All @@ -67,17 +73,28 @@ export const createLinkPlugin = createPluginFactory<LinkPlugin>({
triggerFloatingLinkHotkeys: 'meta+k, ctrl+k',
},
then: (editor, { type }) => ({
props: ({ element }) => ({
nodeProps: getLinkAttributes(editor, element as TLinkElement),
}),
deserializeHtml: {
rules: [
{
validNodeName: 'A',
},
],
getNode: (el) => ({
type,
url: el.getAttribute('href'),
target: el.getAttribute('target') || '_blank',
}),
getNode: (el) => {
const url = el.getAttribute('href');

if (url && validateUrl(editor, url)) {
return {
type,
url,
target: el.getAttribute('target') || '_blank',
};
}

return undefined;
},
},
}),
});
11 changes: 4 additions & 7 deletions packages/nodes/link/src/transforms/submitFloatingLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
floatingLinkSelectors,
} from '../components/FloatingLink/floatingLinkStore';
import { ELEMENT_LINK, LinkPlugin } from '../createLinkPlugin';
import { validateUrl } from '../utils/index';
import { upsertLink } from './index';

/**
Expand All @@ -20,14 +21,10 @@ import { upsertLink } from './index';
export const submitFloatingLink = <V extends Value>(editor: PlateEditor<V>) => {
if (!editor.selection) return;

const { isUrl, forceSubmit } = getPluginOptions<LinkPlugin, V>(
editor,
ELEMENT_LINK
);
const { forceSubmit } = getPluginOptions<LinkPlugin, V>(editor, ELEMENT_LINK);

const url = floatingLinkSelectors.url();
const isValid = isUrl?.(url) || forceSubmit;
if (!isValid) return;
if (!forceSubmit && !validateUrl(editor, url)) return;

const text = floatingLinkSelectors.text();
const target = floatingLinkSelectors.newTab() ? undefined : '_self';
Expand All @@ -38,7 +35,7 @@ export const submitFloatingLink = <V extends Value>(editor: PlateEditor<V>) => {
url,
text,
target,
isUrl: (_url) => (forceSubmit || !isUrl ? true : isUrl(_url)),
skipValidation: true,
});

setTimeout(() => {
Expand Down
36 changes: 33 additions & 3 deletions packages/nodes/link/src/transforms/upsertLink.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ describe('upsertLink', () => {
});
});

describe('when isUrl always true', () => {
describe('when skipValidation is false and url is invalid', () => {
const input = (
<editor>
<hp>
Expand All @@ -426,16 +426,46 @@ describe('upsertLink', () => {
) as any;

const output = (
<editor>
<hp>insert link.</hp>
</editor>
) as any;

it('should do nothing', () => {
const editor = createEditor(input);
upsertLink(editor, {
url: 'invalid',
skipValidation: false,
});

expect(input.children).toEqual(output.children);
});
});

describe('when skipValidation is true and url is invalid', () => {
const input = (
<editor>
<hp>
insert link<ha url="test">test</ha>.
insert link
<cursor />.
</hp>
</editor>
) as any;

const output = (
<editor>
<hp>
insert link<ha url="invalid">invalid</ha>.
</hp>
</editor>
) as any;

it('should insert', () => {
const editor = createEditor(input);
upsertLink(editor, { url: 'test', isUrl: (_url) => true });
upsertLink(editor, {
url: 'invalid',
skipValidation: true,
});

expect(input.children).toEqual(output.children);
});
Expand Down
11 changes: 5 additions & 6 deletions packages/nodes/link/src/transforms/upsertLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
getEditorString,
getNodeLeaf,
getNodeProps,
getPluginOptions,
getPluginType,
InsertNodesOptions,
isDefined,
Expand All @@ -16,9 +15,9 @@ import {
Value,
WrapNodesOptions,
} from '@udecode/plate-core';
import { ELEMENT_LINK, LinkPlugin } from '../createLinkPlugin';
import { ELEMENT_LINK } from '../createLinkPlugin';
import { TLinkElement } from '../types';
import { CreateLinkNodeOptions } from '../utils/index';
import { CreateLinkNodeOptions, validateUrl } from '../utils/index';
import { insertLink } from './insertLink';
import { unwrapLink } from './unwrapLink';
import { upsertLinkText } from './upsertLinkText';
Expand All @@ -34,7 +33,7 @@ export type UpsertLinkOptions<
insertNodesOptions?: InsertNodesOptions<V>;
unwrapNodesOptions?: UnwrapNodesOptions<V>;
wrapNodesOptions?: WrapNodesOptions<V>;
isUrl?: (url: string) => boolean;
skipValidation?: boolean;
};

/**
Expand All @@ -53,7 +52,7 @@ export const upsertLink = <V extends Value>(
target,
insertTextInLink,
insertNodesOptions,
isUrl = getPluginOptions<LinkPlugin, V>(editor, ELEMENT_LINK).isUrl,
skipValidation = false,
}: UpsertLinkOptions<V>
) => {
const at = editor.selection;
Expand All @@ -72,7 +71,7 @@ export const upsertLink = <V extends Value>(
return true;
}

if (!isUrl?.(url)) return;
if (!skipValidation && !validateUrl(editor, url)) return;

if (isDefined(text) && !text.length) {
text = url;
Expand Down
Loading

2 comments on commit 93dd571

@vercel
Copy link

@vercel vercel bot commented on 93dd571 Feb 28, 2023

Choose a reason for hiding this comment

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

Successfully deployed to the following URLs:

plate – ./

plate-git-main-udecode.vercel.app
plate-udecode.vercel.app
plate.udecode.io
www.plate.udecode.io

@vercel
Copy link

@vercel vercel bot commented on 93dd571 Feb 28, 2023

Choose a reason for hiding this comment

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

Successfully deployed to the following URLs:

plate-examples – ./

plate-examples.vercel.app
plate-examples-git-main-udecode.vercel.app
plate-examples-udecode.vercel.app

Please sign in to comment.