mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[HTTP] Add support for configuring a CDN (part I) (#169408)
This commit is contained in:
parent
af84131321
commit
8727c68047
24 changed files with 467 additions and 56 deletions
|
@ -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,
|
||||
|
|
57
packages/core/http/core-http-server-internal/src/cdn.test.ts
Normal file
57
packages/core/http/core-http-server-internal/src/cdn.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
50
packages/core/http/core-http-server-internal/src/cdn.ts
Normal file
50
packages/core/http/core-http-server-internal/src/cdn.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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'"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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'`);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -8,4 +8,4 @@
|
|||
|
||||
export { CspConfig } from './csp_config';
|
||||
export { cspConfig } from './config';
|
||||
export type { CspConfigType } from './config';
|
||||
export type { CspConfigType, CspAdditionalConfig } from './config';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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])
|
||||
),
|
||||
|
|
|
@ -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'],
|
||||
}),
|
||||
|
|
|
@ -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 ?? [];
|
||||
|
|
|
@ -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(`
|
||||
|
|
|
@ -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`,
|
||||
]),
|
||||
];
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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: {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue