[HTTP] Add support for configuring a CDN (part I) (#169408)

This commit is contained in:
Jean-Louis Leysens 2023-10-21 15:40:05 +02:00 committed by GitHub
parent af84131321
commit 8727c68047
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 467 additions and 56 deletions

View file

@ -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,

View file

@ -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({});
});
});

View file

@ -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);
}
}

View file

@ -108,6 +108,21 @@ const configSchema = schema.object(
*/
export type CspConfigType = TypeOf<typeof configSchema>;
/**
* @internal
*/
export type CspAdditionalConfig = Pick<
Partial<CspConfigType>,
| 'connect_src'
| 'default_src'
| 'font_src'
| 'frame_src'
| 'img_src'
| 'script_src'
| 'style_src'
| 'worker_src'
>;
export const cspConfig: ServiceConfigDescriptor<CspConfigType> = {
// TODO: Move this to server.csp using config deprecations
// ? https://github.com/elastic/kibana/pull/52251

View file

@ -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'"
);
});
});
});

View file

@ -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'`);

View file

@ -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<Partial<CspConfigType>>
): CspDirectives {
const config = otherConfigs.length ? merge(firstConfig, ...otherConfigs) : firstConfig;
const cspDirectives = new CspDirectives();
// combining `default` directive configurations

View file

@ -8,4 +8,4 @@
export { CspConfig } from './csp_config';
export { cspConfig } from './config';
export type { CspConfigType } from './config';
export type { CspConfigType, CspAdditionalConfig } from './config';

View file

@ -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;

View file

@ -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),

View file

@ -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,

View file

@ -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');
});
});

View file

@ -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;
}
}

View file

@ -25,6 +25,7 @@ export interface InternalHttpServicePreboot
InternalHttpServiceSetup,
| 'auth'
| 'csp'
| 'staticAssets'
| 'basePath'
| 'externalUrl'
| 'registerStaticDir'
@ -45,6 +46,7 @@ export interface InternalHttpServiceSetup
extends Omit<HttpServiceSetup, 'createRouter' | 'registerRouteHandlerContext'> {
auth: HttpServerSetup['auth'];
server: HttpServerSetup['server'];
staticAssets: HttpServerSetup['staticAssets'];
externalUrl: ExternalUrlConfig;
createRouter: <Context extends RequestHandlerContextBase = RequestHandlerContextBase>(
path: string,

View file

@ -34,12 +34,13 @@ import type {
import { sessionStorageMock } from './cookie_session_storage.mocks';
type BasePathMocked = jest.Mocked<InternalHttpServiceSetup['basePath']>;
type StaticAssetsMocked = jest.Mocked<InternalHttpServiceSetup['staticAssets']>;
type AuthMocked = jest.Mocked<InternalHttpServiceSetup['auth']>;
export type HttpServicePrebootMock = jest.Mocked<HttpServicePreboot>;
export type InternalHttpServicePrebootMock = jest.Mocked<
Omit<InternalHttpServicePreboot, 'basePath'>
> & { basePath: BasePathMocked };
Omit<InternalHttpServicePreboot, 'basePath' | 'staticAssets'>
> & { basePath: BasePathMocked; staticAssets: StaticAssetsMocked };
export type HttpServiceSetupMock<
ContextType extends RequestHandlerContextBase = RequestHandlerContextBase
> = jest.Mocked<Omit<HttpServiceSetup<ContextType>, 'basePath' | 'createRouter'>> & {
@ -47,10 +48,14 @@ export type HttpServiceSetupMock<
createRouter: jest.MockedFunction<() => RouterMock>;
};
export type InternalHttpServiceSetupMock = jest.Mocked<
Omit<InternalHttpServiceSetup, 'basePath' | 'createRouter' | 'authRequestHeaders' | 'auth'>
Omit<
InternalHttpServiceSetup,
'basePath' | 'staticAssets' | 'createRouter' | 'authRequestHeaders' | 'auth'
>
> & {
auth: AuthMocked;
basePath: BasePathMocked;
staticAssets: StaticAssetsMocked;
createRouter: jest.MockedFunction<(path: string) => RouterMock>;
authRequestHeaders: jest.Mocked<IAuthHeadersStorage>;
};
@ -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(),

View file

@ -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<String>,
"buildNumber": Any<Number>,
"clusterInfo": Object {},
"csp": Object {
"warnLegacyBrowsers": true,
},
"customBranding": Object {},
"env": Object {
"mode": Object {
"dev": Any<Boolean>,
"name": Any<String>,
"prod": Any<Boolean>,
},
"packageInfo": Object {
"branch": Any<String>,
"buildDate": "2023-05-15T23:12:09.000Z",
"buildFlavor": Any<String>,
"buildNum": Any<Number>,
"buildSha": Any<String>,
"dist": Any<Boolean>,
"version": Any<String>,
},
},
"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<String>,
}
`;
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<String>,
"buildNumber": Any<Number>,
"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<Boolean>,
"name": Any<String>,
"prod": Any<Boolean>,
},
"packageInfo": Object {
"branch": Any<String>,
"buildDate": "2023-05-15T23:12:09.000Z",
"buildFlavor": Any<String>,
"buildNum": Any<Number>,
"buildSha": Any<String>,
"dist": Any<Boolean>,
"version": Any<String>,
},
},
"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<String>,
}
`;
exports[`RenderingService setup() render() renders "core" page 1`] = `
Object {
"anonymousStatusPage": false,

View file

@ -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',
});
});
});

View file

@ -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<RendererResult>;
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])
),

View file

@ -46,7 +46,7 @@ const createUiPlugins = (pluginDeps: Record<string, string[]>) => {
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'],
}),

View file

@ -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<string, PluginInfo>();
@ -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 ?? [];

View file

@ -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(`

View file

@ -22,33 +22,36 @@ export const getSettingValue = <T>(
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`,
]),
];
};

View file

@ -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),
});
});

View file

@ -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<InternalRenderingRequestHandlerContext>(''),
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: {