From fe634358c78047a1fada4527c73d483204dc3ca5 Mon Sep 17 00:00:00 2001 From: Lily Kuang Date: Fri, 14 Jan 2022 09:30:28 -0800 Subject: [PATCH] feat: entry for embedded dashboard (#17529) * create entry for embedded dashboard in webpack * add cookies * lint * token message handshake * guestTokenHeaderName * use setupClient instead of calling configure * rename the webpack chunk * simplified handshake * embedded entrypoint: render a proper app * make the embedded page accept anonymous connections * format * lint * fix test # Conflicts: # superset-frontend/src/embedded/index.tsx # superset/views/core.py * lint * Update superset-frontend/src/embedded/index.tsx Co-authored-by: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com> * comment out origins checks * move embedded for core to dashboard * pylint * isort Co-authored-by: David Aaron Suddjian Co-authored-by: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com> --- .../src/connection/SupersetClient.ts | 2 +- .../src/connection/SupersetClientClass.ts | 11 ++ .../superset-ui-core/src/connection/types.ts | 3 +- .../src/utils/featureFlags.ts | 1 + superset-frontend/src/embedded/index.tsx | 117 ++++++++++++++++++ superset-frontend/src/preamble.ts | 4 +- superset-frontend/src/setup/setupClient.ts | 13 +- superset-frontend/src/views/App.tsx | 46 ++----- .../src/views/RootContextProviders.tsx | 55 ++++++++ superset-frontend/webpack.config.js | 1 + superset/templates/superset/spa.html | 2 +- superset/views/dashboard/views.py | 44 ++++++- tests/integration_tests/security_tests.py | 1 + 13 files changed, 252 insertions(+), 48 deletions(-) create mode 100644 superset-frontend/src/embedded/index.tsx create mode 100644 superset-frontend/src/views/RootContextProviders.tsx diff --git a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClient.ts b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClient.ts index b5820255a33b8..0f4c123ca974a 100644 --- a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClient.ts +++ b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClient.ts @@ -20,6 +20,7 @@ import SupersetClientClass from './SupersetClientClass'; import { SupersetClientInterface } from './types'; +// this is local to this file, don't expose it let singletonClient: SupersetClientClass | undefined; function getInstance(): SupersetClientClass { @@ -39,7 +40,6 @@ const SupersetClient: SupersetClientInterface = { reset: () => { singletonClient = undefined; }, - getInstance, delete: request => getInstance().delete(request), get: request => getInstance().get(request), init: force => getInstance().init(force), diff --git a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts index ef52134f31376..39d5022be8a0b 100644 --- a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts +++ b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts @@ -40,6 +40,10 @@ export default class SupersetClientClass { csrfPromise?: CsrfPromise; + guestToken?: string; + + guestTokenHeaderName: string; + fetchRetryOptions?: FetchRetryOptions; baseUrl: string; @@ -64,6 +68,8 @@ export default class SupersetClientClass { timeout, credentials = undefined, csrfToken = undefined, + guestToken = undefined, + guestTokenHeaderName = 'X-GuestToken', }: ClientConfig = {}) { const url = new URL( host || protocol @@ -81,6 +87,8 @@ export default class SupersetClientClass { this.timeout = timeout; this.credentials = credentials; this.csrfToken = csrfToken; + this.guestToken = guestToken; + this.guestTokenHeaderName = guestTokenHeaderName; this.fetchRetryOptions = { ...DEFAULT_FETCH_RETRY_OPTIONS, ...fetchRetryOptions, @@ -89,6 +97,9 @@ export default class SupersetClientClass { this.headers = { ...this.headers, 'X-CSRFToken': this.csrfToken }; this.csrfPromise = Promise.resolve(this.csrfToken); } + if (guestToken) { + this.headers[guestTokenHeaderName] = guestToken; + } } async init(force = false): CsrfPromise { diff --git a/superset-frontend/packages/superset-ui-core/src/connection/types.ts b/superset-frontend/packages/superset-ui-core/src/connection/types.ts index 3f02f1c61d0c2..b8df5a95be136 100644 --- a/superset-frontend/packages/superset-ui-core/src/connection/types.ts +++ b/superset-frontend/packages/superset-ui-core/src/connection/types.ts @@ -130,6 +130,8 @@ export interface ClientConfig { protocol?: Protocol; credentials?: Credentials; csrfToken?: CsrfToken; + guestToken?: string; + guestTokenHeaderName?: string; fetchRetryOptions?: FetchRetryOptions; headers?: Headers; mode?: Mode; @@ -149,7 +151,6 @@ export interface SupersetClientInterface | 'reAuthenticate' > { configure: (config?: ClientConfig) => SupersetClientClass; - getInstance: (maybeClient?: SupersetClientClass) => SupersetClientClass; reset: () => void; } diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index a09f9b4f9f1e1..56135a7c3df30 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -40,6 +40,7 @@ export enum FeatureFlag { DASHBOARD_CROSS_FILTERS = 'DASHBOARD_CROSS_FILTERS', DASHBOARD_NATIVE_FILTERS_SET = 'DASHBOARD_NATIVE_FILTERS_SET', DASHBOARD_FILTERS_EXPERIMENTAL = 'DASHBOARD_FILTERS_EXPERIMENTAL', + EMBEDDED_SUPERSET = 'EMBEDDED_SUPERSET', ENABLE_FILTER_BOX_MIGRATION = 'ENABLE_FILTER_BOX_MIGRATION', VERSIONED_EXPORT = 'VERSIONED_EXPORT', GLOBAL_ASYNC_QUERIES = 'GLOBAL_ASYNC_QUERIES', diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx new file mode 100644 index 0000000000000..146f4ee83e728 --- /dev/null +++ b/superset-frontend/src/embedded/index.tsx @@ -0,0 +1,117 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { lazy, Suspense } from 'react'; +import ReactDOM from 'react-dom'; +import { BrowserRouter as Router, Route } from 'react-router-dom'; +import { bootstrapData } from 'src/preamble'; +import setupClient from 'src/setup/setupClient'; +import { RootContextProviders } from 'src/views/RootContextProviders'; +import ErrorBoundary from 'src/components/ErrorBoundary'; +import Loading from 'src/components/Loading'; + +const LazyDashboardPage = lazy( + () => + import( + /* webpackChunkName: "DashboardPage" */ 'src/dashboard/containers/DashboardPage' + ), +); + +const EmbeddedApp = () => ( + + + }> + + + + + + + + +); + +const appMountPoint = document.getElementById('app')!; + +const MESSAGE_TYPE = '__embedded_comms__'; + +if (!window.parent) { + appMountPoint.innerHTML = + 'This page is intended to be embedded in an iframe, but no window.parent was found.'; +} + +// if the page is embedded in an origin that hasn't +// been authorized by the curator, we forbid access entirely. +// todo: check the referrer on the route serving this page instead +// const ALLOW_ORIGINS = ['http://127.0.0.1:9001', 'http://localhost:9001']; +// const parentOrigin = new URL(document.referrer).origin; +// if (!ALLOW_ORIGINS.includes(parentOrigin)) { +// throw new Error( +// `[superset] iframe parent ${parentOrigin} is not in the list of allowed origins`, +// ); +// } + +async function start(guestToken: string) { + // the preamble configures a client, but we need to configure a new one + // now that we have the guest token + setupClient({ + guestToken, + guestTokenHeaderName: bootstrapData.config?.GUEST_TOKEN_HEADER_NAME, + }); + ReactDOM.render(, appMountPoint); +} + +function validateMessageEvent(event: MessageEvent) { + if ( + event.data?.type === 'webpackClose' || + event.data?.source === '@devtools-page' + ) { + // sometimes devtools use the messaging api and we want to ignore those + throw new Error("Sir, this is a Wendy's"); + } + + // if (!ALLOW_ORIGINS.includes(event.origin)) { + // throw new Error('Message origin is not in the allowed list'); + // } + + if (typeof event.data !== 'object' || event.data.type !== MESSAGE_TYPE) { + throw new Error(`Message type does not match type used for embedded comms`); + } +} + +window.addEventListener('message', function (event) { + try { + validateMessageEvent(event); + } catch (err) { + console.info('[superset] ignoring message', err, event); + return; + } + + console.info('[superset] received message', event); + const hostAppPort = event.ports?.[0]; + if (hostAppPort) { + hostAppPort.onmessage = function receiveMessage(event) { + console.info('[superset] received message event', event.data); + if (event.data.guestToken) { + start(event.data.guestToken); + } + }; + } +}); + +console.info('[superset] embed page is ready to receive messages'); diff --git a/superset-frontend/src/preamble.ts b/superset-frontend/src/preamble.ts index da2fac3f75fec..5380fe269861d 100644 --- a/superset-frontend/src/preamble.ts +++ b/superset-frontend/src/preamble.ts @@ -30,11 +30,11 @@ if (process.env.WEBPACK_MODE === 'development') { setHotLoaderConfig({ logLevel: 'debug', trackTailUpdates: false }); } -let bootstrapData: any; +// eslint-disable-next-line import/no-mutable-exports +export let bootstrapData: any; // Configure translation if (typeof window !== 'undefined') { const root = document.getElementById('app'); - bootstrapData = root ? JSON.parse(root.getAttribute('data-bootstrap') || '{}') : {}; diff --git a/superset-frontend/src/setup/setupClient.ts b/superset-frontend/src/setup/setupClient.ts index 11f3f6a1a2864..8802ae47227d1 100644 --- a/superset-frontend/src/setup/setupClient.ts +++ b/superset-frontend/src/setup/setupClient.ts @@ -16,22 +16,29 @@ * specific language governing permissions and limitations * under the License. */ -import { SupersetClient, logging } from '@superset-ui/core'; +import { SupersetClient, logging, ClientConfig } from '@superset-ui/core'; import parseCookie from 'src/utils/parseCookie'; -export default function setupClient() { +function getDefaultConfiguration(): ClientConfig { const csrfNode = document.querySelector('#csrf_token'); const csrfToken = csrfNode?.value; // when using flask-jwt-extended csrf is set in cookies const cookieCSRFToken = parseCookie().csrf_access_token || ''; - SupersetClient.configure({ + return { protocol: ['http:', 'https:'].includes(window?.location?.protocol) ? (window?.location?.protocol as 'http:' | 'https:') : undefined, host: (window.location && window.location.host) || '', csrfToken: csrfToken || cookieCSRFToken, + }; +} + +export default function setupClient(customConfig: Partial = {}) { + SupersetClient.configure({ + ...getDefaultConfiguration(), + ...customConfig, }) .init() .catch(error => { diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index 2d751cb2b24a9..4e193bbf776aa 100644 --- a/superset-frontend/src/views/App.tsx +++ b/superset-frontend/src/views/App.tsx @@ -18,42 +18,31 @@ */ import React, { Suspense, useEffect } from 'react'; import { hot } from 'react-hot-loader/root'; -import { Provider as ReduxProvider } from 'react-redux'; import { BrowserRouter as Router, Switch, Route, useLocation, } from 'react-router-dom'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; -import { QueryParamProvider } from 'use-query-params'; import { initFeatureFlags } from 'src/featureFlags'; -import { ThemeProvider } from '@superset-ui/core'; -import { DynamicPluginProvider } from 'src/components/DynamicPlugins'; -import { EmbeddedUiConfigProvider } from 'src/components/UiConfigContext'; import ErrorBoundary from 'src/components/ErrorBoundary'; import Loading from 'src/components/Loading'; import Menu from 'src/views/components/Menu'; -import FlashProvider from 'src/components/FlashProvider'; -import { theme } from 'src/preamble'; +import { bootstrapData } from 'src/preamble'; import ToastContainer from 'src/components/MessageToasts/ToastContainer'; import setupApp from 'src/setup/setupApp'; import { routes, isFrontendRoute } from 'src/views/routes'; import { Logger } from 'src/logger/LogUtils'; -import { store } from './store'; +import { RootContextProviders } from './RootContextProviders'; setupApp(); -const container = document.getElementById('app'); -const bootstrap = JSON.parse(container?.getAttribute('data-bootstrap') ?? '{}'); -const user = { ...bootstrap.user }; -const menu = { ...bootstrap.common.menu_data }; -const common = { ...bootstrap.common }; +const user = { ...bootstrapData.user }; +const menu = { ...bootstrapData.common.menu_data }; let lastLocationPathname: string; -initFeatureFlags(bootstrap.common.feature_flags); +initFeatureFlags(bootstrapData.common.feature_flags); -const RootContextProviders: React.FC = ({ children }) => { +const LocationPathnameLogger = () => { const location = useLocation(); useEffect(() => { // reset performance logger timer start point to avoid soft navigation @@ -63,31 +52,12 @@ const RootContextProviders: React.FC = ({ children }) => { } lastLocationPathname = location.pathname; }, [location.pathname]); - - return ( - - - - - - - - {children} - - - - - - - - ); + return <>; }; const App = () => ( + diff --git a/superset-frontend/src/views/RootContextProviders.tsx b/superset-frontend/src/views/RootContextProviders.tsx new file mode 100644 index 0000000000000..f40f228bb8c13 --- /dev/null +++ b/superset-frontend/src/views/RootContextProviders.tsx @@ -0,0 +1,55 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { Route } from 'react-router-dom'; +import { ThemeProvider } from '@superset-ui/core'; +import { Provider as ReduxProvider } from 'react-redux'; +import { QueryParamProvider } from 'use-query-params'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; + +import { store } from './store'; +import FlashProvider from '../components/FlashProvider'; +import { bootstrapData, theme } from '../preamble'; +import { EmbeddedUiConfigProvider } from '../components/UiConfigContext'; +import { DynamicPluginProvider } from '../components/DynamicPlugins'; + +const common = { ...bootstrapData.common }; + +export const RootContextProviders: React.FC = ({ children }) => ( + + + + + + + + {children} + + + + + + + +); diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index c482706cd6c2f..16c0bccdf63d5 100644 --- a/superset-frontend/webpack.config.js +++ b/superset-frontend/webpack.config.js @@ -208,6 +208,7 @@ const config = { theme: path.join(APP_DIR, '/src/theme.ts'), menu: addPreamble('src/views/menu.tsx'), spa: addPreamble('/src/views/index.tsx'), + embedded: addPreamble('/src/embedded/index.tsx'), addSlice: addPreamble('/src/addSlice/index.tsx'), explore: addPreamble('/src/explore/index.jsx'), sqllab: addPreamble('/src/SqlLab/index.tsx'), diff --git a/superset/templates/superset/spa.html b/superset/templates/superset/spa.html index 1e38cb2e75be7..6a0312f4f0955 100644 --- a/superset/templates/superset/spa.html +++ b/superset/templates/superset/spa.html @@ -22,6 +22,6 @@ {% endblock %} {% block tail_js %} - {{ js_bundle("spa") }} + {{ js_bundle(entry) }} {% include "tail_js_custom_extra.html" %} {% endblock %} diff --git a/superset/views/dashboard/views.py b/superset/views/dashboard/views.py index 5e03d24d13fd4..99782def38a68 100644 --- a/superset/views/dashboard/views.py +++ b/superset/views/dashboard/views.py @@ -16,7 +16,7 @@ # under the License. import json import re -from typing import List, Union +from typing import Callable, List, Union from flask import g, redirect, request, Response from flask_appbuilder import expose @@ -24,8 +24,9 @@ from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_appbuilder.security.decorators import has_access from flask_babel import gettext as __, lazy_gettext as _ +from flask_login import AnonymousUserMixin, LoginManager -from superset import db, event_logger, is_feature_enabled +from superset import db, event_logger, is_feature_enabled, security_manager from superset.constants import MODEL_VIEW_RW_METHOD_PERMISSION_MAP, RouteMethod from superset.models.dashboard import Dashboard as DashboardModel from superset.typing import FlaskResponse @@ -33,6 +34,7 @@ from superset.views.base import ( BaseSupersetView, check_ownership, + common_bootstrap_payload, DeleteMixin, generate_download_headers, SupersetModelView, @@ -133,6 +135,44 @@ def new(self) -> FlaskResponse: # pylint: disable=no-self-use db.session.commit() return redirect(f"/superset/dashboard/{new_dashboard.id}/?edit=true") + @expose("//embedded") + @event_logger.log_this_with_extra_payload + def embedded( + self, + dashboard_id_or_slug: str, + add_extra_log_payload: Callable[..., None] = lambda **kwargs: None, + ) -> FlaskResponse: + """ + Server side rendering for a dashboard + :param dashboard_id_or_slug: identifier for dashboard. used in the decorators + :param add_extra_log_payload: added by `log_this_with_manual_updates`, set a + default value to appease pylint + """ + if not is_feature_enabled("EMBEDDED_SUPERSET"): + return Response(status=404) + + # Log in as an anonymous user, just for this view. + # This view needs to be visible to all users, + # and building the page fails if g.user and/or ctx.user aren't present. + login_manager: LoginManager = security_manager.lm + login_manager.reload_user(AnonymousUserMixin()) + + add_extra_log_payload( + dashboard_id=dashboard_id_or_slug, dashboard_version="v2", + ) + + bootstrap_data = { + "common": common_bootstrap_payload(), + } + + return self.render_template( + "superset/spa.html", + entry="embedded", + bootstrap_data=json.dumps( + bootstrap_data, default=utils.pessimistic_json_iso_dttm_ser + ), + ) + class DashboardModelViewAsync(DashboardModelView): # pylint: disable=too-many-ancestors route_base = "/dashboardasync" diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index 232caea284816..a8af64e8b5bd1 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -920,6 +920,7 @@ def test_views_are_secured(self): ["LocaleView", "index"], ["AuthDBView", "login"], ["AuthDBView", "logout"], + ["Dashboard", "embedded"], ["R", "index"], ["Superset", "log"], ["Superset", "theme"],