Add CellOutputViewer for SandboxOutputs (#686)

* Initial commit

* Optimizations

* Optimize notebookOutputViewer bundle size by lazy loading transforms

* Update package-lock.json

* More optimizations

* Updates

* Fix unit test and other updates

* Address feedback

* Update package-lock.json

* Update test snapshots

* Fix build

* Reduce cellOutputViewer bundle size

* Renaming
This commit is contained in:
Tanuj Mittal
2021-04-22 13:37:12 -04:00
committed by GitHub
parent c6766dd69e
commit 448566146f
24 changed files with 998 additions and 334 deletions

View File

@@ -34,8 +34,6 @@ import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../UserContext";
import configureStore from "./NotebookComponent/store";
import { CdbAppState, makeCdbRecord } from "./NotebookComponent/types";
import SandboxJavaScript from "./NotebookRenderer/outputs/SandboxJavaScript";
import SanitizedHTML from "./NotebookRenderer/outputs/SanitizedHTML";
export type KernelSpecsDisplay = { name: string; displayName: string };
@@ -125,62 +123,62 @@ export class NotebookClientV2 {
contents: makeContentsRecord({
byRef: Immutable.Map<string, ContentRecord>(),
}),
transforms: makeTransformsRecord({
displayOrder: Immutable.List([
"application/vnd.jupyter.widget-view+json",
"application/vnd.vega.v5+json",
"application/vnd.vega.v4+json",
"application/vnd.vega.v3+json",
"application/vnd.vega.v2+json",
"application/vnd.vegalite.v3+json",
"application/vnd.vegalite.v2+json",
"application/vnd.vegalite.v1+json",
"application/geo+json",
"application/vnd.plotly.v1+json",
"text/vnd.plotly.v1+html",
"application/x-nteract-model-debug+json",
"application/vnd.dataresource+json",
"application/vdom.v1+json",
"application/json",
"application/javascript",
"text/html",
"text/markdown",
"text/latex",
"image/svg+xml",
"image/gif",
"image/png",
"image/jpeg",
"text/plain",
]),
byId: Immutable.Map({
"text/vnd.plotly.v1+html": NullTransform,
"application/vnd.plotly.v1+json": NullTransform,
"application/geo+json": NullTransform,
"application/x-nteract-model-debug+json": NullTransform,
"application/vnd.dataresource+json": NullTransform,
"application/vnd.jupyter.widget-view+json": NullTransform,
"application/vnd.vegalite.v1+json": NullTransform,
"application/vnd.vegalite.v2+json": NullTransform,
"application/vnd.vegalite.v3+json": NullTransform,
"application/vnd.vega.v2+json": NullTransform,
"application/vnd.vega.v3+json": NullTransform,
"application/vnd.vega.v4+json": NullTransform,
"application/vnd.vega.v5+json": NullTransform,
"application/vdom.v1+json": TransformVDOM,
"application/json": Media.Json,
"application/javascript": userContext.features.sandboxNotebookOutputs
? SandboxJavaScript
: Media.JavaScript,
"text/html": userContext.features.sandboxNotebookOutputs ? SanitizedHTML : Media.HTML,
"text/markdown": Media.Markdown,
"text/latex": Media.LaTeX,
"image/svg+xml": Media.SVG,
"image/gif": Media.Image,
"image/png": Media.Image,
"image/jpeg": Media.Image,
"text/plain": Media.Plain,
}),
}),
transforms: userContext.features.sandboxNotebookOutputs
? undefined
: makeTransformsRecord({
displayOrder: Immutable.List([
"application/vnd.jupyter.widget-view+json",
"application/vnd.vega.v5+json",
"application/vnd.vega.v4+json",
"application/vnd.vega.v3+json",
"application/vnd.vega.v2+json",
"application/vnd.vegalite.v3+json",
"application/vnd.vegalite.v2+json",
"application/vnd.vegalite.v1+json",
"application/geo+json",
"application/vnd.plotly.v1+json",
"text/vnd.plotly.v1+html",
"application/x-nteract-model-debug+json",
"application/vnd.dataresource+json",
"application/vdom.v1+json",
"application/json",
"application/javascript",
"text/html",
"text/markdown",
"text/latex",
"image/svg+xml",
"image/gif",
"image/png",
"image/jpeg",
"text/plain",
]),
byId: Immutable.Map({
"text/vnd.plotly.v1+html": NullTransform,
"application/vnd.plotly.v1+json": NullTransform,
"application/geo+json": NullTransform,
"application/x-nteract-model-debug+json": NullTransform,
"application/vnd.dataresource+json": NullTransform,
"application/vnd.jupyter.widget-view+json": NullTransform,
"application/vnd.vegalite.v1+json": NullTransform,
"application/vnd.vegalite.v2+json": NullTransform,
"application/vnd.vegalite.v3+json": NullTransform,
"application/vnd.vega.v2+json": NullTransform,
"application/vnd.vega.v3+json": NullTransform,
"application/vnd.vega.v4+json": NullTransform,
"application/vnd.vega.v5+json": NullTransform,
"application/vdom.v1+json": TransformVDOM,
"application/json": Media.Json,
"application/javascript": Media.JavaScript,
"text/html": Media.HTML,
"text/markdown": Media.Markdown,
"text/latex": Media.LaTeX,
"image/svg+xml": Media.SVG,
"image/gif": Media.Image,
"image/png": Media.Image,
"image/jpeg": Media.Image,
"text/plain": Media.Plain,
}),
}),
}),
}),
cdb: makeCdbRecord({

View File

@@ -1,10 +1,8 @@
import { actions, ContentRef } from "@nteract/core";
import { KernelOutputError, StreamText } from "@nteract/outputs";
import { Cells, CodeCell, MarkdownCell, RawCell } from "@nteract/stateful-components";
import MonacoEditor from "@nteract/stateful-components/lib/inputs/connected-editors/monacoEditor";
import { PassedEditorProps } from "@nteract/stateful-components/lib/inputs/editor";
import Prompt, { PassedPromptProps } from "@nteract/stateful-components/lib/inputs/prompt";
import TransformMedia from "@nteract/stateful-components/lib/outputs/transform-media";
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
@@ -14,7 +12,7 @@ import { AzureTheme } from "./AzureTheme";
import "./base.css";
import "./default.css";
import "./NotebookReadOnlyRenderer.less";
import IFrameOutputs from "./outputs/IFrameOutputs";
import SandboxOutputs from "./outputs/SandboxOutputs";
export interface NotebookRendererProps {
contentRef: any;
@@ -27,7 +25,9 @@ export interface NotebookRendererProps {
*/
class NotebookReadOnlyRenderer extends React.Component<NotebookRendererProps> {
componentDidMount() {
loadTransform(this.props as any);
if (!userContext.features.sandboxNotebookOutputs) {
loadTransform(this.props as any);
}
}
private renderPrompt(id: string, contentRef: string): JSX.Element {
@@ -63,14 +63,7 @@ class NotebookReadOnlyRenderer extends React.Component<NotebookRendererProps> {
{{
prompt: (props: { id: string; contentRef: string }) => this.renderPrompt(props.id, props.contentRef),
outputs: userContext.features.sandboxNotebookOutputs
? (props: any) => (
<IFrameOutputs id={id} contentRef={contentRef}>
<TransformMedia output_type={"display_data"} id={id} contentRef={contentRef} />
<TransformMedia output_type={"execute_result"} id={id} contentRef={contentRef} />
<KernelOutputError />
<StreamText />
</IFrameOutputs>
)
? () => <SandboxOutputs id={id} contentRef={contentRef} />
: undefined,
editor: {
monaco: (props: PassedEditorProps) =>

View File

@@ -1,11 +1,9 @@
import { CellId } from "@nteract/commutable";
import { CellType } from "@nteract/commutable/src";
import { actions, ContentRef } from "@nteract/core";
import { KernelOutputError, StreamText } from "@nteract/outputs";
import { Cells, CodeCell, RawCell } from "@nteract/stateful-components";
import MonacoEditor from "@nteract/stateful-components/lib/inputs/connected-editors/monacoEditor";
import { PassedEditorProps } from "@nteract/stateful-components/lib/inputs/editor";
import TransformMedia from "@nteract/stateful-components/lib/outputs/transform-media";
import * as React from "react";
import { DndProvider } from "react-dnd";
import HTML5Backend from "react-dnd-html5-backend";
@@ -23,7 +21,7 @@ import KeyboardShortcuts from "./decorators/kbd-shortcuts";
import "./default.css";
import MarkdownCell from "./markdown-cell";
import "./NotebookRenderer.less";
import IFrameOutputs from "./outputs/IFrameOutputs";
import SandboxOutputs from "./outputs/SandboxOutputs";
import Prompt from "./Prompt";
import { promptContent } from "./PromptContent";
import StatusBar from "./StatusBar";
@@ -71,7 +69,9 @@ class BaseNotebookRenderer extends React.Component<NotebookRendererProps> {
}
componentDidMount() {
loadTransform(this.props as any);
if (!userContext.features.sandboxNotebookOutputs) {
loadTransform(this.props as any);
}
this.props.updateNotebookParentDomElt(this.props.contentRef, this.notebookRendererRef.current);
}
@@ -109,14 +109,7 @@ class BaseNotebookRenderer extends React.Component<NotebookRendererProps> {
),
toolbar: () => <CellToolbar id={id} contentRef={contentRef} />,
outputs: userContext.features.sandboxNotebookOutputs
? (props: any) => (
<IFrameOutputs id={id} contentRef={contentRef}>
<TransformMedia output_type={"display_data"} id={id} contentRef={contentRef} />
<TransformMedia output_type={"execute_result"} id={id} contentRef={contentRef} />
<KernelOutputError />
<StreamText />
</IFrameOutputs>
)
? () => <SandboxOutputs id={id} contentRef={contentRef} />
: undefined,
}}
</CodeCell>

View File

@@ -1,70 +0,0 @@
import { AppState, ContentRef, selectors } from "@nteract/core";
import { Output } from "@nteract/outputs";
import Immutable from "immutable";
import React from "react";
import { connect } from "react-redux";
import { SandboxFrame } from "./SandboxFrame";
// Adapted from https://github.com/nteract/nteract/blob/main/packages/stateful-components/src/outputs/index.tsx
// to add support for sandboxing using <iframe>
interface ComponentProps {
id: string;
contentRef: ContentRef;
children: React.ReactNode;
}
interface StateProps {
hidden: boolean;
expanded: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
outputs: Immutable.List<any>;
}
export class IFrameOutputs extends React.PureComponent<ComponentProps & StateProps> {
render(): JSX.Element {
const { outputs, children, hidden, expanded } = this.props;
return (
<SandboxFrame
style={{ border: "none", width: "100%" }}
sandbox="allow-downloads allow-forms allow-pointer-lock allow-same-origin allow-scripts"
>
<div className={`nteract-cell-outputs ${hidden ? "hidden" : ""} ${expanded ? "expanded" : ""}`}>
{outputs.map((output, index) => (
<Output output={output} key={index}>
{children}
</Output>
))}
</div>
</SandboxFrame>
);
}
}
export const makeMapStateToProps = (
initialState: AppState,
ownProps: ComponentProps
): ((state: AppState) => StateProps) => {
const mapStateToProps = (state: AppState): StateProps => {
let outputs = Immutable.List();
let hidden = false;
let expanded = false;
const { contentRef, id } = ownProps;
const model = selectors.model(state, { contentRef });
if (model && model.type === "notebook") {
const cell = selectors.notebook.cellById(model, { id });
if (cell) {
outputs = cell.get("outputs", Immutable.List());
hidden = cell.cell_type === "code" && cell.getIn(["metadata", "jupyter", "outputs_hidden"]);
expanded = cell.cell_type === "code" && cell.getIn(["metadata", "collapsed"]) === false;
}
}
return { outputs, hidden, expanded };
};
return mapStateToProps;
};
export default connect<StateProps, void, ComponentProps, AppState>(makeMapStateToProps)(IFrameOutputs);

View File

@@ -1,69 +0,0 @@
import React from "react";
import ReactDOM from "react-dom";
import { copyStyles } from "../../../../Utils/StyleUtils";
interface SandboxFrameProps {
style: React.CSSProperties;
sandbox: string;
}
interface SandboxFrameState {
frame: HTMLIFrameElement;
frameBody: HTMLElement;
frameHeight: number;
}
export class SandboxFrame extends React.PureComponent<SandboxFrameProps, SandboxFrameState> {
private resizeObserver: ResizeObserver;
private mutationObserver: MutationObserver;
constructor(props: SandboxFrameProps) {
super(props);
this.state = {
frame: undefined,
frameBody: undefined,
frameHeight: 0,
};
}
render(): JSX.Element {
return (
<iframe
ref={(ele) => this.setState({ frame: ele })}
srcDoc={`<!DOCTYPE html>`}
onLoad={(event) => this.onFrameLoad(event)}
style={this.props.style}
sandbox={this.props.sandbox}
height={this.state.frameHeight}
>
{this.state.frameBody && ReactDOM.createPortal(this.props.children, this.state.frameBody)}
</iframe>
);
}
componentWillUnmount(): void {
this.resizeObserver?.disconnect();
this.mutationObserver?.disconnect();
}
onFrameLoad(event: React.SyntheticEvent<HTMLIFrameElement, Event>): void {
const doc = (event.target as HTMLIFrameElement).contentDocument;
copyStyles(document, doc);
this.setState({ frameBody: doc.body });
this.mutationObserver = new MutationObserver(() => {
const bodyFirstElementChild = this.state.frameBody?.firstElementChild;
if (!this.resizeObserver && bodyFirstElementChild) {
this.resizeObserver = new ResizeObserver(() =>
this.setState({
frameHeight: this.state.frameBody?.firstElementChild.scrollHeight,
})
);
this.resizeObserver.observe(bodyFirstElementChild);
}
});
this.mutationObserver.observe(doc.body, { childList: true });
}
}

View File

@@ -1,26 +0,0 @@
import { Media } from "@nteract/outputs";
import React from "react";
interface Props {
/**
* The JavaScript code that we would like to execute.
*/
data: string;
/**
* The media type associated with our component.
*/
mediaType: "text/javascript";
}
export class SandboxJavaScript extends React.PureComponent<Props> {
static defaultProps = {
data: "",
mediaType: "application/javascript",
};
render(): JSX.Element {
return <Media.HTML data={`<script>${this.props.data}</script>`} />;
}
}
export default SandboxJavaScript;

View File

@@ -0,0 +1,132 @@
import { JSONObject } from "@nteract/commutable";
import { outputToJS } from "@nteract/commutable/lib/v4";
import { actions, AppState, ContentRef, selectors } from "@nteract/core";
import IframeResizer from "iframe-resizer-react";
import Immutable from "immutable";
import postRobot from "post-robot";
import React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import { CellOutputViewerProps } from "../../../../CellOutputViewer/CellOutputViewer";
// Adapted from https://github.com/nteract/nteract/blob/main/packages/stateful-components/src/outputs/index.tsx
// to add support for sandboxing using <iframe>
interface ComponentProps {
id: string;
contentRef: ContentRef;
}
interface StateProps {
hidden: boolean;
expanded: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
outputs: Immutable.List<any>;
}
interface DispatchProps {
onMetadataChange?: (metadata: JSONObject, mediaType: string, index?: number) => void;
}
export class SandboxOutputs extends React.PureComponent<ComponentProps & StateProps & DispatchProps> {
private childWindow: Window;
render(): JSX.Element {
// Using min-width to set the width of the iFrame, works around an issue in iOS that can prevent the iFrame from sizing correctly.
return (
<IframeResizer
checkOrigin={false}
loading="lazy"
heightCalculationMethod="taggedElement"
onLoad={(event) => this.handleFrameLoad(event)}
src="./cellOutputViewer.html"
style={{ height: "1px", width: "1px", minWidth: "100%", border: "none" }}
sandbox="allow-downloads allow-popups allow-forms allow-pointer-lock allow-scripts allow-popups-to-escape-sandbox"
/>
);
}
handleFrameLoad(event: React.SyntheticEvent<HTMLIFrameElement, Event>): void {
this.childWindow = (event.target as HTMLIFrameElement).contentWindow;
this.sendPropsToFrame();
}
sendPropsToFrame(): void {
if (!this.childWindow) {
return;
}
const props: CellOutputViewerProps = {
id: this.props.id,
contentRef: this.props.contentRef,
hidden: this.props.hidden,
expanded: this.props.expanded,
outputs: this.props.outputs.toArray().map((output) => outputToJS(output)),
onMetadataChange: this.props.onMetadataChange,
};
postRobot.send(this.childWindow, "props", props);
}
componentDidMount(): void {
this.sendPropsToFrame();
}
componentDidUpdate(): void {
this.sendPropsToFrame();
}
}
export const makeMapStateToProps = (
initialState: AppState,
ownProps: ComponentProps
): ((state: AppState) => StateProps) => {
const mapStateToProps = (state: AppState): StateProps => {
let outputs = Immutable.List();
let hidden = false;
let expanded = false;
const { contentRef, id } = ownProps;
const model = selectors.model(state, { contentRef });
if (model && model.type === "notebook") {
const cell = selectors.notebook.cellById(model, { id });
if (cell) {
outputs = cell.get("outputs", Immutable.List());
hidden = cell.cell_type === "code" && cell.getIn(["metadata", "jupyter", "outputs_hidden"]);
expanded = cell.cell_type === "code" && cell.getIn(["metadata", "collapsed"]) === false;
}
}
return { outputs, hidden, expanded };
};
return mapStateToProps;
};
export const makeMapDispatchToProps = (
initialDispath: Dispatch,
ownProps: ComponentProps
): ((dispatch: Dispatch) => DispatchProps) => {
const { id, contentRef } = ownProps;
const mapDispatchToProps = (dispatch: Dispatch) => {
return {
onMetadataChange: (metadata: JSONObject, mediaType: string, index?: number) => {
dispatch(
actions.updateOutputMetadata({
id,
contentRef,
metadata,
index: index || 0,
mediaType,
})
);
},
};
};
return mapDispatchToProps;
};
export default connect<StateProps, DispatchProps, ComponentProps, AppState>(
makeMapStateToProps,
makeMapDispatchToProps
)(SandboxOutputs);

View File

@@ -1,38 +0,0 @@
import { Media } from "@nteract/outputs";
import React from "react";
import sanitizeHtml from "sanitize-html";
interface Props {
/**
* The HTML string that will be rendered.
*/
data: string;
/**
* The media type associated with the HTML
* string. This defaults to text/html.
*/
mediaType: "text/html";
}
export class SanitizedHTML extends React.PureComponent<Props> {
static defaultProps = {
data: "",
mediaType: "text/html",
};
render(): JSX.Element {
return <Media.HTML data={sanitize(this.props.data)} />;
}
}
function sanitize(html: string): string {
return sanitizeHtml(html, {
allowedTags: false, // allow all tags
allowedAttributes: false, // allow all attrs
transformTags: {
iframe: "iframe-disabled", // disable iframes
},
});
}
export default SanitizedHTML;