From 8727c68047eaf50ea618d1ba83eedb6dc2d267a3 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Sat, 21 Oct 2023 15:40:05 +0200 Subject: [PATCH] [HTTP] Add support for configuring a CDN (part I) (#169408) --- .../__snapshots__/http_config.test.ts.snap | 1 + .../core-http-server-internal/src/cdn.test.ts | 57 +++++++ .../http/core-http-server-internal/src/cdn.ts | 50 +++++++ .../src/csp/config.ts | 15 ++ .../src/csp/csp_config.test.ts | 27 ++++ .../src/csp/csp_config.ts | 6 +- .../src/csp/csp_directives.ts | 7 +- .../src/csp/index.ts | 2 +- .../src/http_config.ts | 10 +- .../src/http_server.ts | 9 ++ .../src/http_service.ts | 1 + .../src/static_assets.test.ts | 31 ++++ .../src/static_assets.ts | 28 ++++ .../core-http-server-internal/src/types.ts | 2 + .../src/http_service.mock.ts | 28 +++- .../rendering_service.test.ts.snap | 139 ++++++++++++++++++ .../src/bootstrap/bootstrap_renderer.test.ts | 12 +- .../src/bootstrap/bootstrap_renderer.ts | 20 +-- .../bootstrap/get_plugin_bundle_paths.test.ts | 4 +- .../src/bootstrap/get_plugin_bundle_paths.ts | 8 +- .../src/render_utils.test.ts | 4 +- .../src/render_utils.ts | 21 +-- .../src/rendering_service.test.ts | 31 +++- .../src/rendering_service.tsx | 10 +- 24 files changed, 467 insertions(+), 56 deletions(-) create mode 100644 packages/core/http/core-http-server-internal/src/cdn.test.ts create mode 100644 packages/core/http/core-http-server-internal/src/cdn.ts create mode 100644 packages/core/http/core-http-server-internal/src/static_assets.test.ts create mode 100644 packages/core/http/core-http-server-internal/src/static_assets.ts diff --git a/packages/core/http/core-http-server-internal/src/__snapshots__/http_config.test.ts.snap b/packages/core/http/core-http-server-internal/src/__snapshots__/http_config.test.ts.snap index c838892038f2ab..b9bb2f7fbaf788 100644 --- a/packages/core/http/core-http-server-internal/src/__snapshots__/http_config.test.ts.snap +++ b/packages/core/http/core-http-server-internal/src/__snapshots__/http_config.test.ts.snap @@ -41,6 +41,7 @@ exports[`basePath throws if not specified, but rewriteBasePath is set 1`] = `"ca exports[`has defaults for config 1`] = ` Object { "autoListen": true, + "cdn": Object {}, "compression": Object { "brotli": Object { "enabled": false, diff --git a/packages/core/http/core-http-server-internal/src/cdn.test.ts b/packages/core/http/core-http-server-internal/src/cdn.test.ts new file mode 100644 index 00000000000000..74f165bfd0f227 --- /dev/null +++ b/packages/core/http/core-http-server-internal/src/cdn.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CdnConfig } from './cdn'; + +describe('CdnConfig', () => { + it.each([ + ['https://cdn.elastic.co', 'cdn.elastic.co'], + ['https://foo.bar', 'foo.bar'], + ['http://foo.bar', 'foo.bar'], + ['https://cdn.elastic.co:9999', 'cdn.elastic.co:9999'], + ['https://cdn.elastic.co:9999/with-a-path', 'cdn.elastic.co:9999'], + ])('host as expected for %p', (url, expected) => { + expect(CdnConfig.from({ url }).host).toEqual(expected); + }); + + it.each([ + ['https://cdn.elastic.co', 'https://cdn.elastic.co'], + ['https://foo.bar', 'https://foo.bar'], + ['http://foo.bar', 'http://foo.bar'], + ['https://cdn.elastic.co:9999', 'https://cdn.elastic.co:9999'], + ['https://cdn.elastic.co:9999/with-a-path', 'https://cdn.elastic.co:9999/with-a-path'], + ])('base HREF as expected for %p', (url, expected) => { + expect(CdnConfig.from({ url }).baseHref).toEqual(expected); + }); + + it.each([['foo'], ['#!']])('throws for invalid URLs (%p)', (url) => { + expect(() => CdnConfig.from({ url })).toThrow(/Invalid URL/); + }); + + it('handles empty urls', () => { + expect(CdnConfig.from({ url: '' }).baseHref).toBeUndefined(); + expect(CdnConfig.from({ url: '' }).host).toBeUndefined(); + }); + + it('generates the expected CSP additions', () => { + const cdnConfig = CdnConfig.from({ url: 'https://foo.bar:9999' }); + expect(cdnConfig.getCspConfig()).toEqual({ + connect_src: ['foo.bar:9999'], + font_src: ['foo.bar:9999'], + img_src: ['foo.bar:9999'], + script_src: ['foo.bar:9999'], + style_src: ['foo.bar:9999'], + worker_src: ['foo.bar:9999'], + }); + }); + + it('generates the expected CSP additions when no URL is provided', () => { + const cdnConfig = CdnConfig.from({ url: '' }); + expect(cdnConfig.getCspConfig()).toEqual({}); + }); +}); diff --git a/packages/core/http/core-http-server-internal/src/cdn.ts b/packages/core/http/core-http-server-internal/src/cdn.ts new file mode 100644 index 00000000000000..0f9b386b09237f --- /dev/null +++ b/packages/core/http/core-http-server-internal/src/cdn.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { URL, format } from 'node:url'; +import type { CspAdditionalConfig } from './csp'; + +export interface Input { + url?: string; +} + +export class CdnConfig { + private url: undefined | URL; + constructor(url?: string) { + if (url) { + this.url = new URL(url); // This will throw for invalid URLs + } + } + + public get host(): undefined | string { + return this.url?.host ?? undefined; + } + + public get baseHref(): undefined | string { + if (this.url) { + return this.url.pathname === '/' ? this.url.origin : format(this.url); + } + } + + public getCspConfig(): CspAdditionalConfig { + const host = this.host; + if (!host) return {}; + return { + font_src: [host], + img_src: [host], + script_src: [host], + style_src: [host], + worker_src: [host], + connect_src: [host], + }; + } + + public static from(input: Input = {}) { + return new CdnConfig(input.url); + } +} diff --git a/packages/core/http/core-http-server-internal/src/csp/config.ts b/packages/core/http/core-http-server-internal/src/csp/config.ts index d192ddda9a108f..687b612cbd89aa 100644 --- a/packages/core/http/core-http-server-internal/src/csp/config.ts +++ b/packages/core/http/core-http-server-internal/src/csp/config.ts @@ -108,6 +108,21 @@ const configSchema = schema.object( */ export type CspConfigType = TypeOf; +/** + * @internal + */ +export type CspAdditionalConfig = Pick< + Partial, + | 'connect_src' + | 'default_src' + | 'font_src' + | 'frame_src' + | 'img_src' + | 'script_src' + | 'style_src' + | 'worker_src' +>; + export const cspConfig: ServiceConfigDescriptor = { // TODO: Move this to server.csp using config deprecations // ? https://github.com/elastic/kibana/pull/52251 diff --git a/packages/core/http/core-http-server-internal/src/csp/csp_config.test.ts b/packages/core/http/core-http-server-internal/src/csp/csp_config.test.ts index ef4358ef42f150..f5e871fc2fb5f7 100644 --- a/packages/core/http/core-http-server-internal/src/csp/csp_config.test.ts +++ b/packages/core/http/core-http-server-internal/src/csp/csp_config.test.ts @@ -178,4 +178,31 @@ describe('CspConfig', () => { }); }); }); + + describe('with additional config', () => { + test(`adds, for example, CDN host name to directives along with 'self'`, () => { + const config = new CspConfig(defaultConfig, { default_src: ['foo.bar'] }); + expect(config.header).toEqual( + "script-src 'report-sample' 'self'; worker-src 'report-sample' 'self' blob:; style-src 'report-sample' 'self' 'unsafe-inline'; default-src 'self' foo.bar" + ); + }); + + test('Empty additional config does not affect existing config', () => { + const config = new CspConfig(defaultConfig, { + /* empty */ + }); + expect(config.header).toEqual( + "script-src 'report-sample' 'self'; worker-src 'report-sample' 'self' blob:; style-src 'report-sample' 'self' 'unsafe-inline'" + ); + }); + test('Passing an empty array in additional config does not affect existing config', () => { + const config = new CspConfig(defaultConfig, { + default_src: [], + worker_src: ['foo.bar'], + }); + expect(config.header).toEqual( + "script-src 'report-sample' 'self'; worker-src 'report-sample' 'self' blob: foo.bar; style-src 'report-sample' 'self' 'unsafe-inline'" + ); + }); + }); }); diff --git a/packages/core/http/core-http-server-internal/src/csp/csp_config.ts b/packages/core/http/core-http-server-internal/src/csp/csp_config.ts index 049d6c02ed4727..2ccc49ce1909b3 100644 --- a/packages/core/http/core-http-server-internal/src/csp/csp_config.ts +++ b/packages/core/http/core-http-server-internal/src/csp/csp_config.ts @@ -7,7 +7,7 @@ */ import type { ICspConfig } from '@kbn/core-http-server'; -import { cspConfig, CspConfigType } from './config'; +import { CspAdditionalConfig, cspConfig, CspConfigType } from './config'; import { CspDirectives } from './csp_directives'; const DEFAULT_CONFIG = Object.freeze(cspConfig.schema.validate({})); @@ -30,8 +30,8 @@ export class CspConfig implements ICspConfig { * Returns the default CSP configuration when passed with no config * @internal */ - constructor(rawCspConfig: CspConfigType) { - this.#directives = CspDirectives.fromConfig(rawCspConfig); + constructor(rawCspConfig: CspConfigType, ...moreConfigs: CspAdditionalConfig[]) { + this.#directives = CspDirectives.fromConfig(rawCspConfig, ...moreConfigs); if (rawCspConfig.disableEmbedding) { this.#directives.clearDirectiveValues('frame-ancestors'); this.#directives.addDirectiveValue('frame-ancestors', `'self'`); diff --git a/packages/core/http/core-http-server-internal/src/csp/csp_directives.ts b/packages/core/http/core-http-server-internal/src/csp/csp_directives.ts index 28ec58f8db9001..d7495e4c09bfc9 100644 --- a/packages/core/http/core-http-server-internal/src/csp/csp_directives.ts +++ b/packages/core/http/core-http-server-internal/src/csp/csp_directives.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { merge } from 'lodash'; import { CspConfigType } from './config'; export type CspDirectiveName = @@ -65,7 +66,11 @@ export class CspDirectives { .join('; '); } - static fromConfig(config: CspConfigType): CspDirectives { + static fromConfig( + firstConfig: CspConfigType, + ...otherConfigs: Array> + ): CspDirectives { + const config = otherConfigs.length ? merge(firstConfig, ...otherConfigs) : firstConfig; const cspDirectives = new CspDirectives(); // combining `default` directive configurations diff --git a/packages/core/http/core-http-server-internal/src/csp/index.ts b/packages/core/http/core-http-server-internal/src/csp/index.ts index 1111222ac76fca..b9841397b24f4c 100644 --- a/packages/core/http/core-http-server-internal/src/csp/index.ts +++ b/packages/core/http/core-http-server-internal/src/csp/index.ts @@ -8,4 +8,4 @@ export { CspConfig } from './csp_config'; export { cspConfig } from './config'; -export type { CspConfigType } from './config'; +export type { CspConfigType, CspAdditionalConfig } from './config'; diff --git a/packages/core/http/core-http-server-internal/src/http_config.ts b/packages/core/http/core-http-server-internal/src/http_config.ts index 9e8984783a1063..201770eba26936 100644 --- a/packages/core/http/core-http-server-internal/src/http_config.ts +++ b/packages/core/http/core-http-server-internal/src/http_config.ts @@ -16,7 +16,7 @@ import { hostname } from 'os'; import url from 'url'; import type { Duration } from 'moment'; -import { IHttpEluMonitorConfig } from '@kbn/core-http-server/src/elu_monitor'; +import type { IHttpEluMonitorConfig } from '@kbn/core-http-server/src/elu_monitor'; import type { HandlerResolutionStrategy } from '@kbn/core-http-router-server-internal'; import { CspConfigType, CspConfig } from './csp'; import { ExternalUrlConfig } from './external_url'; @@ -24,6 +24,7 @@ import { securityResponseHeadersSchema, parseRawSecurityResponseHeadersConfig, } from './security_response_headers_config'; +import { CdnConfig } from './cdn'; const validBasePathRegex = /^\/.*[^\/]$/; @@ -58,6 +59,9 @@ const configSchema = schema.object( } }, }), + cdn: schema.object({ + url: schema.maybe(schema.uri({ scheme: ['http', 'https'] })), + }), cors: schema.object( { enabled: schema.boolean({ defaultValue: false }), @@ -261,6 +265,7 @@ export class HttpConfig implements IHttpConfig { public basePath?: string; public publicBaseUrl?: string; public rewriteBasePath: boolean; + public cdn: CdnConfig; public ssl: SslConfig; public compression: { enabled: boolean; @@ -314,7 +319,8 @@ export class HttpConfig implements IHttpConfig { this.rewriteBasePath = rawHttpConfig.rewriteBasePath; this.ssl = new SslConfig(rawHttpConfig.ssl || {}); this.compression = rawHttpConfig.compression; - this.csp = new CspConfig({ ...rawCspConfig, disableEmbedding }); + this.cdn = CdnConfig.from(rawHttpConfig.cdn); + this.csp = new CspConfig({ ...rawCspConfig, disableEmbedding }, this.cdn.getCspConfig()); this.externalUrl = rawExternalUrlConfig; this.xsrf = rawHttpConfig.xsrf; this.requestId = rawHttpConfig.requestId; diff --git a/packages/core/http/core-http-server-internal/src/http_server.ts b/packages/core/http/core-http-server-internal/src/http_server.ts index dbd0279cae9ec9..c8a6f55e0a55e4 100644 --- a/packages/core/http/core-http-server-internal/src/http_server.ts +++ b/packages/core/http/core-http-server-internal/src/http_server.ts @@ -58,6 +58,7 @@ import { AuthStateStorage } from './auth_state_storage'; import { AuthHeadersStorage } from './auth_headers_storage'; import { BasePath } from './base_path_service'; import { getEcsResponseLog } from './logging'; +import { StaticAssets, type IStaticAssets } from './static_assets'; /** * Adds ELU timings for the executed function to the current's context transaction @@ -130,7 +131,12 @@ export interface HttpServerSetup { * @param router {@link IRouter} - a router with registered route handlers. */ registerRouterAfterListening: (router: IRouter) => void; + /** + * Register a static directory to be served by the Kibana server + * @note Static assets may be served over CDN + */ registerStaticDir: (path: string, dirPath: string) => void; + staticAssets: IStaticAssets; basePath: HttpServiceSetup['basePath']; csp: HttpServiceSetup['csp']; createCookieSessionStorageFactory: HttpServiceSetup['createCookieSessionStorageFactory']; @@ -230,10 +236,13 @@ export class HttpServer { this.setupResponseLogging(); this.setupGracefulShutdownHandlers(); + const staticAssets = new StaticAssets(basePathService, config.cdn); + return { registerRouter: this.registerRouter.bind(this), registerRouterAfterListening: this.registerRouterAfterListening.bind(this), registerStaticDir: this.registerStaticDir.bind(this), + staticAssets, registerOnPreRouting: this.registerOnPreRouting.bind(this), registerOnPreAuth: this.registerOnPreAuth.bind(this), registerAuth: this.registerAuth.bind(this), diff --git a/packages/core/http/core-http-server-internal/src/http_service.ts b/packages/core/http/core-http-server-internal/src/http_service.ts index e4c3b5d4784964..46d12ddbbd68ad 100644 --- a/packages/core/http/core-http-server-internal/src/http_service.ts +++ b/packages/core/http/core-http-server-internal/src/http_service.ts @@ -113,6 +113,7 @@ export class HttpService this.internalPreboot = { externalUrl: new ExternalUrlConfig(config.externalUrl), csp: prebootSetup.csp, + staticAssets: prebootSetup.staticAssets, basePath: prebootSetup.basePath, registerStaticDir: prebootSetup.registerStaticDir.bind(prebootSetup), auth: prebootSetup.auth, diff --git a/packages/core/http/core-http-server-internal/src/static_assets.test.ts b/packages/core/http/core-http-server-internal/src/static_assets.test.ts new file mode 100644 index 00000000000000..d80ec6aaf6ed88 --- /dev/null +++ b/packages/core/http/core-http-server-internal/src/static_assets.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { StaticAssets } from './static_assets'; +import { BasePath } from './base_path_service'; +import { CdnConfig } from './cdn'; + +describe('StaticAssets', () => { + let basePath: BasePath; + let cdnConfig: CdnConfig; + let staticAssets: StaticAssets; + beforeEach(() => { + basePath = new BasePath('/test'); + cdnConfig = CdnConfig.from(); + staticAssets = new StaticAssets(basePath, cdnConfig); + }); + it('provides fallsback to server base path', () => { + expect(staticAssets.getHrefBase()).toEqual('/test'); + }); + + it('provides the correct HREF given a CDN is configured', () => { + cdnConfig = CdnConfig.from({ url: 'https://cdn.example.com/test' }); + staticAssets = new StaticAssets(basePath, cdnConfig); + expect(staticAssets.getHrefBase()).toEqual('https://cdn.example.com/test'); + }); +}); diff --git a/packages/core/http/core-http-server-internal/src/static_assets.ts b/packages/core/http/core-http-server-internal/src/static_assets.ts new file mode 100644 index 00000000000000..b4e7a529fe9489 --- /dev/null +++ b/packages/core/http/core-http-server-internal/src/static_assets.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { BasePath } from './base_path_service'; +import { CdnConfig } from './cdn'; + +export interface IStaticAssets { + getHrefBase(): string; +} + +export class StaticAssets implements IStaticAssets { + constructor(private readonly basePath: BasePath, private readonly cdnConfig: CdnConfig) {} + /** + * Returns a href (hypertext reference) intended to be used as the base for constructing + * other hrefs to static assets. + */ + getHrefBase(): string { + if (this.cdnConfig.baseHref) { + return this.cdnConfig.baseHref; + } + return this.basePath.serverBasePath; + } +} diff --git a/packages/core/http/core-http-server-internal/src/types.ts b/packages/core/http/core-http-server-internal/src/types.ts index 89d62cd7677c90..0acbb0d0508d68 100644 --- a/packages/core/http/core-http-server-internal/src/types.ts +++ b/packages/core/http/core-http-server-internal/src/types.ts @@ -25,6 +25,7 @@ export interface InternalHttpServicePreboot InternalHttpServiceSetup, | 'auth' | 'csp' + | 'staticAssets' | 'basePath' | 'externalUrl' | 'registerStaticDir' @@ -45,6 +46,7 @@ export interface InternalHttpServiceSetup extends Omit { auth: HttpServerSetup['auth']; server: HttpServerSetup['server']; + staticAssets: HttpServerSetup['staticAssets']; externalUrl: ExternalUrlConfig; createRouter: ( path: string, diff --git a/packages/core/http/core-http-server-mocks/src/http_service.mock.ts b/packages/core/http/core-http-server-mocks/src/http_service.mock.ts index 433560c9104565..ad7e310f5a2d8a 100644 --- a/packages/core/http/core-http-server-mocks/src/http_service.mock.ts +++ b/packages/core/http/core-http-server-mocks/src/http_service.mock.ts @@ -34,12 +34,13 @@ import type { import { sessionStorageMock } from './cookie_session_storage.mocks'; type BasePathMocked = jest.Mocked; +type StaticAssetsMocked = jest.Mocked; type AuthMocked = jest.Mocked; export type HttpServicePrebootMock = jest.Mocked; export type InternalHttpServicePrebootMock = jest.Mocked< - Omit -> & { basePath: BasePathMocked }; + Omit +> & { basePath: BasePathMocked; staticAssets: StaticAssetsMocked }; export type HttpServiceSetupMock< ContextType extends RequestHandlerContextBase = RequestHandlerContextBase > = jest.Mocked, 'basePath' | 'createRouter'>> & { @@ -47,10 +48,14 @@ export type HttpServiceSetupMock< createRouter: jest.MockedFunction<() => RouterMock>; }; export type InternalHttpServiceSetupMock = jest.Mocked< - Omit + Omit< + InternalHttpServiceSetup, + 'basePath' | 'staticAssets' | 'createRouter' | 'authRequestHeaders' | 'auth' + > > & { auth: AuthMocked; basePath: BasePathMocked; + staticAssets: StaticAssetsMocked; createRouter: jest.MockedFunction<(path: string) => RouterMock>; authRequestHeaders: jest.Mocked; }; @@ -73,6 +78,13 @@ const createBasePathMock = ( remove: jest.fn(), }); +const createStaticAssetsMock = ( + basePath: BasePathMocked, + cdnUrl: undefined | string = undefined +): StaticAssetsMocked => ({ + getHrefBase: jest.fn(() => cdnUrl ?? basePath.serverBasePath), +}); + const createAuthMock = () => { const mock: AuthMocked = { get: jest.fn(), @@ -91,12 +103,17 @@ const createAuthHeaderStorageMock = () => { return mock; }; -const createInternalPrebootContractMock = () => { +interface CreateMockArgs { + cdnUrl?: string; +} +const createInternalPrebootContractMock = (args: CreateMockArgs = {}) => { + const basePath = createBasePathMock(); const mock: InternalHttpServicePrebootMock = { registerRoutes: jest.fn(), registerRouteHandlerContext: jest.fn(), registerStaticDir: jest.fn(), - basePath: createBasePathMock(), + basePath, + staticAssets: createStaticAssetsMock(basePath, args.cdnUrl), csp: CspConfig.DEFAULT, externalUrl: ExternalUrlConfig.DEFAULT, auth: createAuthMock(), @@ -149,6 +166,7 @@ const createInternalSetupContractMock = () => { registerStaticDir: jest.fn(), basePath: createBasePathMock(), csp: CspConfig.DEFAULT, + staticAssets: { getHrefBase: jest.fn(() => mock.basePath.serverBasePath) }, externalUrl: ExternalUrlConfig.DEFAULT, auth: createAuthMock(), authRequestHeaders: createAuthHeaderStorageMock(), diff --git a/packages/core/rendering/core-rendering-server-internal/src/__snapshots__/rendering_service.test.ts.snap b/packages/core/rendering/core-rendering-server-internal/src/__snapshots__/rendering_service.test.ts.snap index 6316dc056563cc..d444959de3c9e8 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/__snapshots__/rendering_service.test.ts.snap +++ b/packages/core/rendering/core-rendering-server-internal/src/__snapshots__/rendering_service.test.ts.snap @@ -1,5 +1,72 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`RenderingService preboot() render() renders "core" CDN url injected 1`] = ` +Object { + "anonymousStatusPage": false, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "clusterInfo": Object {}, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "customBranding": Object {}, + "env": Object { + "mode": Object { + "dev": Any, + "name": Any, + "prod": Any, + }, + "packageInfo": Object { + "branch": Any, + "buildDate": "2023-05-15T23:12:09.000Z", + "buildFlavor": Any, + "buildNum": Any, + "buildSha": Any, + "dist": Any, + "version": Any, + }, + }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "globalUiSettings": Object { + "defaults": Object {}, + "user": Object {}, + }, + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object { + "theme:darkMode": Object { + "userValue": true, + }, + }, + }, + }, + "publicBaseUrl": "http://myhost.com/mock-server-basepath", + "serverBasePath": "/mock-server-basepath", + "theme": Object { + "darkMode": "theme:darkMode", + "version": "v8", + }, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + exports[`RenderingService preboot() render() renders "core" page 1`] = ` Object { "anonymousStatusPage": false, @@ -449,6 +516,78 @@ Object { } `; +exports[`RenderingService setup() render() renders "core" CDN url injected 1`] = ` +Object { + "anonymousStatusPage": false, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "clusterInfo": Object { + "cluster_build_flavor": "default", + "cluster_name": "cluster-name", + "cluster_uuid": "cluster-uuid", + "cluster_version": "8.0.0", + }, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "customBranding": Object {}, + "env": Object { + "mode": Object { + "dev": Any, + "name": Any, + "prod": Any, + }, + "packageInfo": Object { + "branch": Any, + "buildDate": "2023-05-15T23:12:09.000Z", + "buildFlavor": Any, + "buildNum": Any, + "buildSha": Any, + "dist": Any, + "version": Any, + }, + }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "globalUiSettings": Object { + "defaults": Object {}, + "user": Object {}, + }, + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object { + "theme:darkMode": Object { + "userValue": true, + }, + }, + }, + }, + "publicBaseUrl": "http://myhost.com/mock-server-basepath", + "serverBasePath": "/mock-server-basepath", + "theme": Object { + "darkMode": "theme:darkMode", + "version": "v8", + }, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + exports[`RenderingService setup() render() renders "core" page 1`] = ` Object { "anonymousStatusPage": false, diff --git a/packages/core/rendering/core-rendering-server-internal/src/bootstrap/bootstrap_renderer.test.ts b/packages/core/rendering/core-rendering-server-internal/src/bootstrap/bootstrap_renderer.test.ts index df9589b043d2bc..5c699e905e9cd9 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/bootstrap/bootstrap_renderer.test.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/bootstrap/bootstrap_renderer.test.ts @@ -62,7 +62,7 @@ describe('bootstrapRenderer', () => { auth, packageInfo, uiPlugins, - serverBasePath: '/base-path', + baseHref: '/base-path', }); }); @@ -134,7 +134,7 @@ describe('bootstrapRenderer', () => { auth, packageInfo, uiPlugins, - serverBasePath: '/base-path', + baseHref: '/base-path', userSettingsService, }); @@ -160,7 +160,7 @@ describe('bootstrapRenderer', () => { auth, packageInfo, uiPlugins, - serverBasePath: '/base-path', + baseHref: '/base-path', userSettingsService, }); @@ -186,7 +186,7 @@ describe('bootstrapRenderer', () => { auth, packageInfo, uiPlugins, - serverBasePath: '/base-path', + baseHref: '/base-path', userSettingsService, }); @@ -212,7 +212,7 @@ describe('bootstrapRenderer', () => { auth, packageInfo, uiPlugins, - serverBasePath: '/base-path', + baseHref: '/base-path', userSettingsService, }); @@ -319,7 +319,7 @@ describe('bootstrapRenderer', () => { expect(getPluginsBundlePathsMock).toHaveBeenCalledWith({ isAnonymousPage, uiPlugins, - regularBundlePath: '/base-path/42/bundles', + bundlesHref: '/base-path/42/bundles', }); }); }); diff --git a/packages/core/rendering/core-rendering-server-internal/src/bootstrap/bootstrap_renderer.ts b/packages/core/rendering/core-rendering-server-internal/src/bootstrap/bootstrap_renderer.ts index 861cd7495ccce9..e8c30819a0b6eb 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/bootstrap/bootstrap_renderer.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/bootstrap/bootstrap_renderer.ts @@ -17,12 +17,14 @@ import { getPluginsBundlePaths } from './get_plugin_bundle_paths'; import { getJsDependencyPaths } from './get_js_dependency_paths'; import { getThemeTag } from './get_theme_tag'; import { renderTemplate } from './render_template'; +import { getBundlesHref } from '../render_utils'; export type BootstrapRendererFactory = (factoryOptions: FactoryOptions) => BootstrapRenderer; export type BootstrapRenderer = (options: RenderedOptions) => Promise; interface FactoryOptions { - serverBasePath: string; + /** Can be a URL, in the case of a CDN, or a base path if serving from Kibana */ + baseHref: string; packageInfo: PackageInfo; uiPlugins: UiPlugins; auth: HttpAuth; @@ -42,7 +44,7 @@ interface RendererResult { export const bootstrapRendererFactory: BootstrapRendererFactory = ({ packageInfo, - serverBasePath, + baseHref, uiPlugins, auth, userSettingsService, @@ -78,23 +80,23 @@ export const bootstrapRendererFactory: BootstrapRendererFactory = ({ darkMode, }); const buildHash = packageInfo.buildNum; - const regularBundlePath = `${serverBasePath}/${buildHash}/bundles`; + const bundlesHref = getBundlesHref(baseHref, String(buildHash)); const bundlePaths = getPluginsBundlePaths({ uiPlugins, - regularBundlePath, + bundlesHref, isAnonymousPage, }); - const jsDependencyPaths = getJsDependencyPaths(regularBundlePath, bundlePaths); + const jsDependencyPaths = getJsDependencyPaths(bundlesHref, bundlePaths); // These paths should align with the bundle routes configured in // src/optimize/bundles_route/bundles_route.ts const publicPathMap = JSON.stringify({ - core: `${regularBundlePath}/core/`, - 'kbn-ui-shared-deps-src': `${regularBundlePath}/kbn-ui-shared-deps-src/`, - 'kbn-ui-shared-deps-npm': `${regularBundlePath}/kbn-ui-shared-deps-npm/`, - 'kbn-monaco': `${regularBundlePath}/kbn-monaco/`, + core: `${bundlesHref}/core/`, + 'kbn-ui-shared-deps-src': `${bundlesHref}/kbn-ui-shared-deps-src/`, + 'kbn-ui-shared-deps-npm': `${bundlesHref}/kbn-ui-shared-deps-npm/`, + 'kbn-monaco': `${bundlesHref}/kbn-monaco/`, ...Object.fromEntries( [...bundlePaths.entries()].map(([pluginId, plugin]) => [pluginId, plugin.publicPath]) ), diff --git a/packages/core/rendering/core-rendering-server-internal/src/bootstrap/get_plugin_bundle_paths.test.ts b/packages/core/rendering/core-rendering-server-internal/src/bootstrap/get_plugin_bundle_paths.test.ts index 619765cdc7b6ae..e5443e98aefee1 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/bootstrap/get_plugin_bundle_paths.test.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/bootstrap/get_plugin_bundle_paths.test.ts @@ -46,7 +46,7 @@ const createUiPlugins = (pluginDeps: Record) => { describe('getPluginsBundlePaths', () => { it('returns an entry for each plugin and their bundle dependencies', () => { const pluginBundlePaths = getPluginsBundlePaths({ - regularBundlePath: '/regular-bundle-path', + bundlesHref: '/regular-bundle-path', uiPlugins: createUiPlugins({ a: ['b', 'c'], b: ['d'], @@ -59,7 +59,7 @@ describe('getPluginsBundlePaths', () => { it('returns correct paths for each bundle', () => { const pluginBundlePaths = getPluginsBundlePaths({ - regularBundlePath: '/regular-bundle-path', + bundlesHref: '/regular-bundle-path', uiPlugins: createUiPlugins({ a: ['b'], }), diff --git a/packages/core/rendering/core-rendering-server-internal/src/bootstrap/get_plugin_bundle_paths.ts b/packages/core/rendering/core-rendering-server-internal/src/bootstrap/get_plugin_bundle_paths.ts index ad9f3edb4aa51f..58b149fd42b3f0 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/bootstrap/get_plugin_bundle_paths.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/bootstrap/get_plugin_bundle_paths.ts @@ -16,11 +16,11 @@ export interface PluginInfo { export const getPluginsBundlePaths = ({ uiPlugins, - regularBundlePath, + bundlesHref, isAnonymousPage, }: { uiPlugins: UiPlugins; - regularBundlePath: string; + bundlesHref: string; isAnonymousPage: boolean; }) => { const pluginBundlePaths = new Map(); @@ -35,8 +35,8 @@ export const getPluginsBundlePaths = ({ const { version } = plugin; pluginBundlePaths.set(pluginId, { - publicPath: `${regularBundlePath}/plugin/${pluginId}/${version}/`, - bundlePath: `${regularBundlePath}/plugin/${pluginId}/${version}/${pluginId}.plugin.js`, + publicPath: `${bundlesHref}/plugin/${pluginId}/${version}/`, + bundlePath: `${bundlesHref}/plugin/${pluginId}/${version}/${pluginId}.plugin.js`, }); const pluginBundleIds = uiPlugins.internal.get(pluginId)?.requiredBundles ?? []; diff --git a/packages/core/rendering/core-rendering-server-internal/src/render_utils.test.ts b/packages/core/rendering/core-rendering-server-internal/src/render_utils.test.ts index 7fa091d6381fbe..8a5d2e4c7377b5 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/render_utils.test.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/render_utils.test.ts @@ -16,7 +16,7 @@ describe('getStylesheetPaths', () => { getStylesheetPaths({ darkMode: true, themeVersion: 'v8', - basePath: '/base-path', + baseHref: '/base-path', buildNum: 17, }) ).toMatchInlineSnapshot(` @@ -36,7 +36,7 @@ describe('getStylesheetPaths', () => { getStylesheetPaths({ darkMode: false, themeVersion: 'v8', - basePath: '/base-path', + baseHref: '/base-path', buildNum: 69, }) ).toMatchInlineSnapshot(` diff --git a/packages/core/rendering/core-rendering-server-internal/src/render_utils.ts b/packages/core/rendering/core-rendering-server-internal/src/render_utils.ts index 01c96570bd09b4..6f74320098b16d 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/render_utils.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/render_utils.ts @@ -22,33 +22,36 @@ export const getSettingValue = ( return convert(value); }; +export const getBundlesHref = (baseHref: string, buildNr: string): string => + `${baseHref}/${buildNr}/bundles`; + export const getStylesheetPaths = ({ themeVersion, darkMode, - basePath, + baseHref, buildNum, }: { themeVersion: UiSharedDepsNpm.ThemeVersion; darkMode: boolean; buildNum: number; - basePath: string; + baseHref: string; }) => { - const regularBundlePath = `${basePath}/${buildNum}/bundles`; + const bundlesHref = getBundlesHref(baseHref, String(buildNum)); return [ ...(darkMode ? [ - `${regularBundlePath}/kbn-ui-shared-deps-npm/${UiSharedDepsNpm.darkCssDistFilename( + `${bundlesHref}/kbn-ui-shared-deps-npm/${UiSharedDepsNpm.darkCssDistFilename( themeVersion )}`, - `${regularBundlePath}/kbn-ui-shared-deps-src/${UiSharedDepsSrc.cssDistFilename}`, - `${basePath}/ui/legacy_dark_theme.min.css`, + `${bundlesHref}/kbn-ui-shared-deps-src/${UiSharedDepsSrc.cssDistFilename}`, + `${baseHref}/ui/legacy_dark_theme.min.css`, ] : [ - `${regularBundlePath}/kbn-ui-shared-deps-npm/${UiSharedDepsNpm.lightCssDistFilename( + `${bundlesHref}/kbn-ui-shared-deps-npm/${UiSharedDepsNpm.lightCssDistFilename( themeVersion )}`, - `${regularBundlePath}/kbn-ui-shared-deps-src/${UiSharedDepsSrc.cssDistFilename}`, - `${basePath}/ui/legacy_light_theme.min.css`, + `${bundlesHref}/kbn-ui-shared-deps-src/${UiSharedDepsSrc.cssDistFilename}`, + `${baseHref}/ui/legacy_light_theme.min.css`, ]), ]; }; diff --git a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts index 193ad54918d9f6..521e697f29a400 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts @@ -180,10 +180,25 @@ function renderTestCases( expect(getStylesheetPathsMock).toHaveBeenCalledWith({ darkMode: true, themeVersion: 'v8', - basePath: '/mock-server-basepath', + baseHref: '/mock-server-basepath', buildNum: expect.any(Number), }); }); + + it('renders "core" CDN url injected', async () => { + const userSettings = { 'theme:darkMode': { userValue: true } }; + uiSettings.client.getUserProvided.mockResolvedValue(userSettings); + (mockRenderingPrebootDeps.http.staticAssets.getHrefBase as jest.Mock).mockImplementation( + () => 'http://foo.bar:1773' + ); + const [render] = await getRender(); + const content = await render(createKibanaRequest(), uiSettings, { + isAnonymousPage: false, + }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data') ?? '""'); + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); }); } @@ -233,7 +248,7 @@ function renderDarkModeTestCases( expect(getStylesheetPathsMock).toHaveBeenCalledWith({ darkMode: true, themeVersion: 'v8', - basePath: '/mock-server-basepath', + baseHref: '/mock-server-basepath', buildNum: expect.any(Number), }); }); @@ -259,7 +274,7 @@ function renderDarkModeTestCases( expect(getStylesheetPathsMock).toHaveBeenCalledWith({ darkMode: false, themeVersion: 'v8', - basePath: '/mock-server-basepath', + baseHref: '/mock-server-basepath', buildNum: expect.any(Number), }); }); @@ -283,7 +298,7 @@ function renderDarkModeTestCases( expect(getStylesheetPathsMock).toHaveBeenCalledWith({ darkMode: false, themeVersion: 'v8', - basePath: '/mock-server-basepath', + baseHref: '/mock-server-basepath', buildNum: expect.any(Number), }); }); @@ -307,7 +322,7 @@ function renderDarkModeTestCases( expect(getStylesheetPathsMock).toHaveBeenCalledWith({ darkMode: true, themeVersion: 'v8', - basePath: '/mock-server-basepath', + baseHref: '/mock-server-basepath', buildNum: expect.any(Number), }); }); @@ -331,7 +346,7 @@ function renderDarkModeTestCases( expect(getStylesheetPathsMock).toHaveBeenCalledWith({ darkMode: false, themeVersion: 'v8', - basePath: '/mock-server-basepath', + baseHref: '/mock-server-basepath', buildNum: expect.any(Number), }); }); @@ -355,7 +370,7 @@ function renderDarkModeTestCases( expect(getStylesheetPathsMock).toHaveBeenCalledWith({ darkMode: false, themeVersion: 'v8', - basePath: '/mock-server-basepath', + baseHref: '/mock-server-basepath', buildNum: expect.any(Number), }); }); @@ -379,7 +394,7 @@ function renderDarkModeTestCases( expect(getStylesheetPathsMock).toHaveBeenCalledWith({ darkMode: true, themeVersion: 'v8', - basePath: '/mock-server-basepath', + baseHref: '/mock-server-basepath', buildNum: expect.any(Number), }); }); diff --git a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx index 854202e4aebc49..1791f55e563b65 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx +++ b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx @@ -55,7 +55,7 @@ export class RenderingService { router, renderer: bootstrapRendererFactory({ uiPlugins, - serverBasePath: http.basePath.serverBasePath, + baseHref: http.staticAssets.getHrefBase(), packageInfo: this.coreContext.env.packageInfo, auth: http.auth, }), @@ -79,7 +79,7 @@ export class RenderingService { router: http.createRouter(''), renderer: bootstrapRendererFactory({ uiPlugins, - serverBasePath: http.basePath.serverBasePath, + baseHref: http.staticAssets.getHrefBase(), packageInfo: this.coreContext.env.packageInfo, auth: http.auth, userSettingsService: userSettings, @@ -114,6 +114,7 @@ export class RenderingService { packageInfo: this.coreContext.env.packageInfo, }; const buildNum = env.packageInfo.buildNum; + const staticAssetsHrefBase = http.staticAssets.getHrefBase(); const basePath = http.basePath.get(request); const { serverBasePath, publicBaseUrl } = http.basePath; @@ -180,7 +181,7 @@ export class RenderingService { const stylesheetPaths = getStylesheetPaths({ darkMode, themeVersion, - basePath: serverBasePath, + baseHref: staticAssetsHrefBase, buildNum, }); @@ -188,7 +189,7 @@ export class RenderingService { const bootstrapScript = isAnonymousPage ? 'bootstrap-anonymous.js' : 'bootstrap.js'; const metadata: RenderingMetadata = { strictCsp: http.csp.strict, - uiPublicUrl: `${basePath}/ui`, + uiPublicUrl: `${staticAssetsHrefBase}/ui`, bootstrapScriptUrl: `${basePath}/${bootstrapScript}`, i18n: i18n.translate, locale: i18n.getLocale(), @@ -212,6 +213,7 @@ export class RenderingService { clusterInfo, anonymousStatusPage: status?.isStatusPageAnonymous() ?? false, i18n: { + // TODO: Make this load as part of static assets! translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`, }, theme: {