@nocobase/client-v2 2.3.0-beta.6 → 2.3.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.3.0-beta.6",
3
+ "version": "2.3.0-beta.8",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -27,11 +27,11 @@
27
27
  "@formily/antd-v5": "1.2.3",
28
28
  "@formily/react": "^2.2.27",
29
29
  "@formily/shared": "^2.2.27",
30
- "@nocobase/evaluators": "2.3.0-beta.6",
31
- "@nocobase/flow-engine": "2.3.0-beta.6",
32
- "@nocobase/sdk": "2.3.0-beta.6",
33
- "@nocobase/shared": "2.3.0-beta.6",
34
- "@nocobase/utils": "2.3.0-beta.6",
30
+ "@nocobase/evaluators": "2.3.0-beta.8",
31
+ "@nocobase/flow-engine": "2.3.0-beta.8",
32
+ "@nocobase/sdk": "2.3.0-beta.8",
33
+ "@nocobase/shared": "2.3.0-beta.8",
34
+ "@nocobase/utils": "2.3.0-beta.8",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -48,5 +48,5 @@
48
48
  "react-i18next": "^11.15.1",
49
49
  "react-router-dom": "^6.30.1"
50
50
  },
51
- "gitHead": "45b23791f5245d1417047b122d1a76764aaf5473"
51
+ "gitHead": "5d458884528593b2d3142a6a33de93bf0ad96543"
52
52
  }
@@ -7,16 +7,17 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import React, { useRef, useState, useEffect } from 'react';
11
- import { useParseMarkdown, convertToText } from './util';
12
- import { useMarkdownStyles } from './style';
10
+ import React, { useEffect, useRef, useState } from 'react';
13
11
  import { Tooltip } from 'antd';
12
+ import { useMarkdownStyles } from './style';
13
+ import { convertToText, useParseMarkdown } from './util';
14
14
 
15
15
  export const DisplayMarkdown = (props) => {
16
- const { textOnly, overflowMode, style } = props;
16
+ const { textOnly, overflowMode, sanitizeHtml, style } = props;
17
17
  const markdownClass = useMarkdownStyles();
18
18
  const { html = '' } = useParseMarkdown(props.value);
19
- const text: any = convertToText(html);
19
+ const displayHtml = sanitizeHtml ? sanitizeHtml(html) : html;
20
+ const text = convertToText(displayHtml);
20
21
  const isEllipsis = overflowMode === 'ellipsis';
21
22
 
22
23
  const contentRef = useRef<HTMLDivElement>(null);
@@ -29,7 +30,7 @@ export const DisplayMarkdown = (props) => {
29
30
  const overflowed = el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight;
30
31
  setIsOverflowed(overflowed);
31
32
  }
32
- }, [html, style, overflowMode]);
33
+ }, [displayHtml, style, overflowMode]);
33
34
 
34
35
  // 通用样式(用于 ellipsis 模式)
35
36
  const baseStyle: React.CSSProperties = {
@@ -48,11 +49,15 @@ export const DisplayMarkdown = (props) => {
48
49
  : {}),
49
50
  };
50
51
 
51
- const content = (
52
+ const content = textOnly ? (
53
+ <div ref={contentRef} className={`${markdownClass} nb-markdown`} style={baseStyle}>
54
+ {text}
55
+ </div>
56
+ ) : (
52
57
  <div
53
58
  ref={contentRef}
54
59
  className={`${markdownClass} nb-markdown`}
55
- dangerouslySetInnerHTML={{ __html: textOnly ? text : html }}
60
+ dangerouslySetInnerHTML={{ __html: displayHtml }}
56
61
  style={baseStyle}
57
62
  />
58
63
  );
@@ -61,7 +66,7 @@ export const DisplayMarkdown = (props) => {
61
66
  <Tooltip
62
67
  title={
63
68
  <div
64
- dangerouslySetInnerHTML={{ __html: html }}
69
+ dangerouslySetInnerHTML={{ __html: displayHtml }}
65
70
  style={{
66
71
  maxHeight: 500,
67
72
  overflowY: 'auto',
@@ -0,0 +1,46 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { render } from '@nocobase/test/client';
11
+ import { sanitizeRichTextHtml } from '@nocobase/utils/client';
12
+ import React from 'react';
13
+ import { DisplayMarkdown } from '../DisplayMarkdown';
14
+
15
+ vi.mock('../util', () => ({
16
+ useParseMarkdown: (value: string) => ({ html: value }),
17
+ convertToText: (value: string) => {
18
+ const element = document.createElement('div');
19
+ element.innerHTML = value;
20
+ return element.textContent || '';
21
+ },
22
+ }));
23
+
24
+ describe('DisplayMarkdown', () => {
25
+ it('sanitizes HTML mode', () => {
26
+ const { container } = render(
27
+ <DisplayMarkdown
28
+ value={'<p>safe</p><img src="https://example.com/image.png" onerror="alert(1)"><script>alert(1)</script>'}
29
+ sanitizeHtml={sanitizeRichTextHtml}
30
+ textOnly={false}
31
+ />,
32
+ );
33
+
34
+ expect(container.querySelector('p')).toHaveTextContent('safe');
35
+ expect(container.querySelector('img')).not.toHaveAttribute('onerror');
36
+ expect(container.querySelector('script')).toBeNull();
37
+ });
38
+
39
+ it('renders decoded text as a text node', () => {
40
+ const payload = '&lt;img src=x onerror=alert(document.domain)&gt;';
41
+ const { container } = render(<DisplayMarkdown value={payload} sanitizeHtml={sanitizeRichTextHtml} textOnly />);
42
+
43
+ expect(container).toHaveTextContent('<img src=x onerror=alert(document.domain)>');
44
+ expect(container.querySelector('img')).toBeNull();
45
+ });
46
+ });
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { DisplayItemModel, tExpr } from '@nocobase/flow-engine';
11
+ import { sanitizeRichTextHtml } from '@nocobase/utils/client';
11
12
  import React from 'react';
12
13
  import { DisplayMarkdown } from '../../internal/components/Markdown/DisplayMarkdown';
13
14
  import { DisplayTitleFieldModel } from './DisplayTitleFieldModel';
@@ -15,7 +16,7 @@ import { DisplayTitleFieldModel } from './DisplayTitleFieldModel';
15
16
  export class DisplayHtmlFieldModel extends DisplayTitleFieldModel {
16
17
  public renderComponent(value) {
17
18
  const { textOnly = true } = this.props;
18
- return <DisplayMarkdown {...this.props} textOnly={textOnly} value={value} />;
19
+ return <DisplayMarkdown {...this.props} sanitizeHtml={sanitizeRichTextHtml} textOnly={textOnly} value={value} />;
19
20
  }
20
21
  }
21
22
 
@@ -0,0 +1,74 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { act, render } from '@nocobase/test/client';
11
+ import { sanitizeRichTextHtml } from '@nocobase/utils/client';
12
+ import React from 'react';
13
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
14
+ import { RichTextField } from '..';
15
+
16
+ const mockState = vi.hoisted(() => ({
17
+ onChange: undefined as ((value: string) => void) | undefined,
18
+ value: undefined as unknown,
19
+ }));
20
+
21
+ vi.mock('../../../../../flow-compat', async (importOriginal) => {
22
+ const actual = await importOriginal<typeof import('../../../../../flow-compat')>();
23
+ return {
24
+ ...actual,
25
+ lazy: () => (props: { onChange?: (value: string) => void; value?: unknown }) => {
26
+ mockState.onChange = props.onChange;
27
+ mockState.value = props.value;
28
+ return null;
29
+ },
30
+ };
31
+ });
32
+
33
+ describe('RichTextField edit mode sanitization', () => {
34
+ beforeEach(() => {
35
+ mockState.onChange = undefined;
36
+ mockState.value = undefined;
37
+ });
38
+
39
+ it('sanitizes stored HTML before passing it to ReactQuill', () => {
40
+ const payload = '<p>safe</p><img src="https://example.com/image.png" onerror="alert(1)"><script>alert(1)</script>';
41
+
42
+ render(<RichTextField value={payload} onChange={vi.fn()} />);
43
+
44
+ expect(mockState.value).toBe(sanitizeRichTextHtml(payload));
45
+ });
46
+
47
+ it('preserves Delta values', () => {
48
+ const delta = { ops: [{ insert: 'safe\n' }] };
49
+
50
+ render(<RichTextField value={delta} onChange={vi.fn()} />);
51
+
52
+ expect(mockState.value).toBe(delta);
53
+ });
54
+
55
+ it('does not normalize active editor feedback', () => {
56
+ const editorValue = '<p>Hello<br>World</p>';
57
+ expect(sanitizeRichTextHtml(editorValue)).not.toBe(editorValue);
58
+
59
+ const { rerender } = render(<RichTextField value="" onChange={vi.fn()} />);
60
+ act(() => mockState.onChange?.(editorValue));
61
+ rerender(<RichTextField value={editorValue} onChange={vi.fn()} />);
62
+
63
+ expect(mockState.value).toBe(editorValue);
64
+ });
65
+
66
+ it('sanitizes subsequent external values', () => {
67
+ const payload = '<img src=x onerror="alert(1)">';
68
+ const { rerender } = render(<RichTextField value="" onChange={vi.fn()} />);
69
+
70
+ rerender(<RichTextField value={payload} onChange={vi.fn()} />);
71
+
72
+ expect(mockState.value).toBe(sanitizeRichTextHtml(payload));
73
+ });
74
+ });
@@ -9,6 +9,8 @@
9
9
 
10
10
  import React from 'react';
11
11
  import { largeField, EditableItemModel } from '@nocobase/flow-engine';
12
+ import { sanitizeRichTextHtml } from '@nocobase/utils/client';
13
+ import type ReactQuillComponent from 'react-quill';
12
14
  import { lazy } from '../../../../flow-compat';
13
15
  import { useRichTextStyles } from './style';
14
16
  import { FieldModel } from '../../base';
@@ -26,6 +28,8 @@ const ReactQuill = lazy(async () => {
26
28
  return import('react-quill');
27
29
  });
28
30
 
31
+ type ReactQuillValue = ReactQuillComponent.ReactQuillProps['value'];
32
+
29
33
  export const RichTextField = (props) => {
30
34
  const richTextClass = useRichTextStyles();
31
35
  const boundsClass = React.useMemo(() => `quill-bounds-${Math.random().toString(36).slice(2, 9)}`, []);
@@ -83,19 +87,36 @@ export const RichTextField = (props) => {
83
87
  'break',
84
88
  ];
85
89
  const { value, onChange, disabled, modules: propsModules, formats: propsFormats } = props;
90
+ const previousIncomingValueRef = React.useRef(value);
91
+ const pendingEditorValueRef = React.useRef<{ value: ReactQuillValue }>();
92
+ const [editorValue, setEditorValue] = React.useState<ReactQuillValue>(() =>
93
+ typeof value === 'string' ? sanitizeRichTextHtml(value) : value,
94
+ );
95
+
96
+ React.useEffect(() => {
97
+ if (Object.is(value, previousIncomingValueRef.current)) {
98
+ return;
99
+ }
100
+ previousIncomingValueRef.current = value;
101
+ const pendingEditorValue = pendingEditorValueRef.current;
102
+ pendingEditorValueRef.current = undefined;
103
+ if (pendingEditorValue && Object.is(value, pendingEditorValue.value)) {
104
+ return;
105
+ }
106
+ setEditorValue(typeof value === 'string' ? sanitizeRichTextHtml(value) : value);
107
+ }, [value]);
86
108
 
87
109
  return (
88
110
  <ReactQuill
89
111
  className={`${richTextClass} ${boundsClass}`}
90
112
  modules={propsModules || modules}
91
113
  formats={propsFormats || formats}
92
- value={value}
114
+ value={editorValue}
93
115
  onChange={(value) => {
94
- if (value === '<p><br></p>') {
95
- onChange('');
96
- } else {
97
- onChange(value);
98
- }
116
+ const nextValue = value === '<p><br></p>' ? '' : value;
117
+ pendingEditorValueRef.current = { value: nextValue };
118
+ setEditorValue(nextValue);
119
+ onChange(nextValue);
99
120
  }}
100
121
  readOnly={disabled}
101
122
  bounds={`.${boundsClass}`}