mirror of
https://github.com/ianstormtaylor/slate.git
synced 2025-08-10 17:24:02 +02:00
implement scrubber for end user data in exceptions (#4999)
This commit is contained in:
committed by
GitHub
parent
25be3b7031
commit
fe13a8f9e7
7
.changeset/flat-parrots-crash.md
Normal file
7
.changeset/flat-parrots-crash.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
'slate': minor
|
||||||
|
'slate-react': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add new Slate.Scrubber interface to allow scrubbing end user data from exception
|
||||||
|
text. The default behavior remains unchanged.
|
@@ -48,6 +48,7 @@
|
|||||||
- [RangeRef](api/locations/range-ref.md)
|
- [RangeRef](api/locations/range-ref.md)
|
||||||
- [Span](api/locations/span.md)
|
- [Span](api/locations/span.md)
|
||||||
- [Operation](api/operation.md)
|
- [Operation](api/operation.md)
|
||||||
|
- [Scrubber](api/scrubber.md)
|
||||||
|
|
||||||
## Libraries
|
## Libraries
|
||||||
|
|
||||||
|
78
docs/api/scrubber.md
Normal file
78
docs/api/scrubber.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Scrubber API
|
||||||
|
|
||||||
|
When Slate throws an exception, it includes a stringified representation of the
|
||||||
|
relevant data. For example, if your application makes an API call to access the
|
||||||
|
child of a text Node (an impossible operation), Slate will throw an exception
|
||||||
|
like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
Cannot get the child of a text node: {"text": "This is my text node."}
|
||||||
|
```
|
||||||
|
|
||||||
|
If your rich text editor can include sensitive customer data, you may want to
|
||||||
|
scrub or obfuscate that text. To help with that, you can use the Scrubber API.
|
||||||
|
Here's an example of recursively scrubbing the `'text'` fields of any entity
|
||||||
|
that gets logged.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Scrubber } from 'slate'
|
||||||
|
|
||||||
|
Scrubber.setScrubber((key, value) => {
|
||||||
|
if (key === 'text') return '... scrubbed ...'
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
By setting the scrubber in this way, the error example given above will be
|
||||||
|
printed as
|
||||||
|
|
||||||
|
```
|
||||||
|
Cannot get the child of a text node: {"text": "... scrubbed ..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Text Randomizer Example
|
||||||
|
|
||||||
|
Here's an example "textRandomizer" scrubber, which randomizes particular fields
|
||||||
|
of Nodes, preserving their length, but replacing their contents with randomly
|
||||||
|
chosen alphanumeric characters.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Scrubber } from 'slate'
|
||||||
|
|
||||||
|
const textRandomizer = (fieldNames: string[]) => (key, value) => {
|
||||||
|
if (fieldNames.includes(key)) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
.split('')
|
||||||
|
.map(generateRandomCharacter)
|
||||||
|
.join('')
|
||||||
|
} else {
|
||||||
|
return '... scrubbed ...'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateRandomCharacter = (): string => {
|
||||||
|
const chars =
|
||||||
|
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLKMNOPQRSTUVWXYZ1234567890'
|
||||||
|
return chars.charAt(Math.floor(Math.random() * chars.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomize the 'text' and 'src' fields of any Node that is included in an
|
||||||
|
// exception thrown by Slate
|
||||||
|
Scrubber.setScrubber(Scrubber.textRandomizer(['text', 'src']))
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example, a Node that looked like
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "text": "My test input string", "count": 5 }
|
||||||
|
```
|
||||||
|
|
||||||
|
will be logged by Slate in an exception as (the random string will differ):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "text": "rSIvEzKe39l6rqQSCfyv", "count": 5 }
|
||||||
|
```
|
@@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react'
|
import React, { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Editor, Node, Element, Descendant } from 'slate'
|
import { Editor, Node, Descendant, Scrubber } from 'slate'
|
||||||
import { ReactEditor } from '../plugin/react-editor'
|
import { ReactEditor } from '../plugin/react-editor'
|
||||||
import { FocusedContext } from '../hooks/use-focused'
|
import { FocusedContext } from '../hooks/use-focused'
|
||||||
import { EditorContext } from '../hooks/use-slate-static'
|
import { EditorContext } from '../hooks/use-slate-static'
|
||||||
@@ -30,12 +30,13 @@ export const Slate = (props: {
|
|||||||
if (!Node.isNodeList(value)) {
|
if (!Node.isNodeList(value)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[Slate] value is invalid! Expected a list of elements` +
|
`[Slate] value is invalid! Expected a list of elements` +
|
||||||
`but got: ${JSON.stringify(value)}`
|
`but got: ${Scrubber.stringify(value)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!Editor.isEditor(editor)) {
|
if (!Editor.isEditor(editor)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[Slate] editor is invalid! you passed:` + `${JSON.stringify(editor)}`
|
`[Slate] editor is invalid! you passed:` +
|
||||||
|
`${Scrubber.stringify(editor)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
editor.children = value
|
editor.children = value
|
||||||
|
@@ -1,4 +1,13 @@
|
|||||||
import { Editor, Node, Path, Point, Range, Transforms, BaseEditor } from 'slate'
|
import {
|
||||||
|
BaseEditor,
|
||||||
|
Editor,
|
||||||
|
Node,
|
||||||
|
Path,
|
||||||
|
Point,
|
||||||
|
Range,
|
||||||
|
Scrubber,
|
||||||
|
Transforms,
|
||||||
|
} from 'slate'
|
||||||
|
|
||||||
import { Key } from '../utils/key'
|
import { Key } from '../utils/key'
|
||||||
import {
|
import {
|
||||||
@@ -108,7 +117,7 @@ export const ReactEditor = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unable to find the path for Slate node: ${JSON.stringify(node)}`
|
`Unable to find the path for Slate node: ${Scrubber.stringify(node)}`
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -285,7 +294,7 @@ export const ReactEditor = {
|
|||||||
|
|
||||||
if (!domNode) {
|
if (!domNode) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot resolve a DOM node from Slate node: ${JSON.stringify(node)}`
|
`Cannot resolve a DOM node from Slate node: ${Scrubber.stringify(node)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +346,9 @@ export const ReactEditor = {
|
|||||||
|
|
||||||
if (!domPoint) {
|
if (!domPoint) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot resolve a DOM point from Slate point: ${JSON.stringify(point)}`
|
`Cannot resolve a DOM point from Slate point: ${Scrubber.stringify(
|
||||||
|
point
|
||||||
|
)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
export * from './create-editor'
|
export * from './create-editor'
|
||||||
|
export * from './interfaces/custom-types'
|
||||||
export * from './interfaces/editor'
|
export * from './interfaces/editor'
|
||||||
export * from './interfaces/element'
|
export * from './interfaces/element'
|
||||||
export * from './interfaces/location'
|
export * from './interfaces/location'
|
||||||
@@ -10,6 +11,6 @@ export * from './interfaces/point'
|
|||||||
export * from './interfaces/point-ref'
|
export * from './interfaces/point-ref'
|
||||||
export * from './interfaces/range'
|
export * from './interfaces/range'
|
||||||
export * from './interfaces/range-ref'
|
export * from './interfaces/range-ref'
|
||||||
|
export * from './interfaces/scrubber'
|
||||||
export * from './interfaces/text'
|
export * from './interfaces/text'
|
||||||
export * from './interfaces/custom-types'
|
|
||||||
export * from './transforms'
|
export * from './transforms'
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { produce } from 'immer'
|
import { produce } from 'immer'
|
||||||
import { Editor, Path, Range, Text } from '..'
|
import { Editor, Path, Range, Text, Scrubber } from '..'
|
||||||
import { Element, ElementEntry } from './element'
|
import { Element, ElementEntry } from './element'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,7 +112,9 @@ export const Node: NodeInterface = {
|
|||||||
|
|
||||||
if (Text.isText(node)) {
|
if (Text.isText(node)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot get the ancestor node at path [${path}] because it refers to a text node instead: ${node}`
|
`Cannot get the ancestor node at path [${path}] because it refers to a text node instead: ${Scrubber.stringify(
|
||||||
|
node
|
||||||
|
)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +147,7 @@ export const Node: NodeInterface = {
|
|||||||
child(root: Node, index: number): Descendant {
|
child(root: Node, index: number): Descendant {
|
||||||
if (Text.isText(root)) {
|
if (Text.isText(root)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot get the child of a text node: ${JSON.stringify(root)}`
|
`Cannot get the child of a text node: ${Scrubber.stringify(root)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +155,7 @@ export const Node: NodeInterface = {
|
|||||||
|
|
||||||
if (c == null) {
|
if (c == null) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot get child at index \`${index}\` in node: ${JSON.stringify(
|
`Cannot get child at index \`${index}\` in node: ${Scrubber.stringify(
|
||||||
root
|
root
|
||||||
)}`
|
)}`
|
||||||
)
|
)
|
||||||
@@ -203,7 +205,9 @@ export const Node: NodeInterface = {
|
|||||||
|
|
||||||
if (Editor.isEditor(node)) {
|
if (Editor.isEditor(node)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot get the descendant node at path [${path}] because it refers to the root editor node instead: ${node}`
|
`Cannot get the descendant node at path [${path}] because it refers to the root editor node instead: ${Scrubber.stringify(
|
||||||
|
node
|
||||||
|
)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +291,7 @@ export const Node: NodeInterface = {
|
|||||||
fragment(root: Node, range: Range): Descendant[] {
|
fragment(root: Node, range: Range): Descendant[] {
|
||||||
if (Text.isText(root)) {
|
if (Text.isText(root)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot get a fragment starting from a root text node: ${JSON.stringify(
|
`Cannot get a fragment starting from a root text node: ${Scrubber.stringify(
|
||||||
root
|
root
|
||||||
)}`
|
)}`
|
||||||
)
|
)
|
||||||
@@ -339,7 +343,7 @@ export const Node: NodeInterface = {
|
|||||||
|
|
||||||
if (Text.isText(node) || !node.children[p]) {
|
if (Text.isText(node) || !node.children[p]) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot find a descendant at path [${path}] in node: ${JSON.stringify(
|
`Cannot find a descendant at path [${path}] in node: ${Scrubber.stringify(
|
||||||
root
|
root
|
||||||
)}`
|
)}`
|
||||||
)
|
)
|
||||||
@@ -428,7 +432,9 @@ export const Node: NodeInterface = {
|
|||||||
|
|
||||||
if (!Text.isText(node)) {
|
if (!Text.isText(node)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot get the leaf node at path [${path}] because it refers to a non-leaf node: ${node}`
|
`Cannot get the leaf node at path [${path}] because it refers to a non-leaf node: ${Scrubber.stringify(
|
||||||
|
node
|
||||||
|
)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
33
packages/slate/src/interfaces/scrubber.ts
Normal file
33
packages/slate/src/interfaces/scrubber.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export type Scrubber = (key: string, value: unknown) => unknown
|
||||||
|
|
||||||
|
export interface ScrubberInterface {
|
||||||
|
setScrubber(scrubber: Scrubber | undefined): void
|
||||||
|
stringify(value: any): string
|
||||||
|
}
|
||||||
|
|
||||||
|
let _scrubber: Scrubber | undefined = undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This interface implements a stringify() function, which is used by Slate
|
||||||
|
* internally when generating exceptions containing end user data. Developers
|
||||||
|
* using Slate may call Scrubber.setScrubber() to alter the behavior of this
|
||||||
|
* stringify() function.
|
||||||
|
*
|
||||||
|
* For example, to prevent the cleartext logging of 'text' fields within Nodes:
|
||||||
|
*
|
||||||
|
* import { Scrubber } from 'slate';
|
||||||
|
* Scrubber.setScrubber((key, val) => {
|
||||||
|
* if (key === 'text') return '...scrubbed...'
|
||||||
|
* return val
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const Scrubber: ScrubberInterface = {
|
||||||
|
setScrubber(scrubber: Scrubber | undefined): void {
|
||||||
|
_scrubber = scrubber
|
||||||
|
},
|
||||||
|
|
||||||
|
stringify(value: any): string {
|
||||||
|
return JSON.stringify(value, _scrubber)
|
||||||
|
},
|
||||||
|
}
|
@@ -1,17 +1,18 @@
|
|||||||
import { createDraft, finishDraft, isDraft } from 'immer'
|
import { createDraft, finishDraft, isDraft } from 'immer'
|
||||||
import {
|
import {
|
||||||
Node,
|
|
||||||
Editor,
|
|
||||||
Selection,
|
|
||||||
Range,
|
|
||||||
Point,
|
|
||||||
Text,
|
|
||||||
Element,
|
|
||||||
Operation,
|
|
||||||
Descendant,
|
|
||||||
NodeEntry,
|
|
||||||
Path,
|
|
||||||
Ancestor,
|
Ancestor,
|
||||||
|
Descendant,
|
||||||
|
Editor,
|
||||||
|
Element,
|
||||||
|
Node,
|
||||||
|
NodeEntry,
|
||||||
|
Operation,
|
||||||
|
Path,
|
||||||
|
Point,
|
||||||
|
Range,
|
||||||
|
Scrubber,
|
||||||
|
Selection,
|
||||||
|
Text,
|
||||||
} from '..'
|
} from '..'
|
||||||
|
|
||||||
export interface GeneralTransforms {
|
export interface GeneralTransforms {
|
||||||
@@ -73,7 +74,9 @@ const applyToDraft = (editor: Editor, selection: Selection, op: Operation) => {
|
|||||||
prev.children.push(...node.children)
|
prev.children.push(...node.children)
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot apply a "merge_node" operation at path [${path}] to nodes of different interfaces: ${node} ${prev}`
|
`Cannot apply a "merge_node" operation at path [${path}] to nodes of different interfaces: ${Scrubber.stringify(
|
||||||
|
node
|
||||||
|
)} ${Scrubber.stringify(prev)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +239,7 @@ const applyToDraft = (editor: Editor, selection: Selection, op: Operation) => {
|
|||||||
if (selection == null) {
|
if (selection == null) {
|
||||||
if (!Range.isRange(newProperties)) {
|
if (!Range.isRange(newProperties)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot apply an incomplete "set_selection" operation properties ${JSON.stringify(
|
`Cannot apply an incomplete "set_selection" operation properties ${Scrubber.stringify(
|
||||||
newProperties
|
newProperties
|
||||||
)} when there is no current selection.`
|
)} when there is no current selection.`
|
||||||
)
|
)
|
||||||
|
@@ -1,15 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
|
Ancestor,
|
||||||
Editor,
|
Editor,
|
||||||
Element,
|
Element,
|
||||||
Location,
|
Location,
|
||||||
Node,
|
Node,
|
||||||
|
NodeEntry,
|
||||||
Path,
|
Path,
|
||||||
Point,
|
Point,
|
||||||
Range,
|
Range,
|
||||||
|
Scrubber,
|
||||||
Text,
|
Text,
|
||||||
Transforms,
|
Transforms,
|
||||||
NodeEntry,
|
|
||||||
Ancestor,
|
|
||||||
} from '..'
|
} from '..'
|
||||||
import { NodeMatch, PropsCompare, PropsMerge } from '../interfaces/editor'
|
import { NodeMatch, PropsCompare, PropsMerge } from '../interfaces/editor'
|
||||||
import { PointRef } from '../interfaces/point-ref'
|
import { PointRef } from '../interfaces/point-ref'
|
||||||
@@ -406,9 +407,9 @@ export const NodeTransforms: NodeTransforms = {
|
|||||||
properties = rest as Partial<Element>
|
properties = rest as Partial<Element>
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot merge the node at path [${path}] with the previous sibling because it is not the same kind: ${JSON.stringify(
|
`Cannot merge the node at path [${path}] with the previous sibling because it is not the same kind: ${Scrubber.stringify(
|
||||||
node
|
node
|
||||||
)} ${JSON.stringify(prevNode)}`
|
)} ${Scrubber.stringify(prevNode)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { Editor, Location, Point, Range, Transforms } from '..'
|
import { Editor, Location, Point, Range, Scrubber, Transforms } from '..'
|
||||||
import { SelectionEdge, MoveUnit } from '../interfaces/types'
|
import { SelectionEdge, MoveUnit } from '../interfaces/types'
|
||||||
|
|
||||||
export interface SelectionCollapseOptions {
|
export interface SelectionCollapseOptions {
|
||||||
@@ -132,7 +132,7 @@ export const SelectionTransforms: SelectionTransforms = {
|
|||||||
|
|
||||||
if (!Range.isRange(target)) {
|
if (!Range.isRange(target)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`When setting the selection and the current selection is \`null\` you must provide at least an \`anchor\` and \`focus\`, but you passed: ${JSON.stringify(
|
`When setting the selection and the current selection is \`null\` you must provide at least an \`anchor\` and \`focus\`, but you passed: ${Scrubber.stringify(
|
||||||
target
|
target
|
||||||
)}`
|
)}`
|
||||||
)
|
)
|
||||||
|
24
packages/slate/test/interfaces/Scrubber/scrubber.ts
Normal file
24
packages/slate/test/interfaces/Scrubber/scrubber.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Node, Scrubber } from 'slate'
|
||||||
|
|
||||||
|
export const input = {
|
||||||
|
customField: 'some very long custom field value that will get scrubbed',
|
||||||
|
anotherField: 'this field should not get scrambled',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const test = (value: Node) => {
|
||||||
|
Scrubber.setScrubber((key, value) =>
|
||||||
|
key === 'customField' ? '... scrubbed ...' : value
|
||||||
|
)
|
||||||
|
const stringified = Scrubber.stringify(value)
|
||||||
|
Scrubber.setScrubber(undefined)
|
||||||
|
|
||||||
|
const unmarshaled = JSON.parse(stringified)
|
||||||
|
return (
|
||||||
|
// ensure that first field has been scrubbed
|
||||||
|
unmarshaled.customField === '... scrubbed ...' &&
|
||||||
|
// ensure that second field is unaltered
|
||||||
|
unmarshaled.anotherField === input.anotherField
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const output = true
|
Reference in New Issue
Block a user