mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 17:31:43 +00:00
Allow embedding a subset of the Sourcegraph web app into iframes (#30669)
This commit is contained in:
parent
fff65c394c
commit
484179dc0a
80
client/web/src/enterprise/embed/EmbeddedWebApp.tsx
Normal file
80
client/web/src/enterprise/embed/EmbeddedWebApp.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import classNames from 'classnames'
|
||||
import React, { Suspense } from 'react'
|
||||
import { BrowserRouter, Route, RouteComponentProps, Switch } from 'react-router-dom'
|
||||
|
||||
import { lazyComponent } from '@sourcegraph/shared/src/util/lazyComponent'
|
||||
import {
|
||||
Alert,
|
||||
AnchorLink,
|
||||
LoadingSpinner,
|
||||
setLinkComponent,
|
||||
WildcardTheme,
|
||||
WildcardThemeContext,
|
||||
} from '@sourcegraph/wildcard'
|
||||
|
||||
import '../../SourcegraphWebApp.scss'
|
||||
|
||||
setLinkComponent(AnchorLink)
|
||||
|
||||
const WILDCARD_THEME: WildcardTheme = {
|
||||
isBranded: true,
|
||||
}
|
||||
|
||||
const EmbeddedNotebookPage = lazyComponent(
|
||||
() => import('../../search/notebook/EmbeddedNotebookPage'),
|
||||
'EmbeddedNotebookPage'
|
||||
)
|
||||
|
||||
const EMPTY_SETTINGS_CASCADE = { final: {}, subjects: [] }
|
||||
|
||||
export const EmbeddedWebApp: React.FunctionComponent = () => {
|
||||
// We only support light theme for now, but this can be made dynamic through a URL param in the embedding link.
|
||||
const isLightTheme = true
|
||||
|
||||
// 🚨 SECURITY: The `EmbeddedWebApp` is intended to be embedded into 3rd party sites where we do not have total control.
|
||||
// That is why it is essential to be mindful when adding new routes that may be vulnerable to clickjacking or similar exploits.
|
||||
// It is crucial not to embed any components that an attacker could hijack and use to leak personal information (e.g., the sign-in page).
|
||||
// The embedded components should be limited to displaying read-only, publicly accessible content.
|
||||
//
|
||||
// IMPORTANT: Please consult with the security team if you are unsure whether your changes could introduce security exploits.
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<WildcardThemeContext.Provider value={WILDCARD_THEME}>
|
||||
<div className={classNames(isLightTheme ? 'theme-light' : 'theme-dark', 'p-3')}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="d-flex justify-content-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
<Route
|
||||
path="/embed/notebooks/:notebookId"
|
||||
render={(props: RouteComponentProps<{ notebookId: string }>) => (
|
||||
<EmbeddedNotebookPage
|
||||
notebookId={props.match.params.notebookId}
|
||||
searchContextsEnabled={true}
|
||||
showSearchContext={true}
|
||||
isSourcegraphDotCom={window.context.sourcegraphDotComMode}
|
||||
authenticatedUser={null}
|
||||
isLightTheme={isLightTheme}
|
||||
settingsCascade={EMPTY_SETTINGS_CASCADE}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
render={() => (
|
||||
<Alert variant="danger">
|
||||
Invalid embedding route, please check the embedding URL.
|
||||
</Alert>
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</div>
|
||||
</WildcardThemeContext.Provider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
9
client/web/src/enterprise/embed/README.md
Normal file
9
client/web/src/enterprise/embed/README.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Developing the EmbeddedWebApp
|
||||
|
||||
- To enable the development of the `embed` bundle, you'll have to set the `EMBED_DEVELOPMENT=true` env variable
|
||||
- To test your local changes on 3rd party sites, you will have to proxy your dev environment using [ngrok](https://ngrok.com/)
|
||||
- Run ngrok using this command: `./ngrok http 3080 --host-header=rewrite`
|
||||
- Copy the Forwarding http:// address and use it to replace the `externalURL` property in your `site-config.json`
|
||||
- Start (or restart) your local dev environment
|
||||
- Navigate to a 3rd party site (e.g., codepen.io), create a new page, and embed an iframe using the ngrok URL
|
||||
- Example iframe tag: `<iframe src="http://your-dev-env.ngrok.io/embed/notebooks/123" frameborder="0" sandbox="allow-scripts allow-same-origin"></iframe>`
|
||||
12
client/web/src/enterprise/embed/main.tsx
Normal file
12
client/web/src/enterprise/embed/main.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import '@sourcegraph/shared/src/polyfills'
|
||||
|
||||
import React from 'react'
|
||||
import { render } from 'react-dom'
|
||||
|
||||
import { EmbeddedWebApp } from './EmbeddedWebApp'
|
||||
|
||||
// It's important to have a root component in a separate file to create a react-refresh boundary and avoid page reload.
|
||||
// https://github.com/pmmmwh/react-refresh-webpack-plugin/blob/main/docs/TROUBLESHOOTING.md#edits-always-lead-to-full-reload
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
render(<EmbeddedWebApp />, document.querySelector('#root'))
|
||||
})
|
||||
84
client/web/src/search/notebook/EmbeddedNotebookPage.tsx
Normal file
84
client/web/src/search/notebook/EmbeddedNotebookPage.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { noop } from 'lodash'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { catchError, startWith } from 'rxjs/operators'
|
||||
|
||||
import { asError, isErrorLike, isMacPlatform } from '@sourcegraph/common'
|
||||
import { createController as createExtensionsController } from '@sourcegraph/shared/src/extensions/controller'
|
||||
import { aggregateStreamingSearch } from '@sourcegraph/shared/src/search/stream'
|
||||
import { Alert, LoadingSpinner, useObservable } from '@sourcegraph/wildcard'
|
||||
|
||||
import { createPlatformContext } from '../../platform/context'
|
||||
import { fetchHighlightedFileLineRanges, fetchRepository, resolveRevision } from '../../repo/backend'
|
||||
import { eventLogger } from '../../tracking/eventLogger'
|
||||
|
||||
import { fetchNotebook } from './backend'
|
||||
import { NotebookContent, NotebookContentProps } from './NotebookContent'
|
||||
|
||||
interface EmbeddedNotebookPageProps
|
||||
extends Pick<
|
||||
NotebookContentProps,
|
||||
| 'isLightTheme'
|
||||
| 'searchContextsEnabled'
|
||||
| 'showSearchContext'
|
||||
| 'isSourcegraphDotCom'
|
||||
| 'authenticatedUser'
|
||||
| 'settingsCascade'
|
||||
> {
|
||||
notebookId: string
|
||||
}
|
||||
|
||||
const LOADING = 'loading' as const
|
||||
|
||||
export const EmbeddedNotebookPage: React.FunctionComponent<EmbeddedNotebookPageProps> = ({ notebookId, ...props }) => {
|
||||
useEffect(() => eventLogger.logViewEvent('EmbeddedNotebookPage'), [])
|
||||
|
||||
const platformContext = useMemo(() => createPlatformContext(), [])
|
||||
const extensionsController = useMemo(() => createExtensionsController(platformContext), [platformContext])
|
||||
const isMacPlatformMemoized = useMemo(() => isMacPlatform(), [])
|
||||
|
||||
const notebookOrError = useObservable(
|
||||
useMemo(
|
||||
() =>
|
||||
fetchNotebook(notebookId).pipe(
|
||||
startWith(LOADING),
|
||||
catchError(error => [asError(error)])
|
||||
),
|
||||
[notebookId]
|
||||
)
|
||||
)
|
||||
|
||||
const location = useLocation()
|
||||
return (
|
||||
<div className="p-3">
|
||||
{notebookOrError === LOADING && (
|
||||
<div className="d-flex justify-content-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
{isErrorLike(notebookOrError) && (
|
||||
<Alert variant="danger">
|
||||
Error while loading the notebook: <strong>{notebookOrError.message}</strong>
|
||||
</Alert>
|
||||
)}
|
||||
{notebookOrError && notebookOrError !== LOADING && !isErrorLike(notebookOrError) && (
|
||||
<NotebookContent
|
||||
{...props}
|
||||
location={location}
|
||||
blocks={notebookOrError.blocks}
|
||||
onUpdateBlocks={noop}
|
||||
viewerCanManage={false}
|
||||
globbing={true}
|
||||
isMacPlatform={isMacPlatformMemoized}
|
||||
fetchRepository={fetchRepository}
|
||||
fetchHighlightedFileLineRanges={fetchHighlightedFileLineRanges}
|
||||
resolveRevision={resolveRevision}
|
||||
streamSearch={aggregateStreamingSearch}
|
||||
telemetryService={eventLogger}
|
||||
platformContext={platformContext}
|
||||
extensionsController={extensionsController}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -14,7 +14,7 @@ import { SearchNotebook } from './SearchNotebook'
|
||||
|
||||
import { Block, BlockInit } from '.'
|
||||
|
||||
interface NotebookContentProps
|
||||
export interface NotebookContentProps
|
||||
extends SearchStreamingProps,
|
||||
ThemeProps,
|
||||
TelemetryProps,
|
||||
|
||||
@ -29,4 +29,12 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.monaco-mouse-cursor-text) {
|
||||
/* Hack: remove focus ring around Monaco editor cursor */
|
||||
&:focus-within,
|
||||
&:focus-visible {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ export type BlockMenuActionComponentProps = {
|
||||
Pick<ButtonProps, 'variant'>
|
||||
|
||||
const BlockMenuActionComponent: React.FunctionComponent<BlockMenuActionComponentProps> = props => {
|
||||
const { className, label, type, id, isDisabled, icon, iconClassName } = props
|
||||
const { className, label, type, id, isDisabled, icon, iconClassName, variant } = props
|
||||
|
||||
const element = type === 'button' ? 'button' : 'a'
|
||||
const elementSpecificProps =
|
||||
@ -51,6 +51,7 @@ const BlockMenuActionComponent: React.FunctionComponent<BlockMenuActionComponent
|
||||
role="menuitem"
|
||||
data-testid={label}
|
||||
size="sm"
|
||||
variant={variant}
|
||||
{...elementSpecificProps}
|
||||
>
|
||||
<div className={iconClassName}>{icon}</div>
|
||||
|
||||
@ -35,6 +35,7 @@ const isDevelopment = mode === 'development'
|
||||
const isProduction = mode === 'production'
|
||||
const isCI = process.env.CI === 'true'
|
||||
const isCacheEnabled = isDevelopment && !isCI
|
||||
const isEmbedDevelopment = isDevelopment && process.env.EMBED_DEVELOPMENT === 'true'
|
||||
|
||||
/** Allow overriding default Webpack naming behavior for debugging */
|
||||
const useNamedChunks = process.env.WEBPACK_USE_NAMED_CHUNKS === 'true'
|
||||
@ -60,6 +61,7 @@ const hotLoadablePaths = ['branded', 'shared', 'web', 'wildcard'].map(workspace
|
||||
)
|
||||
|
||||
const isEnterpriseBuild = process.env.ENTERPRISE && Boolean(JSON.parse(process.env.ENTERPRISE))
|
||||
const isEmbedEntrypointEnabled = isEnterpriseBuild && (isProduction || isEmbedDevelopment)
|
||||
const enterpriseDirectory = path.resolve(__dirname, 'src', 'enterprise')
|
||||
|
||||
const styleLoader = isDevelopment ? 'style-loader' : MiniCssExtractPlugin.loader
|
||||
@ -109,6 +111,9 @@ const config = {
|
||||
// Enterprise vs. OSS builds use different entrypoints. The enterprise entrypoint imports a
|
||||
// strict superset of the OSS entrypoint.
|
||||
app: isEnterpriseBuild ? path.join(enterpriseDirectory, 'main.tsx') : path.join(__dirname, 'src', 'main.tsx'),
|
||||
// Embedding entrypoint. It uses a small subset of the main webapp intended to be embedded into
|
||||
// iframes on 3rd party sites. Added only in production enterprise builds or if embed development is enabled.
|
||||
...(isEmbedEntrypointEnabled && { embed: path.join(enterpriseDirectory, 'embed', 'main.tsx') }),
|
||||
},
|
||||
output: {
|
||||
path: path.join(ROOT_PATH, 'ui', 'assets'),
|
||||
|
||||
47
cmd/frontend/internal/app/ui/embed.html
Normal file
47
cmd/frontend/internal/app/ui/embed.html
Normal file
@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="base">
|
||||
<head>
|
||||
{{.Injected.HeadTop}}
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<meta name="google" content="notranslate" />
|
||||
<meta http-equiv="Content-Language" content="en" />
|
||||
<meta name="viewport" content="width=device-width, viewport-fit=cover" />
|
||||
<meta name="referrer" content="origin-when-cross-origin" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
|
||||
<title>{{.Title}}</title>
|
||||
{{if .Manifest.EmbedCSSBundlePath}}
|
||||
<link rel="stylesheet" href="{{.Manifest.EmbedCSSBundlePath}}" />
|
||||
{{end}}
|
||||
|
||||
<script ignore-csp>
|
||||
window.context = {{.Context}}
|
||||
window.pageError = {{.Error}}
|
||||
</script>
|
||||
|
||||
{{.Injected.HeadBottom}}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{{.Injected.BodyTop}}
|
||||
<div id="root"></div>
|
||||
<noscript>
|
||||
<p>
|
||||
Sourcegraph is a web-based code search and navigation tool for dev teams. Search, navigate, and review
|
||||
code. Find answers.
|
||||
</p>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
{{if .Manifest.AppJSRuntimeBundlePath}}
|
||||
<script src="{{.Manifest.AppJSRuntimeBundlePath}}"></script>
|
||||
{{end}}
|
||||
<script src="{{.Manifest.ReactJSBundlePath}}" {{if .Manifest.IsModule}}type="module" {{end}}></script>
|
||||
<script src="{{.Manifest.EmbedJSBundlePath}}" {{if .Manifest.IsModule}}type="module" {{end}}></script>
|
||||
{{.Injected.BodyBottom}}
|
||||
</body>
|
||||
</html>
|
||||
@ -33,6 +33,7 @@ import (
|
||||
"github.com/sourcegraph/sourcegraph/internal/database"
|
||||
"github.com/sourcegraph/sourcegraph/internal/env"
|
||||
"github.com/sourcegraph/sourcegraph/internal/errcode"
|
||||
"github.com/sourcegraph/sourcegraph/internal/featureflag"
|
||||
"github.com/sourcegraph/sourcegraph/internal/gitserver"
|
||||
"github.com/sourcegraph/sourcegraph/internal/gitserver/gitdomain"
|
||||
"github.com/sourcegraph/sourcegraph/internal/repoupdater"
|
||||
@ -329,6 +330,35 @@ func serveSignIn(db database.DB) handlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func serveEmbed(db database.DB) handlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
flagSet := featureflag.FromContext(r.Context())
|
||||
if enabled := flagSet["enable-embed-route"]; !enabled {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 🚨 SECURITY: Removing the `X-Frame-Options` header allows embedding the `/embed` route in an iframe.
|
||||
// The embedding is safe because the `/embed` route serves the `embed` JS bundle instead of the
|
||||
// regular Sourcegraph (web) app bundle (see `client/web/webpack.config.js` for the entrypoint definitions).
|
||||
// It contains only the components needed to render the embedded content, and it should not include sensitive pages, like the sign-in page.
|
||||
// The embed bundle also has its own React router that only recognizes specific routes (e.g., for embedding a notebook).
|
||||
//
|
||||
// Any changes to this function could have security implications. Please consult the security team before making changes.
|
||||
w.Header().Del("X-Frame-Options")
|
||||
|
||||
common, err := newCommon(w, r, db, "", index, serveError)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if common == nil {
|
||||
return nil // request was handled
|
||||
}
|
||||
|
||||
return renderTemplate(w, "embed.html", common)
|
||||
}
|
||||
}
|
||||
|
||||
// redirectTreeOrBlob redirects a blob page to a tree page if the file is actually a directory,
|
||||
// or a tree page to a blob page if the directory is actually a file.
|
||||
func redirectTreeOrBlob(routeName, path string, common *Common, w http.ResponseWriter, r *http.Request, db database.DB) (requestHandled bool, err error) {
|
||||
|
||||
@ -77,6 +77,7 @@ const (
|
||||
routeStats = "stats"
|
||||
routeViews = "views"
|
||||
routeDevToolTime = "devtooltime"
|
||||
routeEmbed = "embed"
|
||||
|
||||
routeSearchStream = "search.stream"
|
||||
routeSearchConsole = "search.console"
|
||||
@ -165,6 +166,13 @@ func newRouter() *mux.Router {
|
||||
r.PathPrefix("/devtooltime").Methods("GET").Name(routeDevToolTime)
|
||||
r.Path("/ping-from-self-hosted").Methods("GET", "OPTIONS").Name(uirouter.RoutePingFromSelfHosted)
|
||||
|
||||
if envvar.SourcegraphDotComMode() {
|
||||
// 🚨 SECURITY: The embed route is used to serve embeddable content (via an iframe) to 3rd party sites.
|
||||
// Any changes to the embedding route could have security implications. Please consult the security team
|
||||
// before making changes. See the `serveEmbed` function for further details.
|
||||
r.PathPrefix("/embed").Methods("GET").Name(routeEmbed)
|
||||
}
|
||||
|
||||
// Community search contexts pages. Must mirror client/web/src/communitySearchContexts/routes.tsx
|
||||
if envvar.SourcegraphDotComMode() {
|
||||
communitySearchContexts := []string{"kubernetes", "stanford", "stackstorm", "temporal", "o3de", "chakraui"}
|
||||
@ -265,6 +273,13 @@ func initRouter(db database.DB, router *mux.Router, codeIntelResolver graphqlbac
|
||||
router.Get(routeViews).Handler(brandedNoIndex("View"))
|
||||
router.Get(uirouter.RoutePingFromSelfHosted).Handler(handler(db, servePingFromSelfHosted))
|
||||
|
||||
if envvar.SourcegraphDotComMode() {
|
||||
// 🚨 SECURITY: The embed route is used to serve embeddable content (via an iframe) to 3rd party sites.
|
||||
// Any changes to the embedding route could have security implications. Please consult the security team
|
||||
// before making changes. See the `serveEmbed` function for further details.
|
||||
router.Get(routeEmbed).Handler(handler(db, serveEmbed(db)))
|
||||
}
|
||||
|
||||
router.Get(routeUserSettings).Handler(brandedNoIndex("User settings"))
|
||||
router.Get(routeUserRedirect).Handler(brandedNoIndex("User"))
|
||||
router.Get(routeUser).Handler(handler(db, serveBasicPage(db, func(c *Common, r *http.Request) string {
|
||||
|
||||
@ -19,6 +19,9 @@ import (
|
||||
//go:embed app.html
|
||||
var appHTML string
|
||||
|
||||
//go:embed embed.html
|
||||
var embedHTML string
|
||||
|
||||
//go:embed error.html
|
||||
var errorHTML string
|
||||
|
||||
@ -101,6 +104,8 @@ func doLoadTemplate(path string) (*template.Template, error) {
|
||||
switch path {
|
||||
case "app.html":
|
||||
data = appHTML
|
||||
case "embed.html":
|
||||
data = embedHTML
|
||||
case "error.html":
|
||||
data = errorHTML
|
||||
default:
|
||||
|
||||
@ -8,6 +8,10 @@ type WebpackManifest struct {
|
||||
// Webpack bundle that serves as the entrypoint
|
||||
// for the webapp code.
|
||||
AppJSBundlePath string `json:"app.js"`
|
||||
// EmbedJSBundlePath contains the file name of the
|
||||
// Webpack bundle that serves as the entrypoint
|
||||
// for the embeddable webapp code.
|
||||
EmbedJSBundlePath string `json:"embed.js"`
|
||||
// AppJSRuntimeBundlePath contains the file name of the
|
||||
// JS runtime bundle which is served only in development environment
|
||||
// to kick off the main application code.
|
||||
@ -16,4 +20,6 @@ type WebpackManifest struct {
|
||||
IsModule bool `json:"isModule"`
|
||||
// Main CSS bundle, only present in production.
|
||||
AppCSSBundlePath *string `json:"app.css"`
|
||||
// Embed CSS bundle, only present in production.
|
||||
EmbedCSSBundlePath *string `json:"embed.css"`
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user